Plus courts chemins : Dijkstra
Cours complet · OCaml (option informatique), chapitre 16 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>16.1 Introduction et motivation
Le parcours en largeur (chapitre 15) trouve le plus court chemin… quand toutes les arêtes « coûtent » pareil. Mais sur un réseau routier, les routes ont des longueurs différentes : le plus court chemin n'est plus celui qui traverse le moins de villes, mais celui dont la somme des distances est minimale. Il faut des graphes pondérés et un algorithme adapté : c'est l'algorithme de Dijkstra, l'un des plus célèbres de l'informatique.
Ce chapitre clôt l'étude des graphes. On y voit pourquoi le BFS échoue sur un graphe pondéré, comment Dijkstra construit les distances minimales depuis une source, pourquoi il exige des poids positifs (avec un contre-exemple), comment reconstruire le chemin, et deux variantes (Floyd-Warshall pour toutes les paires, Bellman-Ford pour les poids négatifs).
Faute de distance infinie en machine, on choisit une constante infini plus grande que toute distance réalisable (par exemple 1_000_000 si les distances restent petites, ou la somme de tous les poids plus un). dist.(v) = infini signifie alors « v pas encore atteint ».
16.2 Graphes pondérés et relâchement
On représente un graphe pondéré par des listes d'adjacence portant le poids : adj.(u) est la liste des couples (v, poids) pour chaque arête u -> v.
L'opération de base est le relâchement d'une arête (u, v, poids) : si passer par u améliore la distance estimée à v, on la met à jour.
(* si dist.(u) + poids < dist.(v), on a trouvé mieux pour v *)
if dist.(u) + poids < dist.(v) then dist.(v) <- dist.(u) + poids
Le BFS traite les sommets par nombre d'arêtes croissant — ce qui ne correspond plus à la distance pondérée. Un chemin de deux arêtes courtes peut être plus court qu'un chemin d'une seule arête longue. Il faut traiter les sommets par distance pondérée croissante : c'est l'idée de Dijkstra.
16.3 L'algorithme de Dijkstra
Méthode : Principe de Dijkstra (poids positifs)
On maintient une distance estimée dist.(v) pour chaque sommet, initialement 0 pour la source et infini ailleurs. On répète :
- choisir le sommet
unon encore traité dedistminimale ; - le marquer traité — sa distance est désormais définitive ;
- relâcher toutes ses arêtes sortantes.
Comme les poids sont positifs, le sommet le plus proche non traité ne pourra jamais être amélioré ensuite : on peut le figer.
let dijkstra adj depart =
let n = Array.length adj in
let infini = 1_000_000 in
let dist = Array.make n infini in
let traite = Array.make n false in
dist.(depart) <- 0;
for _i = 0 to n - 1 do
(* 1. sommet non traité de distance minimale *)
let u = ref (-1) in
for v = 0 to n - 1 do
if not traite.(v) && dist.(v) < infini
&& (!u = -1 || dist.(v) < dist.(!u)) then u := v
done;
if !u <> -1 then begin
traite.(!u) <- true; (* 2. distance définitive *)
List.iter (fun (v, poids) -> (* 3. relâcher les voisins *)
if dist.(!u) + poids < dist.(v) then dist.(v) <- dist.(!u) + poids
) adj.(!u)
end
done;
dist
Complexité : Dijkstra en
À chaque tour, on cherche le minimum en parcourant les n sommets ; il y a n tours, d'où . Une file de priorité (tas) abaisserait ce coût à , mais elle dépasse le programme : la version ci-dessus, parfaitement correcte, est celle à connaître.
Avec une arête de poids négatif, Dijkstra peut se tromper : il fige une distance qu'un détour par une arête négative rendrait pourtant plus courte. Exemple : arêtes 0 -> 1 (poids 2), 0 -> 2 (poids 5), 2 -> 1 (poids -4), 1 -> 3 (poids 1). Dijkstra fige 1 à la distance 2, puis 3 à la distance 3, avant de traiter 2 ; le détour 0 -> 2 -> 1 -> 3 coûte pourtant 5 - 4 + 1 = 2. Pour les poids négatifs (sans cycle négatif), on emploie Bellman-Ford.
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>16.4 Exercices résolus
Niveau (application directe du cours)
Construire un graphe pondéré en listes d'adjacence (int * int) list array et écrire ajoute_arc adj u v poids.
Démonstration
let pondere_vide n = Array.make n []
let ajoute_arc adj u v poids = adj.(u) <- (v, poids) :: adj.(u)
let ajoute_arete adj u v poids = (* non orienté *)
adj.(u) <- (v, poids) :: adj.(u);
adj.(v) <- (u, poids) :: adj.(v)
Chaque voisin est un couple (sommet, poids). C'est l'extension naturelle des listes d'adjacence du chapitre 14.
Écrire longueur adj chemin : la somme des poids le long d'un chemin donné (liste de sommets), ou failwith si une arête manque.
Démonstration
let rec poids_arc adj u v =
match adj.(u) with
| [] -> failwith "arête absente"
| (w, p) :: reste -> if w = v then p else poids_arc_liste reste v
and poids_arc_liste l v =
match l with
| [] -> failwith "arête absente"
| (w, p) :: reste -> if w = v then p else poids_arc_liste reste v
let rec longueur adj chemin =
match chemin with
| [] | [_] -> 0
| u :: (v :: _ as reste) -> poids_arc adj u v + longueur adj reste
On additionne le poids de chaque arête consécutive du chemin. Le motif u :: (v :: _ as reste) (chapitre 9) donne les deux premiers sommets et la queue.
Expliquer ce que calcule if dist.(u) + poids < dist.(v) then dist.(v) <- dist.(u) + poids et pourquoi c'est le cœur de Dijkstra.
Démonstration
Le relâchement teste si atteindre v en passant par u (distance dist.(u) jusqu'à u, plus le poids de l'arête) est meilleur que la meilleure distance connue à v ; si oui, on met à jour. Dijkstra n'est qu'une discipline de relâchements : relâcher les arêtes des sommets dans l'ordre de leur distance croissante garantit qu'on ne relâche jamais à partir d'une distance non définitive.
Niveau (raisonnement intermédiaire)
Écrire dijkstra adj depart renvoyant le tableau des distances minimales.
Démonstration
let dijkstra adj depart =
let n = Array.length adj in
let infini = 1_000_000 in
let dist = Array.make n infini in
let traite = Array.make n false in
dist.(depart) <- 0;
for _i = 0 to n - 1 do
let u = ref (-1) in
for v = 0 to n - 1 do
if not traite.(v) && dist.(v) < infini
&& (!u = -1 || dist.(v) < dist.(!u)) then u := v
done;
if !u <> -1 then begin
traite.(!u) <- true;
List.iter (fun (v, poids) ->
if dist.(!u) + poids < dist.(v) then dist.(v) <- dist.(!u) + poids
) adj.(!u)
end
done;
dist
À chaque tour on fige le sommet non traité le plus proche, puis on relâche ses voisins. Les sommets inatteignables conservent infini.
Écrire distance adj a b : int option (None si b est inatteignable).
Démonstration
let distance adj a b =
let dist = dijkstra adj a in
if dist.(b) >= 1_000_000 then None else Some dist.(b)
On lance Dijkstra depuis a et on lit la case b. La valeur sentinelle infini (restée inchangée) signale l'inaccessibilité, qu'on traduit en None.
Montrer que, si tous les poids valent 1, Dijkstra donne les mêmes distances qu'un BFS.
Démonstration
Avec des poids tous égaux à 1, la distance pondérée d'un chemin est son nombre d'arêtes. Choisir le sommet non traité de distance minimale revient alors à traiter les sommets par nombre d'arêtes croissant — exactement l'ordre du BFS. Dijkstra généralise donc le BFS aux poids quelconques (positifs) : pour des poids unitaires, le BFS (en ) est préférable car plus simple et plus rapide.
Sur le graphe 0 -> 1 (poids 2), 0 -> 2 (poids 5), 2 -> 1 (poids -4), 1 -> 3 (poids 1), donner les distances que calcule la fonction dijkstra de l'exercice 4 depuis 0, et les vraies.
Démonstration
Dijkstra traite d'abord 0 (relâche : dist.(1) = 2, dist.(2) = 5), puis fige 1 (le plus proche, 2) et relâche dist.(3) = 3, puis fige 3 (3 < 5), et traite 2 en dernier. Ce dernier relâchement ramène dist.(1) à 5 - 4 = 1 (le code relâche sans tester traite), mais 3, déjà figé, n'est plus revu : la fonction renvoie [|0; 1; 5; 3|], alors que la vraie distance de 0 à 3 est 5 - 4 + 1 = 2. Sans l'arc 1 -> 3, l'erreur resterait invisible : le relâchement tardif corrigerait dist.(1) seul. Dijkstra se trompe dès qu'un sommet figé trop tôt a transmis une distance fausse à d'autres : c'est pourquoi Dijkstra suppose les poids positifs.
Niveau (approfondissement)
Modifier Dijkstra pour renvoyer aussi un tableau pere, et écrire chemin adj depart arrivee : int list option.
Démonstration
let dijkstra_peres adj depart =
let n = Array.length adj in
let infini = 1_000_000 in
let dist = Array.make n infini and pere = Array.make n (-1) in
let traite = Array.make n false in
dist.(depart) <- 0;
for _i = 0 to n - 1 do
let u = ref (-1) in
for v = 0 to n - 1 do
if not traite.(v) && dist.(v) < infini
&& (!u = -1 || dist.(v) < dist.(!u)) then u := v
done;
if !u <> -1 then begin
traite.(!u) <- true;
List.iter (fun (v, poids) ->
if dist.(!u) + poids < dist.(v) then begin
dist.(v) <- dist.(!u) + poids;
pere.(v) <- !u
end
) adj.(!u)
end
done;
(dist, pere)
let chemin adj depart arrivee =
let (dist, pere) = dijkstra_peres adj depart in
if dist.(arrivee) >= 1_000_000 then None
else
let rec remonte s =
if s = depart then [depart] else remonte pere.(s) @ [s]
in
Some (remonte arrivee)
À chaque relâchement réussi, on note le prédécesseur pere.(v) <- u. On reconstruit le chemin en remontant les pères — exactement comme pour le BFS (chapitre 15) et la programmation dynamique (chapitre 13).
Écrire floyd cout qui calcule les distances minimales entre toutes les paires de sommets (cout : matrice des poids, infini si pas d'arête, 0 sur la diagonale).
Démonstration
let floyd cout =
let n = Array.length cout in
let infini = 1_000_000 in
let d = Array.make_matrix n n 0 in
for i = 0 to n - 1 do
for j = 0 to n - 1 do d.(i).(j) <- cout.(i).(j) done
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) < infini && d.(k).(j) < infini
&& d.(i).(k) + d.(k).(j) < d.(i).(j) then
d.(i).(j) <- d.(i).(k) + d.(k).(j)
done
done
done;
d
C'est de la programmation dynamique (chapitre 13) sur les graphes : d.(i).(j) devient le plus court chemin de i à j n'empruntant que des sommets intermédiaires < k+1 ; la boucle sur k (la plus externe) autorise un intermédiaire de plus à chaque fois. Coût ; gère les poids négatifs (sans cycle négatif). La garde évite de « passer par l'infini ».
Écrire bellman_ford n aretes depart (arêtes (u, v, poids), poids éventuellement négatifs, sans cycle de poids négatif).
Démonstration
let bellman_ford n aretes depart =
let infini = 1_000_000 in
let dist = Array.make n infini in
dist.(depart) <- 0;
for _i = 1 to n - 1 do (* n-1 passes *)
List.iter (fun (u, v, poids) ->
if dist.(u) < infini && dist.(u) + poids < dist.(v) then
dist.(v) <- dist.(u) + poids
) aretes
done;
dist
On relâche toutes les arêtes, n-1 fois de suite. Comme un plus court chemin a au plus n-1 arêtes, n-1 passes suffisent à propager les bonnes distances. Plus lent que Dijkstra () mais il gère les poids négatifs (et une n-ième passe qui améliorerait encore révélerait un cycle de poids négatif).
- Graphe pondéré : listes d'adjacence
(voisin, poids)(ou matrice des poids).infini= sentinelle « pas atteint ». - Relâchement :
if dist.(u) + poids < dist.(v) then dist.(v) <- ...— le cœur de tous ces algorithmes. - Dijkstra (poids positifs) : figer le sommet non traité le plus proche, relâcher ses voisins ; (version tableau). Généralise le BFS (qui suffit pour des poids unitaires).
- Poids négatifs : Dijkstra échoue (contre-exemple) Bellman-Ford ( relâchements de toutes les arêtes, ).
- Toutes les paires : Floyd-Warshall (), programmation dynamique sur les graphes (boucle
kexterne). - Reconstruire le chemin : tableau des
peremis à jour à chaque relâchement réussi, puis remontée.
16.5 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — Graphes pondérés.
- [11.] Écrire
poids_total adj: la somme des poids de toutes les arêtes (graphe orienté). - [12.] Convertir une matrice de poids en listes d'adjacence pondérées (et réciproquement).
- [13.]
voisin_le_plus_proche adj u: le voisin direct de poids minimal.
Thème B — Dijkstra.
- [14.] Modifier
dijkstrapour s'arrêter dès que le sommet d'arrivée est figé. - [15.] Vérifier expérimentalement, sur de petits graphes à poids positifs, que
dijkstraetbellman_fordcoïncident. - [16.] Plus court chemin dans une grille pondérée (coût d'entrée par case) : modéliser puis appliquer Dijkstra.
Thème C — Variantes du chemin.
- [17.] Chemin maximin : maximiser le plus petit poids d'arête traversée (goulot d'étranglement).
- [18.] Nombre de plus courts chemins distincts de la source à chaque sommet (poids positifs).
- [19.] Excentricité pondérée d'un sommet (plus grande distance de Dijkstra).
Thème D — Toutes paires et négatifs.
- [20.] Avec
floyd, détecter l'existence d'un cycle de poids négatif (d.(i).(i) < 0). - [21.] Reconstruire un chemin entre deux sommets à partir de la matrice de Floyd (tableau des intermédiaires).
- [22.] Discuter : pour un GPS sur un réseau routier (poids positifs, graphe creux), quel algorithme et quelle représentation choisir ?