backend / algorithmes-structures-donnees
Parcours de graphes : BFS et DFS
Explication
Ce que vous allez apprendre
- Différencier BFS (file, couche par couche) et DFS (pile ou récursion, profondeur d'abord)
- Justifier pourquoi BFS garantit le plus court chemin en nombre d'arêtes sur un graphe non pondéré
- Utiliser DFS pour détecter les composantes connexes et calculer un tri topologique
- Distinguer un sommet "en cours d'exploration" d'un sommet "terminé" pour détecter un cycle correctement
- Choisir entre BFS et DFS selon la question posée : plus court chemin ou exploration structurelle
Dans quel contexte ?
Un réseau social veut calculer la distance en nombre de relations entre deux utilisateurs pour proposer "vos amis en commun" (une fonctionnalité "à 2 degrés de connexion"). BFS, en explorant couche par couche depuis l'utilisateur de départ, garantit de trouver le chemin le plus court en nombre de relations dès la première fois qu'il atteint un sommet, exactement ce qu'il faut ici. Un gestionnaire de paquets comme npm, lui, doit déterminer dans quel ordre installer des dépendances qui dépendent les unes des autres : c'est un tri topologique calculé via DFS, et une dépendance circulaire (A dépend de B qui dépend de A) est détectée précisément grâce à la distinction entre sommet "en cours" et sommet "terminé".
D'abord, la question à se poser face à un graphe inconnu
Une fois un graphe représenté en mémoire, il faut pouvoir le parcourir pour visiter tous ses sommets. Deux stratégies radicalement différentes existent pour cela.
Prérequis
Cette leçon s'appuie sur la représentation en liste d'adjacence vue à la leçon précédente, ainsi que sur les piles et files (leçon 3) et la récursivité (leçon 4).
Étape 1 : le parcours en largeur (BFS)
BFS avance "en cercles concentriques" depuis le point de départ : il visite d'abord tous les voisins directs, puis tous leurs voisins, et ainsi de suite. Cette progression couche par couche est rendue possible par une file (FIFO).
Pourquoi BFS garantit le plus court chemin
En visitant les sommets par distance croissante, BFS découvre chaque sommet pour la toute première fois via l'un de ses chemins les plus courts possibles depuis le départ, sur un graphe SANS pondération.
| Critère | BFS | DFS |
|---|---|---|
| Structure de données | File (FIFO) | Pile (ou récursion) |
| Progression | Couche par couche | Une branche jusqu'au bout |
| Garantit le plus court chemin (non pondéré) | Oui | Non |
| Cas d'usage typique | Distance minimale, plus proche voisin | Composantes connexes, tri topologique, cycles |
Étape 2 : le parcours en profondeur (DFS)
DFS fonce au contraire le plus loin possible dans une direction avant de faire demi-tour à la première impasse, en utilisant naturellement la récursivité (ou une pile explicite).
Ce que révèle cette stratégie différente
En s'enfonçant profondément avant de revenir en arrière, DFS révèle la structure récursive du graphe : quels sommets sont accessibles depuis lesquels (composantes connexes), et dans quel ordre des dépendances doivent être satisfaites (tri topologique).
Un piège à connaître : détecter un cycle
Sur un graphe orienté, un simple ensemble "déjà visité" ne suffit pas à détecter un cycle. Il faut distinguer un sommet "en cours d'exploration sur le chemin actuel" d'un sommet "totalement terminé".
Piège fréquent
Sur un graphe orienté, retomber sur un sommet "en cours d'exploration sur le chemin actuel" (souvent marqué en gris) signale un cycle. Retomber sur un sommet "totalement terminé" (marqué en noir) est parfaitement normal : cela signifie juste qu'il existe deux chemins distincts vers la même destination, pas un cycle.
Pourquoi cette distinction est nécessaire
Un cycle n'existe que si on retombe sur un sommet encore en cours d'exploration. Retomber sur un sommet déjà totalement terminé est parfaitement normal : cela signifie simplement qu'il existe deux chemins distincts vers la même destination.
Vers la suite
BFS et DFS sont les deux briques fondamentales de presque tous les algorithmes de graphes plus avancés. La prochaine leçon généralise directement BFS pour gérer des arêtes qui ont un coût différent les unes des autres : l'algorithme de Dijkstra.
Commandes & code
Parcours de graphes : BFS et DFS
from collections import deque
graphe = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"],
}
# --- BFS (Breadth-First Search) : parcours en largeur, avec une FILE ---
# Garantit le plus court chemin en NOMBRE D'ARÊTES sur un graphe non pondéré
def bfs(graphe: dict, depart: str) -> list[str]:
visites = {depart}
file = deque([depart])
ordre = []
while file:
sommet = file.popleft()
ordre.append(sommet)
for voisin in graphe.get(sommet, []):
if voisin not in visites:
visites.add(voisin) # marquer visité DÈS l'ajout en file, pas à l'extraction
file.append(voisin)
return ordre
assert bfs(graphe, "A") == ["A", "B", "C", "D", "E", "F"]
# BFS pour le plus court chemin (non pondéré) avec reconstruction du chemin
def plus_court_chemin_bfs(graphe: dict, depart: str, arrivee: str) -> list[str] | None:
if depart == arrivee:
return [depart]
visites = {depart}
file = deque([depart])
parent = {depart: None}
while file:
sommet = file.popleft()
for voisin in graphe.get(sommet, []):
if voisin not in visites:
visites.add(voisin)
parent[voisin] = sommet
if voisin == arrivee:
# reconstruire le chemin en remontant les parents
chemin = [arrivee]
while parent[chemin[-1]] is not None:
chemin.append(parent[chemin[-1]])
return chemin[::-1]
file.append(voisin)
return None # aucun chemin trouvé
assert plus_court_chemin_bfs(graphe, "A", "F") == ["A", "C", "F"]
# --- DFS (Depth-First Search) récursif : parcours en profondeur ---
def dfs_recursif(graphe: dict, sommet: str, visites: set | None = None, ordre: list | None = None) -> list[str]:
if visites is None:
visites, ordre = set(), []
visites.add(sommet)
ordre.append(sommet)
for voisin in graphe.get(sommet, []):
if voisin not in visites:
dfs_recursif(graphe, voisin, visites, ordre)
return ordre
assert dfs_recursif(graphe, "A") == ["A", "B", "D", "E", "F", "C"]
# --- DFS itératif avec une pile explicite (évite la limite de récursion sur grands graphes) ---
def dfs_iteratif(graphe: dict, depart: str) -> list[str]:
visites = set()
pile = [depart]
ordre = []
while pile:
sommet = pile.pop()
if sommet not in visites:
visites.add(sommet)
ordre.append(sommet)
# inverser pour visiter les voisins dans le même ordre que la version récursive
for voisin in reversed(graphe.get(sommet, [])):
if voisin not in visites:
pile.append(voisin)
return ordre
# --- Détecter les composantes connexes d'un graphe non orienté ---
def composantes_connexes(graphe: dict) -> list[list[str]]:
visites = set()
composantes = []
for sommet in graphe:
if sommet not in visites:
composante = dfs_recursif(graphe, sommet, visites, [])
composantes.append(composante)
return composantes
# --- Détecter un cycle dans un graphe ORIENTÉ (3 couleurs : blanc/gris/noir) ---
def a_un_cycle_oriente(graphe: dict) -> bool:
BLANC, GRIS, NOIR = 0, 1, 2
couleur = {sommet: BLANC for sommet in graphe}
def visiter(sommet) -> bool:
couleur[sommet] = GRIS # en cours de visite (sur la pile d'appels actuelle)
for voisin in graphe.get(sommet, []):
if couleur.get(voisin, BLANC) == GRIS:
return True # arête vers un sommet EN COURS de visite -> cycle
if couleur.get(voisin, BLANC) == BLANC and visiter(voisin):
return True
couleur[sommet] = NOIR # visite terminée, définitivement sûr
return False
return any(visiter(s) for s in graphe if couleur[s] == BLANC)
graphe_cyclique = {"A": ["B"], "B": ["C"], "C": ["A"]}
assert a_un_cycle_oriente(graphe_cyclique) is True
# --- Tri topologique (DFS) : ordonner les sommets d'un DAG (graphe orienté acyclique) ---
def tri_topologique(graphe: dict) -> list[str]:
visites = set()
pile_resultat = []
def visiter(sommet):
visites.add(sommet)
for voisin in graphe.get(sommet, []):
if voisin not in visites:
visiter(voisin)
pile_resultat.append(sommet) # ajouté APRÈS avoir visité tous ses successeurs
for sommet in graphe:
if sommet not in visites:
visiter(sommet)
return pile_resultat[::-1] # inverser : les "sources" doivent apparaître en premier
dag = {"habillage": ["chaussures"], "sous_vetements": ["habillage"], "chaussures": []}
assert tri_topologique(dag) == ["sous_vetements", "habillage", "chaussures"]Résumé
- BFS explore niveau par niveau via une file : garantit le plus court chemin en nombre d'arêtes sur un graphe non pondéré.
- DFS explore en profondeur (récursif ou pile explicite) : utile pour la connexité, la détection de cycles, le tri topologique.
- La détection de cycle sur un graphe orienté nécessite 3 états (blanc/gris/noir), pas un simple ensemble "visité".
- Le tri topologique n'existe que sur un DAG (pas de cycle) : il ordonne les sommets pour respecter toutes les dépendances.
Exercices pratiques
Mission : réparer le gestionnaire de dépendances qui boucle à l'infini
Objectif : Choisir le bon parcours pour une question précise, puis diagnostiquer une dépendance circulaire dans un tri topologique.
Contexte
Un gestionnaire de paquets interne doit installer des modules qui dépendent les uns des autres, et doit aussi proposer une fonctionnalité "distance minimale de collaboration" entre deux employés dans l'organigramme des mentorats. Après l'ajout d'un nouveau paquet, l'installation se bloque indéfiniment, et personne ne sait si c'est un bug de parcours ou une vraie dépendance circulaire.