Module 4 · Les graphes

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.

💡 Le concept

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.

📖 La formule

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.

detection_cycle.cpp
// 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.

⚠️ Piège fréquent

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.

🧭 Les préalables de cours

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.

tri_topologique.cpp
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.

✍️ Exercice de lecture

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.

🧠 Quiz éclair

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.

💡 Pourquoi ce compte ne marche pas sur un graphe orienté

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.

🧭 Les quartiers à sens unique

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.

ComposantePourquoi 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 même graphe, et son transposé
graphe d'origine — voisins sortants
A → B · D
B → C · E
C → F · B
D → E
E → F
F → D
transposé — les neuf mêmes arcs, retournés
A → aucun voisin sortant
B → A · C
C → B
D → A · F
E → B · D
F → C · E
Les neuf arcs sont les mêmes, chacun lu dans l'autre sens : l'arc A→B du premier rangement devient l'entrée A dans la liste de B du second. A n'a plus aucun voisin sortant, parce que rien n'arrivait sur lui : dans le transposé, une liste vide signale un sommet que rien n'atteignait. C'est cette inversion qui fait tout le travail de Kosaraju — partir de A dans le graphe d'origine dit ce que A atteint, partir de A dans le transposé dit ce qui atteignait A, et une composante fortement connexe est exactement l'intersection des deux.
kosaraju.cpp
// 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.

  1. Un premier parcours en profondeur sur le graphe d'origine, en empilant chaque sommet en sortant — exactement l'empilement du tri topologique.
  2. Construire le transposé.
  3. 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.
💡 Pourquoi le transposé enferme les composantes

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).

🧠 Quiz éclair

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.

graphe_pondere.h
// 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.

🧭 La meilleure route trouvée jusqu'ici

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.

📖 La formule

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.

Le même sommet, avant et après un relâchement
avant — on connaît E par D
estimation6
venant deD
on relâche B → E, de poids 1, avec B à 4
calcul4 + 1 = 5
verdict5 < 6, on relâche
après — on connaît E par B
estimation5
venant deB
Le sommet E est le même dans les trois états : seule son estimation change. Un relâchement écrit toujours deux cases ensemble — la distance et le prédécesseur —, et les dissocier est ce qui donne des distances justes accompagnées de chemins faux. Le test est une inégalité stricte : à égalité, on ne raccroche pas.
🧠 Quiz éclair

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.

🔗 Pont — la file avec priorité, enfin employée

« 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.

dijkstra.cpp
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 extraitABCDEF
— (départ)0
A (0)04 / A2 / A
D (2)04 / A2 / A6 / D
B (4)04 / A7 / B2 / A5 / B
E (5)04 / A7 / B2 / A5 / B10 / E
C (7)04 / A7 / B2 / A5 / B9 / 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.

⚠️ Piège fréquent

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.

🧠 Quiz éclair

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.

💡 Pourquoi exactement n − 1 passes

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.cpp
// 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èsSABCCe qui a bougé
départ0
passe 10−1 / B2 / S7 / AA d'abord à 4 par S, puis à −1 par B ; B à 2 ; C à 7
passe 20−1 / B2 / S2 / AC tombe de 7 à 2
passe 30−1 / B2 / S2 / Arien : convergé
passe de testrien 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.

✍️ Exercice de lecture

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 largeurDijkstraBellman-Ford
Poidsaucunpositifs ou nulsquelconques
Poids négatifhors sujetrésultat faux, en silencetraité correctement
Circuit négatifhors sujetnon détectédétecté
ComplexitéΘ(V + E)Θ((V + E) log V)Θ(V × E)
Structureune fileune 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.

💡 Ce que le module entier tient en une phrase

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.

🎓 À l'examen

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.

À retenir

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.

🗂️ L'aide-mémoire
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] + poids et pred[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 dans priority_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 — sinon INT_MAX plus 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] devient poids < 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