Fiche récapitulative générée pour impression / export PDF.

Licence 3 · Informatique L3 — Algorithmique, structures de données et graphes

Algorithmes de graphes : parcours et plus courts chemins

Algorithmes de graphes : parcours et plus courts chemins

1. Représentation des graphes

Un graphe G=(V,E)G=(V,E) est constitué d'un ensemble de sommets VV (taille n=Vn=|V|) et d'arêtes EE (taille m=Em=|E|), orientées ou non. Deux représentations usuelles : la matrice d'adjacence (tableau n×nn\times n, accès O(1)O(1) à une arête, mais O(n2)O(n^2) de mémoire — adaptée aux graphes denses) et la liste d'adjacence (pour chaque sommet, la liste de ses voisins, mémoire O(n+m)O(n+m) — adaptée aux graphes creux, cas le plus fréquent en pratique).

2. Parcours en profondeur (DFS)

Le parcours en profondeur (Depth-First Search) explore un graphe en allant aussi loin que possible avant de revenir en arrière (backtracking), à l'aide d'une pile (explicite ou via la récursion). Complexité : O(n+m)O(n+m) (chaque sommet et chaque arête est visité une fois).

DFS(G, s) :

marquer s comme visité

pour chaque voisin v de s non visité :

DFS(G, v)

Applications : détection de cycles, tri topologique d'un graphe orienté acyclique (DAG), recherche des composantes connexes.

3. Parcours en largeur (BFS)

Le parcours en largeur (Breadth-First Search) explore le graphe niveau par niveau à partir d'une source ss, à l'aide d'une file. Complexité O(n+m)O(n+m).

BFS(G, s) :

enfiler s, marquer s comme visité, distance(s)=0

tant que la file n'est pas vide :

u = défiler()

pour chaque voisin v de u non visité :

marquer v, distance(v) = distance(u)+1, enfiler v

Propriété clé : dans un graphe non pondéré, le BFS calcule exactement les plus courts chemins (en nombre d'arêtes) depuis ss vers tous les autres sommets atteignables.

4. Algorithme de Dijkstra (plus courts chemins, poids positifs)

Pour un graphe pondéré par des poids strictement positifs, l'algorithme de Dijkstra calcule les plus courts chemins depuis une source ss vers tous les autres sommets, en utilisant une file de priorité (tas-min) sur les distances provisoires :

Dijkstra(G, s) :

dist(s) = 0, dist(v) = +infini pour v ≠ s

file de priorité Q = {tous les sommets, priorité = dist}

tant que Q n'est pas vide :

u = extraire le sommet de Q de plus petite distance

pour chaque voisin v de u (arête de poids w) :

si dist(u) + w < dist(v) :

dist(v) = dist(u) + w (relaxation)

mettre à jour la priorité de v dans Q

Complexité : O((n+m)logn)O((n+m)\log n) avec une implémentation par tas binaire (chaque extraction et chaque mise à jour de priorité coûte O(logn)O(\log n), et il y a O(m)O(m) relaxations au total).

Exemple résolu. Graphe orienté : ABA\to B (poids 44), ACA\to C (poids 11), CBC\to B (poids 22), BDB\to D (poids 11), CDC\to D (poids 55). Depuis AA : dist(A)=0\text{dist}(A)=0. On extrait AA, on relâche BB (dist(B)=4\text{dist}(B)=4) et CC (dist(C)=1\text{dist}(C)=1). On extrait CC (distance minimale 11), on relâche BB via CC : dist(C)+2=3<4\text{dist}(C)+2=3<4, donc dist(B)=3\text{dist}(B)=3 ; on relâche DD via CC : dist(D)=1+5=6\text{dist}(D)=1+5=6. On extrait BB (distance 33), on relâche DD via BB : 3+1=4<63+1=4<6, donc dist(D)=4\text{dist}(D)=4. Résultat final : dist(A,B,C,D)=(0,3,1,4)\text{dist}(A,B,C,D)=(0,3,1,4).

5. Pourquoi Dijkstra échoue avec des poids négatifs

Dijkstra suppose qu'une fois un sommet extrait de la file (sa distance finalisée), aucune amélioration future n'est possible — ce qui n'est vrai que si tous les poids restants sont positifs (toute relaxation ultérieure ne peut qu'augmenter une distance). Avec des poids négatifs, un chemin découvert plus tard pourrait être plus court, invalidant cette hypothèse. Pour des graphes avec poids négatifs (sans cycle de poids négatif), on utilise l'algorithme de Bellman-Ford (O(nm)O(nm), plus lent mais correct dans ce cas).

6. Algorithme de Floyd-Warshall (tous les plus courts chemins)

Pour calculer les plus courts chemins entre toutes les paires de sommets, l'algorithme de Floyd-Warshall utilise une programmation dynamique en O(n3)O(n^3) :

dk[i][j]=min(dk1[i][j], dk1[i][k]+dk1[k][j])d_k[i][j] = \min\big(d_{k-1}[i][j],\ d_{k-1}[i][k]+d_{k-1}[k][j]\big)

dk[i][j]d_k[i][j] est la plus courte distance de ii à jj en n'utilisant que les sommets intermédiaires {1,,k}\{1,\dots,k\}. C'est avantageux par rapport à nn exécutions de Dijkstra (O(n(n+m)logn)O(n(n+m)\log n)) lorsque le graphe est dense (mm proche de n2n^2).

7. Récapitulatif


AlgorithmeProblème résoluComplexité
|---|---|---|



DFS / BFSparcours, plus courts chemins (non pondéré)O(n+m)O(n+m)
Dijkstraplus courts chemins depuis une source (poids 0\geq0)O((n+m)logn)O((n+m)\log n)
Bellman-Fordplus courts chemins depuis une source (poids quelconques, sans cycle négatif)O(nm)O(nm)
Floyd-Warshallplus courts chemins entre toutes les pairesO(n3)O(n^3)

Exercices de la leçon

Exercice 1

Quelle structure de données utilise naturellement le parcours en largeur (BFS) ?

Corrigé

Le BFS enfile les voisins découverts et les traite dans l'ordre FIFO, garantissant une exploration niveau par niveau.

Exercice 2

Vrai ou faux : dans un graphe non pondéré, le BFS depuis une source ss calcule les plus courts chemins (en nombre d'arêtes) vers tous les sommets atteignables.

Corrigé

Vrai. C'est la propriété clé du BFS : comme il explore niveau par niveau, la première fois qu'un sommet est atteint, c'est nécessairement par un chemin le plus court possible (en nombre d'arêtes).

Exercice 3

Quelle structure de données est utilisée par l'algorithme de Dijkstra pour sélectionner efficacement le prochain sommet à traiter ?

Corrigé

Dijkstra utilise une file de priorité (typiquement un tas-min) pour extraire efficacement, à chaque étape, le sommet non encore traité ayant la plus petite distance provisoire.

Exercice 4

Vrai ou faux : l'algorithme de Dijkstra fonctionne correctement même si certaines arêtes ont un poids négatif.

Corrigé

Faux. Dijkstra suppose que les distances ne peuvent qu'augmenter par relaxation future, ce qui échoue avec des poids négatifs (un chemin découvert plus tard pourrait raccourcir une distance déjà finalisée). Il faut alors utiliser Bellman-Ford.

Exercice 5

Quelle est la complexité de l'algorithme de Floyd-Warshall pour calculer les plus courts chemins entre toutes les paires de sommets ?

Corrigé

Floyd-Warshall procède par programmation dynamique avec trois boucles imbriquées sur les nn sommets, donnant une complexité O(n3)O(n^3).

Exercice 6

Pour un graphe avec nn sommets et mm arêtes représenté en liste d'adjacence, quelle est la complexité totale du DFS ou du BFS ?

Corrigé

Chaque sommet est visité (et marqué) une seule fois, et chaque arête est examinée au plus une ou deux fois (selon orientation), d'où une complexité linéaire O(n+m)O(n+m) — optimale, puisqu'il faut au moins lire toute l'entrée.

Exercice 7

Sur le graphe orienté ABA\to B (poids 2), ACA\to C (poids 5), BCB\to C (poids 1), exécuter Dijkstra depuis AA et donner dist(C)\text{dist}(C).

Corrigé

Chemin direct ACA\to C : coût 55. Chemin ABCA\to B\to C : coût 2+1=32+1=3, strictement meilleur. Dijkstra trouve donc dist(C)=3\text{dist}(C)=3 (en passant par BB).

Exercice 8

Pourquoi préfère-t-on la liste d'adjacence à la matrice d'adjacence pour représenter un graphe creux (peu d'arêtes par rapport au nombre de sommets, m=O(n)m=O(n)) ?

Corrigé

Pour un graphe creux, m=O(n)n2m=O(n)\ll n^2, donc la liste d'adjacence (O(n+m)=O(n)O(n+m)=O(n) mémoire) est bien plus économe que la matrice d'adjacence (O(n2)O(n^2) mémoire, dont la plupart des cases seraient à 00).

Exercice 9

Vrai ou faux : le DFS peut être utilisé pour détecter la présence d'un cycle dans un graphe orienté.

Corrigé

Vrai. En suivant les couleurs des sommets pendant le parcours (blanc = non visité, gris = en cours d'exploration, noir = terminé), on détecte un cycle si l'on rencontre une arête menant vers un sommet gris (déjà sur la pile d'appels en cours) — c'est une arête de retour.

Exercice 10

Expliquer pourquoi la complexité de Dijkstra avec une file de priorité par tas binaire est O((n+m)logn)O((n+m)\log n) plutôt que O(nm)O(nm).

Corrigé

Décomposition du coût total en deux parties.

Extractions : chaque sommet est extrait de la file de priorité exactement une fois (une fois sa distance finalisée, il n'y retourne jamais). Avec un tas binaire, chaque extraction du minimum coûte O(logn)O(\log n). Total pour les nn extractions : O(nlogn)O(n\log n).

Relaxations (mises à jour de priorité) : chaque arête du graphe est examinée au plus une fois (lors du traitement de son sommet de départ), et peut déclencher une mise à jour de la priorité du sommet d'arrivée dans le tas, coûtant O(logn)O(\log n) avec une implémentation adaptée (tas indexé permettant la mise à jour de clé). Total pour les mm arêtes : O(mlogn)O(m\log n).

Somme totale : O(nlogn)+O(mlogn)=O((n+m)logn)O(n\log n) + O(m\log n) = O((n+m)\log n).

C'est bien plus efficace que O(nm)O(nm) pour les graphes creux où m=O(n)m=O(n) : on obtient O(nlogn)O(n\log n) au lieu de O(n2)O(n^2).

Exercice 11

Sur le graphe orienté ABA\to B (poids 4), ACA\to C (poids 1), CBC\to B (poids 2), BDB\to D (poids 1), CDC\to D (poids 5), exécuter Dijkstra depuis AA pas à pas et donner les distances finales pour A,B,C,DA,B,C,D.

Corrigé

Initialisation : dist(A)=0\text{dist}(A)=0, dist(B)=dist(C)=dist(D)=+\text{dist}(B)=\text{dist}(C)=\text{dist}(D)=+\infty.

Étape 1 — extraire AA (distance 00). Relâcher ses voisins : dist(B)=min(,0+4)=4\text{dist}(B)=\min(\infty,0+4)=4 ; dist(C)=min(,0+1)=1\text{dist}(C)=\min(\infty,0+1)=1.

Étape 2 — extraire CC (distance minimale 11). Relâcher ses voisins : dist(B)=min(4,1+2)=min(4,3)=3\text{dist}(B)=\min(4,1+2)=\min(4,3)=3 (amélioration !) ; dist(D)=min(,1+5)=6\text{dist}(D)=\min(\infty,1+5)=6.

Étape 3 — extraire BB (distance minimale 33). Relâcher ses voisins : dist(D)=min(6,3+1)=min(6,4)=4\text{dist}(D)=\min(6,3+1)=\min(6,4)=4 (amélioration !).

Étape 4 — extraire DD (distance 44). Aucun voisin sortant, rien à relâcher.

Résultat final : dist(A)=0\text{dist}(A)=0, dist(B)=3\text{dist}(B)=3, dist(C)=1\text{dist}(C)=1, dist(D)=4\text{dist}(D)=4. Le plus court chemin vers DD passe par ACBDA\to C\to B\to D (coût 1+2+1=41+2+1=4), pas par le chemin direct ACDA\to C\to D (coût 1+5=61+5=6) ni par ABDA\to B\to D (coût 4+1=54+1=5).

Exercice 12

Démontrer pourquoi l'algorithme de Dijkstra peut donner un résultat incorrect en présence d'une arête de poids négatif, à l'aide d'un contre-exemple explicite.

Corrigé

Construction du contre-exemple. Graphe orienté : ABA\to B (poids 11), ACA\to C (poids 44), CBC\to B (poids 10-10).

Exécution de Dijkstra depuis AA :
- Initialisation : dist(A)=0\text{dist}(A)=0.
- Extraction de AA : relâcher BB (dist(B)=1\text{dist}(B)=1) et CC (dist(C)=4\text{dist}(C)=4).
- Extraction du sommet de distance minimale parmi les non traités : BB (distance 1<41<4). On finalise dist(B)=1\text{dist}(B)=1 et on ne le remet jamais en question (Dijkstra suppose qu'aucune amélioration future n'est possible pour un sommet déjà extrait).
- Extraction de CC (distance 44) : on relâche BB via CC, donnant 4+(10)=64+(-10)=-6 — mais BB a déjà été extrait et finalisé à la valeur 11, donc Dijkstra ignore cette amélioration (selon l'implémentation standard, qui ne retraite jamais un sommet déjà extrait).

Résultat erroné de Dijkstra : dist(B)=1\text{dist}(B)=1.

Vrai plus court chemin : ACBA\to C\to B, de coût 4+(10)=64+(-10)=-6, strictement inférieur à 11.

Conclusion : Dijkstra donne ici un résultat incorrect (11 au lieu de 6-6), car son hypothèse fondamentale (un sommet extrait ne peut plus être amélioré) est violée par la présence du poids négatif 10-10 sur l'arête CBC\to B. C'est exactement pourquoi Dijkstra exige des poids non négatifs, et pourquoi on utilise Bellman-Ford dans le cas contraire. \square

Exercice 13

Vrai ou faux : l'algorithme de Bellman-Ford peut détecter la présence d'un cycle de poids négatif accessible depuis la source.

Corrigé

Vrai. Après les n1n-1 itérations habituelles de relaxation (suffisantes pour des plus courts chemins simples, sans cycle), Bellman-Ford effectue une itération supplémentaire : si une relaxation améliore encore une distance, c'est la preuve qu'un cycle de poids négatif est accessible (sinon le plus court chemin ne serait pas borné, n'existant simplement pas au sens usuel).

Exercice 14

Justifier, dans le cadre de la programmation dynamique de Floyd-Warshall, la relation de récurrence dk[i][j]=min(dk1[i][j],dk1[i][k]+dk1[k][j])d_k[i][j]=\min\big(d_{k-1}[i][j],\,d_{k-1}[i][k]+d_{k-1}[k][j]\big).

Corrigé

Définition de dk[i][j]d_k[i][j]. C'est la longueur du plus court chemin de ii à jj dans le graphe, en n'autorisant comme sommets intermédiaires (ni ii ni jj eux-mêmes, mais les sommets traversés en chemin) que ceux de l'ensemble {1,,k}\{1,\dots,k\}.

Disjonction de cas sur le rôle de kk. Considérons le plus court chemin optimal de ii à jj n'utilisant que {1,,k}\{1,\dots,k\} comme intermédiaires. Deux cas s'excluent mutuellement :

Cas 1 — ce chemin optimal n'utilise pas kk comme sommet intermédiaire. Alors il n'utilise en fait que {1,,k1}\{1,\dots,k-1\}, donc sa longueur est exactement dk1[i][j]d_{k-1}[i][j] (qui est déjà optimal pour cet ensemble plus restreint de sommets autorisés).

Cas 2 — ce chemin optimal utilise kk comme sommet intermédiaire (nécessairement une seule fois, car repasser deux fois par kk créerait un cycle, ce qui n'est jamais optimal avec des poids positifs ou en l'absence de cycle négatif). Le chemin se décompose alors en un trajet iki\to k et un trajet kjk\to j, chacun n'utilisant comme intermédiaires que {1,,k1}\{1,\dots,k-1\} (puisque kk n'apparaît qu'une fois, à la jonction). Pour que le chemin total soit optimal, chacun de ces deux sous-trajets doit aussi être optimal (principe d'optimalité de Bellman) : leur longueur totale est donc dk1[i][k]+dk1[k][j]d_{k-1}[i][k]+d_{k-1}[k][j].

Conclusion. Le plus court chemin global est le meilleur des deux cas :

dk[i][j]=min(dk1[i][j], dk1[i][k]+dk1[k][j])d_k[i][j] = \min\big(d_{k-1}[i][j],\ d_{k-1}[i][k]+d_{k-1}[k][j]\big) \qquad \square

Exercice 15

Comparer, pour un graphe dense (m=Θ(n2)m=\Theta(n^2)), la complexité de calculer les plus courts chemins entre toutes les paires de sommets via (a) nn exécutions de Dijkstra, contre (b) une seule exécution de Floyd-Warshall. Quelle méthode est préférable ?

Corrigé

Méthode (a) — nn exécutions de Dijkstra. Chaque exécution de Dijkstra depuis un sommet source coûte O((n+m)logn)O((n+m)\log n). En répétant pour chacun des nn sommets comme source :

n×O((n+m)logn)=O(n(n+m)logn)n \times O((n+m)\log n) = O\big(n(n+m)\log n\big)

Pour un graphe dense (m=Θ(n2)m=\Theta(n^2)), cela devient O(nn2logn)=O(n3logn)O\big(n\cdot n^2\cdot\log n\big)=O(n^3\log n).

Méthode (b) — Floyd-Warshall. Complexité directe O(n3)O(n^3), indépendamment de la densité du graphe.

Comparaison. Pour un graphe dense, O(n3)<O(n3logn)O(n^3)<O(n^3\log n) : Floyd-Warshall est asymptotiquement meilleur (et plus simple à implémenter, sans nécessiter de structure de tas).

Remarque complémentaire (cas creux). Pour un graphe creux (m=O(n)m=O(n)), la méthode (a) donnerait plutôt O(n(n+n)logn)=O(n2logn)O(n(n+n)\log n)=O(n^2\log n), ce qui est meilleur que O(n3)O(n^3) de Floyd-Warshall. Le choix optimal entre les deux méthodes dépend donc de la densité du graphe : Floyd-Warshall pour les graphes denses, nn exécutions de Dijkstra pour les graphes creux.

AlphaMath Académie · Algorithmes de graphes : parcours et plus courts chemins · Informatique L3 — Algorithmique, structures de données et graphes