Comme on l'a déjà fait avec les arbres, on a souvent besoin de parcourir un graphe : c'est en effet ainsi que l'on va pouvoir par exemple trouver un chemin d'un sommet à un autre, trouver le plus court chemin entre deux sommets, vérifier qu'il existe un chemin entre deux sommets, etc...
Les applications en sont très nombreuses : trouver le chemin de sortie d'un labyrinthe, déterminer le plus court chemin entre deux routeurs dans un réseau ou entre deux villes sur une carte, déterminer le score PageRank © donné par Google aux pages web qu'il recense, etc...
Parcourir un graphe, c'est "l'explorer" sommet par sommet en partant d'un sommet de départ, le but étant de "découvrir" tous les sommets du graphe en fonction des relations qui existent entre eux.
Lors du parcours, les sommets peuvent être alors dans deux "états" :
Comme pour les arbres, deux manières d'explorer un graphe existent :
On a donc besoin de connaître les voisins de chaque sommet : l'implémentation sous forme de listes d'adjacence semble donc bien adaptée.
Vous vous souvenez de l'algorithme de parcours d'un arbre, en profondeur par exemple :
traiter le nœud courant ( pour un parcours préfixe...)
si le nœud courant a un fils gauche:
parcourir le fils gauche
si le nœud courant a un fils droit:
parcourir le fils droit
On "descend" donc naturellement dans l'arbre jusqu'aux feuilles, il n'y a aucun moyen de "revenir sur ses pas" dans un arbre ( Rappel : un arbre est un graphe orienté acyclique ).
A l'inverse, dans un graphe, a fortiori dans un graphe non-orienté, il existe toujours une probabilité pour que l'on revienne sur un sommet déjà parcouru, si il existe l'arc/l'arête qui permette ce "retour en arrière"; le parcours "tourne alors en boucle" à l'infini...
Conséquence : dans le cas d'un graphe, pour éviter de parcourir plusieurs fois le même sommet, on va garder la trace des sommets déja visités :
traiter le sommet courant
si le sommet voisin 1 n'a pas encore été visité:
marquer le voisin 1 comme visité
parcourir le graphe à partir de 1
si le sommet voisin 2 n'a pas encore été visité:
marquer le voisin 2 comme visité
parcourir le graphe à partir de 2
...
etc.( pour chaque sommet voisin du sommet courant)
Pour garder la trace des sommets déjà visités, il y a plusieurs solutions :
True
/False
pour savoir si le sommet a déjà été visité,
soit une autre information ( comme par exemple, le prédécesseur du sommet courant dans le parcours ( cela pourra servir dans certaines applications ).Un sommet a été totalement exploré quand tous ses voisins, puis les voisins de ses voisins, etc... ont été "découverts", puis on passe à l'exploration du sommet suivant.
fonction parcours_profondeur_récursif: G = graphe, u = sommet ( celui de départ au premier appel )
traiter le sommet u
pour chaque sommet v voisins sommet u:
si v n'a pas encore été visité :
marquer v comme visité
parcours_profondeur_récursif(G, v)
Simple, non ? Et si, il y a bien un cas de base qui fait que la récursion se termine : quand il n'y a plus de sommets non marqués, il n'y a plus d'appels récursif !
L'inconvénient est de passer à chaque récursion de nombreux arguments ( voir l'implémentation au chapitre suivant ).
Pour déterminer l'ordre de parcours des sommets, c'est donc ici le dernier visité qui sera le premier exploré : on utilise donc une structure LIFO ( une pile ) pour stocker les sommets à explorer :
fonction parcours_profondeur: G = graphe, s = sommet de départ
pile ← pile vide
marquer s comme visité
enpiler(s, pile)
tant que pile non vide:
u = dépiler(pile)
traiter le sommet u
pour chaque sommet v adjacent au sommet u :
si v n'a pas encore été visité:
marquer v comme visité
empiler(v, pile)
Pile ⇄ | Parcours |
---|---|
( On remarque que l'algorithme semble "revenir en arrière" à chaque intersection : on parle de backtracking, et cela a de nombreuses applications...)
Pour déterminer l'ordre de parcours des sommets, c'est le premier visité qui sera le premier exploré : on utilise donc une structure FIFO ( une file ) pour stocker les sommets à explorer une fois qu'ils ont été découverts.
fonction parcours_largeur: G = graphe, s = départ du parcours
file ← file vide
enfiler(s,file)
marquer s comme visité
tant que file non vide:
u = défiler(file)
traiter le sommet u
pour chaque sommet v adjacent au sommet u :
si v n'a pas encore été visité:
enfiler(v, file)
marquer v comme visité
( oh mais dites donc, c'est exactement le même que le parcours en profondeur itératif, avec une file à la place d'une pile 😮 ...)
← File ← | Parcours |
---|---|
Aucune "branche" du graphe n'est plus importante qu'une autre ( il n'y a pas d'ordre sur les sommets ) : il y a donc plusieurs possibilités équivalentes de parcours selon la "branche" que l'on choisit de parcourir en premier, lorsque ce choix se présente.
A retenir :
Les deux algorithmes de parcours nécessitent de parcourir chacun des sommets et chacune des arètes/des arcs du graphe une et une seule fois : la complexité en temps de ces algorithmes est donc directement proportionnelle à la somme des nombres de sommets ( N ) et d'arcs/d'arètes ( M ) du graphe, soit en O(N + M).
Entraînez-vous à l'aide de l'animation ci-dessous sur quelques exemples de parcours en largeur et en profondeur.
N'oubliez pas qu'à "niveau" de parcours équivalent, il y a ( parfois ) plusieurs possibilités pour un même type de parcours selon le choix de la branche que l'on décide de parcourir d'abord.
BFS ( largeur d'abord ) |
---|
DFS ( profondeur d'abord ) |
---|