Prérequis : graphes (vocabulaire et représentations) , piles et files , récursivité
À l’issue de ce chapitre, vous saurez :
- implémenter et tracer un parcours en profondeur (DFS), en version récursive et itérative ;
- implémenter et tracer un parcours en largeur (BFS) ;
- expliquer pourquoi le DFS utilise une pile et le BFS une file ;
- utiliser un parcours pour détecter un cycle ou rechercher un chemin.
Implémentations
Nous allons, tout d’abord, présenter différentes façons de représenter informatiquement un graphe. Nous les illustrerons par des exemples de graphes orientés, mais ces exemples sont tous transposables aux graphes non orientés en considérant simplement qu’une arête \(A – B\) correspond à deux arcs \(A → B\) et \(B → A\).
Matrice d’adjacence
Il s’agit d’un tableau de dimension 2 dans lequel on indique la présence ou le poids d’un arc (ou d’une arête) à l’intersection de la ligne et de la colonne dont les numéros (ou index) permettent d’identifier les deux sommets ainsi reliés.
Voici un exemple de matrice d’adjacence avec un graphe orienté pondéré.

On commence par numéroter les sommets : A:0, B:1 C:2, D:3.
On établit la matrice en passant en revue tous les arcs :
- Ainsi la présence de l’arc \(A → C\) se traduit par un 17 à l’intersection de la ligne 0 (sommet A) et de la colonne 2 (sommet C).
- Tandis que l’absence d’arc \(B → D\) fait qu’on retrouve 0 à l’intersection de la ligne 1 (sommet B) et de la colonne 3 (sommet D).
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 25 | 17 | 0 |
| 1 | 0 | 0 | 34 | 0 |
| 2 | 0 | 34 | 0 | 20 |
| 3 | 0 | 18 | 20 | 0 |
ou
$$\begin{pmatrix} 0 & 25 & 17 & 0 \\ 0 & 0 & 34 & 0 \\ 0 & 34 & 0 & 20 \\ 0 & 18 & 20 & 0 \end{pmatrix}$$
Remarques. Il faut un tableau de \(n^2\) entiers pour stocker un graphe de \(n\) sommets. Dans le cas d’un graphe non-orienté on obtient une matrice symétrique (suivant la diagonale descendante).
Liste d’adjacence
Lorsqu’il y a peu d’arcs ou d’arêtes, il devient plus intéressant de mémoriser pour chaque sommet du graphe, la liste de ses voisins (par leur numéro ou leur étiquette).
Voici un exemple de liste d’adjacence par numéro avec un graphe orienté non pondéré.

On commence par numéroter les sommets : A:0, B:1, C:2, D:3.
On établit dans l’ordre les listes des sommets liés aux sommets :
Ava versB:1etC:2ce qui donne[1, 2].Bva versA:0etD:3ce qui donne[0, 3].Cva versA:0ce qui donne[0].Dva versA:0etC:2ce qui donne[0, 2].
Ce graphe non pondéré est donc représenté par la liste [[1, 2], [0, 3], [0], [0, 2]].
La numérotation des sommets, à la fois arbitraire et imposée dans les représentations précédentes, peut être évitée en utilisant un dictionnaire. L’étiquette d’un sommet du graphe sert alors de clé et on lui associe la liste des étiquettes des sommets voisins.
Voici un exemple de liste d’adjacence par dictionnaire.

Les clés sont "A", "B", "C", "D" et "E". Pour chaque clé on établit la liste des clés voisines :
"A"avec la liste"B","C","D"."B"avec la liste"C","D"."C"avec la liste"B","D","E"."D"avec la liste"B"."E"avec la liste vide.
Ce qui donne le dictionnaire :
{
"A": ["B", "C", "D"],
"B": ["C", "D"],
"C": ["B", "D", "E"],
"D": ["B"],
"E": []
}
Remarques. Il n’y a pas d’ordre prédéfini dans l’écriture d’une liste de voisins. Ces représentations peuvent être adaptées aux graphes pondérés en mémorisant des listes de couples (sommet voisin, poids).
Par la suite nous allons nous restreindre aux graphes non orientés connexes , pour lesquels il existe toujours un chemin reliant deux sommets quelconques ; contrairement aux autres graphes dont le parcours nécessite d’être repris à partir d’un sommet non encore parcouru.
Parcours
Tout comme pour les arbres, il existe plusieurs façons de parcourir les sommets en suivant les arêtes d’un graphe. On peut choisir d’avancer en passant en revue les voisins directs dans un premier temps, puis les voisins des voisins dans un second temps, et ainsi de suite. Ou au contraire, choisir d’avancer en passant par le premier sommet voisin disponible. Dans tous les cas il faut veiller à ne pas repasser par des sommets déjà parcourus. Aussi, il est nécessaire de marquer les sommets rencontrés au cours de l’exploration du graphe.
Nous allons illustrer ces différents parcours en prenant comme exemple le graphe ci-dessous.

Et nous utiliserons sa représentation sous forme de liste d’adjacence au moyen du dictionnaire suivant :
G = {
"A": ["C", "I"],
"C": ["A", "E"],
"I": ["A", "R", "S"],
"R": ["I"],
"E": ["C", "S"],
"S": ["E", "I"]
}
Nous utiliserons aussi une liste afin de marquer les sommets en les plaçant tout simplement dedans.
Parcours en profondeur (Depth-First Search - DFS)
Une fois établi le sommet de départ, le parcours en profondeur consiste à avancer tant que cela reste possible, ainsi à chaque étape on passe par le premier (ou dernier) voisin qui se présente et qui n’a pas encore été parcouru (marqué). Le parcours en profondeur utilise une pile pour stocker les sommets à parcourir : soit explicitement dans l’algorithme itératif, soit au travers de la pile d’appel dans l’algorithme récursif.
Parcours en profondeur récursif
# G : le graphe
# S : le sommet de départ
# deja_vus : la liste des sommets déjà visités qui est vide au départ et vaut donc []
def parcourir_profondeur_recursif(G, S, deja_vus):
deja_vus.append(S) # marquer S
print(S, end=" ") # traiter S
for voisin in G[S]:
if voisin not in deja_vus:
parcourir_profondeur_recursif(G, voisin, deja_vus)
parcourir_profondeur_recursif(G, "A", [])
A C E S I R
Le parcours en profondeur récursif passe à chaque fois par le premier voisin non encore marqué : \(A\rightarrow C\) (premier voisin de A) \(\rightarrow E \rightarrow S \rightarrow I \rightarrow R\).
Parcours en profondeur itératif
def parcourir_profondeur_iteratif(G, S, deja_vus):
pile_a_traiter = [S] # pile initialisée avec S
while pile_a_traiter != []:
S = pile_a_traiter.pop() # dépiler le sommet
if S not in deja_vus:
deja_vus.append(S) # marquer S
print(S, end=" ") # traiter S
for voisin in G[S]:
if voisin not in deja_vus:
pile_a_traiter.append(voisin)
parcourir_profondeur_iteratif(G, "A", [])
A I S E C R
Le parcours en profondeur itératif passe par le dernier voisin non marqué (car c’est celui qui se retrouve au sommet de la pile) : \(A\rightarrow I\) (dernier voisin empilé de A) \(\rightarrow S \rightarrow E \rightarrow C \rightarrow R\).
Parcours en largeur (Breadth-First Search - BFS)
Le parcours en largeur consiste à examiner les sommets voisins puis les voisins des voisins et ainsi de suite… Il procède ainsi par éloignement progressif depuis le sommet de départ, c’est pourquoi il emploie une file dans l’algorithme itératif présenté ici.
def parcourir_largeur(G, S, deja_vus):
file_a_traiter = [S] # file initialisée avec S
deja_vus.append(S) # marquer S immédiatement
while file_a_traiter != []:
S = file_a_traiter.pop(0) # défiler le sommet
print(S, end=" ") # traiter le sommet
for voisin in G[S]:
if voisin not in deja_vus:
deja_vus.append(voisin) # marquer le voisin
file_a_traiter.append(voisin) # enfiler le voisin
parcourir_largeur(G, "A", [])
A C I E R S
Le parcours en largeur itératif examine à chaque fois tous les voisins directs, non encore marqués, avant de passer aux voisins plus éloignés : A puis C et I (à une arête de A), puis E, R et S (à deux arêtes de A). Un sommet marqué est un sommet qui va être parcouru lors du défilement de la file ou qui a déjà été parcouru auparavant.
Comparaison des deux parcours
Les deux parcours itératifs ont une structure quasi identique. Deux points les distinguent :
| Parcours en profondeur (DFS) | Parcours en largeur (BFS) | |
|---|---|---|
| Structure utilisée | pile (LIFO) | file (FIFO) |
| Moment du marquage | au dépilement | à l’enfilement |
Pourquoi marquer au dépilement en DFS ? Un même sommet peut être empilé plusieurs fois par des voisins différents. On ne sait pas encore par quelle arête le parcours y arrivera. Par exemple, "C" est empilé dès le départ (voisin de "A"), mais le parcours visite d’abord \(I \to S \to E\) avant de dépiler "C".
Pourquoi marquer à l’enfilement en BFS ? Le BFS explore les sommets par distance croissante. Quand on enfile un voisin, on sait que c’est par l’arête la plus courte en nombre d’arêtes. Marquer à l’enfilement évite d’enfiler un sommet en double.
Ordre DFS récursif vs itératif. En récursif, les voisins sont traités dans l’ordre de la liste d’adjacence. En itératif, ils sont empilés dans cet ordre puis dépilés en ordre inverse. C’est pourquoi les résultats diffèrent : A C E S I R (récursif) contre A I S E C R (itératif).
Propriété du BFS. Le parcours en largeur permet de trouver un plus court chemin en nombre d’arêtes entre deux sommets, car il explore par éloignement progressif.
Vérifiez votre compréhension
- Quelle structure de données distingue le parcours en profondeur du parcours en largeur dans les versions itératives ?
Réponse
Le parcours en profondeur utilise une pile (LIFO) ; le parcours en largeur utilise une file (FIFO). - Le parcours en profondeur récursif du graphe donne
A C E S I R. La version itérative donneA I S E C R. Pourquoi les ordres diffèrent-ils ?Réponse
En récursif, les voisins sont traités dans l'ordre de la liste d'adjacence. En itératif, ils sont empilés dans cet ordre puis dépilés en ordre inverse (le dernier empilé est traité en premier). - Quel parcours permet de trouver un plus court chemin en nombre d’arêtes ? Pourquoi ?
Réponse
Le parcours en largeur, car il explore les sommets par distance croissante au sommet de départ (d'abord les voisins directs, puis les voisins à distance 2, etc.).
Détection de cycle
Un cycle est un chemin dont le sommet d’arrivée est le sommet de départ. On identifie sa présence par le fait qu’on rencontre un sommet déjà parcouru (en s’interdisant un retour en arrière, bien évidemment).
Le graphe ci-dessous possède un cycle : G, F, I, J.

G = {
"A": ["C"],
"B": ["C"],
"C": ["A", "B", "D"],
"D": ["C", "E", "G"],
"E": ["D"],
"F": ["G", "I"],
"G": ["D", "F", "H", "J"],
"H": ["G", "K"],
"I": ["F", "J"],
"J": ["G", "I"],
"K": ["H"]
}
Voici une implémentation de l’algorithme basée sur le parcours en profondeur itératif.
def possede_cycle(G, S, deja_vus):
pile_a_traiter = [S]
while pile_a_traiter != []:
S = pile_a_traiter.pop()
if S in deja_vus: # S a-t-il été parcouru ?
return True # dans ce cas on a un cycle
deja_vus.append(S)
for voisin in G[S]:
if voisin not in deja_vus:
pile_a_traiter.append(voisin)
return False
print(possede_cycle(G, "A", []))
# On supprime l'arête entre I et J dans le graphe G
G["I"].remove("J") # J n'est plus un voisin de I
G["J"].remove("I") # I n'est plus un voisin de J
# Il n'y a alors plus de cycle dans le graphe G
print(possede_cycle(G, "A", []))
True
False
Comme un sommet marqué est un sommet déjà parcouru, dès qu’on rencontre un sommet marqué cela signifie qu’on a réalisé un cycle.
La présence de doublons dans la pile, se traduit par la présence d’un cycle : la première occurrence va être marquée, c’est lors du dépilement de la seconde (qui aura emprunté une arête différente) qu’on constatera la présence du marquage et donc du cycle.
Recherche d’un chemin
La recherche d’un chemin, entre un sommet de départ et un sommet d’arrivée, s’effectue simplement en contrôlant à chaque étape si le sommet à traiter correspond à celui d’arrivée, tout en veillant à conserver la trace des arêtes parcourues.
def recherche_chemin(G, S, D):
M = {} # les prédécesseurs
R = [] # le chemin inversé
if parcourir(G, S, D, M):
R.append(D)
while D != S:
D = M[D]
R.append(D)
R.reverse()
return R
def parcourir(G, S, D, deja_vus):
file_a_traiter = [S] # file de départ
deja_vus[S] = None
while file_a_traiter != []:
S = file_a_traiter.pop(0) # défiler
if S == D: # destination ?
return True # chemin trouvé
for voisin in G[S]:
if voisin not in deja_vus:
deja_vus[voisin] = S # prédécesseur
file_a_traiter.append(voisin)
return False # aucun chemin
print(recherche_chemin(G, "A", "K"))
['A', 'C', 'D', 'G', 'H', 'K']
Le parcours en largeur utilise ici un dictionnaire pour marquer les sommets parcourus et mémoriser l’origine de l’arête empruntée. Ce dictionnaire sert ensuite à reconstituer le chemin en remontant les arêtes suivies du sommet d’arrivée jusqu’au sommet de départ.
- Un graphe se représente en Python par matrice d’adjacence (tableau 2D) ou par dictionnaire d’adjacence (dictionnaire de listes de voisins).
- Le parcours en profondeur (DFS) avance le plus loin possible avant de revenir en arrière ; il utilise une pile (explicite ou via la récursivité).
- Le parcours en largeur (BFS) explore les sommets par distance croissante ; il utilise une file et garantit de trouver un plus court chemin en nombre d’arêtes.
- Pour détecter un cycle, on cherche un sommet déjà marqué lors du parcours en profondeur.
- Pour rechercher un chemin, on mémorise le prédécesseur de chaque sommet visité, puis on reconstruit le chemin en remontant du sommet d’arrivée au départ.