Parcours de graphes et plus courts chemins
Cours complet · informatique (MP2I/MPI), chapitre 19 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
19.1 Une seule idée, deux structures
Parcourir un graphe, c'est visiter chaque sommet accessible une fois et une seule. L'algorithme tient en quatre lignes, et il est le même pour les deux parcours du programme : on garde une réserve de sommets découverts mais non traités, on en retire un, on marque et on empile ses voisins.
Tout tient dans la nature de la réserve.
| Réserve | Parcours obtenu | Ce qu'il donne |
|---|---|---|
| Pile (lifo) | en profondeur | tri topologique, cycles, composantes |
| File (fifo) | en largeur | plus courts chemins à arcs unitaires |
| File de priorité | Dijkstra | plus courts chemins pondérés |
C'est l'illustration la plus nette du chapitre chap:abstraction. Les trois lignes du tableau partagent le même squelette ; seule la structure de réserve change, et avec elle la nature du parcours. Une pile explore au plus loin avant de revenir ; une file explore par cercles concentriques ; une file de priorité explore par distance croissante.
19.2 Le squelette
(* Marque tous les sommets accessibles depuis s. g est en listes d'adjacence.
Précondition : 0 <= s < Array.length g. *)
let parcours g s =
let n = Array.length g in
let vu = Array.make n false in
let reserve = creer () in (* pile OU file : tout est là *)
ajouter reserve s;
vu.(s) <- true;
while not (est_vide reserve) do
let u = retirer reserve in
List.iter (fun v ->
if not vu.(v) then begin
vu.(v) <- true; (* on marque EN AJOUTANT *)
ajouter reserve v
end) g.(u)
done;
vu
Si l'on ne marquait qu'au retrait, un sommet ayant plusieurs prédécesseurs serait ajouté plusieurs fois à la réserve avant d'être traité. Le résultat resterait correct, mais la complexité pourrait exploser — jusqu'à sur certains graphes. La ligne vu.(v) <- true doit accompagner l'ajout.
Avec des listes d'adjacence, tout parcours est en : chaque sommet entre au plus une fois dans la réserve, et chaque arc est examiné au plus une fois. Avec une matrice d'adjacence, il devient — ce que le chapitre chap:graphes chiffrait.
19.3 Le parcours en profondeur
Avec une pile, ou — plus naturellement — par récursion, la pile d'exécution jouant le rôle de la réserve :
(* Marque les sommets accessibles depuis u. vu est modifié en place. *)
let rec profondeur g vu u =
vu.(u) <- true;
List.iter (fun v -> if not vu.(v) then profondeur g vu v) g.(u)
Les arcs qui ont servi à découvrir un sommet forment un arbre — l'arborescence du parcours. Les autres arcs se classent : arcs arrière (vers un ancêtre), avant (vers un descendant déjà vu), transverses. La présence d'un arc arrière caractérise l'existence d'un cycle.
19.3.1 Composantes connexes
(* Renvoie un tableau c tel que c.(u) est le numéro de la composante de u.
Complexité : Theta(n + m). *)
let composantes g =
let n = Array.length g in
let c = Array.make n (-1) in
let numero = ref 0 in
for s = 0 to n - 1 do
if c.(s) = -1 then begin (* sommet encore non atteint *)
let rec visiter u =
c.(u) <- !numero;
List.iter (fun v -> if c.(v) = -1 then visiter v) g.(u)
in
visiter s;
incr numero
end
done;
c
La boucle extérieure relance un parcours depuis chaque sommet non encore atteint : c'est ce qui permet d'atteindre toutes les composantes, et non seulement celle du départ. Le nombre final de !numero est le nombre de composantes.
19.3.2 Tri topologique
Dans un graphe orienté acyclique, un tri topologique est un ordre total sur les sommets tel que tout arc aille d'un sommet plus petit vers un plus grand. C'est un ordre d'exécution compatible avec les dépendances.
Méthode : Par un parcours en profondeur
Le programme précise le procédé : « tri topologique d'un graphe orienté acyclique à partir de parcours en profondeur ». La règle est d'une simplicité déconcertante : on empile un sommet quand on a fini de le traiter, et l'ordre topologique est la pile lue de haut en bas.
(* Ordre topologique de g. Précondition : g est acyclique.
Complexité : Theta(n + m). *)
let tri_topologique g =
let n = Array.length g in
let vu = Array.make n false in
let ordre = ref [] in
let rec visiter u =
vu.(u) <- true;
List.iter (fun v -> if not vu.(v) then visiter v) g.(u);
ordre := u :: !ordre (* APRÈS les descendants : c'est le postfixe *)
in
for s = 0 to n - 1 do if not vu.(s) then visiter s done;
!ordre
Démonstration (Pourquoi cela donne bien un ordre topologique)
Soit un arc . Au moment où l'on traite , deux cas.
- n'est pas encore vu : la visite de appelle celle de , qui se termine avant celle de . Donc est empilé avant , et se retrouve après lui dans la liste.
- est déjà vu : sa visite est nécessairement terminée. Car si elle était en cours, serait un ancêtre de dans l'arborescence, et l'arc fermerait un cycle — exclu par hypothèse. Donc est déjà empilé, et , empilé plus tard, le précède.
Dans les deux cas précède .
Le programme demande de « faire le lien entre accessibilité dans un graphe orienté acyclique et ordre ». Le voici, effectif : un dag définit un ordre partiel bien fondé (chapitre chap:induction), et le tri topologique en produit une extension linéaire — un ordre total compatible — en temps .
19.4 Le parcours en largeur
La réserve est une file. Le parcours visite alors les sommets par distance croissante à la source : d'abord la source, puis ses voisins, puis les voisins de ceux-ci.
(* Distances en nombre d'arcs depuis s ; -1 si inaccessible.
Complexité : Theta(n + m). *)
let largeur g s =
let n = Array.length g in
let d = Array.make n (-1) in
let f = Queue.create () in
d.(s) <- 0;
Queue.push s f;
while not (Queue.is_empty f) do
let u = Queue.pop f in
List.iter (fun v ->
if d.(v) = -1 then begin
d.(v) <- d.(u) + 1;
Queue.push v f
end) g.(u)
done;
d
est la longueur minimale d'un chemin de à .
Démonstration (Idée)
On montre par récurrence que la file contient à tout instant des sommets dont les distances prennent au plus deux valeurs consécutives et , les étant devant. Un sommet découvert depuis un sommet de distance reçoit donc ; et il ne peut exister de chemin plus court vers lui, faute de quoi il aurait été atteint depuis un sommet de distance , traité plus tôt.
Bonne pratique (Les usages que le programme cite)
« On peut évoquer la recherche de cycle, la bicolorabilité d'un graphe, la recherche de plus courts chemins dans un graphe à distance unitaire. » La bicolorabilité s'obtient en coloriant alternativement au fil d'un parcours en largeur : si une arête relie deux sommets de même couleur, il existe un cycle impair, et le graphe n'est pas biparti — c'est la réciproque annoncée au chapitre chap:graphes, et elle est ici effective, en .
19.5 Dijkstra
Graphe orienté à poids positifs ou nuls, une source : trouver la distance minimale de à chaque sommet.
Méthode : L'algorithme
On maintient une estimation de chaque distance, initialement sauf . À chaque tour, on extrait le sommet non traité d'estimation minimale, on le déclare définitif, et l'on relâche ses arcs sortants.
(* Distances minimales depuis s. g.(u) est la liste des couples (voisin, poids).
Précondition : tous les poids sont >= 0. Complexité : O((n + m) log n). *)
let dijkstra g s =
let n = Array.length g in
let d = Array.make n infinity in
let fige = Array.make n false in
let f = file_priorite_vide () in
d.(s) <- 0.0;
inserer f (0.0, s);
while not (est_vide f) do
let (_, u) = extraire_min f in
if not fige.(u) then begin
fige.(u) <- true; (* u est DÉFINITIF *)
List.iter (fun (v, p) ->
if d.(u) +. p < d.(v) then begin
d.(v) <- d.(u) +. p; (* relâchement *)
inserer f (d.(v), v)
end) g.(u)
end
done;
d
Le programme demande précisément cette forme : « on présente l'algorithme de Dijkstra avec une file de priorité et en lien avec la représentation de graphes par listes d'adjacences ».
À l'instant où un sommet est extrait et figé, est la distance minimale de à .
Démonstration
Par l'absurde, soit le premier sommet figé avec strictement supérieur à la vraie distance . Considérons un plus court chemin de à , et soit le premier sommet non figé sur ce chemin, son prédécesseur — figé, donc avec correct par minimalité de .
Le relâchement de l'arc a eu lieu quand a été figé, donc . Or est sur un plus court chemin vers et les poids sont positifs, donc . On aurait donc : l'extraction du minimum aurait choisi avant . Contradiction.
La démonstration utilise la positivité en un point précis : parce qu'un préfixe d'un plus court chemin ne peut pas être plus long que le chemin entier. Avec un arc de poids plus loin, un chemin passant par un sommet éloigné peut devenir le plus court, et le sommet figé trop tôt garde une valeur fausse — définitivement, puisqu'on ne le rouvre jamais.
Dijkstra ne signale pas l'erreur : il rend un résultat faux. Le plus petit contre-exemple tient en quatre arcs :
Le vrai plus court chemin de à passe par : . Dijkstra rend .
Pourquoi : le sommet est figé avec et relâche aussitôt , posant . Plus tard, corrige à — mais l'extraction de est ignorée, puisqu'il est déjà figé, et l'arc n'est jamais rejoué. La correction arrive, et ne se propage pas.
Sur des poids négatifs, il faut donc un autre algorithme, hors programme ici.
Un même sommet peut être inséré plusieurs fois dans la file, avec des estimations successives. Plutôt que de modifier la priorité d'un élément déjà présent — opération que le tas du chapitre chap:tas n'offre pas —, on insère un doublon et l'on ignore les extractions redondantes. La file contient alors éléments au lieu de , d'où la complexité .
19.6 Floyd-Warshall
Méthode : Tous les couples, par programmation dynamique
Trois boucles imbriquées, et la récurrence du chapitre chap:dynamique : s'améliore si passer par est plus court.
/* Remplace d par la matrice des distances minimales entre tous les couples.
Précondition : d[u][v] est le poids de l'arc u->v, INFINI s'il n'existe pas,
et d[u][u] = 0. Complexité : Theta(n³). */
void floyd_warshall(double d[][N_MAX], int n) {
for (int k = 0; k < n; k = k + 1) { /* LE SOMMET INTERMÉDIAIRE */
for (int u = 0; u < n; u = u + 1) {
for (int v = 0; v < n; v = v + 1) {
if (d[u][k] + d[k][v] < d[u][v]) {
d[u][v] = d[u][k] + d[k][v];
}
}
}
}
}
La boucle sur doit être la plus externe. C'est elle qui porte la récurrence : après le tour , la matrice contient les plus courts chemins n'utilisant que comme sommets intermédiaires. Placer à l'intérieur produit un résultat faux — et, comme souvent, sans le moindre signe. C'est l'erreur la plus fréquente sur cet algorithme.
| Dijkstra | Floyd-Warshall | |
|---|---|---|
| Ce qu'il calcule | depuis une source | entre tous les couples |
| Complexité | ||
| Représentation | listes d'adjacence | matrice d'adjacence |
| Poids négatifs | interdit | admis (sans cycle négatif) |
| Code | une trentaine de lignes | quatre lignes |
Sur un graphe creux et une seule source, Dijkstra gagne largement. Sur un petit graphe dense où l'on veut toutes les distances, Floyd-Warshall gagne — et sa concision le rend imbattable à l'écrit. Dijkstra coûteraient , ce qui n'est meilleur que si le graphe est creux.
19.7 Ce qu'il faut retenir
- Un seul squelette, trois structures de réserve : pile profondeur, file largeur, file de priorité Dijkstra.
- On marque en ajoutant, jamais en retirant.
- Le tri topologique s'obtient en empilant chaque sommet après ses descendants — l'ordre postfixe, renversé.
- Le parcours en largeur donne les plus courts chemins à arcs unitaires, gratuitement.
- Dijkstra exige des poids positifs, et rend un résultat faux sans le dire si l'on transgresse. Floyd-Warshall tient en quatre lignes, et sa boucle sur doit être la plus externe.