Parcours de graphes

Temps de lecture25 min

En bref

Résumé de l’article

Dans cet article, vous apprendrez à explorer un graphe en utilisant des algorithmes appelés parcours.

Points clés à retenir

  • Un parcours de graphe explore un graphe un sommet après l’autre pour construire un arbre couvrant du graphe.

  • Le parcours en profondeur (DFS) explore aussi loin que possible et revient en arrière s’il est bloqué.

  • Le parcours en largeur (BFS) explore les sommets par distance croissante depuis le point de départ.

  • Le BFS trouve un chemin le plus court entre un sommet initial et tous les sommets atteignables dans un graphe non pondéré.

  • Terminaison, correction et complexité sont des notions importantes pour évaluer un algorithme.

  • Le BFS et le DFS ont une complexité de $O(n + m)$, soit $O(n)$ dans un labyrinthe PyRat.

Contenu de l’article

Vidéo

Veuillez consulter la vidéo ci-dessous (en anglais, affichez les sous-titres si besoin). Nous fournissons également une traduction du contenu de la vidéo juste après, si vous préférez.

Information
Cliquez ici pour afficher la traduction de la vidéo.

Bonjour ! Et bienvenue dans cette leçon sur le parcours de graphes.

Trouver la sortie d’un labyrinthe nécessite de parcourir une partie du labyrinthe. Le parcours de graphes est le domaine de la théorie des graphes qui décrit comment explorer un graphe, un sommet à la fois, en partant d’un sommet initial.

Dans de nombreux cas, nous nous intéressons à l’atteignabilité. L’atteignabilité consiste à trouver comment atteindre un sommet cible à partir d’un sommet initial, en suivant un chemin dans le graphe.

Pour obtenir des chemins depuis le sommet initial vers tous les sommets atteignables, je vais vous montrer comment construire un arbre, obtenu à partir du graphe initial, qui contiendra juste assez d’informations pour nous guider vers n’importe quel sommet atteignable depuis le sommet initial.

Voisins et arbres couvrants

Rappelons qu’un graphe est un couple $G$ composé d’un ensemble de sommets $V$ et d’un ensemble d’arêtes $E$. Nous devons maintenant définir quelques notions supplémentaires.

Nous définissons les voisins de $u$ dans $G$ comme l’ensemble des sommets $v$ tels que $\{u,v\}$ appartient à $E$.

Comme nous l’avons vu dans la leçon précédente, un arbre est un graphe connexe sans cycles.

Par exemple, à gauche se trouve un dessin d’un arbre. Cependant, le graphe à droite n’est pas un arbre, car il contient un cycle.

Un arbre couvrant d’un graphe $G$ est un arbre obtenu à partir de $G$ en supprimant certaines arêtes. Pour illustrer, considérons le graphe suivant. Nous pouvons obtenir un arbre couvrant en ne gardant que les arêtes suivantes.

Notez que pour un graphe donné, d’autres arbres couvrants peuvent être définis, comme celui-ci.

Trouver des arbres couvrants peut avoir de nombreuses applications. En particulier, trouver un arbre couvrant est très utile lorsque vous voulez éviter les boucles, comme dans les algorithmes de routage en réseau.

Un autre exemple de son utilité est lorsqu’il s’agit de vérifier si un graphe est connexe. Imaginez que vous voulez isoler une portion d’autoroute pour des travaux d’entretien. Tout d’abord, vous devez vous assurer que le graphe des rues reste connexe pendant les travaux.

Dans cette leçon, nous allons voir comment trouver un chemin entre deux sommets d’un graphe connexe, afin de pouvoir naviguer entre deux points quelconques. Par définition, un arbre couvrant inclura exactement un chemin entre ces deux points. Nous comparerons deux approches : le parcours en profondeur et le parcours en largeur.

Parcours en profondeur

L’idée principale d’un parcours en profondeur est d’explorer le graphe en sautant d’un sommet donné à l’un de ses voisins non explorés. Parfois, tous les voisins du sommet où nous nous trouvons ont déjà été explorés, auquel cas nous revenons en arrière, suivant le chemin dans la direction opposée jusqu’à trouver un sommet non exploré où sauter.

Prenons l’exemple suivant. Un parcours en profondeur, ou DFS, peut être effectué de la manière suivante. Commençons par $v_1$.

Tout d’abord, nous allons vers le haut. Notez que nous aurions pu choisir d’aller vers la droite.

Nous continuons à explorer le graphe, nous allons à nouveau vers le haut.

Ensuite, nous avons un seul voisin non exploré, qui est à droite. Nous allons donc vers la droite.

Maintenant, nous allons vers le bas. Alternativement, nous aurions pu continuer vers la droite.

À partir de là, il y a quatre voisins, dont deux non explorés. Nous choisissons d’aller vers la droite.

Ensuite, nous allons vers le haut.

À ce stade, nous ne pouvons pas aller plus loin, car tous les voisins actuels ont déjà été explorés. Nous revenons au dernier sommet pour lequel il y avait un voisin non exploré, qui est le précédent.

Et nous allons vers le bas.

Depuis ce sommet, il ne reste qu’un seul voisin non exploré, celui de gauche. C’est le dernier sommet non exploré du graphe.

Le graphe en rouge est un arbre couvrant obtenu à partir du graphe en utilisant un DFS.

Le DFS est une stratégie simple mais efficace pour s’assurer que tous les sommets atteignables sont explorés.

C’est un moyen facile de trouver la sortie d’un labyrinthe ou de s’assurer que les rues restent connectées pendant des travaux de construction.

Cependant, elle ne garantit pas nécessairement les chemins les plus courts entre les paires de sommets du graphe. Dans cet exemple, il faut 7 sauts pour aller de $v_1$ à $v_2$, malgré le fait qu’ils pourraient être reliés en seulement 1 saut.

Parcours en largeur

L’autre approche que je vais décrire ici est le parcours en largeur. Lorsqu’on utilise un parcours en largeur, ou BFS, on explore le graphe en augmentant progressivement le nombre de sauts depuis le sommet initial.

Prenons le même exemple, où nous commençons à $v_1$.

Nous commençons par explorer tous les sommets à un saut de $v_1$ : nous connectons ainsi $v_2$ et $v_4$ à $v_1$.

Ensuite, nous explorons tous les sommets à deux sauts de $v_1$. Un sommet à deux sauts de $v_1$ est à un saut d’un sommet qui est lui-même à un saut de $v_1$. Nous regardons donc les voisins de $v_2$ et $v_4$ et les connectons à $v_2$ ou $v_4$. Nous obtenons $v_3$, $v_5$ et $v_7$.

Ensuite, nous explorons les sommets à trois sauts du sommet $v_1$. Nous regardons donc les sommets à deux sauts, qui sont $v_3$, $v_5$ et $v_7$, et leurs voisins non explorés. Nous obtenons $v_8$ et $v_6$.

Enfin, le sommet orange est à un saut de $v_8$, qui est à trois sauts du sommet $v_1$. Nous les connectons.

Le graphe résultant en rouge est un arbre couvrant obtenu à partir du BFS, composé exclusivement des plus courts chemins depuis le sommet $v_1$ vers les autres sommets du graphe.

Un BFS est une stratégie prudente dans laquelle nous essayons de maintenir les distances au sommet initial aussi petites que possible pendant l’exploration.

Cela est particulièrement utile lorsque le sommet initial contient des ressources spécifiques dont nous pourrions avoir besoin à tout moment. Cependant, cela nécessite plus d’organisation que son homologue DFS plus simple.

Comparaison entre BFS et DFS

Dans l’exemple précédent, les arbres couvrants obtenus par un BFS et un DFS sont très différents.

Voici un autre exemple utilisant le graphe que nous avons considéré plus tôt dans cette leçon.

Ceci est un arbre couvrant obtenu à partir d’un DFS en partant de $v_1$.

Tandis que ceci est un arbre couvrant obtenu à partir d’un BFS en partant de $v_1$.

Comme un BFS est effectué en augmentant progressivement les sauts depuis $v_1$, un arbre couvrant obtenu à partir d’un BFS fournira toujours le plus court chemin depuis $v_1$ vers tout autre sommet du graphe, à condition que le graphe soit non pondéré. Cela peut être clairement observé dans cet exemple.

Conclusion

Dans la prochaine leçon, nous verrons comment la différence entre un BFS et un DFS est directement liée au type de structure mémoire qui peut être utilisée pour implémenter ces deux algorithmes.

Pour aller plus loin

Propriétés du BFS

Terminaison

L’algorithme BFS sur un graphe fini termine, c’est-à-dire qu’il s’arrêtera à un moment donné (pas de risque de boucle infinie).

Cette propriété n’est pas aussi évidente qu’elle en a l’air. Dès que le graphe contient un cycle, un parcours qui ne retiendrait pas les sommets déjà explorés pourrait tourner en rond indéfiniment. Par exemple, dans un graphe formé de trois sommets $a$, $b$ et $c$ reliés deux à deux, un tel parcours partant de $a$ irait en $b$, puis en $c$, puis à nouveau en $a$, et ainsi de suite. C’est l’ensemble des sommets explorés qui permet d’éviter cette situation.

Preuve
Cliquez ici pour afficher la preuve.

Un sommet n’est exploré qu’une seule fois : dès son premier retrait de la FIFO, il est marqué comme exploré, et tous ses retraits suivants sont ignorés.

Par ailleurs, des éléments ne sont ajoutés à la FIFO que lors de l’exploration d’un sommet $v$, et il y en a alors au plus $\deg(v)$ (un par voisin). Au total, en comptant le sommet de départ, le nombre d’ajouts est donc au plus :

$$1 + \sum\limits_{v \in V} \deg(v) = 1 + 2m$$

Cette égalité est le lemme des poignées de main (voir l’article sur les graphes).

Comme chaque itération retire un élément de la FIFO, il y a au plus $1 + 2m$ itérations. L’algorithme termine donc.

Correction

L’algorithme BFS retourne un chemin le plus court entre un sommet $u$ et tous les autres sommets atteignables depuis $u$ via les arêtes du graphe.

Ici, « le plus court » signifie « avec le moins d’arêtes possible ». Ce résultat n’est donc valable que pour des graphes non pondérés. Dans un graphe pondéré, un chemin avec plus d’arêtes peut avoir une longueur totale plus petite. C’est ce qui se passera dans la prochaine séance, lorsque le labyrinthe contiendra de la boue : il faudra alors utiliser un autre algorithme.

Preuve
Cliquez ici pour afficher la preuve.

Pour chaque sommet exploré $v$, notons $h(v)$ le nombre d’arêtes du chemin obtenu en remontant la table de routage de $v$ jusqu’à $u$. On a $h(u) = 0$, et $h(v) = h(p) + 1$ si $p$ est le parent de $v$. C’est exactement la valeur distance calculée dans le code du BFS de l’activité pratique.

Nous allons montrer que :

  • Tous les sommets atteignables sont explorés.
  • Les sommets sont explorés par valeurs de $h$ croissantes.
  • Pour tout sommet exploré $v$, $h(v) = d(u, v)$, c’est-à-dire que le chemin obtenu est de longueur minimale.

Tous les sommets atteignables sont explorés

Lorsqu’un sommet est exploré, tous ses voisins non explorés sont ajoutés à la FIFO. Comme l’algorithme ne s’arrête que lorsque la FIFO est vide, chacun de ces voisins finira par en être retiré, et donc par être exploré (s’il ne l’a pas été entre-temps).

Soit $v$ un sommet atteignable, et $(u = v_0, v_1, \dots, v_k = v)$ la séquence de sommets d’un chemin de $u$ à $v$. Le sommet $v_0 = u$ est exploré lors de la première itération. Si $v_i$ est exploré, alors son voisin $v_{i+1}$ finit aussi par être exploré. Par induction, $v$ est exploré.

Les sommets sont explorés par valeurs de $h$ croissantes

Associons une valeur à chaque élément de la FIFO : 0 pour le sommet de départ, et $h(p) + 1$ pour un élément ajouté lors de l’exploration d’un sommet $p$. Montrons qu’à tout moment, les valeurs des éléments de la FIFO, lues du premier au dernier, sont croissantes (au sens large), et que la dernière dépasse la première d’au plus 1.

C’est vrai au départ, puisque la FIFO ne contient que $u$. Supposons que ce soit vrai avant une itération, et notons $t$ la valeur du premier élément. Toutes les valeurs de la FIFO sont donc égales à $t$ ou $t+1$. L’itération retire ce premier élément, puis, si son sommet n’était pas encore exploré, ajoute à la fin de la FIFO des éléments de valeur $t+1$. Les valeurs restent croissantes, et la dernière dépasse toujours la première d’au plus 1.

Puisqu’on retire toujours le premier élément de la FIFO, c’est-à-dire celui de plus petite valeur, et que les éléments ajoutés ont une valeur au moins égale à la sienne, les valeurs des éléments retirés sont croissantes au fil des itérations. Or, lorsqu’un sommet $v$ est exploré, $h(v)$ est justement la valeur de l’élément retiré. Les sommets sont donc explorés par valeurs de $h$ croissantes.

Les chemins obtenus sont de longueur minimale

Le chemin obtenu pour un sommet $v$ est un chemin de $u$ à $v$ avec $h(v)$ arêtes, donc $h(v) \geq d(u, v)$. Montrons l’inégalité inverse par l’absurde.

Supposons qu’il existe des sommets explorés tels que $h(v) > d(u, v)$, et choisissons-en un, $v$, pour lequel $d(u, v)$ est minimal. On a $v \neq u$, car $h(u) = 0 = d(u, u)$. Considérons un plus court chemin de $u$ à $v$, et notons $w$ le sommet qui précède $v$ sur ce chemin. On a $d(u, w) = d(u, v) - 1$. Le sommet $w$ est atteignable, donc exploré, et par minimalité de $v$, on a $h(w) = d(u, w) = d(u, v) - 1$.

Deux cas sont possibles :

  • Si $v$ a été exploré avant $w$, alors $h(v) \leq h(w) = d(u, v) - 1$, puisque les sommets sont explorés par valeurs de $h$ croissantes. C’est impossible, car $h(v) \geq d(u, v)$.
  • Sinon, $v$ n’était pas encore exploré lors de l’exploration de $w$. Un élément concernant $v$, de valeur $h(w) + 1 = d(u, v)$, a donc été ajouté à la FIFO. Cet élément finit par être retiré, et comme les valeurs des éléments retirés sont croissantes, $v$ est exploré au plus tard à ce moment-là, avec une valeur au plus $d(u, v)$. Donc $h(v) \leq d(u, v)$, ce qui contredit l’hypothèse.

Dans les deux cas, on aboutit à une contradiction. Le chemin obtenu pour chaque sommet atteignable est donc un plus court chemin.

Complexité

L’algorithme BFS a une complexité (c’est-à-dire un nombre d’opérations élémentaires) de $O(n + m)$, où $m$ est le nombre d’arêtes du graphe et $n$ est le nombre de sommets.

Dans un labyrinthe PyRat, chaque case a au plus 4 voisins, et on a vu que $m < 2n$ (voir l’article sur les graphes). La complexité est donc $O(n)$ : le temps nécessaire pour parcourir un labyrinthe est proportionnel à son nombre de cases.

Preuve
Cliquez ici pour afficher la preuve.

Comptons les opérations effectuées par l’algorithme :

  • D’après la preuve de terminaison, il y a au plus $1 + 2m$ ajouts dans la FIFO, et donc au plus $1 + 2m$ retraits.
  • Chacun des $n$ sommets est exploré au plus une fois. Son exploration demande de mettre à jour la table de routage et l’ensemble des sommets explorés, puis de parcourir ses $\deg(v)$ voisins.

Si chacune de ces opérations élémentaires se fait en temps constant, le nombre total d’opérations est de l’ordre de :

$$2(1 + 2m) + n + \sum\limits_{v \in V} \deg(v) = 2 + n + 6m$$

La complexité est donc $O(n + m)$.

Important

Cette preuve repose sur deux hypothèses, qui dépendent de la manière dont le code est écrit :

  • Obtenir les voisins d’un sommet $v$ prend un temps proportionnel à $\deg(v)$. C’est le cas si chaque sommet stocke ses voisins, par exemple dans un dictionnaire, comme le fait la classe Graph de PyRat. Avec une matrice d’adjacence, il faut parcourir une ligne entière de la matrice, soit $n$ cases, pour chaque sommet exploré. La complexité devient alors $O(n^2)$ (voir l’article sur la représentation des graphes).

  • Ajouter et retirer un élément de la FIFO prend un temps constant. En Python, ce n’est pas le cas avec une liste : pop(0) décale tous les éléments restants d’une case, et prend donc un temps proportionnel à la taille de la liste. Pour obtenir une vraie FIFO, on utilise plutôt collections.deque, dont la méthode popleft() s’exécute en temps constant.

Dans l’activité pratique, nous utilisons une liste et pop(0) par simplicité. Pour des labyrinthes de la taille de ceux de PyRat, la différence est négligeable, mais voici comment utiliser une deque si vous le souhaitez :

​
from collections import deque

# Create a FIFO containing the source vertex
vertices_to_visit = deque()
vertices_to_visit.append((0, source, None))

# Remove the first element of the FIFO in constant time
distance, vertex, parent = vertices_to_visit.popleft()

Propriétés du DFS

Terminaison

L’algorithme DFS sur un graphe fini termine.

Preuve
Cliquez ici pour afficher la preuve.

La preuve est identique à celle du BFS. En effet, elle n’utilise à aucun moment l’ordre dans lequel les éléments sont retirés de la structure de données. Elle s’applique donc aussi bien à une LIFO qu’à une FIFO.

Correction

L’algorithme DFS est tel qu’il visitera tous les sommets atteignables depuis $u$. De plus, la table de routage obtenue donne, pour chacun de ces sommets, un chemin depuis $u$.

Preuve
Cliquez ici pour afficher la preuve.

Les sommets explorés sont atteignables

Montrons par induction, dans l’ordre d’exploration, que la table de routage donne un chemin de $u$ vers chaque sommet exploré. C’est vrai pour $u$, qui est le premier sommet exploré. Un autre sommet $v$ est exploré parce qu’il a été ajouté à la LIFO par un voisin $p$, exploré avant lui, qui devient son parent. Par hypothèse d’induction, la table de routage donne un chemin de $u$ à $p$, et il suffit d’y ajouter l’arête $\{p,v\}$ pour obtenir un chemin de $u$ à $v$.

Notez qu’en remontant la table de routage, on passe toujours d’un sommet à un sommet exploré avant lui. On ne rencontre donc jamais deux fois le même sommet, et on obtient bien un chemin, sans arête répétée.

Tous les sommets atteignables sont explorés

Là encore, l’argument utilisé pour le BFS ne dépend pas de l’ordre des retraits. Si un sommet est exploré, tous ses voisins finissent par l’être, car ceux qui ne l’étaient pas encore ont été ajoutés à la LIFO, et l’algorithme ne s’arrête que lorsqu’elle est vide. En suivant un chemin de $u$ à un sommet atteignable $v$, on conclut par induction que $v$ est exploré.

En revanche, contrairement au BFS, le DFS ne garantit pas que les chemins obtenus soient les plus courts. Reprenons un graphe de trois sommets reliés deux à deux, notés cette fois $u$, $a$ et $b$, et lançons un DFS depuis $u$ :

  • On explore $u$, et on ajoute $a$ puis $b$ à la LIFO, avec $u$ pour parent.
  • On retire $b$, le dernier élément ajouté. On l’explore, et on ajoute $a$ à la LIFO, avec $b$ pour parent.
  • On retire $a$, avec $b$ pour parent. On l’explore, et la table de routage indique donc que $b$ est le parent de $a$.

Le chemin obtenu de $u$ à $a$ passe par $b$ et contient 2 arêtes, alors que l’arête $\{u,a\}$ permet d’y aller directement. Selon l’ordre dans lequel les voisins sont ajoutés, le DFS peut aussi trouver le chemin direct, mais rien ne le garantit. La vidéo en montre un exemple plus frappant, avec un chemin de 7 arêtes entre deux sommets voisins.

Complexité

L’algorithme DFS a une complexité de $O(n + m)$, où $m$ est le nombre d’arêtes du graphe et $n$ est le nombre de sommets.

Preuve
Cliquez ici pour afficher la preuve.

Le décompte des opérations est le même que pour le BFS, puisqu’il ne dépend pas de l’ordre des retraits. Il faut simplement que l’ajout et le retrait d’un élément de la LIFO se fassent en temps constant.

C’est le cas en Python avec une liste : append(...) ajoute un élément à la fin de la liste, et pop() retire le dernier élément, sans décaler les autres. Une liste Python est donc directement une bonne LIFO.

Information

Dans l’activité pratique, une version récursive du DFS vous est également proposée. Chaque appel récursif occupe de la place dans la pile d’appels, et le nombre d’appels imbriqués peut atteindre le nombre de sommets atteignables, par exemple dans un labyrinthe en forme de long couloir. Or, Python limite par défaut cette profondeur à 1000 appels (voir sys.getrecursionlimit()), et lève une RecursionError au-delà. Pour les labyrinthes de taille par défaut (15 × 13, soit au plus 195 cases), ce n’est pas un problème, mais ça peut le devenir pour de très grands labyrinthes. La version avec une LIFO explicite n’a pas cette limite.

Récapitulatif

BFS DFS
Structure de données FIFO (file) LIFO (pile)
Termine sur un graphe fini Oui Oui
Explore tous les sommets atteignables Oui Oui
Donne des plus courts chemins (graphe non pondéré) Oui Pas nécessairement
Complexité $O(n + m)$ $O(n + m)$
Structure conseillée en Python collections.deque list

Pour aller encore plus loin

  • Une approche mixte entre DFS et BFS qui combine les avantages des deux.