backend / algorithmes-structures-donnees
Plus court chemin : Dijkstra
Explication
Ce que vous allez apprendre
- Expliquer pourquoi BFS ne suffit plus dès que les arêtes ont des poids différents
- Décrire l'algorithme de Dijkstra étape par étape : distance connue, sommet le plus prometteur, relâchement des arêtes
- Justifier pourquoi le choix glouton de Dijkstra est correct
- Expliquer pourquoi les poids négatifs cassent la garantie de Dijkstra et quand utiliser Bellman-Ford à la place
- Implémenter Dijkstra efficacement à l'aide d'un tas plutôt que d'une recherche linéaire du minimum
Dans quel contexte ?
Une application de navigation GPS doit calculer le trajet le plus rapide entre deux villes, où chaque route a un temps de parcours différent (le poids de l'arête). BFS trouverait le chemin avec le MOINS de routes traversées, ce qui n'est pas la même chose que le chemin le PLUS RAPIDE : un trajet direct sur une route encombrée peut être plus lent qu'un détour par l'autoroute. Dijkstra, en utilisant les poids réels de chaque arête, calcule exactement le bon résultat — c'est l'algorithme au cœur de la plupart des moteurs de calcul d'itinéraire.
D'abord, la limite de BFS
BFS trouve le plus court chemin quand chaque arête "coûte" la même chose. Mais dès qu'un trajet en voiture peut être plus long en distance tout en étant plus rapide en temps, ce modèle ne suffit plus : il faut tenir compte du POIDS réel de chaque arête.
L'idée centrale de Dijkstra
Pour chaque sommet, on garde la meilleure distance connue depuis le départ, initialement infinie sauf pour le départ lui-même, fixé à 0.
Étape 1 : choisir le sommet le plus prometteur
À chaque étape, l'algorithme choisit le sommet non encore finalisé ayant la plus petite distance connue jusqu'ici.
Astuce
Implémenter Dijkstra avec une recherche linéaire du sommet minimal coûte O(V²) au total. En la remplaçant par un tas (heapq en Python), la sélection du prochain sommet passe à O(log V), ce qui ramène la complexité totale à O((V + E) log V), bien plus rapide sur un grand graphe creux.
Étape 2 : mettre à jour ses voisins
Depuis ce sommet, on "relâche" chacune de ses arêtes sortantes : si passer par ce sommet donne un chemin plus court vers un voisin que ce qui était connu jusque-là, on met à jour cette distance.
Pourquoi ce choix glouton est correct
Si l'on choisit toujours le sommet non finalisé de plus petite distance connue, cette distance ne pourra plus jamais être améliorée par la suite, car tout autre chemin passerait forcément par un sommet dont la distance est déjà au moins aussi grande. C'est cette garantie qui permet de "figer" définitivement chaque sommet, un par un.
| Algorithme | Poids négatifs | Complexité (avec tas) | Cas d'usage |
|---|---|---|---|
| BFS | Sans objet (pas de poids) | O(V + E) | Plus court chemin en nombre d'arêtes |
| Dijkstra | Interdits | O((V + E) log V) | GPS, routage réseau, poids positifs |
| Bellman-Ford | Autorisés | O(V × E) | Détection de cycle négatif, poids négatifs |
Un piège fondamental : les poids négatifs
Cette garantie repose entièrement sur l'hypothèse qu'ajouter une arête ne peut jamais réduire une distance déjà considérée comme minimale. Un poids négatif viole cette hypothèse : un chemin traversant une arête négative peut redevenir meilleur après coup, une fois le sommet déjà finalisé à tort.
Piège fréquent
N'utilisez jamais Dijkstra tel quel sur un graphe pouvant contenir des poids négatifs : il donnera un résultat silencieusement faux, sans erreur ni avertissement. Vérifiez toujours la nature des poids avant de choisir entre Dijkstra et Bellman-Ford.
La solution dans ce cas précis
Bellman-Ford, plus lent mais plus robuste, résout ce cas en relâchant systématiquement toutes les arêtes V-1 fois, sans jamais supposer qu'un sommet est définitivement traité avant la fin.
Vers la suite
Dijkstra cherche le chemin le moins coûteux entre deux sommets précis. La prochaine leçon change légèrement de problème : comment relier TOUS les sommets d'un graphe au coût total minimal, avec Kruskal et Prim.
Commandes & code
Plus court chemin : Dijkstra
import heapq
from math import inf
graphe_pondere = {
"A": [("B", 4), ("C", 1)],
"B": [("A", 4), ("D", 1)],
"C": [("A", 1), ("B", 2), ("D", 5)],
"D": [("B", 1), ("C", 5)],
}
def dijkstra(graphe: dict, depart: str) -> dict[str, float]:
# Complexité : O((V + E) log V) avec un tas binaire -- exige des poids POSITIFS
distances = {sommet: inf for sommet in graphe}
distances[depart] = 0
file_priorite = [(0, depart)] # (distance_actuelle, sommet)
visites = set()
while file_priorite:
distance_actuelle, sommet = heapq.heappop(file_priorite)
if sommet in visites:
continue # entrée obsolète (on a trouvé mieux depuis) -- on l'ignore simplement
visites.add(sommet)
for voisin, poids in graphe.get(sommet, []):
nouvelle_distance = distance_actuelle + poids
if nouvelle_distance < distances[voisin]: # relâchement (relaxation) de l'arête
distances[voisin] = nouvelle_distance
heapq.heappush(file_priorite, (nouvelle_distance, voisin))
return distances
resultat = dijkstra(graphe_pondere, "A")
assert resultat == {"A": 0, "B": 3, "C": 1, "D": 4} # A->C->B->D = 1+2+1 = 4, meilleur que A->B direct
# --- Reconstruire le CHEMIN complet, pas seulement la distance ---
def dijkstra_avec_chemin(graphe: dict, depart: str, arrivee: str) -> tuple[float, list[str]]:
distances = {sommet: inf for sommet in graphe}
distances[depart] = 0
predecesseurs: dict[str, str | None] = {depart: None}
file_priorite = [(0, depart)]
visites = set()
while file_priorite:
distance_actuelle, sommet = heapq.heappop(file_priorite)
if sommet in visites:
continue
visites.add(sommet)
if sommet == arrivee:
break # optimisation : on peut s'arrêter dès que la cible est finalisée
for voisin, poids in graphe.get(sommet, []):
nouvelle_distance = distance_actuelle + poids
if nouvelle_distance < distances[voisin]:
distances[voisin] = nouvelle_distance
predecesseurs[voisin] = sommet
heapq.heappush(file_priorite, (nouvelle_distance, voisin))
if distances[arrivee] == inf:
return inf, []
chemin = [arrivee]
while predecesseurs[chemin[-1]] is not None:
chemin.append(predecesseurs[chemin[-1]])
return distances[arrivee], chemin[::-1]
distance, chemin = dijkstra_avec_chemin(graphe_pondere, "A", "D")
assert distance == 4
assert chemin == ["A", "C", "B", "D"]
# --- Pourquoi Dijkstra échoue avec des poids négatifs ---
# Dijkstra suppose qu'une fois un sommet "visité" (finalisé), sa distance ne peut plus diminuer.
# Un poids négatif peut invalider cette hypothèse APRÈS la finalisation -- résultat incorrect silencieux.
# Solution avec poids négatifs (sans cycle négatif) : Bellman-Ford, O(V * E)
def bellman_ford(graphe_aretes: list[tuple[str, str, float]], sommets: list[str], depart: str) -> dict[str, float]:
distances = {sommet: inf for sommet in sommets}
distances[depart] = 0
for _ in range(len(sommets) - 1): # V-1 itérations garantissent la convergence
for a, b, poids in graphe_aretes:
if distances[a] != inf and distances[a] + poids < distances[b]:
distances[b] = distances[a] + poids
# Vème itération : si une distance diminue encore, il y a un cycle de poids négatif
for a, b, poids in graphe_aretes:
if distances[a] != inf and distances[a] + poids < distances[b]:
raise ValueError("Cycle de poids négatif détecté")
return distances
aretes = [("A", "B", 4), ("A", "C", 1), ("C", "B", 2), ("B", "D", 1), ("C", "D", 5)]
assert bellman_ford(aretes, ["A", "B", "C", "D"], "A") == {"A": 0, "B": 3, "C": 1, "D": 4}Résumé
- Dijkstra calcule les plus courts chemins depuis une source en O((V+E) log V) grâce à un tas binaire, mais exige des poids positifs.
- Le "relâchement" (relaxation) d'une arête met à jour la distance dès qu'un chemin plus court est trouvé via le sommet courant.
- Reconstruire le chemin (pas seulement la distance) nécessite de mémoriser le prédécesseur de chaque sommet.
- Bellman-Ford (O(V*E), plus lent) gère les poids négatifs et détecte les cycles de poids négatif, que Dijkstra ne peut pas traiter correctement.
Exercices pratiques
Mission : corriger le moteur d'itinéraire qui donne parfois un résultat faux
Objectif : Diagnostiquer un usage incorrect de Dijkstra sur un graphe à poids négatifs et tracer manuellement l'algorithme.
Contexte
Un moteur de calcul d'itinéraire utilise Dijkstra pour trouver le trajet le moins cher entre deux entrepôts, où le "poids" d'une arête représente un coût de transport. Une nouvelle fonctionnalité de remise promotionnelle a introduit des coûts négatifs sur certaines routes (des bonus de trajet), et le moteur s'est mis à retourner, dans de rares cas, un chemin plus cher que le vrai optimum, sans jamais planter.