Aller au contenu

Note

Une grande partie est issue verbatim de mon ancien poly. À affiner.

Graphes orientés

Définition

Définition

Un graphe orienté est un couple \(G = (S, A)\) où \(S\) est un ensemble fini et \(A\) est une relation irréflexive sur \(S\), c'est-à-dire \(A \subset \{ (x,y) ~|~ x,y \in S, x \neq y\}\).

Les éléments de \(S\) sont appelés les sommets du graphe \(G\) et les éléments de \(A\) les arêtes. Si \((x,y) \in A\), on dit que \(x\) est la source de l'arête et \(y\) est son but. Quand le contexte n'est pas ambigu, on notera \(x \rightarrow y\).

Exemple

  • \(G = ( \{ a, b, c \}, \{ (a, b), (b, c), (c, b) \} )\)
  • \(D_n = ( \range{1}{n}, \{ (a,b) ~|~ a \text{ divise } b, a \neq b \} )\)

Remarque

Ici, on a exclu les graphes infinis ainsi que les graphes avec des boucles ou des arêtes parallèles. On est dans le contexte des graphes finis simples.

On représente graphiquement un graphe sous la forme d'un diagramme sagittaire où les sommets sont des points et où une arête \((a,b)\) est une flèche allant du point \(a\) au point \(b\).

Exemple

  • \(G\) est représenté par

    Diagramme sagittaire du graphe G : arêtes a rightarrow b, b rightarrow c et c rightarrow b. Diagramme sagittaire du graphe G : arêtes a rightarrow b, b rightarrow c et c rightarrow b.
    Diagramme sagittaire du graphe \(G\) : arêtes \(a \rightarrow b\), \(b \rightarrow c\) et \(c \rightarrow b\).

  • \(D_{10}\) est représenté par

    Graphe D_10 : une flèche x rightarrow y quand x divise strictement y. Graphe D_10 : une flèche x rightarrow y quand x divise strictement y.
    Graphe \(D_{10}\) : une flèche \(x \rightarrow y\) quand \(x\) divise strictement \(y\).

Voisins et degrés

Définition

Soit \(G = (S, A)\) un graphe orienté et \(x \in S\).

On appelle voisins sortants, ou successeurs, de \(x\) les éléments de

\[ v_+(x) = \enscomp{y \in S}{(x,y) \in A} \]

et on appelle degré sortant de \(x\) le cardinal de cet ensemble \(d_+(x) = |v_+(x)|\).

De même, on parle de voisins entrants, ou prédécesseurs pour les éléments de

\[ v_-(x) = \enscomp{z \in S}{(z,x) \in A} \]

et on parle de degré entrant pour \(d_-(x) = |v_-(x)|\).

Théorème

Soit \(G = (S, A)\) un graphe, on a \(\sum_{x \in S} d_+(x) = \sum_{x \in S} d_-(x) = |A|\)

Preuve

On peut partitionner \(A\) en regroupant les arêtes de même source, on a ainsi :

\[ A = \bigcup_{x \in S} \enscomp{(x,y)}{y \in S, (x,y) \in A} = \bigcup_{x \in S} \enscomp{(x,y)}{y \in v_+(x)} \]

Cette réunion étant disjointe, en prenant le cardinal de cette égalité, on obtient directement \(|A| = \sum_{x \in S} d_+(x)\). L'autre égalité est symétrique en considérant les arêtes de même but.

Chemin

Définition

Soit \(G = (S,A)\) un graphe orienté et \(x, y \in S\).

Une suite finie \(\varphi = (s_0, s_1, \dots, s_p)\) où \(p \in \N, s_0 = x, s_p = y\) et

\[ \forall i \in \range{1}{p}, (s_{i-1},s_i) \in A \]

est appelée un chemin, de longueur \(p\), de \(x\) vers \(y\). On a donc

\[ s_0 \rightarrow s_1 \rightarrow \dots \rightarrow s_p \]

On notera \(\varphi : x \leadsto y\) pour signifier que \(\varphi\) est un tel chemin et \(x \leadsto y\) pour signifier qu'il existe un chemin de \(x\) vers \(y\). On dit alors que \(y\) est accessible depuis \(x\).

Théorème

\(\leadsto\) est la plus petite relation sur \(S\) contenant \(\rightarrow\) et qui soit réflexive et transitive.

Preuve

Montrons d'abord que \(\leadsto\) convient. Le chemin \((x)\) de longueur \(0\) donne \(x \leadsto x\), donc \(\leadsto\) est réflexive. Si \(x \rightarrow y\), le chemin \((x, y)\) donne \(x \leadsto y\), donc \(\rightarrow \subset \leadsto\). Enfin, si \(\varphi : x \leadsto y\) et \(\psi : y \leadsto z\), la concaténation de \(\varphi\) et \(\psi\) (en ne répétant pas \(y\)) est un chemin de \(x\) à \(z\), donc \(\leadsto\) est transitive.

Réciproquement, on considère \(\rightarrow \subset R \subset S^2\) telle que \(R\) soit réflexive et transitive.

Soient \(x, y \in S\) et \(\varphi = (s_0, \dots, s_p) : x \leadsto y\).

Si \(p = 0\), \(x = y\) et \(x R y\) par réflexivité. Sinon, comme \(s_{i-1} \rightarrow s_i\) on a \(s_{i-1} R s_i\) pour tout \(i \in \range{1}{p}\) et, par transitivité, \(x = s_0 R s_p = y\). Donc \(x \leadsto y \Rightarrow x R y\).

Ainsi \(\leadsto \subset R\).

On dit que \(\leadsto\) est la fermeture réflexive et transitive de \(\rightarrow\).

Remarque

Vocabulaire additionnel :

  • Si tous les sommets sont distincts, sauf éventuellement le premier et le dernier, on dit que le chemin est élémentaire.
  • Si toutes les arêtes sont distinctes, on dit que le chemin est simple.
  • Si \(x = y\), on dit que le chemin est fermé.
  • Un chemin simple dont tous les sommets sont distincts est appelé une chaîne.
  • Un chemin élémentaire fermé simple de longueur au moins 1 est appelé un cycle. Comme le chemin vide issu de \(x\) est fermé, il est nécessaire de considérer des chemins non vides.
  • Un graphe contenant au moins un cycle est dit cyclique. Dans le cas contraire, on dit qu'il est acyclique.

Remarque

Si \(\varphi : x \leadsto y\) et \(\psi : y \leadsto z\) on note \(\varphi \psi :x \leadsto z\) la concaténée des deux chemins.

Définition

On note \(\leftrightarrow\) la plus grande relation d'équivalence incluse dans \(\leadsto\).

Plus précisément, on a

\[ x \leftrightarrow y \iff (x \leadsto y \wedge y \leadsto x) \]

Les classes d'équivalence pour \(\leftrightarrow\) sont appelées les composantes fortement connexes du graphe. S'il n'y a qu'une classe, on dit que le graphe est fortement connexe.

Sous-graphe

Définition

Soit \(G = (S,A)\) et \(X \subset S\). On appelle sous-graphe induit par \(X\) le graphe \((X, A_X)\) où

\[ A_X = \enscomp{ (a,b) \in A}{a \in X\wedge b \in X} \]

Un graphe \(G'\) est un sous-graphe de \(G\) quand c'est le sous-graphe de \(G\) induit par les sommets de \(G'\) (ils sont alors nécessairement des sommets de \(G\)).

Remarque

Les composantes fortement connexes sont les sous-graphes fortement connexes maximaux pour l'inclusion.

Implémentation

Énumération de sommets

Si on considère un graphe \(G = (S,A)\), il est assez naturel de représenter ses sommets dans un tableau. Pour cela, on fixe naturellement un ordre sur ces sommets et on va associer à chaque sommet son indice dans le tableau.

Par exemple, si \(S = \{ a, b, c, d\}\) on va pouvoir considérer [ a, b, c, d ] et ainsi associer à a son indice 0 dans le tableau. L'ordre est arbitraire, on aurait pu considérer [ b, c, a, d ] et l'indice de a aurait alors été 2.

Ce qui compte, c'est de pouvoir travailler directement sur les indices et pas sur les éléments. Une manière de s'en convaincre est d'imaginer le graphe d'un réseau social où un sommet correspond au profil d'une personne, et contient donc beaucoup (trop) d'informations. Il est bien plus raisonnable de lui associer un identifiant unique et d'utiliser cet identifiant ensuite.

Quand on va implémenter des graphes, on peut donc supposer que les sommets sont les entiers de 0 à n-1 où \(|S| = n\). Il sera toujours possible de retrouver la correspondance avec les sommets eux-mêmes.

Remarque

Si on est bloqué par le coût de l'opération permettant d'obtenir l'indice d'un sommet, qui est en \(O(|S|)\) par une recherche linéaire, on peut très bien définir un dictionnaire associant à chaque sommet son indice en \(O(1)\).

Il se trouve qu'on ne va pas forcément s'intéresser à ces questions mais plus aux raisonnements sur la structure du graphe.

Matrice d'adjacence

Définition

Soit \(G = (S,A)\) un graphe et \(S = \{ s_1, \dots, s_n \}\) une énumération sans répétition des sommets de \(G\). La matrice \(M_G = \left( m_{ij} \right)_{1 \le i, j \le n} \in \mathcal{M}_n(\R)\) définie par

\[ \forall i, j \in \range{1}{n}, m_{ij} = \begin{cases} 1 &\text{ si } s_i \rightarrow s_j \\ 0 & \text{ sinon } \end{cases} \]

est appelée une matrice d'adjacence de \(G\).

Remarque

Il s'agit bien d'une matrice d'adjacence et pas de la matrice car elle dépend de l'énumération des sommets.

Exemple

Si on considère le graphe

Graphe orienté à quatre sommets a, b, c, d. Graphe orienté à quatre sommets a, b, c, d.
Graphe orienté à quatre sommets \(a, b, c, d\).

On aura pour l'énumération \(a,b,c,d\) la matrice :

\[ \begin{pmatrix} 0 & 1 & 1 & 1 \\ 1 & 0 & 0 & 1 \\ 0 & 0 & 0 & 1 \\ 1 & 1 & 0 & 0 \end{pmatrix} \]

En fait, la ième ligne correspond au ième sommet de l'énumération. Ici, la dernière ligne \(1 1 0 0\) correspond au sommet d et donne dans l'ordre \(a,b,c,d\) la présence ou non d'une arête \(d \rightarrow *\).

Si on considère l'énumération \(b,a,d,c\) on aura la matrice :

\[ \begin{pmatrix} 0 & 1 & 1 & 0 \\ 1 & 0 & 1 & 1 \\ 1 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \end{pmatrix} \]

On en déduit ainsi une implémentation directe d'un graphe en représentant la matrice d'adjacence par un tableau de tableaux.

m = [ [ 0, 1, 1, 1 ],
      [ 1, 0, 0, 1 ],
      [ 0, 0, 0, 1 ],
      [ 1, 1, 0, 0 ] ]
let m = [| [| 0; 1; 1; 1 |];
           [| 1; 0; 0; 1 |];
           [| 0; 0; 0; 1 |];
           [| 1; 1; 0; 0 |] |]
int m[4][4] = { { 0, 1, 1, 1 },
                { 1, 0, 0, 1 },
                { 0, 0, 0, 1 },
                { 1, 1, 0, 0 } };

Remarque

Il y a le même lien entre les différentes matrices d'adjacence d'un graphe donné et la matrice d'un endomorphisme dans les différentes permutations d'une même base, à chaque fois on obtient la nouvelle matrice en permutant de même ses lignes et ses colonnes.

Cette représentation permet d'accéder, en lecture comme en écriture, à une arête en temps constant. Cependant, pour récupérer les voisins d'un sommet, il est nécessaire de parcourir toute la ligne correspondante, donc en \(O(|S|)\).

Remarque

Dans la majorité des cas, \(|A|\) est petit par rapport à \(|S|^2\). Ainsi, la matrice contient très peu de 1 et beaucoup de 0, on dit qu'elle est creuse. Une telle matrice creuse peut être efficacement représentée par l'ensemble fini des arêtes \((i,j)\). Par exemple, avec une table de hachage. Cependant, dans le cas des graphes, il est possible d'avoir une structure creuse plus efficace.

À titre d'exemple, un graphe issu d'un réseau social contenant \(300\,000\) arêtes pour \(8\,000\) sommets a un rapport \(|A|/|S|^2\) est de 4 pour 1000 !

Listes d'adjacences

La donnée de \(v_+(x)\) pour chaque sommet \(x \in S\) suffit à reconstruire \(A\).

Exemple

Si on reprend le graphe précédent,

on a \(S = \{ a,b,c,d\}\) et \(A = \{ (a, b), (a, c), (a,d), (b,a), (b, d) , (c,d), (d, a), (d, b) \}\).

Or \(v_+(a) = \{ b, c, d \}, v_+(b) = \{ a, d \}, v_+(c) = \{ d \}\) et \(v_+(d) = \{ a, b \}\).

Si on pose \(V_+(x) = \enscomp{ (x,y) }{ y \in v_+(x) }\) on a directement \(A = \bigcup_{x \in S} V_+(x)\).

On en déduit ainsi une représentation d'un graphe où on place dans un tableau chaque \(v_+(x)\) représenté par une liste chaînée ou un tableau dynamique.

Remarque

Pour représenter \(v_+(x)\) on a plusieurs choix : on peut utiliser directement les valeurs des sommets ou utiliser des indices dans une énumération. C'est le second choix qu'on fera en général car il est plus simple. Cependant, en cas de suppression d'un sommet, les indices changent, et il faut renuméroter dans les listes.

Pour le graphe précédent, on pourra donc considérer une énumération [ a, b, c, d ] et la représentation sous forme de liste d'adjacence pourra être :

l = [ [ 1, 2, 3 ], [ 0, 3 ], [ 3 ], [ 0, 1 ] ]
let l = [|
    [ 1; 2; 3 ]; [ 0; 3 ]; [ 3 ]; [ 0; 1 ]
    |]
struct graphe {
    int n;         // nombre de sommets
    int *degre;    // degre[x] = d_+(x)
    int **voisins; // voisins[x][0], ..., voisins[x][degre[x] - 1]
};
typedef struct graphe graphe;

int v0[] = { 1, 2, 3 };
int v1[] = { 0, 3 };
int v2[] = { 3 };
int v3[] = { 0, 1 };
int *voisins[] = { v0, v1, v2, v3 };
int degre[] = { 3, 2, 1, 2 };
graphe l = { 4, degre, voisins };

En C, on stocke chaque \(v_+(x)\) dans un tableau de taille \(d_+(x)\), ce qui impose de mémoriser aussi les degrés. Ici, les tableaux sont alloués statiquement ; pour un graphe construit à l'exécution, on les allouerait avec malloc.

L'accès en lecture ou en écriture à une arête \(x \rightarrow y\) est alors en \(O(|A|)\), plus précisément en \(O(d_+(x))\), et on peut parcourir les voisins sortants de \(x\) en \(O(d_+(x))\) également. Accéder à la liste elle-même se fait en \(O(1)\).

Pour obtenir les voisins entrants, il est par contre nécessaire de tester la présence de \(x\) dans chacune des autres listes, on obtient donc un algorithme en \(O(|S| + |A|)\) : on parcourt chaque case du tableau des listes puis chaque maillon de listes d'adjacence.

Il est possible d'améliorer cela en utilisant une structure plus efficace pour stocker les ensembles. Cela peut être un dictionnaire reposant sur une table de hachage. L'avantage de cela est que pour tester l'appartenance \(y \in v_+(x)\) on sera en \(O(1)\) avec un dictionnaire alors qu'on sera en \(O(d_+(x))\) avec une liste.

Comparaison

opération Matrice Listes Dictionnaire
complexité spatiale \(O(\vert S\vert^2)\) \(O(\vert S\vert+\vert A\vert)\) \(O(\vert S\vert+\vert A\vert)\)
arête test \(O(1)\) \(O(\vert A\vert)\) \(O(1)\)
arête ajout \(O(1)\) \(O(1)\) \(O(1)\)
arête suppression \(O(1)\) \(O(\vert A\vert)\) \(O(1)\)
sommet ajout \(O(\vert S\vert^2)\) \(O(\vert S\vert)\) \(O(\vert S\vert)\)
sommet suppression \(O(\vert S\vert^2)\) \(O(\vert S\vert+\vert A\vert)\) \(O(\vert S\vert)\)
voisins/degré + \(O(\vert S\vert)\) \(O(1)\) \(O(1)\)
voisins/degré - \(O(\vert S\vert)\) \(O(\vert S\vert+\vert A\vert)\) \(O(\vert S\vert)\)

Notons qu'il est possible d'améliorer certaines complexités en utilisant des tableaux dynamiques, notamment les ajouts et suppressions de sommets.

Remarque

Il semble a priori clair qu'on devrait utiliser des dictionnaires pour manipuler des graphes en voyant ce tableau. Pourtant, on utilisera presque exclusivement des listes d'adjacence. Pourquoi ? En grande partie parce que les opérations rendues efficaces sont rares et ne justifient pas la difficulté accrue de manipulation. En effet, l'opération la plus importante est de pouvoir énumérer les voisins, et une liste chaînée le permet facilement et efficacement.

Graphes non orientés

Définition et adaptation du vocabulaire

Définition

Un graphe non orienté est un couple \(G = (S, A)\) où \(S\) est un ensemble fini et \(A\) est un ensemble de paires d'éléments de \(S\), c'est-à-dire d'ensembles à deux éléments \(\{x,y\}\) où \(x, y \in S\).

Les éléments de \(S\) sont appelés les sommets du graphe \(G\) et les éléments de \(A\) les arêtes. Si \(\{ x,y \} \in A\), on note \(x \smile y\).

Remarque

Si \(G = (S,A)\) est un graphe orienté, on peut en déduire deux graphes non orientés :

  • un graphe par défaut \(G^- = (S, A^-)\) où \(\forall x, y \in S, x \smile y \iff (x \rightarrow y \wedge y \rightarrow x)\) ;
  • un graphe par excès \(G^+ = (S, A^+)\) où \(\forall x, y \in S, x \smile y \iff (x \rightarrow y \vee y \rightarrow x)\).

On a également \(A\) symétrique \(\iff G^+ = G^-\). Cela correspond à un graphe où chaque arête est dans les deux sens (\(x \rightarrow y\) et \(y \rightarrow x\)) et, donc, on peut oublier l'orientation.

De la même manière, on peut associer à un graphe non orienté \(G\) son graphe orienté symétrique obtenu en doublant chaque arête. C'est-à-dire en posant \(x \rightarrow y\) si \(x \smile y\). On a donc \(\forall x, y \in S, x \rightarrow y \iff y \rightarrow x\) et le graphe est bien symétrique.

On reprend directement l'essentiel du vocabulaire des graphes orientés symétriques avec des simplifications :

Définition

Soit \(G= (S,A)\) un graphe et \(x \in S\).

  • On appelle voisins de \(x\) les éléments de

    \[ v(x) = \enscomp{y \in S}{x \smile y} \]
  • On appelle degré de \(x\) l'entier \(d(x) = | v(x)|\).

Théorème

\(\sum_{x \in S} d(x) = 2 |A|\)

Preuve

Il suffit d'appliquer le théorème analogue au graphe orienté symétrique associé : chaque sommet y a \(d(x)\) voisins sortants et chaque arête \(\{x,y\}\) donne exactement deux arêtes \((x,y)\) et \((y,x)\).

On étend directement la notion de chemin mais il faut faire attention au fait que simple n'a pas le même sens entre un graphe orienté symétrique et un graphe non orienté. En effet, \(x \rightarrow y \rightarrow x\) est simple pour un graphe orienté alors que \(x \smile y \smile x\) ne l'est pas vu qu'il s'agit de la même arête.

Connexité

Définition

On définit \(\smile^*\) comme étant la fermeture réflexive et transitive de \(\smile\).

Théorème

  • \(\smile^*\) est une relation d'équivalence dont les classes sont appelées les composantes connexes du graphe.
  • \(x \smile^* y \iff\) il existe un chemin de \(x\) à \(y\).

Définition

Un graphe n'ayant qu'une classe d'équivalence pour \(\smile^*\) est dit connexe. Cela signifie qu'il existe un chemin entre toute paire de sommets.

Graphe acyclique connexe

Définition

On appelle arbre un graphe acyclique connexe. Quand on a distingué un sommet, on parle d'arbre enraciné.

Graphe biparti

Définition

Soit \(G = (S,A)\) un graphe, on dit que le graphe est biparti lorsqu'il existe une partition \(S = S_1 \cup S_2\) telle que

\[ \forall \{ x, y \} \in A, x \in S_1 \iff y \in S_2 \]

Par exemple, le graphe suivant est biparti avec \(S_1 = \{a,e,f,g\}\) et \(S_2 = \{b,c,d\}\).

Un graphe biparti. Un graphe biparti.
Un graphe biparti.

On peut s'en rendre compte en coloriant les sommets.

Le même graphe colorié : S_1 = a,e,f,g en orange, S_2 = b,c,d en bleu. Le même graphe colorié : S_1 = a,e,f,g en orange, S_2 = b,c,d en bleu.
Le même graphe colorié : $S_1 = \{a,e,f,g\}$ en orange, $S_2 = \{b,c,d\}$ en bleu.

En fait, être biparti est équivalent à pouvoir être colorié en deux couleurs de sorte que deux sommets reliés soient de couleurs différentes.

Graphes classiques

On présente ici brièvement des graphes ou des familles de graphes classiques qui serviront, notamment pour les exemples.

Graphes complets

Définition

Soit \(n \in \N^*\), on appelle graphe complet à \(n\) sommets le graphe non orienté \(K_n\) de sommets \(\range{0}{n-1}\) et tel que

\[ \forall x, y \in \range{0}{n-1}, x \neq y \iff x \smile y \]

\(K_5\) :

Le graphe complet K_5. Le graphe complet K_5.
Le graphe complet $K_5$.

\(K_8\) :

Le graphe complet K_8. Le graphe complet K_8.
Le graphe complet $K_8$.

Définition

Soit \(n, p \in \N^*\), on appelle graphe biparti complet à \((n,p)\) sommets le graphe non orienté \(K_{n,p}\) de sommets \(\range{0}{n+p-1}\) et tel que

\[ \forall x \in \range{0}{n-1}, \forall y \in \range{n}{n+p-1}, x \smile y \]

\(K_{3,4}\) :

Le graphe biparti complet K_3,4. Le graphe biparti complet K_3,4.
Le graphe biparti complet $K_{3,4}$.

Cycles

Définition

Soit \(n \ge 3\), on appelle cycle de longueur \(n\) le graphe non orienté \(C_n\) de sommets \(\range{0}{n-1}\) et tel que

\[ \forall x, y \in \range{0}{n-1}, y \equiv x \pm 1 ~ [n] \iff x \smile y \]

\(C_5\) :

Le cycle C_5. Le cycle C_5.
Le cycle $C_5$.

\(C_8\) :

Le cycle C_8. Le cycle C_8.
Le cycle $C_8$.

Définition

Soit \(n \ge 2\), on appelle cycle orienté de longueur \(n\) le graphe orienté \(C^o_n\) de sommets \(\range{0}{n-1}\) et tel que

\[ \forall x, y \in \range{0}{n-1}, y \equiv x + 1 ~ [n] \iff x \rightarrow y \]

\(C^o_5\) :

Le cycle orienté C^o_5. Le cycle orienté C^o_5.
Le cycle orienté $C^o_5$.

\(C^o_8\) :

Le cycle orienté C^o_8. Le cycle orienté C^o_8.
Le cycle orienté $C^o_8$.

Grilles et hypercubes

On va manipuler ici des vecteurs de dimension \(d\) à coordonnées entières dans \(\range{0}{n-1}\).

On sait que \(\range{0}{n-1}^d\) est en bijection avec \(\range{0}{n^d-1}\) et on considère la bijection explicite suivante :

\[ \varphi(x) = \sum_{i=0}^{d-1} x_i n^i \text{ où } x = (x_0, \dots, x_{d-1}) \]

Soit \(x = (x_0, \dots, x_{d-1})\) et \(y = (y_0, \dots, y_{d-1}) \in \range{0}{n-1}^d\), on note \(d_1(x,y) = \sum_{i=0}^{d-1} |x_i-y_i|\) qui compte la somme des différences de coordonnées. Ainsi \(d_1( (1,2,3), (3,2,2) ) = |1-3| + |2-2| + |3-2| = 3\). Comme les coordonnées sont entières, on a le théorème suivant qui indique que les vecteurs à distance 1 sont ceux qui diffèrent exactement de 1 dans une unique coordonnée :

Théorème

Soient \(x, y \in \range{0}{n-1}^d\), on a

\[ d_1(x,y) = 1 \iff \exists i \in \range{0}{d-1}, \begin{cases} \forall j \in \range{0}{d-1}, j \neq i \Rightarrow x_j = y_j \\ |x_i-y_i| = 1 \end{cases} \]

On en déduit un graphe de grille des vecteurs à distance 1 :

Définition

Soient \(n, d \in \N^*\), on appelle grille de dimension \(d\) et de taille \(n\) le graphe \(G_n^d\) dont les sommets sont dans \(\range{0}{n-1}^d\) et où

\[ \forall x, y \in \range{0}{n-1}^d, x \smile y \iff d_1(x,y) = 1 \]

\(G_3^2\) :

La grille G_3^2. La grille G_3^2.
La grille $G_3^2$.

\(G_2^3\) :

La grille G_2^3, c'est-à-dire l'hypercube H_3. La grille G_2^3, c'est-à-dire l'hypercube H_3.
La grille $G_2^3$, c'est-à-dire l'hypercube $H_3$.

Définition

Soit \(n \in \N^*\), on appelle hypercube de dimension \(n\) le graphe \(H_n = G_2^n\).

On vient de voir l'hypercube de dimension 3 (\(G_2^3\)) dans l'exemple précédent.

Remarque

L'hypercube de dimension \(n\) est intéressant car il permet de représenter chaque nombre de \(\range{0}{2^n-1}\) comme un sommet du graphe (via la bijection \(\varphi\) : les coordonnées sont les bits). Par exemple, toute chaîne hamiltonienne, c'est-à-dire qui contient chaque sommet du graphe, correspond à un ordre d'énumération des nombres qui vérifie qu'un nombre et son successeur ne diffèrent que sur un bit.

Parcours

Principe

La grande majorité des algorithmes sur les graphes consistent à parcourir les sommets de voisins en voisins pour effectuer des traitements, la manière dont on les parcourt pouvant changer selon les différentes applications.

Citons, par exemple, le fait de déterminer les composantes connexes d'un graphe ou de trouver le plus court chemin entre deux sommets.

On va se placer dans le cadre d'un graphe orienté représenté par listes d'adjacence et en supposant que les sommets sont identifiés par leur indice. Dans ce cadre, si on a, en fait, un graphe non orienté, il sera représenté par son graphe orienté symétrique comme on l'a vu plus haut.

On va également considérer qu'on veut effectuer un traitement, ou une visite, pour ces sommets ou les arêtes empruntées.

Parcours en profondeur récursif

On présente ici une première manière élémentaire de les parcourir en tirant parti de la récursivité : on considère une fonction parcours et l'appel à parcours pour le sommet x va effectuer des appels récursifs à parcours pour chaque sommet \(y\) de \(v_+(x)\).

Le problème est qu'on ne veut pas traiter deux fois un sommet et on veut que les appels terminent. Pour cela, on introduit une notion d'état associé à chaque sommet. Un sommet peut être

  • Inconnu, cela correspond au fait qu'il n'est pas encore apparu en tant que voisin.
  • Découvert, il est apparu mais n'a pas encore été traité complètement.
  • Traité (ou Visité), il a non seulement été traité, mais également tous les sommets parcourus grâce à lui.

Pour maintenir cet état dans le code, le plus simple est de considérer un tableau d'entiers etat où etat[i] donne l'état du sommet i.

Définition

Ce parcours est appelé un parcours en profondeur récursif. En anglais, on parle de depth-first search et on utilise couramment l'acronyme DFS.

Première version

On adapte directement le principe précédent en un programme.

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat):
    if etat[x] != TRAITE:
        print(x)
        for y in ladj[x]:
            if etat[y] == INCONNU:
                etat[y] = DECOUVERT
                parcours(ladj, y, etat)
        etat[x] = TRAITE

def lance_parcours(ladj, x):
    # État initial inconnu pour tous
    etat = [ INCONNU ] * len(ladj)
    etat[x] = DECOUVERT
    parcours(ladj, x, etat)
type etat = Inconnu | Decouvert | Traite

let rec parcours ladj x etat =
    if etat.(x) <> Traite
    then begin
        Printf.printf "%d\n" x;
        List.iter (fun y ->
            if etat.(y) = Inconnu
            then begin
                etat.(y) <- Decouvert;
                parcours ladj y etat
            end
        ) ladj.(x);
        etat.(x) <- Traite
    end

let lance_parcours ladj x =
    (* État initial inconnu pour tous *)
    let etat = Array.make (Array.length ladj) Inconnu in
    etat.(x) <- Decouvert;
    parcours ladj x etat
#include <stdio.h>
#include <stdlib.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

void parcours(graphe g, int x, int *etat) {
    if (etat[x] != TRAITE) {
        printf("%d\n", x);
        for (int i = 0; i < g.degre[x]; i++) {
            int y = g.voisins[x][i];
            if (etat[y] == INCONNU) {
                etat[y] = DECOUVERT;
                parcours(g, y, etat);
            }
        }
        etat[x] = TRAITE;
    }
}

void lance_parcours(graphe g, int x) {
    // État initial inconnu pour tous
    int *etat = malloc(g.n * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
    }
    etat[x] = DECOUVERT;
    parcours(g, x, etat);
    free(etat);
}

Ici, on se contente d'afficher les sommets rencontrés. En OCaml, on utilise un type somme pour les trois états ; en C, une énumération et le type graphe défini plus haut.

Exemple

On va réaliser un parcours en profondeur du graphe suivant en partant du sommet 0 :

Graphe orienté parcouru en profondeur depuis 0. Graphe orienté parcouru en profondeur depuis 0.
Graphe orienté parcouru en profondeur depuis 0.

On indique les sommets inconnus en gris, les sommets découverts en bleu (en orange celui dont l'appel est en cours) et les sommets traités en vert.

On affiche 0 et on relance le parcours du voisin 1.

On affiche 1 et on relance le parcours du voisin 2.

On affiche 2 et on relance le parcours de son voisin 4 (on suppose ici que 4 apparaît avant 3 dans la liste d'adjacence de 2).

On affiche 4 et comme il n'a pas de voisins, on marque 4 comme traité et on sort de l'appel.

On revient à 2 et on relance le parcours du voisin 3. On affiche 3, et on le marque comme traité car son unique voisin 0 est déjà découvert.

On revient à 2 et cette fois-ci il n'y a plus de voisins, on peut marquer 2 comme étant traité.

On revient à 1, son autre voisin 3 est déjà traité, on peut marquer 1 comme étant traité.

On revient à 0 et on peut marquer 0 comme étant traité.

On a donc une vague de descente dans le graphe qui marque les sommets comme découverts et ensuite on remonte la pile d'appels récursifs en marquant les sommets comme étant traités.

Les sommets sont ici affichés dans l'ordre 0 1 2 4 3. On remarque que les sommets 5 et 6 sont inaccessibles, ils restent ainsi inconnus tout le long du parcours.

Arbre de parcours

On peut noter les arêtes empruntées lors du parcours précédent pour découvrir un nouveau sommet, et donc relancer un appel récursif. On obtient alors une structure arborescente qui est appelée l'arbre du parcours en profondeur.

Exemple

Dans le parcours précédent, on obtient l'arbre suivant :

Arêtes de l'arbre du parcours en profondeur (en orange) ; 5 et 6 restent inconnus. Arêtes de l'arbre du parcours en profondeur (en orange) ; 5 et 6 restent inconnus.
Arêtes de l'arbre du parcours en profondeur (en orange) ; 5 et 6 restent inconnus.

L'arbre du parcours en profondeur issu de 0. L'arbre du parcours en profondeur issu de 0.
L'arbre du parcours en profondeur issu de 0.

Pour obtenir cet arbre, on va construire un tableau parent pendant le parcours. Lorsqu'on emprunte une arête \(x \rightarrow y\) pour découvrir \(y\), on note \(parent[y] = x\). Par défaut, \(parent[y]\) est indéfini (None ou une valeur d'indice invalide comme \(-1\)).

Théorème

Si \(parent[y]\) est défini à l'issue du parcours depuis \(x\), alors il existe un chemin \(\varphi : x \leadsto y\) où les arêtes \(s \rightarrow s'\) empruntées lors du chemin vérifient toutes \(parent[s'] = s\).

Preuve

Quand on pose \(parent[y] = s\), le sommet \(s\) est en cours de traitement, donc découvert avant \(y\). Ainsi, en remontant les parents depuis \(y\), on obtient des sommets découverts de plus en plus tôt : la remontée ne boucle pas et s'arrête sur le seul sommet découvert sans parent, à savoir \(x\). En renversant cette suite, on obtient le chemin voulu, puisque \(parent[y] = s\) n'est posé que si \(s \rightarrow y\).

Comme \(parent[y]\) est défini pour tous les sommets découverts autres que \(x\) et que les sommets découverts finissent tous par être traités, on en déduit que les sommets traités sont accessibles depuis \(x\). La réciproque fait l'objet du théorème suivant :

Théorème

À l'issue du parcours depuis \(x\), les sommets traités sont les sommets accessibles depuis \(x\), c'est-à-dire les éléments de

\[ \enscomp{y \in S}{x \leadsto y} \]
Preuve

On vient de voir que les sommets traités sont accessibles depuis \(x\). Réciproquement, montrons par récurrence sur \(p\) que si \(\varphi : x \leadsto y\) est de longueur \(p\), alors \(y\) est traité. Pour \(p = 0\), \(y = x\) est traité à la fin de l'appel initial. Si \(p \ge 1\), on écrit \(\varphi = \psi\, (z \rightarrow y)\) où \(\psi\) est de longueur \(p - 1\) : par hypothèse de récurrence, \(z\) est traité, donc l'appel sur \(z\) a parcouru tous ses voisins. Au moment où l'on examine \(y\) dans cet appel, soit \(y\) est inconnu et on lance un appel sur \(y\) qui se termine en le marquant traité, soit \(y\) est déjà découvert ou traité. Or tout sommet découvert est découvert juste avant l'appel sur lui et est traité à la fin de cet appel, qui termine avant la fin du parcours. Dans tous les cas, \(y\) est traité.

On présente le calcul de ces chemins dans le programme suivant :

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat, parent):
    if etat[x] != TRAITE:
        print(x)
        for y in ladj[x]:
            if etat[y] == INCONNU:
                parent[y] = x
                etat[y] = DECOUVERT
                parcours(ladj, y, etat, parent)
        etat[x] = TRAITE

def arbre_parcours(ladj, x):
    etat = [ INCONNU ] * len(ladj)
    parent = [ None ] * len(ladj)
    etat[x] = DECOUVERT
    parcours(ladj, x, etat, parent)
    return parent

def chemin(parent, y):
    # Renvoie le chemin x -> ... -> y
    # en sens inverse
    p = [ y ]
    while parent[y] != None:
        y = parent[y]
        p.append(y)
    return p
type etat = Inconnu | Decouvert | Traite

let rec parcours ladj x etat parent =
    if etat.(x) <> Traite
    then begin
        Printf.printf "%d\n" x;
        List.iter (fun y ->
            if etat.(y) = Inconnu
            then begin
                parent.(y) <- x;
                etat.(y) <- Decouvert;
                parcours ladj y etat parent
            end
        ) ladj.(x);
        etat.(x) <- Traite
    end

(* -1 signifie que le parent est indéfini *)
let arbre_parcours ladj x =
    let n = Array.length ladj in
    let etat = Array.make n Inconnu in
    let parent = Array.make n (-1) in
    etat.(x) <- Decouvert;
    parcours ladj x etat parent;
    parent

(* Renvoie le chemin x -> ... -> y, cette fois dans le bon sens *)
let chemin parent y =
    let rec remonte y acc =
        if parent.(y) = -1
        then y :: acc
        else remonte parent.(y) (y :: acc)
    in
    remonte y []
#include <stdio.h>
#include <stdlib.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

void parcours(graphe g, int x, int *etat, int *parent) {
    if (etat[x] != TRAITE) {
        printf("%d\n", x);
        for (int i = 0; i < g.degre[x]; i++) {
            int y = g.voisins[x][i];
            if (etat[y] == INCONNU) {
                parent[y] = x;
                etat[y] = DECOUVERT;
                parcours(g, y, etat, parent);
            }
        }
        etat[x] = TRAITE;
    }
}

// Renvoie un tableau parent alloué, -1 signifiant indéfini
int *arbre_parcours(graphe g, int x) {
    int *etat = malloc(g.n * sizeof(int));
    int *parent = malloc(g.n * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
        parent[i] = -1;
    }
    etat[x] = DECOUVERT;
    parcours(g, x, etat, parent);
    free(etat);
    return parent;
}

// Écrit dans p le chemin x -> ... -> y en sens inverse
// et renvoie sa longueur (p doit pouvoir contenir n sommets)
int chemin(int *parent, int y, int *p) {
    int k = 0;
    p[k++] = y;
    while (parent[y] != -1) {
        y = parent[y];
        p[k++] = y;
    }
    return k;
}

Composantes connexes

On peut déduire directement du théorème précédent, le corollaire suivant dans le cas non orienté (représenté par son graphe orienté symétrique, dans lequel \(x \leadsto y \iff x \smile^* y\)) :

Théorème

Dans un graphe non orienté, les sommets traités depuis un parcours en profondeur issu d'un sommet sont exactement les sommets de sa composante connexe.

On en déduit alors un algorithme pour obtenir les composantes connexes d'un graphe non orienté :

Algorithme : Composantes connexes

Entrées :

Un graphe non orienté \(G = (S,A)\)

Déroulement :

  • Tant qu'il y a des sommets non traités
    • On choisit un sommet non traité \(x\)
    • On effectue un parcours récursif depuis \(x\) (sans réinitialiser les états)
    • Tous les sommets nouvellement traités par ce parcours forment une composante connexe

On va en déduire le programme suivant :

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat, composante):
    if etat[x] != TRAITE:
        composante.append(x)
        for y in ladj[x]:
            if etat[y] == INCONNU:
                etat[y] = DECOUVERT
                parcours(ladj, y, etat, composante)
        etat[x] = TRAITE

def composantes_connexes(ladj):
    composantes = []
    etat = [ INCONNU ] * len(ladj)
    for i in range(len(ladj)):
        if etat[i] == INCONNU:
            composante = [ ]
            etat[i] = DECOUVERT
            parcours(ladj, i, etat, composante)
            composantes.append( composante )
    return composantes
type etat = Inconnu | Decouvert | Traite

let rec parcours ladj x etat composante =
    if etat.(x) <> Traite
    then begin
        composante := x :: !composante;
        List.iter (fun y ->
            if etat.(y) = Inconnu
            then begin
                etat.(y) <- Decouvert;
                parcours ladj y etat composante
            end
        ) ladj.(x);
        etat.(x) <- Traite
    end

let composantes_connexes ladj =
    let n = Array.length ladj in
    let composantes = ref [] in
    let etat = Array.make n Inconnu in
    for i = 0 to n - 1 do
        if etat.(i) = Inconnu
        then begin
            let composante = ref [] in
            etat.(i) <- Decouvert;
            parcours ladj i etat composante;
            composantes := List.rev !composante :: !composantes
        end
    done;
    List.rev !composantes
#include <stdlib.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

void parcours(graphe g, int x, int *etat, int *composante, int num) {
    if (etat[x] != TRAITE) {
        composante[x] = num;
        for (int i = 0; i < g.degre[x]; i++) {
            int y = g.voisins[x][i];
            if (etat[y] == INCONNU) {
                etat[y] = DECOUVERT;
                parcours(g, y, etat, composante, num);
            }
        }
        etat[x] = TRAITE;
    }
}

// Remplit composante[x] avec le numéro de la composante de x
// et renvoie le nombre de composantes
int composantes_connexes(graphe g, int *composante) {
    int nb = 0;
    int *etat = malloc(g.n * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
    }
    for (int i = 0; i < g.n; i++) {
        if (etat[i] == INCONNU) {
            etat[i] = DECOUVERT;
            parcours(g, i, etat, composante, nb);
            nb++;
        }
    }
    free(etat);
    return nb;
}

En C, plutôt que de construire une liste de listes, on numérote les composantes dans un tableau : deux sommets sont dans la même composante si et seulement s'ils ont le même numéro.

Détection de cycles

Si on est en train de traiter le sommet \(x\) et qu'on rencontre une arête \(x \rightarrow y\) où \(y\) est découvert mais non traité, c'est qu'on est dans l'appel récursif de \(y\) et donc que \(x\) est un de ses descendants : \(y \leadsto x\). En rajoutant la nouvelle arête, \(y \leadsto x \rightarrow y\), on en déduit un cycle dans le cas d'un graphe orienté.

Pour un graphe non orienté représenté par un graphe orienté symétrique, il faut faire attention à ne pas prendre un aller-retour \(x \rightarrow y \rightarrow x\) pour un cycle. On demande donc à avoir la condition :

etat[y] == DECOUVERT and parent[x] != y

On en déduit le programme suivant :

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat, parent, est_oriente):
    if etat[x] != TRAITE:

        for y in ladj[x]:
            if etat[y] == INCONNU:
                parent[y] = x
                etat[y] = DECOUVERT
                cycle = parcours(ladj, y, etat, parent, est_oriente)
                if cycle != None:
                    return cycle
            elif etat[y] == DECOUVERT and (est_oriente or parent[x] != y):
                # y est un ancêtre de x : on renvoie le cycle y -> ... -> x -> y
                cycle = [ y ]
                z = x
                while z != y:
                    cycle.append(z)
                    z = parent[z]
                cycle.append(y)
                cycle.reverse()
                return cycle

        etat[x] = TRAITE
    return None

def detecte_cycle(ladj, x, est_oriente):
    etat = [ INCONNU ] * len(ladj)
    parent = [ None ] * len(ladj)
    etat[x] = DECOUVERT
    cycle = parcours(ladj, x, etat, parent, est_oriente)
    if cycle != None:
        print('Cyclique : ', cycle)
    else:
        print('Acyclique')
type etat = Inconnu | Decouvert | Traite

exception Cycle of int list

let rec parcours ladj x etat parent est_oriente =
    if etat.(x) <> Traite
    then begin
        List.iter (fun y ->
            if etat.(y) = Inconnu
            then begin
                parent.(y) <- x;
                etat.(y) <- Decouvert;
                parcours ladj y etat parent est_oriente
            end
            else if etat.(y) = Decouvert && (est_oriente || parent.(x) <> y)
            then begin
                (* y est un ancêtre de x : on construit le cycle
                   y -> ... -> x -> y en remontant depuis x *)
                let rec remonte z acc =
                    if z = y
                    then y :: acc
                    else remonte parent.(z) (z :: acc)
                in
                raise (Cycle (remonte x [y]))
            end
        ) ladj.(x);
        etat.(x) <- Traite
    end

let detecte_cycle ladj x est_oriente =
    let n = Array.length ladj in
    let etat = Array.make n Inconnu in
    let parent = Array.make n (-1) in
    etat.(x) <- Decouvert;
    try
        parcours ladj x etat parent est_oriente;
        print_endline "Acyclique"
    with Cycle c ->
        print_string "Cyclique :";
        List.iter (Printf.printf " %d") c;
        print_newline ()
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

// Renvoie la longueur du cycle écrit dans le tableau cycle
// (de taille n + 1), ou 0 si aucun cycle n'est trouvé
int parcours(graphe g, int x, int *etat, int *parent, bool est_oriente,
             int *cycle) {
    if (etat[x] != TRAITE) {
        for (int i = 0; i < g.degre[x]; i++) {
            int y = g.voisins[x][i];
            if (etat[y] == INCONNU) {
                parent[y] = x;
                etat[y] = DECOUVERT;
                int k = parcours(g, y, etat, parent, est_oriente, cycle);
                if (k != 0) {
                    return k;
                }
            } else if (etat[y] == DECOUVERT
                       && (est_oriente || parent[x] != y)) {
                // y est un ancêtre de x : on écrit le cycle y -> ... -> x -> y
                // à l'envers puis on le retourne
                int k = 0;
                cycle[k++] = y;
                for (int z = x; z != y; z = parent[z]) {
                    cycle[k++] = z;
                }
                cycle[k++] = y;
                for (int a = 0, b = k - 1; a < b; a++, b--) {
                    int tmp = cycle[a];
                    cycle[a] = cycle[b];
                    cycle[b] = tmp;
                }
                return k;
            }
        }
        etat[x] = TRAITE;
    }
    return 0;
}

void detecte_cycle(graphe g, int x, bool est_oriente) {
    int *etat = malloc(g.n * sizeof(int));
    int *parent = malloc(g.n * sizeof(int));
    int *cycle = malloc((g.n + 1) * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
        parent[i] = -1;
    }
    etat[x] = DECOUVERT;
    int k = parcours(g, x, etat, parent, est_oriente, cycle);
    if (k != 0) {
        printf("Cyclique :");
        for (int i = 0; i < k; i++) {
            printf(" %d", cycle[i]);
        }
        printf("\n");
    } else {
        printf("Acyclique\n");
    }
    free(etat);
    free(parent);
    free(cycle);
}

En OCaml, on interrompt le parcours avec une exception dès qu'un cycle est trouvé, List.iter ne permettant pas de sortir de la boucle.

Classification des arêtes

On considère ici un parcours complet d'un graphe orienté \(G\) : comme pour les composantes connexes, tant qu'il reste des sommets inconnus, on en choisit un et on lance un parcours depuis celui-ci, sans réinitialiser les états. À la fin, tous les sommets sont traités.

Les arêtes de découverte \(parent[y] \rightarrow y\) forment alors une forêt, la forêt du parcours : chaque sommet a au plus un parent et, en remontant les parents, on aboutit à une racine, c'est-à-dire un sommet depuis lequel on a lancé un parcours. On parle d'ancêtre et de descendant dans cette forêt, en convenant qu'un sommet est ancêtre et descendant de lui-même.

Lors du parcours, chaque arête \(x \rightarrow y\) est examinée exactement une fois, par l'appel sur \(x\). On classe les arêtes selon la position de \(y\) par rapport à \(x\) dans la forêt.

Définition

Soit \(x \rightarrow y\) une arête de \(G\). On dit que c'est une arête :

  • de liaison si \(parent[y] = x\), c'est-à-dire si elle a servi à découvrir \(y\) ;
  • arrière si \(y\) est un ancêtre de \(x\) ;
  • avant si \(y\) est un descendant de \(x\) et que ce n'est pas une arête de liaison ;
  • transverse sinon, c'est-à-dire quand aucun des deux sommets n'est un ancêtre de l'autre.

Comme \(x \neq y\), \(y\) ne peut pas être à la fois un ancêtre et un descendant de \(x\) : ces quatre cas sont exclusifs.

Exemple

On reprend le graphe de l'exemple du parcours en profondeur

Graphe orienté parcouru en profondeur depuis 0. Graphe orienté parcouru en profondeur depuis 0.
Graphe orienté parcouru en profondeur depuis 0.

avec les listes d'adjacence [1], [2, 3], [4, 3], [0], [], [6], [], et on effectue un parcours complet en lançant des parcours depuis 0 puis depuis 5.

  • Les arêtes de liaison sont \(0 \rightarrow 1\), \(1 \rightarrow 2\), \(2 \rightarrow 4\), \(2 \rightarrow 3\) et \(5 \rightarrow 6\).
  • \(3 \rightarrow 0\) est une arête arrière : 0 est un ancêtre de 3.
  • \(1 \rightarrow 3\) est une arête avant : 3 est un descendant de 1, mais il a été découvert depuis 2.
  • Il n'y a pas d'arête transverse.

Si la liste d'adjacence de 1 était [3, 2], on découvrirait 3 depuis 1, puis 2 depuis 1 : l'arête \(2 \rightarrow 3\) deviendrait transverse et \(1 \rightarrow 3\) une arête de liaison. La classification dépend donc de l'ordre des listes d'adjacence et pas seulement du graphe.

Pour relier cette classification au déroulement du parcours, on utilise la pile des appels récursifs.

Lemme

À tout moment d'un parcours complet :

  • les sommets découverts non traités sont exactement ceux dont l'appel est en cours, et, dans l'ordre de leur découverte, ils forment une branche \(s_0, s_1, \dots, s_k\) de la forêt : \(s_0\) est une racine et \(parent[s_i] = s_{i-1}\) pour \(i \in \range{1}{k}\) ;
  • \(y\) est un descendant de \(x\) si et seulement si \(y = x\) ou \(y\) est découvert à un moment où \(x\) est découvert non traité.
Preuve

Un sommet est marqué découvert juste avant l'appel sur lui et traité à la fin de cet appel, d'où la première affirmation. Les appels en cours forment la pile d'appels : le plus ancien a été lancé sur une racine par la boucle du parcours complet, et chacun des autres a été lancé par l'appel situé juste en dessous dans la pile, lors de l'examen d'une arête \(s_{i-1} \rightarrow s_i\), ce qui a posé \(parent[s_i] = s_{i-1}\).

Si \(y \neq x\) est découvert alors que \(x\) est découvert non traité, \(x\) est dans la pile \(s_0, \dots, s_k\) et \(y\) est découvert par l'appel au sommet de la pile : \(parent[y] = s_k\) et \(y\) prolonge la branche, donc \(x\) est un ancêtre de \(y\).

Réciproquement, on raisonne par récurrence sur le nombre de remontées de parents nécessaires pour aller de \(y\) à \(x\). Si \(y = x\), il n'y a rien à montrer. Sinon, \(z = parent[y]\) est un descendant de \(x\) et \(y\) est découvert par l'appel sur \(z\), donc alors que \(z\) est découvert non traité. Si \(z = x\), c'est terminé. Sinon, par hypothèse de récurrence, \(z\) a été découvert alors que \(x\) était dans la pile : \(x\) est en dessous de \(z\) dans la pile et y reste tant que l'appel sur \(z\) n'est pas terminé. Ainsi \(x\) est découvert non traité quand \(y\) est découvert.

Propriété

Au moment où l'appel sur \(x\) examine l'arête \(x \rightarrow y\) :

  • \(y\) est inconnu si et seulement si l'arête est de liaison ;
  • \(y\) est découvert non traité si et seulement si l'arête est arrière ;
  • \(y\) est traité si et seulement si l'arête est avant ou transverse.
Preuve

Les trois cas de gauche recouvrent toutes les possibilités et ceux de droite sont exclusifs : il suffit donc de montrer les implications de gauche à droite.

  • Si \(y\) est inconnu, on pose \(parent[y] = x\) : l'arête est de liaison.
  • Si \(y\) est découvert non traité, \(y\) est dans la pile d'appels, dont \(x\) est le sommet. D'après le lemme, \(y\) est un ancêtre de \(x\) : l'arête est arrière.
  • Si \(y\) est traité, il n'est pas dans la pile, donc ce n'est pas un ancêtre de \(x\). De plus, \(parent[y] = x\) ne peut être posé que lors de l'examen de l'arête \(x \rightarrow y\), qui n'a lieu qu'une fois, maintenant, et ne le pose pas puisque \(y\) n'est pas inconnu. L'arête est donc avant ou transverse.

Remarque

Le programme de détection de cycles vu plus haut s'arrête exactement à la première arête arrière rencontrée, et le cycle qu'il renvoie est formé de cette arête et de la branche de la forêt qui va de \(y\) à \(x\). On va voir qu'un graphe orienté est cyclique si et seulement si un parcours complet rencontre une arête arrière : pour détecter un cycle n'importe où dans le graphe, et pas seulement parmi les sommets accessibles depuis \(x\), il suffit donc de relancer ce parcours depuis chaque sommet encore inconnu.

Temps d'entrée et de sortie

On ajoute au parcours complet une horloge : un compteur, initialement nul, qu'on incrémente à chaque fois qu'un sommet est découvert et à chaque fois qu'un sommet devient traité.

Définition

Le temps d'entrée \(t_e(x)\) d'un sommet \(x\) est la valeur de l'horloge juste après son incrémentation lors de la découverte de \(x\). Le temps de sortie \(t_s(x)\) est sa valeur juste après son incrémentation quand \(x\) devient traité.

Chaque sommet est découvert une fois et traité une fois : les temps d'entrée et de sortie sont les entiers de \(1\) à \(2|S|\), deux à deux distincts, et \(t_e(x) < t_s(x)\) pour tout sommet \(x\).

On calcule ces temps lors d'un parcours complet. Ici, c'est parcours qui marque le sommet comme découvert, au moment où on note son temps d'entrée.

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat, horloge, entree, sortie):
    # renvoie la valeur de l'horloge à la fin du traitement de x
    etat[x] = DECOUVERT
    horloge += 1
    entree[x] = horloge
    for y in ladj[x]:
        if etat[y] == INCONNU:
            horloge = parcours(ladj, y, etat, horloge, entree, sortie)
    etat[x] = TRAITE
    horloge += 1
    sortie[x] = horloge
    return horloge

def dates(ladj):
    # parcours complet
    n = len(ladj)
    etat = [ INCONNU ] * n
    entree = [ None ] * n
    sortie = [ None ] * n
    horloge = 0
    for x in range(n):
        if etat[x] == INCONNU:
            horloge = parcours(ladj, x, etat, horloge, entree, sortie)
    return entree, sortie
type etat = Inconnu | Decouvert | Traite

let rec parcours ladj x etat horloge entree sortie =
    etat.(x) <- Decouvert;
    incr horloge;
    entree.(x) <- !horloge;
    List.iter (fun y ->
        if etat.(y) = Inconnu
        then parcours ladj y etat horloge entree sortie
    ) ladj.(x);
    etat.(x) <- Traite;
    incr horloge;
    sortie.(x) <- !horloge

(* Parcours complet : renvoie les tableaux des dates d'entrée et de sortie *)
let dates ladj =
    let n = Array.length ladj in
    let etat = Array.make n Inconnu in
    let entree = Array.make n 0 in
    let sortie = Array.make n 0 in
    let horloge = ref 0 in
    for x = 0 to n - 1 do
        if etat.(x) = Inconnu
        then parcours ladj x etat horloge entree sortie
    done;
    entree, sortie
#include <stdlib.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

void parcours(graphe g, int x, int *etat, int *horloge,
              int *entree, int *sortie) {
    etat[x] = DECOUVERT;
    *horloge += 1;
    entree[x] = *horloge;
    for (int i = 0; i < g.degre[x]; i++) {
        int y = g.voisins[x][i];
        if (etat[y] == INCONNU) {
            parcours(g, y, etat, horloge, entree, sortie);
        }
    }
    etat[x] = TRAITE;
    *horloge += 1;
    sortie[x] = *horloge;
}

// Parcours complet : remplit les tableaux entree et sortie (de taille n)
void dates(graphe g, int *entree, int *sortie) {
    int *etat = malloc(g.n * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
    }
    int horloge = 0;
    for (int x = 0; x < g.n; x++) {
        if (etat[x] == INCONNU) {
            parcours(g, x, etat, &horloge, entree, sortie);
        }
    }
    free(etat);
}

Exemple

Sur le graphe de l'exemple précédent, avec les listes d'adjacence [1], [2, 3], [4, 3], [0], [], [6], [], on obtient :

sommet 0 1 2 3 4 5 6
\(t_e\) 1 2 3 6 4 11 12
\(t_s\) 10 9 8 7 5 14 13

Si on écrit une parenthèse ouvrante étiquetée par \(x\) au temps \(t_e(x)\) et une parenthèse fermante au temps \(t_s(x)\), on obtient le mot bien parenthésé

\[ (_0\, (_1\, (_2\, (_4\, )_4\, (_3\, )_3\, )_2\, )_1\, )_0\, (_5\, (_6\, )_6\, )_5 \]

qui reflète exactement la forêt du parcours. C'est un fait général :

Théorème (des parenthèses)

Soient \(x \neq y\) deux sommets. Après un parcours complet, on est dans un et un seul des trois cas suivants :

  • les intervalles \([t_e(x), t_s(x)]\) et \([t_e(y), t_s(y)]\) sont disjoints, et aucun des deux sommets n'est un descendant de l'autre ;
  • \(t_e(x) < t_e(y) < t_s(y) < t_s(x)\) et \(y\) est un descendant de \(x\) ;
  • \(t_e(y) < t_e(x) < t_s(x) < t_s(y)\) et \(x\) est un descendant de \(y\).
Preuve

Les trois cas sont clairement exclusifs. Quitte à échanger \(x\) et \(y\), on suppose \(t_e(x) < t_e(y)\). Comme \(x\) est découvert avant \(y\), \(x\) n'est pas un descendant de \(y\) d'après le lemme.

  • Si \(t_s(x) < t_e(y)\), les intervalles sont disjoints et \(y\) est découvert après que \(x\) est traité : d'après le lemme, \(y\) n'est pas un descendant de \(x\).
  • Sinon, \(t_e(x) < t_e(y) < t_s(x)\) : \(y\) est découvert alors que \(x\) est découvert non traité, donc \(y\) est un descendant de \(x\) d'après le lemme. De plus, \(x\) est alors dans la pile d'appels, en dessous de \(y\), et y reste jusqu'à la fin de l'appel sur \(y\) : on a donc \(t_s(y) < t_s(x)\).

On en déduit une caractérisation de la classification des arêtes à l'aide des temps d'entrée et de sortie.

Corollaire

Soit \(x \rightarrow y\) une arête. Après un parcours complet :

  • elle est de liaison ou avant si et seulement si \(t_e(x) < t_e(y) < t_s(y) < t_s(x)\) ;
  • elle est arrière si et seulement si \(t_e(y) < t_e(x) < t_s(x) < t_s(y)\) ;
  • elle est transverse si et seulement si \(t_s(y) < t_e(x)\).

En particulier, l'arête est arrière si et seulement si \(t_s(x) < t_s(y)\).

Preuve

Les deux premiers points découlent directement du théorème des parenthèses. Si l'arête est transverse, les intervalles sont disjoints d'après ce même théorème. Or, d'après la propriété précédente, \(y\) est déjà traité quand l'appel sur \(x\) examine l'arête, donc \(t_s(y) < t_s(x)\) et ainsi \(t_s(y) < t_e(x)\).

Les quatre types d'arêtes recouvrant tous les cas et les trois conditions de droite étant exclusives, on a bien des équivalences. Enfin, dans les trois cas autres que celui d'une arête arrière, on a \(t_s(y) < t_s(x)\).

Théorème

Un graphe orienté \(G\) est acyclique si et seulement si un parcours complet de \(G\) ne rencontre aucune arête arrière, et ceci quels que soient l'ordre dans lequel on choisit les sommets de départ et l'ordre des listes d'adjacence.

Preuve

Si \(x \rightarrow y\) est une arête arrière, \(y\) est un ancêtre de \(x\) : les arêtes de liaison donnent un chemin \(y \leadsto x\) le long d'une branche de la forêt, dont les sommets sont distincts. En le complétant avec l'arête \(x \rightarrow y\), on obtient un cycle.

Réciproquement, s'il n'y a aucune arête arrière, le corollaire donne \(t_s(y) < t_s(x)\) pour toute arête \(x \rightarrow y\). Si \(s_0 \rightarrow s_1 \rightarrow \dots \rightarrow s_p = s_0\) était un cycle, on aurait \(t_s(s_0) > t_s(s_1) > \dots > t_s(s_p) = t_s(s_0)\), ce qui est absurde.

Corollaire

Si \(G\) est un graphe orienté acyclique, les sommets triés dans l'ordre décroissant de leurs temps de sortie lors d'un parcours complet forment un tri topologique de \(G\).

Preuve

D'après le théorème précédent, il n'y a pas d'arête arrière, donc \(t_s(y) < t_s(x)\) pour toute arête \(x \rightarrow y\).

On retrouve ainsi le théorème de la partie Tri topologique, sans avoir à lancer les parcours depuis les sommets de degré entrant nul.

Remarque

Pour un graphe non orienté, représenté par son graphe orienté symétrique, il n'y a pas d'arête transverse. En effet, si \(x \rightarrow y\) était transverse, on aurait \(t_s(y) < t_e(x)\) ; mais l'arête \(y \rightarrow x\) est examinée par l'appel sur \(y\), donc avant \(t_s(y)\), à un moment où \(x\) est encore inconnu : \(x\) deviendrait un descendant de \(y\), ce qui est contradictoire. De plus, \(x \rightarrow y\) est une arête avant si et seulement si \(y \rightarrow x\) est une arête arrière qui n'est pas le retour d'une arête de liaison : c'est pour cela que la détection de cycles dans le cas non orienté exclut seulement le cas \(parent[x] = y\).

Parcours quelconque

On va considérer ici que l'on dispose d'une structure de données sac dans laquelle on peut placer des éléments et en sortir. On considère aussi qu'on a un moyen de marquer les sommets. Par défaut, ils sont non marqués.

On considère alors l'algorithme suivant décrit en pseudo-code et auquel on fera référence comme étant le parcours quelconque dans la suite :

Algorithme : parcours quelconque

Entrées :

  • Un graphe \(G = (S,A)\)
  • Un sommet \(s \in S\) source du parcours

Déroulement :

  • On crée un sac ne contenant que \(s\)
  • Tant que le sac est non vide
    • On tire un sommet \(x\) du sac
    • Si \(x\) n'est pas marqué
      • On marque \(x\)
      • pour chaque \(x \rightarrow y \in A\)
        • On ajoute y dans le sac

On remarque que les sommets marqués sont exactement les sommets accessibles depuis \(s\), on retrouve ainsi le théorème vu pour le parcours récursif. En effet, un sommet n'est placé dans le sac que s'il est \(s\) ou le successeur d'un sommet marqué, donc par récurrence tout sommet marqué est accessible. Réciproquement, si \(s = s_0 \rightarrow \dots \rightarrow s_p\), on montre par récurrence sur \(i\) que \(s_i\) est marqué : quand \(s_{i-1}\) est marqué, on ajoute \(s_i\) dans le sac, et l'algorithme ne termine que lorsque le sac est vide, donc après avoir tiré \(s_i\), qui est alors marqué s'il ne l'était pas déjà.

En fait, le parcours quelconque ne permet pas de déduire plus d'information que cela, mais dans un graphe non orienté c'est déjà suffisant pour en déduire les composantes connexes.

La question qui se pose alors est celle de la stratégie d'ajout/tirage dans le sac. Selon ce que l'on considère, l'ordre de visite des sommets va changer. On va considérer trois stratégies :

  • LIFO : Last In First Out, le prochain sommet tiré est le dernier ajouté
  • FIFO : First In First Out, le prochain sommet tiré est le sommet le plus anciennement ajouté
  • aléatoire : on tire aléatoirement et uniformément un sommet du sac

Définition

Un sac avec une stratégie LIFO est appelé une pile.

Un sac avec une stratégie FIFO est appelé une file.

Exemple

Si on considère le graphe suivant :

Graphe orienté parcouru depuis a. Graphe orienté parcouru depuis a.
Graphe orienté parcouru depuis \(a\).

En effectuant un parcours quelconque issu de \(a\) :

  • en stratégie LIFO, on va voir les sommets dans l'ordre \(a, b, e, c, f, d, g\) (si on ajoute les voisins dans le sac en commençant par le dernier de la liste d'adjacence, sinon on obtient \(a, d, g, f, e, c, b\)).
  • en stratégie FIFO, on va voir les sommets dans l'ordre \(a, b, c, d, e, f, g\)
  • en stratégie aléatoire, on pourrait avoir \(a, b, c, e, d, f, g\)

Une manière de voir ces trois différentes stratégies est de considérer une grille et de colorer les sommets marqués avec une couleur changeant à chaque marquage. On peut voir dans l'ordre LIFO, FIFO, aléatoire l'effet de ces trois stratégies en partant du même point sur l'animation suivante :

Remarque

La stratégie LIFO semble bornée : elle choisit un cap et elle s'y tient tant que c'est possible. La stratégie FIFO va elle s'étendre comme une onde partant du point de départ. De manière assez étonnante, la stratégie aléatoire semble être une bonne approximation de la stratégie FIFO, ce qui montre le caractère exceptionnel du LIFO.

Le parcours quelconque en stratégie FIFO s'appelle un parcours en largeur. Comme on va le voir, le parcours avec stratégie LIFO est exactement le parcours en profondeur précédent.

Implémentation en Python

On va supposer qu'on dispose de quatre fonctions manipulant des sacs :

  • sac_vide() qui renvoie un nouveau sac vide
  • ajoute(sac, x) qui ajoute x dans le sac sac
  • retire(sac) qui retire un élément du sac sac et le renvoie
  • est_vide(sac) qui renvoie un booléen indiquant si le sac est vide

Le programme du parcours quelconque s'écrit alors

def parcours_sac(ladj, source):
    a_traiter = sac_vide()
    ajoute(a_traiter, source)
    marque = [ False ] * len(ladj)

    while not est_vide(a_traiter):
        x = retire(a_traiter)
        if not marque[x]:
            marque[x] = True
            for y in ladj[x]:
                ajoute(a_traiter, y)

Remarque

Pour utiliser ce parcours, on peut rajouter des instructions au moment où on marque un sommet ou au moment où on traite une arête.

Pour implémenter un sac, on va d'abord utiliser le type list de Python :

  • Pour créer un sac vide, on utilise la valeur [] :
def sac_vide():
    return []
  • Pour tester si le sac est vide, on utilise donc le test à [] :
def est_vide(sac):
    return sac == []
  • Pour ajouter un élément x dans le sac s dans tous les cas :
def ajoute(sac, x):
    sac.append(x)
  • (LIFO) Pour retirer le dernier élément ajouté, on peut utiliser directement pop :
def retire(sac):
    return sac.pop()
  • (FIFO) Pour retirer l'élément le plus anciennement ajouté, on peut utiliser la fonction de suppression del l[0] qui retire le premier élément
def retire(sac):
    x = sac[0]
    del sac[0]
    return x
  • C'est anecdotique, mais pour retirer un élément au hasard, il faut choisir un indice i, renvoyer l'élément qui s'y trouve en le supprimant du sac avec del s[i] :
from random import randint
def retire(sac):
    i = randint(0, len(sac) - 1)
    x = sac[i]
    del sac[i]
    return x

Si on peut supposer que les quatre premières opérations sont en temps constant, les deux dernières sont linéaires en le nombre d'éléments dans le sac, ce qui est assez coûteux.

En effet, le parcours quelconque va accéder en pire cas à chaque sommet et à chaque liste d'adjacence, et ajouter un élément dans le sac pour chaque arête parcourue, soit au plus \(|A| + 1\) ajouts et donc au plus \(|A| + 1\) retraits dans un sac de taille au plus \(|A| + 1\). Donc, une complexité en \(O(|S| + |A| + |A| f(|A|))\) où \(f(n)\) est la complexité du retrait dans un sac de taille \(n\).

On obtient donc \(O(|S| + |A|)\) en LIFO mais \(O(|S| + |A|^2)\) en FIFO avec cette implémentation.

En Python, il est possible d'avoir une file (queue en anglais) permettant de réaliser le retrait FIFO en \(O(1)\) :

  • On importe le type deque : from collections import deque
  • On crée une file vide avec s = deque()
  • On ajoute un élément x avec s.append(x)
  • Mais on dispose d'une fonction efficace s.popleft() pour retirer le premier élément.

En fait, on dispose également d'un appendleft et du pop. Toutes ces opérations étant en \(O(1)\).

Dans l'implémentation précédente, il suffit donc de remplacer les fonctions par les suivantes :

from collections import deque

def sac_vide():
    return deque()

def est_vide(sac):
    return len(sac) == 0

def ajoute(sac, x):
    sac.append(x)

def retire(sac):
    return sac.popleft()

Remarque

La structure de données deque est une liste doublement chaînée (de blocs). Elle permet de remonter dans la chaîne d'un maillon vers le maillon dont il est le suivant.

En pratique, on pourra définir des fonctions de parcours utilisant directement les bonnes structures :

def parcours_profondeur(ladj, source):
    a_traiter = [ source ]
    marque = [ False ] * len(ladj)

    while a_traiter != []:
        x = a_traiter.pop()
        if not marque[x]:
            marque[x] = True
            for y in ladj[x]:
                a_traiter.append(y)

# à placer en début du programme :
from collections import deque

def parcours_largeur(ladj, source):
    a_traiter = deque([ source ])
    marque = [ False ] * len(ladj)

    while len(a_traiter) != 0:
        x = a_traiter.popleft()
        if not marque[x]:
            marque[x] = True
            for y in ladj[x]:
                a_traiter.append(y)

Parcours en largeur

On a vu que le parcours quelconque en FIFO s'appelait le parcours en largeur. Il permet de parcourir les sommets d'un graphe en rayonnant à partir du sommet source. En effet on traite d'abord :

  • le sommet source \(src\)
  • les voisins de \(src\)
  • les voisins des voisins de \(src\)
  • \(\dots\)

Pour pouvoir parler de chemins, il est nécessaire d'introduire une notion de parenté. Pour cela, on a deux possibilités.

  • Soit on n'ajoute plus des sommets dans le sac mais des couples (sommet, parent) qui correspondent à une arête de découverte :

Algorithme : parcours quelconque avec couples

Entrées :

  • Un graphe \(G = (S,A)\)
  • Un sommet \(s \in S\) source du parcours

Déroulement :

  • On crée un sac ne contenant que \((s, s)\)
  • Tant que le sac est non vide
    • On tire un couple \((x, p)\) du sac
    • Si \(x\) est non marqué
      • On marque \(x\)
      • On indique que \(parent[x] = p\)
      • pour chaque \(x \rightarrow y \in A\)
        • On ajoute \((y, x)\) dans le sac
  • Soit on modifie le parcours quelconque pour en déduire un parcours quelconque qui a l'air a priori d'être optimisé.

Algorithme : parcours quelconque optimisé

Entrées :

  • Un graphe \(G = (S,A)\)
  • Un sommet \(s \in S\) source du parcours

Déroulement :

  • On crée un sac ne contenant que \(s\) et on marque \(s\)
  • Tant que le sac est non vide
    • On tire un sommet \(x\) du sac
    • pour chaque \(x \rightarrow y \in A\)
      • Si \(y\) est non marqué
        • On indique que \(parent[y] = x\)
        • On ajoute \(y\) dans le sac
        • On marque \(y\)

Exemple

On va reprendre l'exemple précédent

Graphe orienté parcouru depuis a. Graphe orienté parcouru depuis a.
Graphe orienté parcouru depuis \(a\).

On obtient avec un parcours en largeur le schéma suivant :

Parcours en largeur depuis a : arêtes de parenté en orange, couleur selon la distance à a. Parcours en largeur depuis a : arêtes de parenté en orange, couleur selon la distance à a.
Parcours en largeur depuis \(a\) : arêtes de parenté en orange, couleur selon la distance à \(a\).

où on a noté les arêtes de parenté ainsi que d'une même couleur les éléments à égale distance de \(a\) dans le parcours en largeur.

On remarque qu'on a obtenu le chemin \(a \rightarrow c \rightarrow f\) là où un parcours en profondeur aurait pu donner le chemin plus long \(a \rightarrow d \rightarrow g \rightarrow f\).

Comme semble le suggérer l'exemple précédent, on peut démontrer le théorème suivant :

Théorème

Les chemins donnés par un parcours en largeur depuis \(x\) sont de plus petite longueur :

Si \(\varphi : x\leadsto y\) est donné par le parcours, alors \(\forall \psi : x \leadsto y\), \(|\psi| \ge |\varphi|\).

Preuve

On raisonne sur le parcours optimisé avec une file, issu de \(x\). Pour un sommet marqué \(y\), on note \(n(y)\) la longueur du chemin donné par le parcours : \(n(x) = 0\) et \(n(y) = n(parent[y]) + 1\). On note \(\delta(y)\) la longueur minimale d'un chemin de \(x\) à \(y\). Comme le chemin donné par le parcours est un chemin, on a toujours \(\delta(y) \le n(y)\).

Invariant. Si la file contient \(y_1, \dots, y_r\) (de la tête à la queue), alors \(n(y_1) \le n(y_2) \le \dots \le n(y_r) \le n(y_1) + 1\). C'est vrai initialement pour la file \([x]\). Si on retire \(y_1\) et qu'on ajoute en queue des sommets \(z\) avec \(n(z) = n(y_1) + 1\), on a bien \(n(y_r) \le n(y_1) + 1 = n(z) \le n(y_2) + 1\), et l'invariant est préservé. On en déduit que les valeurs de \(n\) des sommets retirés successivement de la file forment une suite croissante.

Supposons par l'absurde qu'il existe un sommet \(y\) accessible avec \(\delta(y) < n(y)\) et choisissons-le avec \(\delta(y)\) minimal. On a \(y \neq x\). Soit \(z\) le prédécesseur de \(y\) sur un chemin de longueur \(\delta(y)\) : on a \(\delta(z) = \delta(y) - 1\), donc par minimalité \(n(z) = \delta(z)\). Au moment où l'on retire \(z\) de la file :

  • soit \(y\) n'est pas encore marqué, et alors \(parent[y] = z\) et \(n(y) = n(z) + 1\) ;
  • soit \(y\) a déjà été marqué lors du retrait d'un sommet \(w\) retiré avant \(z\), donc \(n(y) = n(w) + 1 \le n(z) + 1\) par croissance.

Dans les deux cas \(n(y) \le n(z) + 1 = \delta(z) + 1 = \delta(y)\), ce qui est absurde.

Pseudo-parcours en profondeur

On peut se poser la question de la nature du parcours quelconque optimisé effectué avec une stratégie LIFO. Naïvement, on peut penser qu'il s'agit d'un parcours en profondeur. Cependant, si on considère le graphe :

On va avoir les deux arbres différents suivants :

Avec un parcours en profondeur :

Arbre du parcours en profondeur depuis a (en orange). Arbre du parcours en profondeur depuis a (en orange).
Arbre du parcours en profondeur depuis $a$ (en orange).

Avec un pseudo-parcours en profondeur :

Arbre du pseudo-parcours en profondeur depuis a (en orange). Arbre du pseudo-parcours en profondeur depuis a (en orange).
Arbre du pseudo-parcours en profondeur depuis $a$ (en orange).

On perd une propriété fondamentale du parcours en profondeur qui est qu'un parent ne peut empêcher un de ses enfants de découvrir un sommet. Ici, dans le pseudo-parcours, \(a\) bloque la découverte de \(c\) par \(b\).

Exercice

On fixe un graphe, l'ordre de ses listes d'adjacence et un sommet de départ, et on compare l'arbre (le tableau parent) obtenu par le parcours quelconque optimisé à celui du parcours en profondeur récursif utilisant les mêmes listes d'adjacence.

  1. Pour le graphe ci-dessus, parcouru depuis \(a\) avec la liste d'adjacence [b, c] pour \(a\), montrer qu'aucune stratégie de tirage dans le sac ne permet au parcours optimisé d'obtenir l'arbre du parcours en profondeur.
  2. Montrer qu'avec la liste d'adjacence [c, b] pour \(a\), les deux arbres coïncident, quelle que soit la stratégie. L'énoncé « aucune stratégie ne convient » n'a donc de sens qu'à ordre des listes d'adjacence fixé.
  3. Trouver un graphe et un sommet de départ pour lesquels, quel que soit l'ordre des listes d'adjacence, aucune stratégie ne permet au parcours optimisé d'obtenir l'arbre d'un parcours en profondeur.

Tri topologique

Un cas très important de graphes orientés est celui de graphes de dépendances :

  • les sommets sont des tâches à réaliser
  • une arête \(x \rightarrow y\) indique que la tâche \(x\) doit être réalisée avant la tâche \(y\).

Citons, par exemple, les dépendances entre chapitres dans un cours, entre unités de programme lors d'une compilation, entre produits dans un procédé industriel...

Ces graphes ont tous la particularité d'être orientés et acycliques. En effet, si on a un cycle, cela signifie qu'il y a une dépendance inextricable.

Remarque

On parle en anglais de DAG pour directed acyclic graph.

Définition

Soit \(G = (S, A)\) un graphe orienté acyclique. Une énumération \((x_1, \dots, x_n)\) des sommets de \(S\) est appelée un tri topologique lorsque pour toute arête \(x_i \rightarrow x_j\) on a \(i < j\).

Autrement dit, un tri topologique est un ordre linéaire de traitement des tâches respectant les dépendances.

Théorème

Soit \(G\) un graphe orienté acyclique et \(x_1, \dots, x_p\) les sommets de degré entrant nuls de \(G\).

Si on effectue successivement des parcours en profondeur récursifs depuis chacun des \(x_i\), en conservant les états d'un parcours à l'autre et avec la même horloge, alors les sommets triés dans l'ordre décroissant des temps de sortie constituent un tri topologique de \(G\).

Ici, l'horloge est un compteur incrémenté à chaque fois qu'un sommet devient traité, et le temps de sortie \(t_s(x)\) de \(x\) est la valeur de l'horloge à ce moment-là.

Preuve

Tous les sommets sont traités. Soit \(y \in S\). En remontant les prédécesseurs depuis \(y\), on obtient un chemin qui ne peut pas répéter de sommet puisque \(G\) est acyclique : il est donc de longueur au plus \(|S| - 1\) et se termine sur un sommet sans prédécesseur, c'est-à-dire un \(x_i\). Ainsi \(y\) est accessible depuis un \(x_i\), il est donc traité par le parcours issu de \(x_i\) (ou par un parcours antérieur).

Ordre. Il suffit de montrer que pour toute arête \(x \rightarrow y\), on a \(t_s(x) > t_s(y)\). Au moment où l'appel sur \(x\) examine l'arête \(x \rightarrow y\) :

  • si \(y\) est inconnu, l'appel sur \(y\) est lancé et se termine avant celui sur \(x\), donc \(t_s(y) < t_s(x)\) ;
  • si \(y\) est traité, \(t_s(y)\) est déjà fixé alors que \(x\) n'est pas encore traité, donc \(t_s(y) < t_s(x)\) ;
  • si \(y\) est découvert mais non traité, l'appel sur \(y\) est en cours et \(x\) est un de ses descendants : \(y \leadsto x \rightarrow y\) est un cycle, ce qui est impossible.

Remarque

Dans le vocabulaire de la classification des arêtes, les trois cas de cette preuve sont ceux d'une arête de liaison, d'une arête avant ou transverse, et d'une arête arrière : la preuve revient à dire qu'un graphe acyclique n'a pas d'arête arrière et que seules ces arêtes vérifient \(t_s(x) < t_s(y)\). L'horloge utilisée ici ne compte que les sorties alors que celle des temps d'entrée et de sortie compte aussi les entrées, mais l'ordre des temps de sortie est le même et c'est le seul qui intervient. Enfin, on y a vu qu'on peut lancer les parcours depuis tous les sommets, dans n'importe quel ordre, sans calculer les degrés entrants.

Pour programmer le tri topologique, plutôt que de calculer les temps de sortie puis de trier, on ajoute chaque sommet à une liste au moment où il devient traité : cette liste est dans l'ordre croissant des temps de sortie et il suffit de la renverser. En OCaml, on ajoute en tête et on obtient directement le bon ordre ; en C, on remplit le tableau en partant de la fin.

INCONNU = 0
DECOUVERT = 1
TRAITE = 2

def parcours(ladj, x, etat, ordre):
    for y in ladj[x]:
        if etat[y] == INCONNU:
            etat[y] = DECOUVERT
            parcours(ladj, y, etat, ordre)
    etat[x] = TRAITE
    ordre.append(x) # ordre croissant des temps de sortie

def tri_topologique(ladj):
    etat = [ INCONNU ] * len(ladj)
    ordre = []
    for x in range(len(ladj)):
        if etat[x] == INCONNU:
            etat[x] = DECOUVERT
            parcours(ladj, x, etat, ordre)
    ordre.reverse() # ordre décroissant des temps de sortie
    return ordre
type etat = Inconnu | Decouvert | Traite

let rec parcours ladj x etat ordre =
    List.iter (fun y ->
        if etat.(y) = Inconnu
        then begin
            etat.(y) <- Decouvert;
            parcours ladj y etat ordre
        end
    ) ladj.(x);
    etat.(x) <- Traite;
    (* x est traité : on le place devant les sommets déjà traités *)
    ordre := x :: !ordre

let tri_topologique ladj =
    let n = Array.length ladj in
    let etat = Array.make n Inconnu in
    let ordre = ref [] in
    for x = 0 to n - 1 do
        if etat.(x) = Inconnu
        then begin
            etat.(x) <- Decouvert;
            parcours ladj x etat ordre
        end
    done;
    !ordre
#include <stdlib.h>

enum etat { INCONNU, DECOUVERT, TRAITE };

void parcours(graphe g, int x, int *etat, int *ordre, int *k) {
    for (int i = 0; i < g.degre[x]; i++) {
        int y = g.voisins[x][i];
        if (etat[y] == INCONNU) {
            etat[y] = DECOUVERT;
            parcours(g, y, etat, ordre, k);
        }
    }
    etat[x] = TRAITE;
    // x est traité : on le place juste avant les sommets déjà traités
    *k -= 1;
    ordre[*k] = x;
}

// Remplit ordre (de taille n) avec un tri topologique de g supposé acyclique
void tri_topologique(graphe g, int *ordre) {
    int *etat = malloc(g.n * sizeof(int));
    for (int i = 0; i < g.n; i++) {
        etat[i] = INCONNU;
    }
    int k = g.n;
    for (int x = 0; x < g.n; x++) {
        if (etat[x] == INCONNU) {
            etat[x] = DECOUVERT;
            parcours(g, x, etat, ordre, &k);
        }
    }
    free(etat);
}

Plus courts chemins

Graphes pondérés et définition du problème

Définition

Un graphe pondéré est la donnée d'un graphe, orienté ou non, \(G = (S,A)\) et d'une fonction de pondération \(\pi : A \rightarrow \R\) qui associe à chaque arête un nombre réel, son poids.

Remarque

En informatique pratique, \(\R\) n'existe pas vraiment. Les poids seront donc le plus souvent des entiers ou des flottants. Cependant, les problèmes possiblement rencontrés par des poids réels sont pertinents et le cadre unifié rend les définitions plus simples.

Définition

On définit le poids d'un chemin \(\varphi\) comme la somme des poids de ses arêtes.

Exemple

Dans le graphe suivant :

Graphe pondéré ; en orange le chemin c rightarrow b rightarrow d rightarrow a, de poids -2. Graphe pondéré ; en orange le chemin c rightarrow b rightarrow d rightarrow a, de poids -2.
Graphe pondéré ; en orange le chemin \(c \rightarrow b \rightarrow d \rightarrow a\), de poids \(-2\).

le chemin \(\varphi = c \rightarrow b \rightarrow d \rightarrow a\) est de poids \(\pi(\varphi) = 0 - 1 -1 = -2\).

On remarque que \(b \rightarrow d \rightarrow a \rightarrow b\) est un cycle de poids \(-1\), on peut donc l'emprunter autant de fois qu'on le souhaite sur un chemin passant par \(a,b\) ou \(d\) et faire diminuer son poids autant qu'on le souhaite. On parle de cycle de poids négatif et leur présence est problématique.

Théorème

Si \(G\) est un graphe pondéré sans cycles de poids négatif, \(\varphi\) est un chemin et \(\psi\) est un sous-chemin de \(\varphi\) obtenu en enlevant un cycle, alors \(\pi(\varphi) \ge \pi(\psi)\).

Preuve

Si \(\varphi = \varphi_1 c\, \varphi_2\) où \(c\) est un cycle et \(\psi = \varphi_1 \varphi_2\), alors \(\pi(\varphi) = \pi(\psi) + \pi(c) \ge \pi(\psi)\) car \(\pi(c) \ge 0\).

Du théorème précédent, on en déduit que pour obtenir un chemin de poids minimum, on peut supposer que le chemin est élémentaire. En effet, si un chemin \((s_0, \dots, s_p)\) d'un graphe orienté n'est pas élémentaire, on choisit \(i < j\) avec \(s_i = s_j\) et \(j - i\) minimal (et \((i,j) \neq (0,p)\)) : la portion \((s_i, \dots, s_j)\) est alors un cycle (ses sommets sont distincts, sauf les extrémités, et elle est de longueur au moins 2 car il n'y a pas de boucle). On l'enlève, ce qui ne fait pas augmenter le poids, et on recommence ; la longueur diminuant strictement, ce procédé termine sur un chemin élémentaire. Or, comme il existe un nombre fini de chemins élémentaires entre deux sommets, on peut en déduire la définition suivante :

Remarque

Pour un graphe non orienté, on raisonne sur le graphe orienté symétrique associé. Une arête \(\{x, y\}\) de poids négatif y donne le cycle \(x \rightarrow y \rightarrow x\) de poids négatif : on peut faire des allers-retours sur cette arête pour faire diminuer le poids d'un chemin autant qu'on le souhaite. Pour un graphe non orienté, l'hypothèse « sans cycle de poids négatif » impose donc en pratique des poids positifs.

Définition

Soit \(G = (S,A,\pi)\) un graphe pondéré sans cycles de poids négatifs et \(x, y \in S\) tels que \(x \leadsto y\), on appelle plus court chemin de \(x\) à \(y\), un chemin \(\varphi : x \leadsto y\) tel que

\[ \pi(\varphi) = \min \enscomp{\pi(\psi)}{\psi : x \leadsto y} \]

Il est possible de travailler avec la complétion d'un graphe pondéré en considérant toutes les arêtes possibles mais en spécifiant que si \(x \rightarrow y\) n'est pas une arête du graphe initial, alors \(\pi(x \rightarrow y) = \infty\). On peut alors poser pour tout couple \((x,y) \in S^2\), \(dist(x,y) = \min \enscomp{\pi(\psi)}{\psi : x \leadsto y}\) sachant que cette distance vaut \(\infty\) lorsque \(y\) n'est pas accessible depuis \(x\).

On va s'intéresser à deux problèmes :

Problème : PlusCourtCheminSource

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\)
  • Un sommet source \(s \in S\)

Sortie :

  • La donnée pour chaque \(x \in S\), de la valeur \(dist(s,x)\).
  • Éventuellement, un chemin \(\varphi : s \leadsto x\) réalisant cette distance.

Problème : PlusCourtCheminToutCouple

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\)

Sortie :

  • La donnée pour chaque couple \(x, y \in S\), de la valeur \(dist(x,y)\).
  • Éventuellement, un chemin \(\varphi : x \leadsto y\) réalisant cette distance.

Le premier problème est naturellement inclus dans le second.

Cas des poids rationnels

Il se trouve qu'on a déjà trouvé un algorithme permettant de résoudre le problème quand \(\forall e \in A, \pi(e) = 1\) : le parcours en largeur.

Si les poids sont dans \(\Q_+^*\), on peut se ramener à des poids dans \(\N^*\) en multipliant chaque poids par le ppcm des dénominateurs des poids. Cela ne change pas la relation d'ordre entre les poids de chemins, et donc les plus courts chemins sont les mêmes.

Si \(\pi(x \rightarrow y) = k > 1\), on peut rajouter \(k-1\) sommets artificiels \(z_1, \dots, z_{k-1}\) et des arêtes de poids unitaire \(x \rightarrow z_1 \rightarrow z_2 \rightarrow \dots \rightarrow z_{k-1} \rightarrow y\) à la place de l'arête \(x \rightarrow y\). En procédant ainsi, on se ramène donc à un graphe dont les poids sont unitaires et on peut résoudre le problème avec un parcours en largeur.

Si on considère que la longueur des poids en mémoire fait partie de l'entrée, alors cette réduction est très coûteuse (le nombre de sommets ajoutés est de l'ordre de la somme des poids, donc exponentiel en la taille de leur écriture), mais si on considère que les poids sont bornés par une constante, alors toute cette réduction ne fait que grossir la constante du \(O(|S|+|A|)\) !

Remarque

On pourrait être tenté de supprimer les poids négatifs en ajoutant une constante à chaque poids, cependant, en faisant cela, on pénalise les chemins en fonction de leur longueur et on ne préserve pas la notion de plus court chemin.

Considérons par exemple le graphe :

Le plus court chemin de a à c (en orange) est a rightarrow b rightarrow c, de poids 2. Le plus court chemin de a à c (en orange) est a rightarrow b rightarrow c, de poids 2.
Le plus court chemin de \(a\) à \(c\) (en orange) est \(a \rightarrow b \rightarrow c\), de poids 2.

Le chemin le plus court entre \(a\) et \(c\) est de poids \(2\) et est le chemin \(a \rightarrow b \rightarrow c\).

Si on ajoute 5 à chaque poids pour qu'ils soient \(>0\), on obtient le graphe :

Après ajout de 5 à chaque poids, le plus court chemin de a à c devient a rightarrow c. Après ajout de 5 à chaque poids, le plus court chemin de a à c devient a rightarrow c.
Après ajout de 5 à chaque poids, le plus court chemin de \(a\) à \(c\) devient \(a \rightarrow c\).

Et ici, le chemin le plus court devient \(a \rightarrow c\) de poids \(9\) et non plus \(a \rightarrow b \rightarrow c\) qui devient de poids \(12\).

Plus généralement, si on ajoute \(\eta\) à chaque poids, alors le poids de \(\varphi\) devient \(\pi(\varphi) + \eta |\varphi|\) : l'ordre entre les poids de deux chemins de longueurs différentes n'est pas préservé.

Algorithme de Dijkstra

On se concentre ici sur le problème PlusCourtCheminSource dans le cas de poids positifs.

Si \(G = (S,A,\pi)\) est un graphe pondéré et \(s \in S\), on considère un étiquetage \(d : S \rightarrow \R \cup \{ \infty \}\) qu'on va faire évoluer dans un algorithme et tel que \(d(x)\) représente le plus petit poids trouvé jusqu'ici d'un chemin entre \(s\) et \(x\). En l'absence de cycle négatif, on sait qu'un tel chemin peut être supposé élémentaire.

Au départ, on pose \(d(s) = 0\) et \(\forall x \in S \backslash \{ s \}, d(x) = \infty\).

L'idée est d'emprunter les raccourcis, c'est-à-dire les arêtes \(y \rightarrow x\) telles que \(d(y) + \pi(y \rightarrow x) < d(x)\) :

Raccourci : on emprunte y rightarrow x si d(y) + pi(y rightarrow x) < d(x). Raccourci : on emprunte y rightarrow x si d(y) + pi(y rightarrow x) < d(x).
Raccourci : on emprunte $y \rightarrow x$ si $d(y) + \pi(y \rightarrow x) < d(x)$.

Algorithme : Dijkstra

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\) où \(\pi : A \rightarrow \R_+\)
  • Un sommet \(s \in S\)

Déroulement :

  • On crée un tableau d avec \(d(s) = 0\) et \(d(x) = \infty\) pour \(x \neq s\).
  • On crée un tableau a_traiter et on ajoute s
  • Les sommets sont initialement non marqués
  • Tant que a_traiter est non vide
    • On retire le sommet x de a_traiter de valeur \(d(x)\) minimale
    • Si x est non marqué
      • On marque x
      • Pour chaque arête \(x \rightarrow y\) où \(d(x) + \pi(x \rightarrow y) < d(y)\)
        • On pose \(d(y) = d(x) + \pi(x \rightarrow y)\)
        • On ajoute \(y\) dans a_traiter

Exemple

On considère le graphe non orienté suivant (vu comme un graphe orienté symétrique)

Graphe pondéré non orienté, de source s = 1 Graphe pondéré non orienté, de source s = 1
Graphe pondéré non orienté, de source \(s = 1\)

pour \(s = 1\) où les sommets dans a_traiter sont indiqués en bleu, les sommets marqués sont indiqués en vert (en orange celui qui vient d'être retiré), les sommets non atteints en gris et où la valeur de \(d\) est donnée dans le cadre à côté du sommet :

Initialisation : d(1) = 0, d(x) = infty ailleurs. Initialisation : d(1) = 0, d(x) = infty ailleurs.
Initialisation : \(d(1) = 0\), \(d(x) = \infty\) ailleurs.

On commence par retirer 1 qui est seul dans a_traiter avec \(d(1) = 0\). On peut alors ajouter les sommets \(2, 3\) et \(6\) :

On retire alors le sommet 2 qui a la plus petite valeur de \(d\). L'arête \(2 \rightarrow 4\) vérifie la condition mais pas l'arête \(2 \rightarrow 3\) (car \(7 + 10 \ge 9\)). On ajoute donc 4 dans les sommets à traiter :

On retire alors 3 et on voit que les deux arêtes \(3 \rightarrow 6\) et \(3 \rightarrow 4\) permettent d'améliorer \(d\) (\(9 + 2 < 14\) et \(9 + 11 < 22\)). On rajoute donc \(4\) et \(6\) dans a_traiter :

On retire alors le sommet 6 qui permet d'emprunter \(6 \rightarrow 5\) et qui rajoute ainsi 5 à a_traiter :

On traite les deux derniers sommets, 4 et 5 (tous deux avec \(d = 20\)), en les retirant : aucune arête ne vérifie plus la condition.

État final : les valeurs de d sont les distances depuis 1. État final : les valeurs de d sont les distances depuis 1.
État final : les valeurs de \(d\) sont les distances depuis 1.

Cet algorithme est essentiellement un parcours quelconque où la stratégie de sortie du sac est de retirer le sommet de plus petite valeur de \(d\) (un successeur \(y\) d'un sommet marqué qui n'est pas ajouté vérifie \(d(y) < \infty\), il a donc déjà été ajouté). On sait donc déjà qu'il termine et qu'il explore \(acc(s) = \enscomp{ x \in S }{ s \leadsto x }\).

Comme on ne modifie \(d\) que pour mettre des nombres \(\neq \infty\), on est assuré qu'à la fin de l'algorithme les sommets dans \(acc(s)\) ont une valeur dans \(\R_+\) dans le tableau \(d\) et que les autres ont \(\infty\). La force de l'algorithme de Dijkstra découle du contenu final de \(d\) qui répond à la question voulue :

Théorème

À la fin de l'algorithme, si \(s \leadsto x\), \(d(x) = dist(s, x)\).

Preuve

Une récurrence immédiate montre qu'à tout moment, si \(d(x) < \infty\), alors \(d(x)\) est le poids d'un chemin de \(s\) à \(x\), donc \(d(x) \ge dist(s, x)\).

Montrons que lorsqu'un sommet \(x\) est marqué, on a \(d(x) = dist(s,x)\). Comme \(d(x)\) ne peut ensuite que diminuer et reste minoré par \(dist(s,x)\), cette valeur ne changera plus, ce qui prouve le théorème puisque tous les sommets accessibles sont marqués.

Le premier sommet marqué est \(s\) avec \(d(s) = 0 = dist(s,s)\) (les poids étant positifs). Supposons par l'absurde qu'un sommet \(x\) soit marqué avec \(d(x) > dist(s,x)\), et considérons le premier tel sommet. Soit \(\varphi\) un plus court chemin de \(s\) à \(x\). Comme \(s\) est marqué et que \(x\) ne l'est pas encore au moment où on le retire, il existe une première arête \(u \rightarrow v\) de \(\varphi\) avec \(u\) marqué et \(v\) non marqué. Lors du marquage de \(u\), on avait \(d(u) = dist(s,u)\) par minimalité de \(x\), et l'arête \(u \rightarrow v\) a été examinée : on a donc ensuite \(d(v) \le dist(s,u) + \pi(u \rightarrow v) = dist(s,v)\), la portion de \(\varphi\) allant de \(s\) à \(v\) étant un plus court chemin. De plus, \(v\) est alors dans a_traiter : il y a été ajouté lors de la dernière diminution de \(d(v)\) et n'en a pas été retiré puisqu'il n'est pas marqué.

Comme les poids sont positifs, \(dist(s, v) \le dist(s, x)\), donc \(d(v) \le dist(s,x) < d(x)\). Ceci contredit le choix de \(x\) comme sommet de a_traiter de valeur \(d\) minimale (si \(v = x\), c'est directement \(d(x) \le dist(s,x)\) qui est contredit).

C'est la positivité des poids qui permet d'affirmer \(dist(s,v) \le dist(s,x)\) : avec des poids négatifs, ce raisonnement tombe en défaut.

On peut implémenter directement cet algorithme :

let rec extrait_plus_petit l d =
    match l with
    | [] -> failwith "impossible"
    | [x] -> (x, [])
    | x::q ->
        let (y, q') = extrait_plus_petit q d in
        if d.(y) < d.(x)
        then (y, x :: q')
        else (x, q)

let dijkstra g src =
    let n = Array.length g in
    let inf = max_int in
    let d = Array.make n inf in
    d.(src) <- 0;
    let marques = Array.make n false in
    let a_traiter = ref [ src ] in
    while !a_traiter <> [] do
        let x, q = extrait_plus_petit !a_traiter d in
        a_traiter := q;
        if not marques.(x)
        then begin
            marques.(x) <- true;
            List.iter (fun (y, poids) ->
                let n_d = d.(x) + poids in
                if n_d < d.(y)
                then begin
                    d.(y) <- n_d;
                    a_traiter := y :: !a_traiter
                end
            ) g.(x)
        end
    done;
    d
def extrait_plus_petit(t, d):
    x = t[0] # recherche du d[x] min dans t
    for i in t[1:]:
        if d[i] < d[x]:
            x = i
    t.remove(x) # on le retire
    return x # on le renvoie

def dijkstra(g, src):
    n = len(g)
    inf = float('inf')
    d = [ inf ] * n
    d[src] = 0
    a_traiter = [ src ]
    marques = [ False ] * n
    while a_traiter != []:
        x = extrait_plus_petit(a_traiter, d)
        if not marques[x]:
            marques[x] = True
            for y, poids in g[x]:
                n_d = d[x] + poids
                if n_d < d[y]:
                    d[y] = n_d
                    a_traiter.append(y)
    return d # distances finales
#include <stdlib.h>
#include <stdbool.h>
#include <limits.h>
#include <assert.h>

#define INF INT_MAX

struct arete {
    int but;
    int poids;
};
typedef struct arete arete;

struct graphe_pondere {
    int n;
    int *degre;
    arete **voisins; // voisins[x][i] : i-ème arête issue de x
};
typedef struct graphe_pondere graphe_pondere;

// Retire de a_traiter (de taille *taille) un sommet x
// de d[x] minimal et le renvoie
int extrait_plus_petit(int *a_traiter, int *taille, int *d) {
    int i_min = 0; // recherche du d[x] min dans a_traiter
    for (int i = 1; i < *taille; i++) {
        if (d[a_traiter[i]] < d[a_traiter[i_min]]) {
            i_min = i;
        }
    }
    int x = a_traiter[i_min];
    // on le retire en le remplaçant par le dernier élément
    *taille -= 1;
    a_traiter[i_min] = a_traiter[*taille];
    return x;
}

// Renvoie le tableau des distances (à libérer par l'appelant)
int *dijkstra(graphe_pondere g, int src) {
    int *d = malloc(g.n * sizeof(int));
    bool *marques = malloc(g.n * sizeof(bool));
    int nb_aretes = 0;
    for (int x = 0; x < g.n; x++) {
        d[x] = INF;
        marques[x] = false;
        nb_aretes += g.degre[x];
    }
    d[src] = 0;
    // au plus |A| + 1 ajouts dans a_traiter
    int capacite = nb_aretes + 1;
    int *a_traiter = malloc(capacite * sizeof(int));
    int taille = 0;
    a_traiter[taille++] = src;
    while (taille > 0) {
        int x = extrait_plus_petit(a_traiter, &taille, d);
        if (!marques[x]) {
            marques[x] = true;
            for (int i = 0; i < g.degre[x]; i++) {
                int y = g.voisins[x][i].but;
                int n_d = d[x] + g.voisins[x][i].poids;
                if (n_d < d[y]) {
                    d[y] = n_d;
                    assert(taille < capacite);
                    a_traiter[taille++] = y;
                }
            }
        }
    }
    free(a_traiter);
    free(marques);
    return d; // distances finales
}

Dans les trois versions, le graphe est donné par listes d'adjacence où chaque voisin est accompagné du poids de l'arête, et a_traiter est une simple liste (un tableau en C) dans laquelle on cherche linéairement le minimum. En C, un sommet peut figurer plusieurs fois dans a_traiter, comme dans les deux autres versions : on dimensionne donc le tableau avec le nombre maximal d'ajouts \(|A| + 1\) justifié dans la remarque suivante. Les poids étant des entiers, on représente \(\infty\) par INT_MAX (et par max_int en OCaml) : on ne lui ajoute jamais rien car seuls des sommets marqués, donc de distance finie, propagent leur valeur.

Remarque

La complexité de la recherche du sommet de valeur \(d\) minimale est linéaire en la taille de a_traiter. Chaque ajout dans a_traiter correspond à une arête \(x \rightarrow y\) examinée depuis un sommet \(x\) marqué, donc il y a au plus \(|A| + 1\) ajouts, et autant de retraits, dans une liste de taille au plus \(|A| + 1\) : on a une complexité totale en \(O(|S| + |A|^2)\). Si on n'ajoute \(y\) que lorsqu'il n'est pas déjà dans a_traiter, cette liste est de taille au plus \(|S|\) et on obtient \(O(|S| \times |A|)\) (on peut même obtenir \(O(|S|^2)\) en cherchant le minimum directement dans le tableau d parmi les sommets non marqués).

Utilisation d'une file de priorité

On peut améliorer l'algorithme précédent avec une file de priorité où la priorité de \(x\) est \(d(x)\) et où on suppose qu'on extrait la priorité minimale. Cela permet ainsi d'obtenir une complexité en \(O(|S| + |A| \log |S|)\).

Cependant, il est alors nécessaire de modifier la priorité ce qui est une opération assez pénible. À la place, on peut ajouter une autre fois le sommet. Au pire, on fera grossir la file (qui reste de longueur \(O(|A|)\)) et quand on retirera un sommet déjà traité, celui-ci sera ignoré. Comme \(\log |A| \le \log |S|^2 = 2 \log |S|\), la complexité reste en \(O(|S| + |A| \log |S|)\).

On obtient ainsi l'algorithme suivant :

Algorithme : Dijkstra

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\) où \(\pi : A \rightarrow \R_+\)
  • Un sommet \(s \in S\)

Déroulement :

  • On crée un tableau d avec \(d(s) = 0\) et \(d(x) = \infty\) pour \(x \neq s\).
  • On crée une file de min-priorité a_traiter et on ajoute s avec priorité 0
  • Les sommets sont initialement non marqués
  • Tant que a_traiter est non vide
    • On retire le sommet x de a_traiter
    • Si x est non marqué
      • On marque x
      • Pour chaque arête \(x \rightarrow y\) où \(d(x) + \pi(x \rightarrow y) < d(y)\)
        • On pose \(d(y) = d(x) + \pi(x \rightarrow y)\)
        • On insère \(y\) avec priorité \(d(y)\)

Relaxation

Pour éclairer la correction de Dijkstra et le lien avec la positivité des poids des arêtes, on va présenter ici un cadre plus général.

Arêtes tendues

Définition

On dit qu'une arête \(x \rightarrow y\) est tendue si

\[d(y) > d(x) + \pi(x \rightarrow y)\]

Quand on pose ensuite \(d(y) = d(x) + \pi(x \rightarrow y)\) on dit qu'on relâche l'arête.

Ainsi, une arête est tendue quand on peut l'emprunter pour améliorer l'estimation du plus petit chemin de \(s\) à \(y\).

Théorème

On suppose que \(G\) est sans cycle de poids négatif, que \(d(s) = 0\) et que pour tout \(x \in S\), \(d(x)\) vaut \(\infty\) ou le poids d'un chemin de \(s\) à \(x\) (c'est le cas initialement et cela reste vrai quand on relâche une arête). Si aucune arête n'est tendue, alors \(d(x) = dist(s, x)\) pour tout \(x \in S\) (avec \(=\infty\) si \(x\) est inaccessible).

Preuve

Par hypothèse, \(d(x) \ge dist(s,x)\) pour tout \(x\), en particulier \(d(x) = \infty = dist(s,x)\) si \(x\) est inaccessible. Il reste à montrer que \(d(x) \le dist(s,x)\) pour \(x\) accessible.

Pour un tel \(x\), on note \(k(x)\) le nombre minimal d'arêtes d'un plus court chemin de \(s\) à \(x\), et on raisonne par récurrence forte sur \(k(x)\).

Si \(k(x) = 0\), alors \(x = s\) et \(d(s) = 0 = dist(s,s)\) (il n'y a pas de cycle de poids négatif).

Si \(k(x) \ge 1\), soit \(\varphi\) un plus court chemin de \(s\) à \(x\) ayant \(k(x)\) arêtes et \(y\) le prédécesseur de \(x\) dans ce chemin : on a \(\varphi = \psi\,(y \rightarrow x)\) où \(\psi : s \leadsto y\). Nécessairement, le chemin \(\psi\) est un plus court chemin de \(s\) à \(y\), sinon on obtiendrait un chemin vers \(x\) de poids strictement plus petit que celui de \(\varphi\). Ainsi \(k(y) \le k(x) - 1\) et, par hypothèse de récurrence, \(d(y) \le dist(s, y) = \pi(\psi)\).

Comme l'arête \(y \rightarrow x\) n'est pas tendue, on a alors \(d(x) \le d(y) + \pi(y \rightarrow x) \le \pi(\psi) + \pi(y \rightarrow x) = \pi(\varphi) = dist(s,x)\).

On en déduit un pseudo-algorithme appelé méthode de relaxation :

Algorithme : Relaxation

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\) sans cycle de poids négatif
  • Un sommet \(s \in S\)

Déroulement :

  • On pose \(d\) tel que \(d(s) = 0\) et \(\forall x \in S \backslash \{ s \}, d(x) = \infty\).
  • Tant qu'il existe une arête \(e\) tendue
    • On relâche l'arête \(e\)

Remarque

  • On montre par récurrence sur le nombre d'itérations que si \(d(x) < \infty\) alors \(d(x)\) est le poids d'un chemin simple de \(s\) à \(x\). Comme ces chemins simples sont en nombre fini, leurs poids aussi et donc \(d\) ne peut prendre qu'un nombre fini de valeurs. Comme \(d\) diminue strictement en un point à chaque itération, la boucle termine.
  • En présence d'un cycle de poids négatif accessible depuis \(s\), on pourrait continuellement relâcher les arêtes du cycle et donc l'algorithme ne termine pas.
  • En sortie de l'algorithme on a donc \(dist(s,x) \le dist(s,y) + \pi(y\rightarrow x)\) pour toute arête \(y \rightarrow x\). Or, on a l'égalité pour l'avant-dernier sommet \(y\) d'un plus court chemin. On en déduit donc l'égalité très importante suivante :

    \[ \forall x \in S \backslash \{ s \}, dist(s, x) = \min\enscomp{ dist(s,y) + \pi(y \rightarrow x) }{y \in S} \]

    En fait, si on considère l'opération \(\Phi\) définie par \(\Phi(d)(s) = 0\) et :

    \[ \forall x \in S \backslash\{s\}, \Phi(d)(x) = \min \enscomp{ d(y) + \pi(y \rightarrow x) }{y \in S} \]

    on remarque que \(x \mapsto dist(s, x)\) est un point fixe de \(\Phi\).

    On pourrait ainsi le calculer comme la limite de la suite stationnaire \(d_{n+1} = \Phi(d_n)\) où \(d_0(s) = 0\) et \(\forall x \neq s, d_0(x) = \infty\).

Si on considère le graphe

Graphe pondéré non orienté, de source s = 1 Graphe pondéré non orienté, de source s = 1
Graphe pondéré non orienté, de source \(s = 1\)

et que l'on représente la suite \(d\) sous une forme de tableau, on obtient (pour \(s = 1\)) :

\[ \begin{array}{c|cccccc} n &d_n(1) & d_n(2) & d_n(3) &d_n(4) & d_n(5) & d_n(6) \\ \hline 0 & 0 & \infty & \infty & \infty & \infty & \infty \\ 1 & 0 & 7 & 9 & \infty & \infty & 14\\ 2 & 0 & 7 & 9 & 20 & 23 & 11 \\ 3 & 0 & 7 & 9 & 20 & 20 & 11 \\ 4 & 0 & 7 & 9 & 20 & 20 & 11 \\ \vdots & & & & & & \end{array} \]

On peut montrer par récurrence sur \(n\) que, pour \(x \neq s\), \(d_n(x)\) est le plus petit poids d'un chemin de \(s\) à \(x\) ayant au plus \(n\) arêtes. En l'absence de cycles négatifs, un plus court chemin peut être supposé élémentaire, donc avoir au plus \(|S|-1\) arêtes : la suite est stationnaire à partir du rang \(|S|-1\) au plus. L'algorithme obtenu s'appelle l'algorithme de Bellman-Ford.

On en déduit une implémentation directe de l'algorithme de Bellman-Ford, avec la même représentation des graphes pondérés que pour Dijkstra. On calcule \(d_k\) à partir de \(d_{k-1}\) pour \(k\) allant de \(1\) à \(|S|-1\), puis on vérifie qu'aucune arête n'est encore tendue.

def bellman_ford(g, src):
    # renvoie None si un cycle de poids négatif est accessible depuis src
    n = len(g)
    inf = float('inf')
    d = [ inf ] * n
    d[src] = 0
    for k in range(1, n):
        n_d = d.copy() # calcul de d_k à partir de d_{k-1}
        for x in range(n):
            for y, poids in g[x]:
                if d[x] + poids < n_d[y]:
                    n_d[y] = d[x] + poids
        d = n_d
    # une arête encore tendue révèle un cycle de poids négatif
    for x in range(n):
        for y, poids in g[x]:
            if d[x] + poids < d[y]:
                return None
    return d
(* Renvoie Some d, ou None si un cycle de poids négatif
   est accessible depuis src *)
let bellman_ford g src =
    let n = Array.length g in
    let inf = max_int in
    let d = Array.make n inf in
    d.(src) <- 0;
    for _ = 1 to n - 1 do
        (* calcul de d_k à partir de d_{k-1}, recopié ensuite dans d *)
        let n_d = Array.copy d in
        for x = 0 to n - 1 do
            if d.(x) <> inf
            then List.iter (fun (y, poids) ->
                if d.(x) + poids < n_d.(y)
                then n_d.(y) <- d.(x) + poids
            ) g.(x)
        done;
        Array.blit n_d 0 d 0 n
    done;
    (* une arête encore tendue révèle un cycle de poids négatif *)
    let tendue = ref false in
    for x = 0 to n - 1 do
        if d.(x) <> inf
        then List.iter (fun (y, poids) ->
            if d.(x) + poids < d.(y)
            then tendue := true
        ) g.(x)
    done;
    if !tendue then None else Some d
#include <stdlib.h>
#include <limits.h>

#define INF INT_MAX

// Renvoie le tableau des distances (à libérer par l'appelant),
// ou NULL si un cycle de poids négatif est accessible depuis src
int *bellman_ford(graphe_pondere g, int src) {
    int *d = malloc(g.n * sizeof(int));
    int *n_d = malloc(g.n * sizeof(int));
    for (int x = 0; x < g.n; x++) {
        d[x] = INF;
    }
    d[src] = 0;
    for (int k = 1; k < g.n; k++) {
        // calcul de d_k à partir de d_{k-1} dans n_d
        for (int x = 0; x < g.n; x++) {
            n_d[x] = d[x];
        }
        for (int x = 0; x < g.n; x++) {
            if (d[x] != INF) {
                for (int i = 0; i < g.degre[x]; i++) {
                    int y = g.voisins[x][i].but;
                    if (d[x] + g.voisins[x][i].poids < n_d[y]) {
                        n_d[y] = d[x] + g.voisins[x][i].poids;
                    }
                }
            }
        }
        int *tmp = d;
        d = n_d;
        n_d = tmp;
    }
    free(n_d);
    // une arête encore tendue révèle un cycle de poids négatif
    for (int x = 0; x < g.n; x++) {
        if (d[x] != INF) {
            for (int i = 0; i < g.degre[x]; i++) {
                int y = g.voisins[x][i].but;
                if (d[x] + g.voisins[x][i].poids < d[y]) {
                    free(d);
                    return NULL;
                }
            }
        }
    }
    return d;
}

Remarque

  • Le tableau n_d est initialisé avec \(d_{k-1}\) et pas avec des \(\infty\) : en l'absence de cycle de poids négatif, cela ne change rien car \(d_k(y) \le d_{k-1}(y)\), et cela évite de traiter à part le sommet \(s\).
  • La complexité est en \(O(|S| \times (|S| + |A|))\) en temps et \(O(|S|)\) en espace.
  • La vérification finale détecte exactement les cycles de poids négatif accessibles depuis \(s\). S'il n'y en a pas, on peut se restreindre aux sommets accessibles depuis \(s\) : \(d\) contient les distances, qui vérifient \(dist(s,y) \le dist(s,x) + \pi(x \rightarrow y)\), et aucune arête n'est tendue. Réciproquement, si \(c = v_0 \rightarrow v_1 \rightarrow \dots \rightarrow v_p = v_0\) est un cycle accessible depuis \(s\), ses sommets ont une valeur de \(d\) finie (ils sont accessibles par un chemin élémentaire, d'au plus \(|S|-1\) arêtes). Si aucune arête n'était tendue, on aurait \(d(v_i) \le d(v_{i-1}) + \pi(v_{i-1} \rightarrow v_i)\) pour tout \(i \in \range{1}{p}\) et, en sommant, \(0 \le \pi(c)\) : le cycle n'est pas de poids négatif.

Algorithme de Dijkstra comme relaxation

Quand on retire un sommet \(x\) de la file, si \(e = x \rightarrow y\) alors soit l'arête est non tendue et alors \(d(y) \le d(x) + \pi(x \rightarrow y)\), ce qui signifie qu'on connaît déjà un chemin simple de poids \(d(y)\) qui est au moins aussi bon que de passer par \(x\). Cela signifie notamment que \(y\) a déjà été découvert à un moment par l'algorithme (\(d(y) < \infty\)). Soit l'arête est tendue et passer par \(x\) améliore le chemin vers \(y\). On relâche l'arête. On a alors deux possibilités : soit \(y\) est dans la file, et on change sa priorité, soit on l'insère dans la file.

Quand la file est vide, aucune arête n'est tendue : en effet, après le dernier retrait d'un sommet \(x\), les arêtes issues de \(x\) ne sont plus tendues, et elles ne peuvent le redevenir que si \(d(x)\) diminue, auquel cas \(x\) est remis dans la file. D'après le théorème précédent, \(d\) contient alors les distances.

En procédant ainsi, on obtient un algorithme qui implémente le pseudo-algorithme précédent :

Algorithme : ProtoDijkstra

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\) sans cycle négatif
  • Un sommet \(s \in S\)

Déroulement :

  • On crée un tableau d avec \(d(s) = 0\) et \(d(x) = \infty\) pour \(x \neq s\).
  • On crée une file de min-priorité a_traiter et on ajoute s avec priorité 0
  • Tant que a_traiter est non vide
    • On retire le sommet x de a_traiter
    • Pour chaque arête \(x \rightarrow y\) tendue vis-à-vis de \(d\)
      • On relâche l'arête dans \(d\)
      • Si \(y\) est dans a_traiter
        • Alors, on remplace sa priorité par la nouvelle valeur de \(d(y)\)
        • Sinon, on insère \(y\) avec priorité \(d(y)\)

Exemple

L'exemple suivant permet d'observer l'algorithme sur un graphe comportant des poids négatifs. On a indiqué en bleu les sommets sur la file, en orange le sommet actuellement traité, en vert les sommets déjà retirés et en gris les sommets non atteints. Les arêtes de parenté sont présentées comme dans l'exemple précédent à mesure qu'elles sont modifiées.

On va ainsi partir du graphe suivant et du sommet source \(a\) :

Graphe à poids négatifs, source a. Graphe à poids négatifs, source a.
Graphe à poids négatifs, source \(a\).

Contrairement à cet exemple, on constate, comme on l'a fait dans le paragraphe précédent, qu'avec des poids positifs l'algorithme se comporte particulièrement bien et d'une manière assez proche du parcours en largeur. Notamment, un sommet n'est placé qu'une fois sur la file de priorité : quand il en est retiré, \(d\) vaut déjà sa distance à \(s\) (c'est ce qu'on a montré pour l'algorithme de Dijkstra) et ne diminuera plus. On constate donc une vague de traitement qui progresse linéairement sur le graphe.

On peut donc se permettre de ne pas avoir à implémenter l'opération assez pénible de mise à jour de la priorité.

Remarque

Cette opération n'est pas vraiment compliquée dans un tas car il suffit de changer la valeur et de faire remonter l'élément le long de sa branche en direction de la racine en cas d'inversion de priorité. Le problème est qu'on a besoin de préserver un lien entre les sommets et leur emplacement dans le tas afin de savoir où est le nœud qui le contient. Le plus simple est de rajouter un dictionnaire, par exemple avec une table de hachage, mais cela alourdit conséquemment l'implémentation du tas car il faut maintenir l'intégrité du dictionnaire.

À la place, on va ajouter un sommet avec une autre priorité, meilleure, et il sera ainsi retiré avant. Pour ne pas traiter deux fois un sommet, on adopte alors une notion de marquage comme dans les parcours précédents. On retrouve ainsi l'algorithme de Dijkstra avec file de priorité présenté plus haut.

Floyd-Warshall

Pour résoudre le problème PlusCourtCheminToutCouple dans le cas d'un graphe orienté, l'algorithme de Floyd-Warshall va considérer une énumération \(x_1, \dots, x_n\) des sommets du graphe et construire une famille de matrices \(dist^{(k)}\) où \(dist^{(k)}(i, j)\) indique le poids d'un plus court chemin de \(x_i\) à \(x_j\) dont les sommets intermédiaires sont dans \(x_1, \dots, x_k\) lorsqu'un tel chemin existe, et \(\infty\) sinon.

On a donc \(dist^{(0)}(i, j) = \begin{cases} 0 \text{ si } i = j \\ \pi(x_i \rightarrow x_j) \text{ si } x_i \rightarrow x_j \in A \\ \infty \text{ sinon} \end{cases}\).

On remarque rapidement, en l'absence de cycle négatif, que ces matrices vérifient l'égalité, pour \(k > 0\) :

\[ dist^{(k)}(i,j) = \min\left( dist^{(k-1)}(i,j), dist^{(k-1)}(i,k) + dist^{(k-1)}(k,j) \right) \]

En effet, un plus court chemin pouvant être supposé élémentaire, soit il ne passe pas par \(x_k\), et son poids est \(dist^{(k-1)}(i,j)\), soit il passe exactement une fois par \(x_k\), et il se décompose en un chemin de \(x_i\) à \(x_k\) puis un chemin de \(x_k\) à \(x_j\) dont les sommets intermédiaires sont dans \(x_1, \dots, x_{k-1}\).

On en déduit un algorithme consistant à calculer successivement ces \(n+1\) matrices avec trois boucles for imbriquées.

Exemple

Si on considère le graphe

Graphe à poids négatifs sans cycle négatif. Graphe à poids négatifs sans cycle négatif.
Graphe à poids négatifs sans cycle négatif.

on va avoir la suite de matrices suivantes, dans l'ordre d'énumération alphabétique des sommets :

\[ dist^{(0)} = \begin{pmatrix} 0&\infty&-2&\infty\\ 4&0&\infty&\infty\\ \infty&\infty&0&2\\ \infty&-1&\infty&0\\ \end{pmatrix} \]
\[ dist^{(1)} = \begin{pmatrix} 0&\infty&-2&\infty\\ 4&0&2&\infty\\ \infty&\infty&0&2\\ \infty&-1&\infty&0\\ \end{pmatrix} \]
\[ dist^{(2)} = \begin{pmatrix} 0&\infty&-2&\infty\\ 4&0&2&\infty\\ \infty&\infty&0&2\\ 3&-1&1&0\\ \end{pmatrix} \]
\[ dist^{(3)} = \begin{pmatrix} 0&\infty&-2&0\\ 4&0&2&4\\ \infty&\infty&0&2\\ 3&-1&1&0\\ \end{pmatrix} \]
\[ dist^{(4)} = \begin{pmatrix} 0&-1&-2&0\\ 4&0&2&4\\ 5&1&0&2\\ 3&-1&1&0\\ \end{pmatrix} \]

Une première version consisterait à calculer toutes les matrices, ce qui prend un espace en \(O(|S|^3)\). On peut optimiser cela avec une paire de matrices qui changent de rôle, mais on peut faire mieux. En effet, on a \(dist^{(k)}(i,k) = dist^{(k-1)}(i,k)\) car \(dist^{(k-1)}(k,k) = 0\) en l'absence de cycle négatif, et de même \(dist^{(k)}(k,j) = dist^{(k-1)}(k,j)\) : la ligne et la colonne \(k\) ne sont pas modifiées à l'étape \(k\). On en déduit donc qu'on peut effectuer le calcul en place dans une seule matrice. On obtient alors l'algorithme suivant :

Algorithme : Floyd-Warshall

Entrées :

  • Un graphe pondéré \(G = (S,A,\pi)\) et une énumération \(x_1, \dots, x_n\) de \(S\)

Déroulement :

  • On initialise une matrice \(d\) telle que \(d(i,i) = 0\), \(d(i,j) = \pi(x_i\rightarrow x_j)\) si \(x_i \rightarrow x_j \in A\) et \(d(i,j) = \infty\) sinon
  • Pour k allant de 1 à n
    • Pour i allant de 1 à n
      • Pour j allant de 1 à n
        • \(d(i,j) = \min(d(i,j), d(i,k)+d(k,j))\)

On en déduit le programme suivant, où le graphe est donné, comme pour Dijkstra, par listes d'adjacence pondérées et où les sommets sont numérotés à partir de \(0\). En OCaml et en C, on teste explicitement \(\infty\) pour ne pas dépasser la capacité des entiers.

def floyd_warshall(g):
    n = len(g)
    inf = float('inf')
    d = [ [ inf ] * n for _ in range(n) ]
    for i in range(n):
        d[i][i] = 0
        for j, poids in g[i]:
            d[i][j] = poids
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if d[i][k] + d[k][j] < d[i][j]:
                    d[i][j] = d[i][k] + d[k][j]
    return d
let floyd_warshall g =
    let n = Array.length g in
    let inf = max_int in
    let d = Array.make_matrix n n inf in
    for i = 0 to n - 1 do
        d.(i).(i) <- 0;
        List.iter (fun (j, poids) -> d.(i).(j) <- poids) g.(i)
    done;
    for k = 0 to n - 1 do
        for i = 0 to n - 1 do
            for j = 0 to n - 1 do
                if d.(i).(k) <> inf && d.(k).(j) <> inf
                   && d.(i).(k) + d.(k).(j) < d.(i).(j)
                then d.(i).(j) <- d.(i).(k) + d.(k).(j)
            done
        done
    done;
    d
#include <stdlib.h>
#include <limits.h>

#define INF INT_MAX

// Renvoie la matrice des distances (à libérer avec libere_matrice)
int **floyd_warshall(graphe_pondere g) {
    int n = g.n;
    int **d = malloc(n * sizeof(int *));
    for (int i = 0; i < n; i++) {
        d[i] = malloc(n * sizeof(int));
        for (int j = 0; j < n; j++) {
            d[i][j] = INF;
        }
        d[i][i] = 0;
        for (int a = 0; a < g.degre[i]; a++) {
            d[i][g.voisins[i][a].but] = g.voisins[i][a].poids;
        }
    }
    for (int k = 0; k < n; k++) {
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (d[i][k] != INF && d[k][j] != INF
                    && d[i][k] + d[k][j] < d[i][j]) {
                    d[i][j] = d[i][k] + d[k][j];
                }
            }
        }
    }
    return d;
}

void libere_matrice(int **d, int n) {
    for (int i = 0; i < n; i++) {
        free(d[i]);
    }
    free(d);
}

En l'absence de cycle de poids négatif, la matrice obtenue vérifie ainsi \(d(i,j) = dist(x_i,x_j)\). La complexité est en \(O(|S|^3)\) en temps et \(O(|S|^2)\) en espace.

Remarque

Il est aussi possible d'imposer que les plus courts chemins soient de longueur au moins 1, ce qui va mettre des \(\infty\) sur la diagonale dans \(dist^{(0)}\) et qui permettra, en fin d'algorithme, d'en déduire la présence de cycles ainsi que le poids d'un plus court cycle passant par \(x_i\) dans \(d(i,i)\).

Il y a de nombreux contextes dans lesquels la détermination d'une matrice des plus petits poids est suffisante. Cependant, si on a également besoin des chemins eux-mêmes, il est possible de construire une matrice de parenté \(P\) où \(P_{ij}\) indique le prédécesseur de \(x_j\) dans un plus court chemin de \(x_i\) à \(x_j\). On utilisera le symbole \(\perp\) quand un tel chemin n'existe pas (ou quand \(i = j\)).

Pour calculer \(P\) il suffit d'adapter l'algorithme précédent en considérant les \(P^{(k)}\) et en posant :

\[ P^{(0)}_{ij} = \begin{cases} i & \text{ si } x_i \rightarrow x_j \in A \\ \perp & \text{ sinon } \end{cases} \]

Puis,

\[ \text{Pour } k \ge 1, P^{(k)}_{ij} = \begin{cases} P^{(k-1)}_{ij} & \text{ si } d^{(k)}_{ij} = d^{(k-1)}_{ij} \\ P^{(k-1)}_{kj} & \text{ sinon } \end{cases} \]

Ce qui revient à changer le prédécesseur selon la résolution du \(\min\).

Remarque

  • En appliquant Floyd-Warshall à un graphe quelconque, on peut détecter la présence de cycles de poids négatifs. En effet, si un tel cycle existe, on aura nécessairement \(d(i,i) < 0\) à la fin pour un sommet \(x_i\) de ce cycle.
  • Floyd-Warshall a une application importante dans le cas du calcul de la fermeture transitive d'un graphe. En effet, il suffit de considérer la matrice d'adjacence au départ et d'appliquer Floyd-Warshall pour en déduire la matrice d'adjacence de la fermeture réflexive et transitive (en remplaçant les 0 hors de la diagonale par des \(\infty\)) où les arêtes sont en plus pondérées par la longueur d'un plus court chemin qui la réalise.