Vue d'ensemble
Une fois un graphe représenté en machine, la première chose qu'on veut faire est de le parcourir : visiter tous les sommets accessibles depuis un point de départ, sans jamais tourner en rond. Deux stratégies, au programme de première année, se disputent l'ordre de visite : le parcours en largeur (BFS, breadth-first search) qui explore par cercles concentriques, et le parcours en profondeur (DFS, depth-first search) qui plonge aussi loin que possible avant de revenir. Ils partagent le même squelette — marquer les sommets déjà vus pour ne pas boucler — et ne diffèrent que par la structure qui gère les sommets en attente : une file pour BFS, une pile (ou la récursion) pour DFS. Cette fiche code les deux, les décrypte, les trace pas à pas, et montre pourquoi BFS donne « gratuitement » le plus court chemin.
Prérequis
- Représenter un graphe par listes d'adjacence (dictionnaire sommet → voisins) (fiche « graphes »)
- Files et piles :
deque,append/popleft/pop(fiche « piles et files ») - Récursivité : cas de base, pile d'appels (fiche « récursivité »)
BFS et DFS, c'est le même code à une structure près — mais cette différence change tout. La confondre, ou oublier de marquer les sommets vus, sont les erreurs les plus fréquentes aux concours. Nos mentors alumni X · Centrale · Mines te font tracer les deux parcours à la main jusqu'à ce que le mécanisme devienne limpide.
Trouver un mentor →1. Le squelette commun : marquer, mettre en réserve, traiter
Parcourir un graphe, c'est visiter les sommets de proche en proche à partir d'un sommet de départ. Deux dangers : revisiter un sommet (et boucler indéfiniment sur un cycle), ou en oublier. La parade est la même pour les deux parcours :
- Un ensemble vus des sommets déjà rencontrés, pour ne jamais les traiter deux fois.
- Une réserve des sommets rencontrés mais pas encore traités.
- Une boucle : tant que la réserve n'est pas vide, on en retire un sommet, on le traite, et on ajoute à la réserve ses voisins encore inconnus (qu'on marque aussitôt vus).
Pour tout illustrer, on fixe un graphe non orienté à six sommets, donné par ses listes d'adjacence :
from collections import deque
g = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"],
}from collections import dequeOn importe la file efficace. deque (« double-ended queue ») permet de retirer en tête avec popleft() en — indispensable pour BFS, là où liste.pop(0) coûterait (voir fiche « piles et files »).g = { "A": ["B", "C"], ... }Le graphe fil rouge. Depuis A, on atteint B et C ; depuis B, on atteint D et E ; etc. Ce graphe est choisi pour que largeur et profondeur donnent des ordres visiblement différents.2. Parcours en largeur (BFS) — la file
Le parcours en largeur explore le graphe par cercles concentriques : d'abord le sommet de départ, puis tous ses voisins (distance 1), puis les voisins des voisins (distance 2), etc. La réserve est une file.
def bfs(g, depart):
vus = {depart} # sommets deja rencontres
file = deque([depart]) # reserve : une FILE
ordre = []
while file:
s = file.popleft() # on retire le PLUS ANCIEN (FIFO)
ordre.append(s) # on traite s
for v in g[s]:
if v not in vus:
vus.add(v) # marque des la rencontre
file.append(v) # ajoute en fin de file
return ordre
print(bfs(g, "A")) # ['A', 'B', 'C', 'D', 'E', 'F']vus = {depart}On marque le départ tout de suite. vus est un ensemble : le test v not in vus y coûte .file = deque([depart])La réserve, initialisée au départ. C'est une file : on ajoute en fin (append), on retire en tête (popleft).s = file.popleft()Le cœur du BFS. On retire le sommet le plus ancien : c'est la règle FIFO qui garantit qu'on traite les sommets par distance croissante.if v not in vus:On n'enfile que l'inconnu. Sans ce test, un cycle ferait boucler le parcours à l'infini.vus.add(v)Marquer à la RENCONTRE, pas au traitement. Point subtil : on marque v dès qu'on l'enfile, pas quand on le sortira. Sinon un même sommet, voisin de plusieurs autres, serait enfilé plusieurs fois.Sommet sorti s | Voisins ajoutés | File après | ordre après |
|---|---|---|---|
| — | (départ A) | [A] | [] |
| A | B, C | [B, C] | [A] |
| B | D, E | [C, D, E] | [A, B] |
| C | F | [D, E, F] | [A, B, C] |
| D | — (B vu) | [E, F] | [A, B, C, D] |
| E | — (B, F vus) | [F] | [A, B, C, D, E] |
| F | — (C, E vus) | [] | [A, B, C, D, E, F] ✓ |
Plus court chemin en nombre d'arêtes
En mémorisant, pour chaque sommet, la distance du sommet dont on l'a découvert plus un, le BFS calcule la distance (nombre d'arêtes) du départ à tous les sommets.
def distances(g, depart):
dist = {depart: 0}
file = deque([depart])
while file:
s = file.popleft()
for v in g[s]:
if v not in dist: # "pas encore de distance" = pas encore vu
dist[v] = dist[s] + 1 # un pas de plus que s
file.append(v)
return dist
print(distances(g, "A")) # {'A': 0, 'B': 1, 'C': 1, 'D': 2, 'E': 2, 'F': 2}dist = {depart: 0}Le dictionnaire des distances joue aussi le rôle de vus : « avoir une distance » = « avoir été vu ». Le départ est à distance 0.if v not in dist:Première découverte de v. Comme le BFS découvre les sommets par distance croissante, la première fois qu'on atteint v, c'est par un plus court chemin.dist[v] = dist[s] + 1Un pas de plus. v est voisin de s, donc à une arête de plus que s du départ. On fige cette distance, définitive.
Dans un graphe non pondéré, le parcours en largeur depuis un sommet découvre les sommets par
distance croissante à . La valeur dist[v] calculée ci-dessus est le
nombre minimal d'arêtes d'un chemin de à .
Démonstration
Montrons par récurrence sur que, à tout instant, la file contient uniquement des sommets de
distance ou (pour un certain ), les sommets de distance étant devant. Plus
simplement, montrons la propriété clé : les sommets sont retirés de la file par distance
réelle croissante, et dist[v] vaut cette distance.
Initialisation. Seul est dans la file, avec dist[s] = 0, qui est bien
sa distance à lui-même.
Hérédité. Supposons que tout sommet déjà retiré l'a été avec dist[u]
égal à sa vraie distance , et que la file est ordonnée par distance croissante. Quand on
retire (de distance ) et qu'on découvre un voisin encore sans distance, on
pose dist[v] = d + 1. Ce est à distance au plus (il est voisin de ). Il ne
peut pas être plus proche : s'il existait un chemin de longueur vers , son avant-dernier
sommet, de distance < d, aurait déjà été retiré avant (file ordonnée) et aurait
découvert plus tôt — contradiction avec « sans distance ». Donc , et comme
est enfilé après tous les sommets de distance , la file reste ordonnée.
3. Parcours en profondeur (DFS) — la récursion (ou la pile)
Le parcours en profondeur fait l'inverse : depuis un sommet, il plonge dans le premier voisin inconnu, puis dans le premier voisin inconnu de celui-ci, et ne revient en arrière que lorsqu'il est bloqué. C'est exactement le comportement de la récursion.
def dfs(g, depart):
vus = set()
ordre = []
def explore(s):
vus.add(s)
ordre.append(s)
for v in g[s]:
if v not in vus:
explore(v) # on plonge dans v avant de continuer
explore(depart)
return ordre
print(dfs(g, "A")) # ['A', 'B', 'D', 'E', 'F', 'C']def explore(s):Une fonction récursive interne. Elle traite s, puis s'appelle sur chaque voisin inconnu. vus et ordre, définis à l'extérieur, sont partagés entre tous les appels.vus.add(s)
ordre.append(s)On visite s dès l'entrée dans explore : marqué vu, ajouté à l'ordre.if v not in vus: explore(v)La plongée. Dès qu'un voisin v est inconnu, on l'explore immédiatement et complètement avant de regarder le voisin suivant de s. C'est ce « tout de suite et à fond » qui fait la profondeur.explore(depart)On amorce le parcours au sommet de départ. La pile d'appels de Python joue le rôle de la réserve.| Étape | Appel | Action | ordre après |
|---|---|---|---|
| 1 | explore(A) | visite A ; voisin B inconnu → plonge | [A] |
| 2 | explore(B) | visite B ; voisin D inconnu → plonge | [A, B] |
| 3 | explore(D) | visite D ; seul voisin B vu → remonte | [A, B, D] |
| 4 | explore(E) | de retour dans B : E inconnu → plonge ; visite E | [A, B, D, E] |
| 5 | explore(F) | voisin F de E inconnu → plonge ; visite F | [A, B, D, E, F] |
| 6 | explore(C) | voisin C de F inconnu → plonge ; visite C | [A, B, D, E, F, C] ✓ |
[A, B, C, D, E, F] (par cercles) et le DFS [A, B, D, E, F, C] (par plongées).
Le sommet C, voisin direct de A, est visité en 3e par
BFS mais en dernier par DFS, parce que le DFS est parti explorer toute la branche de
B d'abord.
On peut aussi écrire le DFS sans récursion, en gérant explicitement une pile (utile si le graphe est grand, pour éviter de saturer la pile d'appels de Python).
def dfs_iteratif(g, depart):
vus = set()
pile = [depart] # reserve : une PILE
ordre = []
while pile:
s = pile.pop() # on retire le PLUS RECENT (LIFO)
if s not in vus:
vus.add(s)
ordre.append(s)
for v in g[s]:
if v not in vus:
pile.append(v)
return ordrepile = [depart]Une simple liste comme pile. On empile avec append, on dépile avec pop() (par la fin) — LIFO.s = pile.pop()Le cœur du DFS itératif. On retire le sommet le plus récemment ajouté : c'est ce LIFO qui remplace la file du BFS et produit une exploration en profondeur.if s not in vus:On teste au dépilage. Ici un sommet peut être empilé plusieurs fois (par plusieurs voisins) ; on le marque et le traite seulement à sa première sortie, on ignore les doublons ensuite.4. À quoi ça sert, et lequel choisir
- Accessibilité / connexité : l'ensemble des sommets visités depuis est l'ensemble des sommets atteignables depuis . Si ce parcours atteint tous les sommets, le graphe est connexe.
- Plus court chemin en nombre d'arêtes : uniquement le BFS (propriété 2.1).
- Détection de cycle, tri des dépendances, exploration exhaustive : plutôt le DFS, qui suit les chemins jusqu'au bout.
| Critère | BFS (largeur) | DFS (profondeur) |
|---|---|---|
| Réserve | file (FIFO), deque | pile (LIFO) ou récursion |
| Ordre de visite | par distance croissante | par plongées successives |
| Plus court chemin (nb d'arêtes) | oui | non |
| Complexité | ||
| Usage typique | distances, diffusion | cycles, connexité, backtracking |
Savoir choisir BFS ou DFS selon la question posée, c'est un réflexe qui rapporte gros. Nos mentors alumni X · Centrale · Mines t'entraînent sur les grands classiques (labyrinthe, composantes connexes, détection de cycle) pour que tu saches, d'un coup d'œil, quel parcours dégainer.
Trouver un mentor →5. Exercices d'application
Fais-les sur papier (déroule file et pile !) avant d'ouvrir le corrigé, puis passe au quiz en bas de fiche.
Sur le graphe h = {1: [2, 3], 2: [1, 4], 3: [1, 4], 4: [2, 3]}, donne l'ordre de visite du
parcours en largeur depuis le sommet 1, puis la distance de 1 à chaque sommet.
Voir la correction détaillée
[1]. On sort 1 → on ajoute 2, 3. File [2, 3], ordre [1].[3, 4], ordre [1, 2]. On sort 3 → voisins 1 (vu), 4 (vu). File [4], ordre [1, 2, 3]. On sort 4 → tout vu. Ordre final : [1, 2, 3, 4].1 → 0, 2 → 1, 3 → 1, 4 → 2 (on atteint 4 depuis 2, soit à 2 arêtes de 1).Écris est_connexe(g) qui renvoie True si le graphe (listes d'adjacence, supposé
non vide) est connexe, c'est-à-dire si un parcours depuis un sommet atteint tous les sommets.
Voir la correction détaillée
def est_connexe(g):
depart = next(iter(g)) # un sommet quelconque
atteints = set(bfs(g, depart))
return len(atteints) == len(g)bfs(g, "A") atteint les 6 sommets, donc est_connexe(g) vaut True. Si un sommet était isolé, le parcours ne l'atteindrait pas et la fonction renverrait False.Écris existe_chemin(g, u, v) qui renvoie True s'il existe un chemin de u
à v. Indice : un parcours depuis u atteint exactement les sommets reliés à u.
Voir la correction détaillée
u à v existe si et seulement si v fait partie des sommets atteints par un parcours lancé depuis u.def existe_chemin(g, u, v):
return v in set(bfs(g, u))
# ou, sans construire tout l'ensemble, en s'arretant des qu'on trouve v :
def existe_chemin_v2(g, u, v):
vus = {u}
file = deque([u])
while file:
s = file.popleft()
if s == v:
return True
for w in g[s]:
if w not in vus:
vus.add(w)
file.append(w)
return Falsev : inutile de finir le parcours. Sur le fil rouge, existe_chemin(g, "A", "F") vaut True (A–C–F ou A–B–E–F).Récap final — Ce qu'il faut absolument retenir
À la veille d'une khôlle ou d'un DS, parcours cette checklist : tu dois pouvoir répondre « oui, sans hésiter » à chaque question.
- Sais-tu énoncer le squelette commun (vus + réserve + traiter les voisins inconnus) ?
- Sais-tu que BFS utilise une file (FIFO) et DFS une pile ou la récursion ?
- Sais-tu écrire le BFS avec
dequeet marquer les sommets à la rencontre ? - Sais-tu écrire le DFS récursif, et sa version itérative avec une pile ?
- Sais-tu dérouler à la main un BFS et un DFS et obtenir des ordres différents ?
- Sais-tu que le BFS calcule le plus court chemin en nombre d'arêtes, et pourquoi ?
- Sais-tu utiliser un parcours pour tester l'accessibilité et la connexité ?
- Sais-tu que BFS et DFS coûtent tous deux avec des listes d'adjacence ?
À savoir refaire
- BFS et plus court chemin — les sommets sont retirés par distance croissante, donc
dist[v]est le nombre minimal d'arêtes de à .