Vue d'ensemble
Tu sais déjà trouver un chemin dans un graphe avec un parcours en largeur (BFS). Mais le BFS compte le nombre d'arêtes : il suppose que toutes les arêtes « coûtent » pareil. Sur une carte routière, ce n'est pas le cas : aller de Paris à Lyon coûte plus que d'aller de Paris à Versailles. Il faut un algorithme qui tienne compte du poids de chaque arête. C'est exactement le rôle de l'algorithme de Dijkstra : trouver le plus court chemin dans un graphe pondéré à poids positifs.
Prérequis
- Notion de graphe (sommets, arêtes), orienté ou non.
- Parcours de graphes (BFS/DFS) et la notion de chemin.
- Dictionnaires Python :
d[cle],d.items(), testcle in d. - Ensembles Python (
set) : ajout.add(), test d'appartenance.
Dijkstra tombe presque chaque année aux écrits et aux oraux d'info. Le point qui coince : bien distinguer distance provisoire et distance définitive. Nos mentors alumni X · Centrale · Mines te font dérouler l'algo à la main jusqu'à ce que ce soit un réflexe.
Trouver un mentor →1. Représenter un graphe pondéré en Python
Pour un graphe non pondéré, on utilise souvent un dictionnaire sommet → liste de voisins. Ici chaque arête porte un poids : on remplace la liste de voisins par un dictionnaire voisin → poids. On obtient un dictionnaire de dictionnaires.
g tel que g[u] est le dictionnaire des voisins de u avec leurs poids : g[u][v] est le poids de l'arête . L'accès à un poids et le parcours des voisins d'un sommet se font alors en temps proportionnel au nombre de voisins.g = {
"A": {"B": 2, "C": 5},
"B": {"C": 1, "D": 4},
"C": {"D": 2, "E": 7},
"D": {"E": 1},
"E": {},
}g = { ... }Un dictionnaire dont les clés sont les sommets "A" à "E"."A": {"B": 2, "C": 5}Depuis A partent deux arêtes : A→B de poids 2 et A→C de poids 5. On lit le poids par g["A"]["B"], qui vaut 2."E": {}Un dictionnaire vide : E n'a aucun voisin sortant. C'est un cul-de-sac, pas un oubli — il faut le déclarer pour que "E" soit une clé du graphe.g["A"]["B"]=2 ne crée pas automatiquement A←B. Pour un graphe non orienté, on met le poids dans les deux sens : g["A"]["B"]=g["B"]["A"]=2.2. Le principe de Dijkstra
On fixe un sommet de départ et on veut la distance (poids total minimal d'un chemin) du départ vers tous les autres sommets. L'idée de Dijkstra est de faire grandir, étape par étape, un ensemble de sommets dont on connaît la distance définitive.
- Distance provisoire
dist[s]: le poids du meilleur chemin connu pour l'instant du départ verss. On l'initialise à pour le départ et à (float('inf')) pour les autres. - Sommet finalisé : un sommet dont on a décidé que sa distance provisoire est désormais définitive.
- Relâcher l'arête de poids : si
dist[u] + w < dist[v], on a trouvé un chemin plus court versvpassant paru, donc on posedist[v] = dist[u] + w.
- Initialiser
dist[depart]=0,dist[s]=infpour les autres, aucun sommet finalisé. - Tant qu'il reste un sommet non finalisé atteignable (distance provisoire finie) : choisir le sommet non finalisé
ude plus petite distance provisoire. - Finaliser
u(sa distance est définitive). - Relâcher toutes les arêtes sortantes de
u. - Recommencer. Les sommets restés à sont inatteignables depuis le départ.
3. L'algorithme en Python (version simple)
Voici la version dite « simple » : à chaque étape on cherche le minimum par un parcours de tous les sommets. Pas besoin de structure sophistiquée — un dictionnaire dist et un ensemble finalises suffisent.
import math
def dijkstra(g, depart):
dist = {s: math.inf for s in g} # distances provisoires
dist[depart] = 0
finalises = set() # sommets déjà finalisés
while len(finalises) < len(g):
# 1) choisir le non-finalisé de plus petite distance
u = None
meilleure = math.inf
for s in g:
if s not in finalises and dist[s] < meilleure:
meilleure = dist[s]
u = s
if u is None: # les restants sont à l'infini
break # -> inatteignables, on s'arrête
# 2) finaliser u
finalises.add(u)
# 3) relâcher les arêtes sortantes de u
for v, w in g[u].items():
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
return distdist = {s: math.inf for s in g}Compréhension de dictionnaire : chaque sommet reçoit la distance provisoire . math.inf est un flottant plus grand que tout nombre, donc dist[u]+w < inf est toujours vrai la première fois — pratique pour un premier relâchement.dist[depart] = 0On est déjà arrivé au départ : distance nulle. C'est la seule valeur finie au démarrage.finalises = set()Un ensemble, car on ne veut tester que l'appartenance (s not in finalises), en temps constant en moyenne.while len(finalises) < len(g):On continue tant que tous les sommets ne sont pas finalisés : au plus tours de boucle.for s in g: ... if s not in finalises and dist[s] < meilleureCœur de la version simple : on balaie tous les sommets pour trouver le non-finalisé de plus petite distance provisoire. C'est ce balayage qui coûte par tour.if u is None: breakSi aucun sommet non finalisé n'a de distance finie, tous les restants sont inatteignables : inutile de continuer.finalises.add(u)On finalise u : le théorème 4.1 garantit que dist[u] est désormais définitif.for v, w in g[u].items():On parcourt les voisins v de u et le poids w de l'arête u→v.if dist[u] + w < dist[v]: dist[v] = dist[u] + wLe relâchement : si passer par u raccourcit le chemin vers v, on met à jour la distance provisoire de v.return distÀ la fin, dist[s] est la distance minimale du départ à s (ou inf si s est inatteignable).Trace pas à pas
Déroulons dijkstra(g, "A") sur le graphe de la partie 1. À chaque étape : on choisit le sommet non finalisé de plus petite distance (colonne « finalisé »), on le fige, puis on relâche ses arêtes. Le tableau donne l'état de dist après relâchement.
| Étape | Finalisé | dist[A] | dist[B] | dist[C] | dist[D] | dist[E] |
|---|---|---|---|---|---|---|
| init | — | 0 | ∞ | ∞ | ∞ | ∞ |
| 1 | A (0) | 0 | 2 | 5 | ∞ | ∞ |
| 2 | B (2) | 0 | 2 | 3 | 6 | ∞ |
| 3 | C (3) | 0 | 2 | 3 | 5 | 10 |
| 4 | D (5) | 0 | 2 | 3 | 5 | 6 |
| 5 | E (6) | 0 | 2 ✓ | 3 ✓ | 5 ✓ | 6 ✓ |
B (plus petite distance non finalisée : 2). On relâche B→C : dist[B]+1 = 3 < 5, donc dist[C] passe de 5 à 3. On relâche B→D : 2+4 = 6 < ∞, donc dist[D] = 6. Plus tard (étape 3), C→D donnera 3+2 = 5 < 6 : dist[D] descend à 5. Une distance provisoire peut donc être améliorée plusieurs fois avant d'être finalisée.pred : à chaque relâchement réussi dist[v]=dist[u]+w, on note pred[v]=u. On remonte ensuite de l'arrivée au départ via pred (voir exercice 3).« Je comprends le code mais je me perds dans la trace. » C'est le message qu'on reçoit le plus. Un mentor alumni X · Centrale · Mines te fait remplir le tableau ligne par ligne sur trois ou quatre graphes : au bout du troisième, c'est acquis.
Trouver un mentor →4. Pourquoi ça marche : la finalisation est définitive
u, la valeur dist[u] est exactement la distance minimale du départ à u.Démonstration (pour comprendre le rôle des poids positifs)
On raisonne au moment où l'on s'apprête à finaliser u, le sommet non finalisé de plus petite distance provisoire. Notons la vraie distance minimale du départ à u. On a toujours dist[u] , car dist[u] correspond à un chemin réel. Montrons l'inégalité inverse.
Considérons un plus court chemin du départ à u, de poids . Le départ est finalisé (c'est le premier finalisé, à distance 0) et u ne l'est pas encore ; en suivant , il existe donc une première arête où x est finalisé et y ne l'est pas.
Comme x est finalisé, on a déjà relâché l'arête , donc dist[y] dist[x] (le préfixe de jusqu'à y est lui aussi optimal). Ainsi dist[y] .
Or y est non finalisé, donc par le choix de u comme minimum : dist[u] dist[y] . Enfin est sur le plus court chemin vers u, et comme tous les poids sont , la portion de de à a un poids , d'où .
En chaînant : dist[u] dist[u]. Toutes ces quantités sont égales, donc dist[u] .
Où servent les poids positifs ? À la dernière étape : n'est vrai que parce que la portion ne peut pas avoir un poids négatif. C'est précisément ce qui casse au paragraphe suivant.
5. Le piège : les poids négatifs
Un contre-exemple concret. Quatre sommets, une seule arête négative (D→B) :
g = {
"A": {"B": 1, "D": 3},
"B": {"C": 5},
"C": {},
"D": {"B": -3},
}
# Vrai plus court chemin de A vers B :
# direct A->B = 1
# détour A->D->B = 3 + (-3) = 0 <-- meilleur !
# donc vrai plus court chemin A->C = A->D->B->C = 0 + 5 = 5"A": {"B": 1, "D": 3}Deux façons de commencer : aller directement à B (poids 1) ou passer par D (poids 3)."D": {"B": -3}L'arête D→B a un poids négatif. Le détour A→D→B coûte donc 3 − 3 = 0, moins que le chemin direct à 1."B": {"C": 5}On n'atteint C qu'en passant par B : la distance de C dépend entièrement de celle, correcte ou non, de B.Déroulons dijkstra(g, "A"). On finalise A (0) et on relâche : dist[B]=1, dist[D]=3. Le plus petit non finalisé est B à 1 : Dijkstra finalise B à 1 et relâche aussitôt B→C, d'où dist[C]=1+5=6. Ensuite il finalise D (3) et relâche D→B : 3+(−3)=0 < 1, donc dist[B] tombe à 0 — mais B est déjà finalisé : son arête B→C ne sera jamais re-relâchée. Bilan : le programme renvoie dist[C]=6, alors que le vrai plus court chemin A→D→B→C vaut 5. Dijkstra a finalisé B trop tôt et s'en est servi pour calculer C avant de découvrir, via l'arête négative, le meilleur chemin vers B. La garantie du théorème 4.1 tombe.
6. Application et coût
Complexité de la version simple. La boucle while tourne fois (une finalisation par tour). À chaque tour, la recherche du minimum balaie les sommets : coût . Les relâchements, cumulés sur toute l'exécution, parcourent chaque arête une fois : au total. Le terme dominant est la recherche du minimum :
heapq en Python), extraire le minimum coûte au lieu de . La complexité tombe alors à , bien meilleure pour un graphe creux (peu d'arêtes). Cette variante est hors exigence en première année : retiens juste qu'elle existe et pourquoi elle accélère.7. Exercices corrigés
Soit g = {"P": {"Q": 4, "R": 1}, "Q": {"R": 2}, "R": {"Q": 5}}. (a) Quel est le poids de l'arête P→R ? (b) Écris une expression Python qui vaut le poids de Q→R. (c) Le graphe est-il non orienté ?
Voir la correction détaillée
g["P"]["R"] vaut 1.g["Q"]["R"], qui vaut 2.Q→R a le poids 2 mais R→Q a le poids 5. Les deux sens diffèrent, et P n'a aucune arête entrante déclarée. Le graphe est orienté.Soit g = {"S": {"A": 4, "B": 1}, "B": {"A": 2, "C": 5}, "A": {"C": 1}, "C": {}}. Déroule dijkstra(g, "S") : donne l'ordre de finalisation et les distances finales.
Voir la correction détaillée
dist = {S:0, A:∞, B:∞, C:∞}.S→A : A=4 ; S→B : B=1.B→A : 1+2=3 < 4 → A=3 ; B→C : C=6.A→C : 3+1=4 < 6 → C=4.{S:0, A:3, B:1, C:4}. On vérifie : le meilleur chemin vers A est S→B→A (coût 3), pas S→A direct (coût 4).Modifie dijkstra pour qu'il renvoie aussi un dictionnaire pred permettant de reconstituer le plus court chemin, puis écris une fonction chemin(pred, depart, arrivee) renvoyant la liste des sommets du départ à l'arrivée.
Voir la correction détaillée
import math
def dijkstra_pred(g, depart):
dist = {s: math.inf for s in g}
dist[depart] = 0
pred = {depart: None}
finalises = set()
while len(finalises) < len(g):
u, meilleure = None, math.inf
for s in g:
if s not in finalises and dist[s] < meilleure:
meilleure, u = dist[s], s
if u is None:
break
finalises.add(u)
for v, w in g[u].items():
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
pred[v] = u # v est atteint au mieux via u
return dist, pred
def chemin(pred, depart, arrivee):
if arrivee not in pred: # inatteignable
return None
ch = [arrivee]
while ch[-1] != depart:
ch.append(pred[ch[-1]]) # on remonte de prédécesseur en prédécesseur
ch.reverse() # on avait la liste à l'envers
return chpred[v]=u mémorise « le meilleur moyen d'atteindre v passe en dernier par u ». On part de l'arrivée et on remonte via pred jusqu'au départ, puis on retourne la liste.chemin(pred, "S", "A") renvoie ["S", "B", "A"].Récap final — Ce qu'il faut absolument retenir
Dijkstra calcule le plus court chemin dans un graphe pondéré à poids positifs, en finalisant à chaque tour le sommet non finalisé de plus petite distance provisoire, puis en relâchant ses arêtes. Vérifie que tu maîtrises :
- Sais-tu représenter un graphe pondéré par un
dictdedict{voisin: poids}et y lire un poids ? - Sais-tu ce que valent les distances provisoires à l'initialisation (0 au départ,
infailleurs) ? - Sais-tu ce que veut dire « finaliser » un sommet et « relâcher » une arête ?
- Sais-tu que Dijkstra choisit à chaque tour le non-finalisé de plus petite distance provisoire ?
- Sais-tu dérouler la trace sur un petit graphe et remplir le tableau des distances ?
- Sais-tu pourquoi une distance provisoire peut être améliorée plusieurs fois avant d'être finalisée ?
- Sais-tu que l'algorithme échoue avec un poids négatif, et exhiber un contre-exemple ?
- Sais-tu que la version simple est en , et qu'un tas la ramène à ?
À savoir refaire
- Optimalité de la finalisation — au moment où Dijkstra finalise ,
dist[u]est la distance minimale ; la preuve repose sur le choix du minimum et sur la positivité des poids.