Adloun

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éserveParcours obtenuCe qu'il donne
Pile (lifo)en profondeurtri topologique, cycles, composantes
File (fifo)en largeurplus courts chemins à arcs unitaires
File de prioritéDijkstraplus courts chemins pondérés
ImportantRemplacer une structure par une autre change l'algorithme, pas le code

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
AttentionMarquer au moment d'ajouter, jamais au moment de retirer

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) &lt;- true doit accompagner l'ajout.

Proposition 19.1Complexité

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

Définition 19.2En profondeur, et sa version récursive

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)
Définition 19.3Arborescence du parcours

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

Exemple 19.4Les composantes connexes d'un graphe non orienté

(* 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

Définition 19.5Tri 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 .

iRemarqueLe lien avec les ordres bien fondés

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

Définition 19.6En 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
◆Théorème 19.7Le parcours en largeur donne les plus courts chemins à arcs unitaires

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

Définition 19.8Le problème

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 ».

◆Théorème 19.9Correction

À 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.

AttentionUn seul poids négatif détruit tout

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.

iRemarquePourquoi `if not fige.(u)`

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];
                }
            }
        }
    }
}
AttentionL'ordre des trois boucles n'est pas interchangeable

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.

ImportantDijkstra ou Floyd-Warshall ?
DijkstraFloyd-Warshall
Ce qu'il calculedepuis une sourceentre tous les couples
Complexité
Représentationlistes d'adjacencematrice d'adjacence
Poids négatifsinterditadmis (sans cycle négatif)
Codeune trentaine de lignesquatre 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

ImportantParcours et chemins : cinq points
  • 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.

Continuer sur Adloun : animation, QCM, fiches, exercices