Retour au cours

backend / algorithmes-structures-donnees

Plus court chemin : Dijkstra

Leçon 151 exercice

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.

AlgorithmePoids négatifsComplexité (avec tas)Cas d'usage
BFSSans objet (pas de poids)O(V + E)Plus court chemin en nombre d'arêtes
DijkstraInterditsO((V + E) log V)GPS, routage réseau, poids positifs
Bellman-FordAutorisésO(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

python
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

1 disponible
1

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.

Résoudre l’exercice →