Les graphes : ce que les parcours calculent
Le module précédent a montré comment visiter tous les sommets. Celui-ci ne visite rien de nouveau : il change ce qu'on fait pendant la visite, et c'est de là que sortent la détection de cycle, le tri des tâches, les composantes — puis, quand les arcs portent des poids, les deux algorithmes de plus court chemin que le cours exige.
Ce module a deux moitiés, et il faut voir la charnière entre elles pour ne pas les confondre à l'examen.
La première ne coûte rien de plus qu'un parcours. Détecter un cycle, trier des tâches par dépendance, trouver des composantes : ce sont des parcours en profondeur auxquels on a ajouté un geste — noter quelque chose en entrant, ou en sortant. Tous restent en Θ(V + E).
La seconde paie les poids. Dès qu'un arc a un coût, « le plus court » cesse de vouloir dire « le moins d'arcs », et l'ordre de visite ne suffit plus à rien : il faut calculer. Le prix se lit dans les complexités — on quitte le linéaire.
Du parcours à l'algorithme
Le module Les graphes : représenter et parcourir s'est terminé sur un patron unique, dont les deux parcours ne sont que deux réglages. Ce module s'ouvre en le reprenant, parce que les quatre premiers algorithmes qui suivent en sont des variantes — et qu'il vaut mieux voir la variante que réapprendre un code.
Le parcours marqué — rappel
marquer(depart);
attente.ajouter(depart);
while (!attente.vide()) {
u = attente.retirer(); // file -> largeur ; pile -> profondeur
traiter(u); // <-- TOUT CE MODULE TIENT ICI
for (int v : voisins[u]) {
if (!marque(v)) { marquer(v); attente.ajouter(v); }
}
}
La ligne traiter(u) était un affichage dans le module précédent. Remplace-la
par « noter que u est en cours d'exploration » et tu détectes un
cycle ; par « empiler u en sortant » et tu obtiens un tri
topologique ; par « ajouter u au groupe courant » et tu comptes
des composantes. Un seul squelette, quatre algorithmes.
Un mot sur le graphe qui servira d'exemple : c'est celui du module précédent, six sommets A à F et sept arcs — A→B, A→D, B→C, B→E, C→F, D→E, E→F. On le retrouvera pondéré dans la seconde moitié, et augmenté de deux arcs de retour pour la section sur les composantes. Reprendre la même topologie évite d'avoir à la réapprendre à chaque section.
Détecter un cycle
Première variante, et la plus subtile des quatre, parce qu'elle repose sur une distinction
que le tableau visite ne sait pas faire à lui seul.
Reprends l'explorateur de grottes. Tu avances, et tu débouches sur une salle où tu es déjà passé. Deux situations complètement différentes se cachent derrière ce « déjà passé ». Ou bien cette salle est sur ton chemin actuel — tu peux revenir à ton point de départ sans faire demi-tour, donc tu as bouclé. Ou bien tu l'as explorée et quittée plus tôt : tu viens seulement de retomber dessus par un autre couloir, et ce n'est pas une boucle, c'est un raccourci.
Un booléen ne distingue pas les deux. Il en faut un second, qui ne dit pas « visité » mais « actuellement sur la pile » — donc mis à vrai en entrant et remis à faux en sortant.
// Deux tableaux, deux questions différentes :
// visite[u] — « u a-t-il DÉJÀ été exploré ? » (ne redevient jamais faux)
// surPile[u] — « u est-il SUR le chemin actuel ? » (redevient faux en sortant)
bool aUnCircuit(int u, const vector<vector<int>>& voisins,
vector<bool>& visite, vector<bool>& surPile) {
visite[u] = true;
surPile[u] = true; // on ENTRE dans u
for (int v : voisins[u]) {
if (!visite[v]) {
if (aUnCircuit(v, voisins, visite, surPile)) return true;
} else if (surPile[v]) {
return true; // v est sur le chemin courant : circuit
}
// sinon : v est visité mais SORTI. Simple raccourci, on l'ignore.
}
surPile[u] = false; // on SORT de u : il quitte le chemin
return false;
}
Les deux lignes qui comptent sont surPile[u] = true en entrée et
surPile[u] = false juste avant le return : elles encadrent
exactement la durée pendant laquelle u est sur la pile d'appels.
Sur le graphe d'exemple, l'algorithme répond faux : le parcours
descend A→B→C→F, ressort de F, ressort de C, essaie E depuis B, y trouve F déjà visité —
mais surPile[F] est redevenu faux quand on est sorti de F. C'est le cas
« raccourci », et sans le second tableau on aurait annoncé un circuit qui
n'existe pas.
Appliquer ce code tel quel à un graphe NON orienté. Il y trouve un circuit partout, et immédiatement : dans un graphe non orienté, chaque arête est stockée dans les deux sens, donc depuis v on voit toujours son parent u — qui est bien sur la pile. Aller-retour sur la même arête, faussement pris pour un cycle.
Le remède : passer le parent en paramètre et l'ignorer dans la
boucle, soit if (v == parent) continue;. C'est la seule différence entre
les deux versions, et un énoncé qui dit « graphe non orienté » l'attend.
Le réflexe de diagnostic : devant une détection de cycle, la première question n'est pas « le code est-il juste ? » mais « orienté ou non ? » — les deux versions sont justes, chacune pour son cas, et aucune ne marche pour l'autre.
Le tri topologique
Deuxième variante, et celle qui rapporte le plus par rapport à ce qu'elle coûte : une ligne déplacée.
IFT-2008 exige IFT-1006, qui exige autre chose. Si l'on te demande de lister tous les cours d'un programme dans un ordre où chaque cours vient après ses préalables, tu fais un tri topologique.
Deux remarques que l'analogie rend évidentes. D'abord, il y a souvent plusieurs ordres valides : deux cours sans lien entre eux peuvent se placer dans n'importe quel ordre relatif. Ensuite, il n'y en a aucun si le programme contient un cercle vicieux — A exige B qui exige A. Un tri topologique n'existe que sur un graphe sans circuit.
Ce graphe sans circuit a un nom qu'il faut connaître, parce que les énoncés l'emploient sans le développer : un DAG, pour directed acyclic graph — graphe orienté acyclique. Le graphe d'exemple en est un, et c'est ce que la section précédente vient de vérifier.
L'algorithme tient dans une observation, et elle vient tout droit du dernier exercice du module précédent. Quand un parcours en profondeur sort d'un sommet, tous les sommets qu'il pouvait atteindre sont déjà sortis. Dans un graphe de préalables, cela veut dire : tout ce qui dépend de lui est déjà traité. Il faut donc le placer avant eux — c'est-à-dire prendre l'ordre de sortie, et le renverser.
void visiterPourTri(int u, const vector<vector<int>>& voisins,
vector<bool>& visite, stack<int>& ordre) {
visite[u] = true;
for (int v : voisins[u])
if (!visite[v]) visiterPourTri(v, voisins, visite, ordre);
ordre.push(u); // EN SORTANT, pas en entrant : tout ce que u
} // peut atteindre est déjà dans la pile.
C'est le parcours en profondeur du module précédent, avec l'empilement déplacé après la boucle. Rien d'autre ne change.
Une pile rendant ses éléments dans l'ordre inverse de leur arrivée, le renversement est gratuit : il suffit de dépiler.
vector<int> triTopologique(const vector<vector<int>>& voisins) {
int n = voisins.size();
vector<bool> visite(n, false);
stack<int> ordre;
// On lance depuis CHAQUE sommet non visité : un DAG n'a pas forcément
// un sommet d'où tout est atteignable, et un seul départ en oublierait.
for (int u = 0; u < n; u++)
if (!visite[u]) visiterPourTri(u, voisins, visite, ordre);
vector<int> resultat;
while (!ordre.empty()) { resultat.push_back(ordre.top()); ordre.pop(); }
return resultat;
}
Complexité : Θ(V + E), celle d'un parcours. Empiler et dépiler n fois ne change pas l'ordre de grandeur.
Sur le graphe d'exemple — A→B, A→D, B→C, B→E, C→F, D→E, E→F —, l'ordre de sortie du parcours en profondeur depuis A est F, C, E, B, D, A (c'est le corrigé du dernier exercice du module précédent). Quel tri topologique en sort ? Et est-il le seul valide ?
Voir le corrigé
On renverse l'ordre de sortie : A, D, B, E, C, F. Vérifie-le arc par arc — A avant B ✓, A avant D ✓, B avant C ✓, B avant E ✓, C avant F ✓, D avant E ✓, E avant F ✓. Les sept arcs vont bien de gauche à droite.
Non, il n'est pas le seul. A, B, C, D, E, F en est un
autre : aucun arc ne relie C à D ni D à C, donc leur ordre relatif est libre. En
général, un DAG admet autant de tris topologiques qu'il y a de façons d'ordonner les
sommets deux à deux indépendants.
Le piège à connaître pour l'examen : si l'énoncé demande « un tri topologique », n'importe lequel des ordres valides est accepté — mais il faut alors montrer la méthode, parce que c'est elle qui est corrigée. Donne l'ordre de sortie de ton parcours, puis renverse : c'est reproductible, là où un ordre trouvé à l'œil ne l'est pas.
On remonte ordre.push(u) avant la boucle. Le tri reste-t-il valide ?
void visiterPourTri(int u, ...) {
visite[u] = true;
ordre.push(u); // <-- remonté AVANT la boucle
for (int v : voisins[u])
if (!visite[v]) visiterPourTri(v, ...);
}
Non, et le contre-exemple est le graphe du module. Empiler à l'entrée, c'est empiler dans l'ordre préfixe : A, B, C, F, E, D. Dépiler renverse cet ordre et donne D, E, F, C, B, A.
Cet ordre viole le tout premier arc : A→B exige A avant B, et A s'y retrouve en dernière position. La sortie n'est pas un tri topologique du tout.
Ce que la faute révèle, c'est ce que chaque moment garantit. À l'entrée d'un sommet, on ne sait rien de ses descendants — on n'y est pas encore allé. À la sortie, on sait qu'ils sont tous traités. Le tri topologique a besoin de cette garantie-là, et elle n'existe qu'après la boucle. Une seule ligne déplacée, et l'algorithme ne démontre plus rien.
Connexité et composantes
Un parcours ne visite que ce qui est atteignable depuis son départ. Si le graphe est en plusieurs morceaux, un seul appel en manque — et c'est exactement ce qui permet de compter les morceaux.
Le vocabulaire, d'abord. Un graphe non orienté est connexe s'il existe un chemin entre n'importe quelle paire de sommets — autrement dit, s'il est d'un seul tenant. Une composante connexe est un morceau maximal qui l'est : on ne peut pas y ajouter un sommet de plus sans casser la propriété.
int nombreDeComposantes(const vector<vector<int>>& voisins) {
int n = voisins.size();
vector<bool> visite(n, false);
int nb = 0;
for (int u = 0; u < n; u++) {
if (!visite[u]) {
nb++; // u n'a été atteint par AUCUN parcours
parcoursProfondeur(u, voisins, visite); // avale tout son morceau
}
}
return nb;
}
Le compteur s'incrémente une fois par appel de départ, pas une fois par sommet. Chaque appel consomme une composante entière. Coût total : Θ(V + E), chaque sommet et chaque arête n'étant traités qu'une fois.
Le code ci-dessus est correct — pour un graphe non orienté. Sur un graphe orienté, il compte quelque chose, mais pas ce qu'on croit : son résultat dépend de l'ordre dans lequel on essaie les sommets.
Sur le graphe d'exemple, partir de A visite les six sommets, donc il répondrait « une composante ». Mais partir de F d'abord ne visiterait que F, puis E ne visiterait que E et F — on obtiendrait un autre compte. La cause est simple : dans un graphe orienté, « u atteint v » n'implique plus « v atteint u », donc « être dans le même morceau » n'est plus une relation symétrique — et sans symétrie, il n'y a pas de partition en morceaux.
Il faut donc une notion plus exigeante, qui rétablisse la symétrie en la demandant explicitement. C'est l'objet de la section suivante.
Fortement connexe, et Kosaraju
Une composante fortement connexe — CFC — est un ensemble maximal de sommets tel que, pour toute paire u, v qu'on y prend, il existe un chemin orienté de u vers v et un de v vers u. En exigeant les deux sens, on retrouve une relation symétrique, donc une vraie partition : chaque sommet appartient à exactement une CFC.
Une ville dont toutes les rues sont à sens unique. Certains groupes de pâtés de maisons forment des îlots où tu peux circuler librement — de n'importe où vers n'importe où, et revenir. Ailleurs, tu peux entrer dans un quartier sans jamais pouvoir en ressortir.
Les îlots où l'on circule librement sont les composantes fortement connexes. Un carrefour dont on ne peut pas revenir forme, à lui seul, une CFC d'un élément.
Le graphe d'exemple est un DAG : on ne peut jamais revenir sur ses pas, donc chacun de ses six sommets est sa propre CFC — six composantes d'un élément. C'est un résultat général et il vaut d'être retenu : dans un DAG, chaque sommet est sa propre CFC, et réciproquement, une CFC de deux sommets ou plus contient forcément un circuit.
Pour avoir quelque chose d'intéressant à calculer, ajoutons-lui donc deux arcs de retour : C→B et F→D. Le graphe compte maintenant neuf arcs, et il n'est plus acyclique.
| Composante | Pourquoi elle tient ensemble |
|---|---|
| {A} | aucun arc n'arrive sur A : rien ne peut y revenir |
| {B, C} | B→C par l'arc d'origine, C→B par l'arc ajouté |
| {D, E, F} | D→E→F par les arcs d'origine, F→D par l'arc ajouté |
Trois composantes, et {B, C} ne fusionne pas avec {D, E, F} bien que B atteigne D en passant par E et F : pour fusionner, il faudrait aussi un chemin de D vers B, et aucun arc ne remonte de la seconde rangée vers la première.
L'algorithme qui les trouve s'appelle Kosaraju, et c'est le plus simple à expliquer parce qu'il n'est rien d'autre que deux parcours en profondeur. Entre les deux, une opération qu'il faut voir avant de lire le code : le graphe transposé, celui où tous les arcs sont retournés.
// Le transposé se construit en une double boucle : pour chaque arc u -> v
// du graphe d'origine, on inscrit u dans la liste de v.
vector<vector<int>> transposer(const vector<vector<int>>& voisins) {
vector<vector<int>> inverse(voisins.size());
for (int u = 0; u < (int)voisins.size(); u++)
for (int v : voisins[u]) inverse[v].push_back(u);
return inverse;
}
Chaque arc est lu une fois et écrit une fois : Θ(V + E). Retourner un graphe ne coûte pas plus cher que le parcourir.
L'algorithme complet tient alors en trois gestes, et le second parcours est celui qui produit le résultat.
- Un premier parcours en profondeur sur le graphe d'origine, en empilant chaque sommet en sortant — exactement l'empilement du tri topologique.
- Construire le transposé.
- Un second parcours sur le transposé, en prenant les départs dans l'ordre où la pile les rend. Chaque appel de départ délimite une CFC : tous les sommets qu'il atteint en forment une.
C'est le point qui rend Kosaraju mystérieux quand on ne le voit pas, et évident après.
Dans le graphe d'origine, partir d'un sommet u atteint tout ce que u peut atteindre. Dans le transposé, partir du même u atteint tout ce qui pouvait atteindre u. L'intersection des deux ensembles est exactement la CFC de u — les sommets qu'il atteint et qui l'atteignent, ce qui est la définition.
L'ordre de la pile fait le reste du travail. Il garantit qu'on démarre le second parcours par une composante dont on ne peut pas sortir en remontant les arcs : le parcours sur le transposé ne peut donc pas déborder sur une composante voisine, et s'arrête pile à la frontière. D'où la complexité totale : deux parcours plus une transposition, soit Θ(V + E).
Ce fragment compte les composantes d'un graphe ORIENTÉ. Que compte-t-il réellement ?
int nb = 0;
for (int u = 0; u < n; u++) {
if (!visite[u]) { nb++; parcoursProfondeur(u, voisins, visite); }
}
Le nombre d'appels de départ qu'il a fallu, et rien de plus — une quantité qui dépend de l'ordre des indices, donc qui n'est une propriété ni des composantes connexes ni des CFC.
Sur le graphe d'exemple, l'indice 0 est A, et partir de A atteint les six sommets : le code répond 1. Renumérote les sommets pour que F porte l'indice 0 et le premier appel ne visitera que F ; il en faudra d'autres, et le code répondra un autre nombre. Une valeur qui change quand on renomme les sommets ne mesure rien.
Ce qu'il faut retenir : ce code est juste pour un graphe non orienté, où « atteindre » est symétrique. Pour un graphe orienté, il faut Kosaraju, qui répondrait 6 sur le graphe d'origine — c'est un DAG, donc chaque sommet est sa propre CFC — et 3 une fois les arcs C→B et F→D ajoutés.
Le relâchement, l'opération atomique
Deuxième moitié du module. Jusqu'ici, tous les arcs se valaient et la longueur d'un chemin était son nombre d'arcs. Donnons maintenant un poids à chaque arc — une durée, un prix, une distance —, et le coût d'un chemin devient la somme des poids qu'il traverse.
Ce changement fait tomber la propriété du parcours en largeur. Un chemin de trois arcs légers peut coûter moins qu'un arc lourd, si bien qu'arriver le premier ne veut plus dire arriver au meilleur prix. Il faut calculer.
Avant d'aller plus loin, une précision de vocabulaire que les énoncés supposent connue : « le problème du plus court chemin » en recouvre trois, selon ce qu'on demande.
- Source unique : les plus courts chemins depuis un sommet vers tous les autres. C'est celui que résolvent Dijkstra et Bellman-Ford, et le seul que ce cours traite — le travail pratique parle d'ailleurs d'algorithmes « à origine unique ».
- Paire unique : d'un sommet vers un seul autre. On n'a pas mieux que la source unique ; on s'arrête simplement dès que la destination est traitée.
- Toutes les paires : entre chaque couple de sommets. On peut relancer n fois un algorithme à source unique, ou employer un algorithme dédié — Floyd-Warshall — qui sort du programme.
Commençons par la structure. Un voisin ne suffit plus : il faut aussi le poids de l'arc qui y mène, donc un couple.
// pair<int,int> — un COUPLE d'entiers. Ses deux membres
// s'appellent `.first` et `.second`.
// vector<pair<int,int>> — la liste des voisins d'UN sommet, chacun
// accompagné du poids de son arc.
// vector<vector<pair<int,int>>> — une de ces listes par sommet. C'est la
// liste d'adjacence du module précédent,
// dont chaque entrée porte un poids en plus.
vector<vector<pair<int,int>>> voisins(N);
voisins[0].push_back({1, 4}); // A -> B, poids 4
voisins[0].push_back({3, 2}); // A -> D, poids 2
Le graphe pondéré de ce module est celui des sections précédentes, avec ces sept poids : A→B 4, A→D 2, B→C 3, B→E 1, C→F 2, D→E 4, E→F 5.
Vient ensuite l'opération que les deux algorithmes de ce module partagent, et qui est leur seule brique commune. On tient, pour chaque sommet, la meilleure distance connue jusqu'ici depuis la source — une estimation, pas une vérité —, et on la corrige chaque fois qu'on découvre mieux.
Tu cherches le trajet le plus rapide vers chaque ville du pays. À tout moment, tu as sur ta feuille une estimation par ville : la meilleure route que tu aies trouvée pour l'instant. Quelqu'un te signale une route qui passe par une ville intermédiaire. Tu fais un seul calcul : « mon temps jusqu'à cette ville intermédiaire, plus cette route — est-ce mieux que ce que j'ai noté ? » Si oui, tu ratures et tu réécris.
C'est le relâchement d'un arc, et le nom vient de là : on relâche une contrainte trop serrée, l'estimation se détend vers sa vraie valeur.
Le relâchement
// Relâcher l'arc u -> v, de poids `poids`.
if (dist[u] + poids < dist[v]) {
dist[v] = dist[u] + poids; // on a trouvé mieux pour aller en v
pred[v] = u; // et c'est en passant par u
}
Trois lignes, et elles sont tout ce que Dijkstra et Bellman-Ford ont en
commun : les deux algorithmes ne diffèrent que par l'ordre dans lequel ils
relâchent les arcs. Retiens aussi la seconde ligne : sans pred, on
obtient à la fin les distances mais pas les chemins — et l'examen demande
presque toujours le chemin.
Le tableau pred se lit à l'envers. Pour reconstruire le chemin vers un sommet,
on part de lui et on remonte : pred[F], puis
pred[pred[F]], jusqu'à la source. On obtient le chemin en ordre inverse, qu'on
retourne — exactement comme un tri topologique retourne sa pile.
Cette initialisation ne provoque ni erreur ni plantage. Que rend l'algorithme ?
vector<int> dist(n, 0); // au lieu de : vector<int> dist(n, INFINI);
dist[source] = 0;
// ... puis les relâchements habituels
Il rend zéro partout, et il le rend en silence — ce qui est bien pire qu'un plantage, parce que le tableau se lit comme un résultat.
La cause est dans le test du relâchement. dist[u] + poids < dist[v]
devient 0 + poids < 0, c'est-à-dire poids < 0 :
faux pour tout arc de poids positif. Aucun relâchement ne se déclenche
jamais, et le tableau reste tel qu'il a été initialisé.
L'infini de départ n'est donc pas une convention d'écriture, c'est une pièce de
l'algorithme : il faut que la première estimation soit plus mauvaise que
n'importe quel chemin pour que le premier chemin trouvé la batte. En C++ on
écrit INT_MAX ou numeric_limits<int>::max() — et dans
Bellman-Ford, on teste que dist[u] n'y est pas resté avant d'y ajouter
quoi que ce soit.
Dijkstra : explorer par le moins cher
Reste à décider dans quel ordre relâcher les arcs. Dijkstra répond par une idée simple : traiter toujours le sommet dont l'estimation courante est la plus basse parmi ceux qu'on n'a pas encore traités. On le déclare alors définitif, et on relâche ses arcs sortants.
Cette idée repose sur une hypothèse qu'il faut énoncer tout de suite, parce que c'est elle qui décidera du choix entre les deux algorithmes : tous les poids sont positifs ou nuls. On verra plus bas ce qui casse sans elle.
« Extraire le plus petit d'un ensemble qui change tout le temps » est exactement le contrat de la file avec priorité que Les structures linéaires a décrite : Θ(log n) pour insérer, Θ(log n) pour extraire l'extrémum, contre un Θ(n) quelque part dans toutes les implémentations plus naïves.
C'est cette structure qui donne à Dijkstra sa complexité, et c'est pourquoi son coût s'écrit avec un logarithme. Le monceau qui la réalise sera étudié pour lui-même à la semaine 11 ; ici, on s'en sert comme d'une boîte noire dont on connaît le prix — ce qui est précisément l'intérêt d'avoir séparé le type abstrait de son implémentation.
Le type C++ correspondant est le plus long du cours. Nommons-le d'abord, on le lira mieux ensuite.
// priority_queue<T, C, Cmp> prend TROIS paramètres, et c'est ce qui la rend
// illisible d'un coup :
// T — ce qu'on y range. Ici un couple (distance, sommet), et la
// distance vient EN PREMIER : une file avec priorité de couples
// compare sur `.first`, donc c'est lui qui doit porter le critère.
// C — le conteneur qui la porte en interne. Toujours un vector.
// Cmp — la comparaison. Par défaut elle rend le PLUS GRAND ; `greater<>`
// renverse l'ordre pour qu'on obtienne le plus petit.
using FilePrioritaire = priority_queue<pair<int,int>,
vector<pair<int,int>>,
greater<>>;
using donne un nom à un type, comme typedef le faisait autrefois.
Rien n'est créé : c'est une abréviation, et elle ne coûte rien à l'exécution.
const int INFINI = INT_MAX; // « pas encore atteint », depuis <climits>
void dijkstra(int source, const vector<vector<pair<int,int>>>& voisins,
vector<int>& dist, vector<int>& pred) {
int n = voisins.size();
dist.assign(n, INFINI);
pred.assign(n, -1); // -1 = « aucun prédécesseur »
dist[source] = 0;
FilePrioritaire aVoir;
aVoir.push({0, source});
while (!aVoir.empty()) {
// auto [d, u] = ... — une LIAISON STRUCTURÉE : le couple rendu par
// top() est ouvert en DEUX variables d'un coup.
// Équivaut à `int d = p.first, u = p.second;`.
auto [d, u] = aVoir.top();
aVoir.pop();
// On n'efface pas les entrées périmées de la file : on les IGNORE
// en sortant. Si d est pire que la meilleure distance connue pour u,
// c'est qu'on a déjà relâché u depuis, et cette entrée ne vaut rien.
if (d > dist[u]) continue;
for (auto [v, poids] : voisins[u]) {
if (dist[u] + poids < dist[v]) {
dist[v] = dist[u] + poids; // le relâchement
pred[v] = u;
aVoir.push({dist[v], v});
}
}
}
}
Complexité : Θ((V + E) log V) avec une file avec priorité. Chaque arc peut provoquer une insertion, chaque insertion et chaque extraction coûtent un logarithme.
Lisons maintenant ce que ce code produit. La forme du tableau ci-dessous est celle que le cours attend : deux structures parallèles, l'évolution des coûts et, en dessous, le sommet qui a permis chaque relâchement. Source : A.
| On extrait | A | B | C | D | E | F |
|---|---|---|---|---|---|---|
| — (départ) | 0 | ∞ | ∞ | ∞ | ∞ | ∞ |
| A (0) | 0 | 4 / A | ∞ | 2 / A | ∞ | ∞ |
| D (2) | 0 | 4 / A | ∞ | 2 / A | 6 / D | ∞ |
| B (4) | 0 | 4 / A | 7 / B | 2 / A | 5 / B | ∞ |
| E (5) | 0 | 4 / A | 7 / B | 2 / A | 5 / B | 10 / E |
| C (7) | 0 | 4 / A | 7 / B | 2 / A | 5 / B | 9 / C |
| F (9) | aucun arc sortant, rien à relâcher | |||||
Deux lignes méritent qu'on s'y arrête, et ce sont celles où une estimation déjà écrite est corrigée. Au tour de B, E passe de 6 à 5 : la route par D coûtait 2 + 4, celle par B coûte 4 + 1. Au tour de C, F passe de 10 à 9. Une estimation n'est donc pas un résultat tant que son sommet n'a pas été extrait.
Résultat : F est à 9, et le chemin se reconstruit en remontant les prédécesseurs — F vient de C, C vient de B, B vient de A, soit A → B → C → F. Vérifie : 4 + 3 + 2 = 9, contre 10 par A→B→E→F et 11 par A→D→E→F.
Note enfin l'ordre d'extraction : A, D, B, E, C, F. Ce n'est ni l'ordre du parcours en largeur ni celui du parcours en profondeur du module précédent. Dijkstra a son propre ordre — celui du coût croissant —, et c'est ce qui en fait un algorithme et non un parcours.
Employer Dijkstra sur un graphe qui a un seul arc de poids négatif. Il ne plante pas, ne signale rien, et rend une réponse fausse.
Trois sommets suffisent à le voir. A→B coûte 5, A→C coûte 6, C→B coûte −4. Dijkstra extrait A, pose B = 5 et C = 6. Il extrait ensuite le plus petit, B à 5, et le déclare définitif. Puis il extrait C à 6 et voudrait relâcher C→B : 6 − 4 = 2, ce qui est mieux — mais B est sorti, sa distance est réputée acquise. Dijkstra répond 5 là où le vrai coût est 2, par A→C→B.
La cause n'est pas un détail d'implémentation, c'est l'invariant : Dijkstra pose qu'un sommet extrait a sa distance définitive. Cet invariant se démontre quand tous les poids sont positifs — allonger un chemin ne peut alors que l'alourdir — et il est faux dès qu'un arc peut alléger. Le réflexe de diagnostic : avant de choisir Dijkstra, regarde les poids. Un seul négatif suffit à l'interdire.
Que casse-t-on en retirant cette ligne de Dijkstra ?
auto [d, u] = aVoir.top();
aVoir.pop();
if (d > dist[u]) continue; // <-- on la retire
Rien, au sens du résultat — et c'est la bonne réponse, celle qui surprend. Les distances calculées restent exactes.
Ce qu'on perd est du temps. Un sommet est empilé à chaque fois que son
estimation s'améliore, si bien que la file contient plusieurs entrées pour le même
sommet, dont les plus anciennes sont périmées. Sans ce test, on rejoue leur boucle de
relâchement pour rien : tous les tests échoueront, puisqu'une meilleure distance
est déjà écrite dans dist[u].
Pourquoi ne pas plutôt effacer l'entrée périmée ? Parce qu'une file avec priorité ne sait pas retirer un élément quelconque — seulement l'extrémum. On laisse donc les doublons vivre et on les écarte au moment de les rencontrer. C'est un patron courant, et son nom vaut d'être connu : une file avec priorité paresseuse.
Bellman-Ford, et les poids négatifs
Un poids négatif n'est pas une curiosité de manuel. Le travail pratique du cours en donne un cas concret : un réseau aérien dont les trajets portent un niveau de sécurité qui peut être négatif, et qu'on cherche à minimiser. Il faut donc un algorithme qui les accepte.
Bellman-Ford abandonne l'idée qui faisait la force et la fragilité de Dijkstra : il ne cherche plus à traiter les sommets dans le bon ordre. Il relâche simplement tous les arcs, encore et encore, jusqu'à ce que plus rien ne bouge.
La question tombe régulièrement, et la réponse tient en une phrase : un plus court chemin ne repasse jamais deux fois par le même sommet, donc il a au plus n − 1 arcs. Repasser signifierait avoir bouclé, et une boucle de coût positif ou nul ne peut qu'alourdir le chemin.
Le raisonnement se fait ensuite par couches. Après la première passe sur tous les arcs, tous les sommets atteignables en un arc ont leur distance définitive. Après la deuxième, ceux atteignables en deux arcs. Et ainsi de suite : après n − 1 passes, tout chemin possible a été couvert, quelle que soit sa longueur.
Une passe supplémentaire — la n-ième — sert alors de test : si une distance baisse encore, c'est qu'un chemin de plus de n − 1 arcs améliore le résultat, ce qui n'est possible qu'en tournant dans un circuit de poids négatif. Le plus court chemin n'est alors pas défini du tout : on peut y boucler indéfiniment pour descendre toujours plus bas.
// Bellman-Ford travaille sur une LISTE D'ARCS, pas sur des listes
// d'adjacence : il les relâche tous dans un ordre quelconque, sans jamais
// demander « quels sont les voisins de u ». Un tuple porte les trois
// informations d'un arc : départ, arrivée, poids.
bool bellmanFord(int source, int n, const vector<tuple<int,int,int>>& arcs,
vector<int>& dist, vector<int>& pred) {
dist.assign(n, INFINI);
pred.assign(n, -1);
dist[source] = 0;
for (int passe = 1; passe <= n - 1; passe++) {
for (auto [u, v, poids] : arcs) {
// Le test `dist[u] != INFINI` n'est pas décoratif : sans lui,
// INT_MAX + un poids négatif déborde et devient très NÉGATIF,
// ce qui ferait passer un sommet inatteignable pour le meilleur.
if (dist[u] != INFINI && dist[u] + poids < dist[v]) {
dist[v] = dist[u] + poids;
pred[v] = u;
}
}
}
// La passe de trop : si quelque chose bouge encore, il y a un circuit
// de poids négatif et les distances n'ont plus de sens.
for (auto [u, v, poids] : arcs)
if (dist[u] != INFINI && dist[u] + poids < dist[v]) return false;
return true;
}
Complexité : Θ(V × E) — n − 1 passes sur les m arcs. Plus lent que Dijkstra, et sans logarithme parce qu'aucune structure ordonnée n'intervient.
Déroulons-le sur un petit graphe qui a un arc négatif. Quatre sommets — S, A, B, C — et cinq arcs, relâchés toujours dans le même ordre : (S,A,4), (S,B,2), (A,C,3), (B,A,−3), (B,C,6). Source : S.
| Après | S | A | B | C | Ce qui a bougé |
|---|---|---|---|---|---|
| départ | 0 | ∞ | ∞ | ∞ | — |
| passe 1 | 0 | −1 / B | 2 / S | 7 / A | A d'abord à 4 par S, puis à −1 par B ; B à 2 ; C à 7 |
| passe 2 | 0 | −1 / B | 2 / S | 2 / A | C tombe de 7 à 2 |
| passe 3 | 0 | −1 / B | 2 / S | 2 / A | rien : convergé |
| passe de test | rien ne bouge non plus : aucun circuit de poids négatif | ||||
La passe 1 illustre le mécanisme mieux qu'un paragraphe. L'arc (S,A,4) pose A à 4. Puis,
dans la même passe, l'arc (B,A,−3) trouve B déjà à 2 et améliore A à
2 + (−3) = −1. Ces améliorations en cascade à l'intérieur d'une passe
sont normales et bénéfiques : on utilise toujours la valeur courante de
dist[u], jamais celle du début de la passe.
Et la passe 2 montre pourquoi une seule ne suffit pas. C valait 7, calculé avec l'ancienne estimation de A ; il faut repasser sur l'arc (A,C,3) pour que la nouvelle valeur de A se propage. Résultat : C est à 2, par S → B → A → C, soit 2 + (−3) + 3.
On remplace l'arc (B,A,−3) par (B,A,−7), tout le reste inchangé. L'arc (A,B, ?) n'existe pas, mais ajoutons-le avec un poids de 5. Que rend la fonction ? Et à quel moment précis ?
Voir le corrigé
Elle rend false, et c'est la passe de test qui
la fait tomber, pas les passes ordinaires.
La raison : A→B coûte 5 et B→A coûte −7, donc faire l'aller-retour A→B→A coûte 5 + (−7) = −2. C'est un circuit de poids négatif. Chaque tour supplémentaire retranche 2 aux distances de A et de B, indéfiniment : il n'existe pas de plus court chemin, seulement des chemins de plus en plus courts.
Les n − 1 passes ordinaires ne s'en aperçoivent pas — elles se contentent d'améliorer, ce qui est leur travail. C'est la passe supplémentaire qui donne le verdict, et le raisonnement est celui de l'encadré ci-dessus : après n − 1 passes, plus rien ne devrait bouger si tous les plus courts chemins sont bien des chemins. Que quelque chose bouge encore prouve qu'on tourne.
Le réflexe pour l'examen : on ne cherche pas le circuit négatif en le cherchant. On lance l'algorithme, et on lit le verdict de la dernière passe.
Choisir son algorithme
Trois façons de trouver un plus court chemin ont maintenant été vues, et le choix entre elles se fait sur une seule lecture de l'énoncé : les arcs portent-ils des poids, et ces poids peuvent-ils être négatifs ?
| Parcours en largeur | Dijkstra | Bellman-Ford | |
|---|---|---|---|
| Poids | aucun | positifs ou nuls | quelconques |
| Poids négatif | hors sujet | résultat faux, en silence | traité correctement |
| Circuit négatif | hors sujet | non détecté | détecté |
| Complexité | Θ(V + E) | Θ((V + E) log V) | Θ(V × E) |
| Structure | une file | une file avec priorité | une liste d'arcs |
Une précision sur la ligne des complexités, parce qu'elle décide d'un choix de structure et qu'un énoncé peut le demander. Le Θ((V + E) log V) de Dijkstra suppose une file avec priorité. En cherchant le minimum à la main dans un simple tableau, on retombe à Θ(V²) — et sur un graphe dense, où m approche n², c'est en réalité le meilleur des deux : le logarithme se paie alors sur presque n² arcs, pour rien. Même arbitrage qu'au module précédent entre matrice et listes, et il se tranche sur le même critère.
La règle de décision tient en deux questions posées dans cet ordre. Y a-t-il des poids ? Si non, le parcours en largeur suffit, et il est le moins cher des trois — n'emploie jamais Dijkstra sur un graphe non pondéré, c'est un logarithme payé pour rien. Si oui, peuvent-ils être négatifs ? Si non, Dijkstra. Si oui, ou si l'énoncé demande de détecter un circuit négatif, Bellman-Ford.
Les neuf sections se rangent en deux familles, et savoir à laquelle appartient un problème donne l'algorithme presque immédiatement.
Quand l'ordre de visite suffit — cycle, tri topologique, composantes, CFC — le coût reste celui d'un parcours, Θ(V + E), parce qu'on n'a rien fait de plus que visiter en notant quelque chose. Ce sont des variantes de 📖 Le parcours marqué.
Quand il faut comparer des coûts — plus courts chemins pondérés — on paie : un logarithme si les poids sont positifs, un facteur V sinon. Et tout y repose sur les trois lignes de 📖 Le relâchement, dont Dijkstra et Bellman-Ford ne sont que deux ordonnancements.
Cette matière est celle des semaines 6 et 7 — « Les graphes, 2ᵉ partie » au semainier, la semaine 7 la poursuivant avant la révision — et elle est évaluée à l'intra. Le plan de cours dit que l'examen couvre l'introduction à l'algorithmique, les structures de données de base et les graphes en deux parties : c'est donc la dernière matière avant l'intra, et la plus fraîche au moment de le passer.
Sur la feuille manuscrite que l'examen autorise, ce module mérite le tableau de choix ci-dessus, les trois lignes de 📖 Le relâchement, et les trois complexités. Le reste se refait : une trace ne se recopie pas.
« En suivant fidèlement l'algorithme de Dijkstra, donnez le plus court chemin entre deux sommets, en montrant toutes les étapes. »
C'est l'énoncé du recueil d'exercices du cours, presque mot pour mot, et « en montrant toutes les étapes » est souligné dans l'original : c'est la trace qui est corrigée, pas le nombre final.
Dresse le tableau de la section « Dijkstra » : une colonne par sommet, une ligne par extraction, et dans chaque case la distance ET le prédécesseur. Le format attendu par le cours est exactement celui-là — deux structures parallèles, les coûts et les sommets qui ont permis chaque relâchement.
Trois pertes de points classiques. Oublier les prédécesseurs : sans eux tu as les distances mais tu ne peux pas donner le chemin, qui est la question posée. Ne pas relire le sens des arcs : sur un graphe orienté, un sommet peut rester à l'infini parce que rien n'y mène, et c'est une réponse légitime qu'il faut oser écrire. Finaliser un sommet trop tôt : on extrait toujours le minimum parmi les non extraits, en le vérifiant à chaque tour.
« Nommez toutes les composantes fortement connexes de ce graphe orienté. »
Question courte, où le piège est le cas dégénéré. Commence par chercher les circuits : s'il n'y en a aucun, le graphe est un DAG et la réponse est « chaque sommet est sa propre CFC », soit autant de composantes que de sommets. C'est une réponse complète, et c'est celle que plusieurs énoncés attendent.
S'il y a des circuits, chacun donne une composante d'au moins deux sommets, et il faut ensuite vérifier si deux composantes candidates fusionnent — c'est-à-dire s'il existe un chemin dans les deux sens entre elles. Un chemin dans un seul sens ne fusionne rien.
Le complément qui rapporte : nommer Kosaraju et sa complexité, Θ(V + E), en disant en une phrase pourquoi — deux parcours en profondeur et une transposition, chacun linéaire. Et vérifier son travail en comptant : la somme des tailles des composantes doit valoir exactement le nombre de sommets, chacun appartenant à une seule.
Ce module n'a introduit aucune façon nouvelle de traverser un graphe : il a changé ce qu'on fait pendant la traversée. Noter qu'un sommet est sur le chemin courant détecte un circuit ; empiler en sortant plutôt qu'en entrant donne un tri topologique ; relancer le parcours depuis chaque sommet non visité compte des morceaux ; et le faire deux fois, la seconde sur le graphe aux arcs retournés, délimite les composantes fortement connexes. Les quatre restent en Θ(V + E).
Les poids changent de régime. La longueur d'un chemin cesse d'être son nombre d'arcs, l'ordre de visite ne suffit plus, et tout repose alors sur les trois lignes de 📖 Le relâchement — dont Dijkstra et Bellman-Ford ne sont que deux façons de décider quel arc relâcher ensuite. Dijkstra prend toujours le moins cher et paie un logarithme ; il exige des poids positifs, faute de quoi il se trompe en silence. Bellman-Ford relâche tout, n − 1 fois, accepte les poids négatifs et détecte les circuits négatifs, pour un facteur V.
Et ailleurs : le tri topologique est ce qui ordonne les tâches d'un système de compilation et les paquets d'un gestionnaire de dépendances ; les composantes fortement connexes servent à repérer les dépendances circulaires dans un projet ; et Dijkstra tourne derrière chaque itinéraire calculé par une application de navigation. Dans le cours, le relâchement reviendra dès qu'un algorithme améliorera une estimation par étapes — c'est un patron, pas une astuce de plus court chemin.
- Ce qu'il faut EN PLUS de
visite[]pour détecter un circuit surPile[]— mis à vrai en entrant, remis à faux en sortant. Un voisin déjà visité mais hors pile est un raccourci, pas un circuit- Ce qui change à la détection de circuit sur un graphe non orienté
- il faut ignorer le parent (
if (v == parent) continue;) : l'arête de retour vers lui n'est pas un cycle - Où se place l'empilement dans un tri topologique
- après la boucle sur les voisins, donc en sortant. Puis on dépile, ce qui renverse l'ordre gratuitement
- Ce qu'est un DAG, et sa CFC
- graphe orienté acyclique. Chaque sommet y est sa propre composante fortement connexe, et lui seul admet un tri topologique
- Les trois gestes de Kosaraju, dans l'ordre
- 1. parcours en profondeur, empiler en sortant · 2. transposer le graphe · 3. reparcourir le transposé dans l'ordre de la pile — chaque départ délimite une CFC. Θ(V+E)
- Ce qu'un relâchement écrit, et combien de cases
- deux :
dist[v] = dist[u] + poidsetpred[v] = u. Sans la seconde, on a les distances mais pas les chemins - Le test du relâchement, et sa stricte inégalité
dist[u] + poids < dist[v]— strict : à égalité on ne raccroche pas le prédécesseur- L'ordre des membres du couple dans la file avec priorité de Dijkstra
{distance, sommet}— la distance en premier, parce que la comparaison porte sur.first- Ce que
greater<>fait danspriority_queue - il renverse l'ordre par défaut pour obtenir le plus petit en tête ; sans lui on extrait le plus grand
- Ce que fait
if (d > dist[u]) continue; - il écarte une entrée périmée de la file. Le retirer ne fausse rien, ça ralentit seulement
- L'hypothèse de Dijkstra, et ce qui casse sans elle
- poids ≥ 0. Son invariant — un sommet extrait a sa distance définitive — devient faux dès qu'un arc peut alléger un chemin déjà finalisé. Il répond faux, sans rien signaler
- Pourquoi Bellman-Ford fait n − 1 passes
- un plus court chemin ne repasse pas par un sommet, donc il a au plus n−1 arcs. Chaque passe garantit une longueur de plus
- À quoi sert la n-ième passe de Bellman-Ford
- à détecter un circuit de poids négatif : si une distance baisse encore, le plus court chemin n'est pas défini
- Le test à ne pas oublier avant de relâcher dans Bellman-Ford
dist[u] != INFINI— sinonINT_MAXplus un poids négatif déborde et devient très négatif- Pourquoi les distances s'initialisent à l'infini et non à zéro
- à zéro,
dist[u] + poids < dist[v]devientpoids < 0: aucun relâchement ne se déclenche et l'algorithme rend zéro partout, sans erreur - Les trois complexités du plus court chemin
- largeur Θ(V+E) sans poids · Dijkstra Θ((V+E) log V) · Bellman-Ford Θ(V×E)
- Les deux questions qui choisissent l'algorithme
- 1. y a-t-il des poids ? sinon → parcours en largeur · 2. peuvent-ils être négatifs ? non → Dijkstra, oui → Bellman-Ford