Les graphes : représenter et parcourir
Toutes les structures vues jusqu'ici imposaient la même contrainte : un élément, un voisin de chaque côté. Le graphe la lève, et c'est la seule chose qu'il fait. Tout le reste — les deux façons de le ranger en mémoire, les deux façons de le parcourir — découle de cette levée.
Un graphe ne modélise pas des choses, il modélise des relations entre des choses. C'est un déplacement du regard, et il vaut la peine de s'y arrêter : dans une liste chaînée, l'information est dans les valeurs et le chaînage n'est qu'un moyen de les atteindre. Dans un graphe, le chaînage EST l'information. Les sommets sont souvent de simples numéros ; ce qu'on veut savoir, c'est qui touche qui.
D'où le plan de ce module, qui n'a que deux moitiés. La première range ces relations en mémoire — et il n'existe que deux façons de le faire. La seconde les traverse — et il n'existe, là aussi, que deux façons de le faire.
Quand un seul voisin ne suffit plus
Le module Les structures linéaires se termine sur une phrase qui appelle celui-ci : « chaque élément a au plus un voisin de chaque côté ». C'est cette contrainte qui donnait aux cinq structures leur forme, et c'est elle qui les limitait. Une file d'attente n'a qu'un suivant. Une liste doublement chaînée en a deux, un devant et un derrière, et pas un de plus.
Un graphe est ce qu'on obtient quand on retire cette contrainte, et rien d'autre. Un élément peut avoir zéro voisin, ou dix-sept ; deux éléments peuvent se pointer mutuellement ; on peut partir d'un élément, suivre des voisins, et revenir à son point de départ. La liste chaînée devient d'ailleurs un cas particulier de graphe — celui où chacun a exactement un voisin.
La clôture des Les structures linéaires annonçait : « la recherche coûte Θ(n) dans TOUTES les structures linéaires ; les arbres, les monceaux et les tables de dispersion existent pour combler ce trou, et chacun le fera en brisant la contrainte "un seul voisin de chaque côté" ».
Le graphe est cette rupture, à l'état pur — et il est le plus général des trois. Un arbre est un graphe sans cycle avec une racine ; les structures arborescentes des semaines suivantes en seront des cas particuliers. Ce que tu apprends ici sur les parcours resservira donc littéralement pour les arbres : le parcours en profondeur d'un arbre binaire est celui de ce module, appliqué à un graphe qui se trouve n'avoir ni cycle ni retour en arrière.
Ce que ça permet de modéliser est vaste, et la liste vaut d'être lue en entier une fois, parce que c'est elle qui explique pourquoi ce chapitre pèse trois semaines dans la session.
- Le métro : les stations, et les segments qui les relient. « Comment aller d'Université-de-Montréal à Berri-UQAM ? » est une question de graphe, et tu la résous mentalement chaque fois que tu regardes le plan.
- Un réseau social : les personnes, et les liens d'amitié ou d'abonnement.
- Un cursus universitaire : les cours, et les préalables. IFT-2008 exige IFT-1006, qui exige autre chose : c'est un graphe, et l'ordre dans lequel on peut suivre ses cours en est une propriété calculable.
- Le web : les pages, et les hyperliens. Le classement de Google a commencé par un calcul sur ce graphe-là.
- Un réseau routier ou aérien : les villes, et les trajets. C'est exactement le sujet du deuxième travail pratique du cours.
Garde celle-là en tête pour tout le module, parce qu'elle a une vertu que les autres n'ont pas : sur un plan de métro, la géométrie ment et personne ne s'en plaint. Les distances sont fausses, les angles sont faux, les stations ne sont pas là où elles sont dans la ville. Ce qui est vrai, et tout ce qui est vrai, c'est quelle station touche quelle station.
Un graphe, c'est ça et rien de plus. Quand tu en verras un dessiné, souviens-toi que le dessin est une commodité : déplacer un sommet sur la feuille ne change pas le graphe. Deux dessins qui n'ont pas l'air de se ressembler peuvent être le même graphe.
Sommet, arête, arc : le vocabulaire qui sert
Le vocabulaire des graphes est court, et il vaut la peine de le poser proprement une fois : il sert d'une part à lire les énoncés d'examen, qui l'emploient sans le redéfinir, et d'autre part à énoncer les complexités, qui parlent toutes de deux nombres seulement.
Un graphe se note G = (V, E) — deux ensembles. V pour vertices, les sommets : les points, les nœuds, les stations. E pour edges, les liens entre ces sommets. On note n = |V| le nombre de sommets et m = |E| le nombre de liens, et ces deux lettres sont celles dans lesquelles toutes les complexités du chapitre s'écriront.
C'est la première rupture avec tout ce qui précède, et elle a des conséquences sur l'analyse. Une liste, un tableau, une pile ont une taille ; leurs complexités s'écrivent en n. Un graphe a deux tailles indépendantes, et une complexité en Θ(n + m) ne se simplifie pas : selon le graphe, m peut valoir zéro ou presque n².
Beaucoup de textes — dont ceux du cours — écrivent Θ(V + E) au lieu de Θ(n + m), en employant le nom de l'ensemble pour son cardinal. C'est le même énoncé. Ne t'arrête pas dessus : lis Θ(V + E) comme « proportionnel au nombre de sommets plus le nombre de liens ».
Reste le mot le plus important, et c'est celui qui distingue les deux sortes de liens. Un lien peut être réciproque ou à sens unique, et le français a deux mots pour ça : une arête est réciproque, un arc est orienté. La distinction n'est pas cosmétique — elle change ce que le code stocke, ce que les algorithmes calculent, et elle est la source d'erreur numéro un dans les exercices de graphes.
| Terme | Ce qu'il désigne |
|---|---|
| Sommet | un point du graphe. Une station, une ville, une page. |
| Arête | un lien réciproque entre deux sommets. Se note {u, v} avec des accolades — un ensemble, donc sans ordre. |
| Arc | un lien orienté, de u vers v. Se note (u, v) avec des parenthèses — un couple, donc ordonné. |
| Voisin de v | un sommet directement relié à v. Dans un graphe orienté, on précise « voisin sortant ». |
| Degré de v | son nombre de liens. Un graphe orienté distingue le degré entrant (les arcs qui arrivent) du degré sortant (ceux qui partent). |
| Chemin | une suite de sommets où chacun est voisin du suivant. |
| Longueur | le nombre de liens du chemin — pas le nombre de sommets, qui vaut un de plus. |
| Cycle | un chemin qui revient à son point de départ. Dans un graphe orienté, on dit aussi circuit, et c'est le mot qu'emploie l'énoncé du travail pratique. |
| Boucle | un lien d'un sommet vers lui-même. |
Deux relations valent d'être retenues, parce qu'elles servent de garde-fou quand on compte. La première : dans un graphe non orienté, chaque arête ajoute 1 au degré de ses deux extrémités, donc la somme de tous les degrés vaut deux fois le nombre d'arêtes.
La seconde borne le nombre d'arêtes. Dans un graphe non orienté sans boucle ni arête répétée, chaque paire de sommets distincts porte au plus une arête, et il y a n(n − 1) / 2 paires possibles.
Retiens surtout l'ordre de grandeur qui s'en déduit : m est au plus de l'ordre de n². C'est ce plafond qui rendra la section « Dense ou creux » décidable au lieu d'être une affaire de goût.
Orienté, non orienté, pondéré
Trois adjectifs qualifient un graphe, et chacun change quelque chose de concret dans le code. Ils sont indépendants : un graphe peut être orienté et pondéré, non orienté et non pondéré, et toutes les combinaisons existent.
Non orienté : la relation est symétrique par nature. « Alice est amie avec Bob » dit exactement la même chose que « Bob est ami avec Alice ». Les liens sont des arêtes. Le métro, un réseau routier à double sens, Facebook.
Orienté : la relation a un sens, et l'inverse n'est pas garanti. « Alice suit Bob » n'implique rien sur ce que fait Bob. Les liens sont des arcs. Les préalables de cours, les hyperliens du web, les rues à sens unique, Instagram.
Pondéré : chaque lien porte un nombre — une distance, une durée, un prix, un risque. Sans pondération, le seul coût d'un chemin est son nombre de liens ; avec, c'est la somme des poids. Ce module travaille exclusivement sur des graphes non pondérés, et c'est le module suivant qui paiera le prix de la pondération.
Voilà le point qui compte, et il surprend : aucune structure de données ne
sait stocker une arête. Une case mémoire ne connaît qu'un sens. On implémente
donc une arête {u, v} par deux arcs — un de u
vers v, un de v vers u — et l'on obtient la réciprocité par
symétrie plutôt que par nature.
Autrement dit : le code ne manipule jamais que des graphes orientés. « Non orienté » est une discipline qu'on s'impose — celle de toujours ajouter les deux sens ensemble —, pas une structure différente. Tout ce que ce module écrit pour un graphe orienté vaut donc tel quel pour un non orienté ; c'est seulement le remplissage initial qui diffère.
Compter les arêtes d'un graphe non orienté en comptant les entrées de sa structure. Puisque chaque arête y figure deux fois, la somme des tailles des listes de voisins vaut 2m, pas m. Un graphe non orienté à 5 arêtes occupe 10 entrées, et l'annoncer comme « 10 arêtes » double toutes les réponses qui suivent.
Le réflexe de diagnostic : devant un compte d'arêtes, demande-toi d'abord si le graphe est orienté. Si oui, une entrée est un arc et le compte est direct. Si non, une entrée est une moitié d'arête, et il faut diviser par deux. C'est le même fait que la formule de la somme des degrés vue plus haut, regardé du côté du code.
Ce graphe est stocké ainsi. Est-il orienté ?
voisins[0] = {1, 2};
voisins[1] = {0};
voisins[2] = {0};
La structure seule ne le dit pas — et c'est la bonne réponse, parce que la structure est la même dans les deux cas. Ce qu'on peut affirmer, c'est qu'elle est parfaitement symétrique : 1 figure chez 0 et 0 figure chez 1, 2 figure chez 0 et 0 figure chez 2.
Cette symétrie est la signature d'un graphe non orienté à deux arêtes,
{0,1} et {0,2}, chacune stockée dans les deux sens. Elle
pourrait aussi décrire un graphe orienté à quatre arcs qui se trouvent aller par
paires, mais c'est infiniment moins probable — et de toute façon les deux se
parcourent identiquement.
Ce que la lecture doit produire, ce n'est donc pas un verdict, c'est un réflexe de comptage : quatre entrées, symétriques, donc m = 2 si le graphe est non orienté et m = 4 s'il est orienté. L'énoncé de l'examen, lui, dira toujours lequel.
La matrice d'adjacence
Il n'existe que deux façons de ranger un graphe en mémoire, et le reste du chapitre — y compris toutes ses complexités — dépend de laquelle on choisit. Prenons un graphe de référence, qui servira jusqu'à la fin du module.
Six sommets, nommés A à F. Sept arcs, tous orientés : A→B, A→D, B→C, B→E, C→F, D→E, E→F. Lis-les une fois attentivement, parce que chaque trace de ce module s'y réfère. Si tu veux les visualiser : A, B, C forment une rangée qui va vers la droite, D, E, F une seconde rangée en dessous, et trois arcs descendent de la première vers la seconde.
La matrice d'adjacence est la représentation naïve, au bon sens du terme : un tableau carré de n × n cases, où la case en ligne u et colonne v répond à une seule question — l'arc de u vers v existe-t-il ? On y met 1 pour oui, 0 pour non.
const int N = 6; // A..F, donc les indices 0 à 5
// int matrice[N][N] — un tableau à DEUX indices : N lignes de N entiers,
// posées les unes après les autres en mémoire.
// = {} — l'initialisation par accolades VIDES met toutes les
// cases à zéro. Sans elle, un tableau local contient
// n'importe quoi, et le graphe serait aléatoire.
int matrice[N][N] = {};
matrice[0][1] = 1; // A -> B
matrice[0][3] = 1; // A -> D
matrice[1][2] = 1; // B -> C
// ... les quatre autres arcs
Les sommets sont nommés A à F dans la prose et numérotés 0 à 5 dans le code : un tableau s'indexe par des entiers. C'est la traduction qu'on fait de tête tout du long.
Ce que cette forme achète est immédiat : répondre à « l'arc (u, v) existe-t-il ? » coûte Θ(1). On calcule l'adresse d'une case et on la lit, exactement comme pour un tableau ordinaire — le module Les structures linéaires a détaillé pourquoi ce calcul ne dépend pas de la taille.
Ce qu'elle vend l'est tout autant : elle occupe Θ(n²) de mémoire quel que soit le nombre d'arcs. Un graphe à six sommets et sept arcs remplit trente-six cases dont vingt-neuf valent zéro. Et une conséquence moins visible en découle, qui coûtera plus cher que la mémoire : pour énumérer les voisins de u, il faut parcourir toute la ligne u, soit n cases, même si u n'a qu'un seul voisin.
Quand le graphe est pondéré, on remplace le 1 par le poids de l'arc. Et le 0 devient alors ambigu — un poids nul est un poids légitime —, si bien qu'on marque plutôt l'absence d'arc par l'infini, en pratique une valeur assez grande pour qu'aucune somme n'y arrive. Le module suivant s'en servira ; retiens seulement que « pas d'arc » s'écrit ∞ et non 0, faute de quoi tous les plus courts chemins passeraient par les liens qui n'existent pas.
La liste d'adjacence
L'autre représentation part du constat inverse : la plupart des cases d'une matrice valent zéro, alors ne les stockons pas. Pour chaque sommet, on garde seulement la liste de ses voisins. Six listes au lieu de trente-six cases.
Le type qui porte ça en C++ est celui qu'emploie le travail pratique du cours, et il mérite d'être démonté avant d'être lu, parce qu'il empile trois idées en une ligne.
// vector<int> — un tableau dynamique d'entiers : la liste des
// voisins d'UN sommet.
// vector<vector<int>> — un tableau dynamique DE CES LISTES. Le type entre
// chevrons est lui-même un vector : c'est
// l'imbrication qui rend la ligne difficile à lire,
// pas les chevrons eux-mêmes.
// voisins(N) — le constructeur avec une taille : on crée
// d'emblée N listes, toutes vides.
vector<vector<int>> voisins(N);
voisins[0] = {1, 3}; // A -> B, D
voisins[1] = {2, 4}; // B -> C, E
voisins[2] = {5}; // C -> F
voisins[3] = {4}; // D -> E
voisins[4] = {5}; // E -> F
// voisins[5] reste vide : F n'a aucun arc sortant
voisins[u] se lit « la liste des voisins sortants de u »,
et voisins[u][i] « le i-ième d'entre eux ». Deux crochets
successifs, deux niveaux d'imbrication.
Énumérer les voisins de u devient alors une simple boucle sur cette liste, et c'est la boucle la plus fréquente de tout le chapitre — elle apparaîtra dans chacun des algorithmes qui suivent.
// La boucle « pour chaque » vue dans Les éléments de C++ : elle demande
// à `voisins[u]` ses éléments un par un, sans indice à tenir.
for (int v : voisins[u]) {
cout << v << " ";
}
Son coût est proportionnel au degré sortant de u — pas à n. C'est toute la différence avec la matrice, et c'est de là que vient l'écart de complexité des parcours.
En revanche, cette forme perd ce que la matrice offrait : pour savoir si l'arc (u, v) existe, il faut chercher v dans la liste de u, donc la parcourir. Le test passe de Θ(1) à Θ(degré de u).
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| A | 0 | 1 | 0 | 1 | 0 | 0 |
| B | 0 | 0 | 1 | 0 | 1 | 0 |
| C | 0 | 0 | 0 | 0 | 0 | 1 |
| D | 0 | 0 | 0 | 0 | 1 | 0 |
| E | 0 | 0 | 0 | 0 | 0 | 1 |
| F | 0 | 0 | 0 | 0 | 0 | 0 |
Ce fragment remplit un graphe non orienté à partir d'une liste d'arêtes. Que vaut la somme des tailles des six listes à la fin ? Et combien d'arêtes le graphe a-t-il ?
vector<vector<int>> voisins(6);
void ajouterArete(int u, int v) {
voisins[u].push_back(v);
voisins[v].push_back(u);
}
ajouterArete(0, 1);
ajouterArete(0, 3);
ajouterArete(1, 2);
ajouterArete(3, 4);
Voir le corrigé
Huit entrées au total, pour quatre arêtes. Chaque appel à
ajouterArete fait deux push_back — c'est précisément la
discipline dont parle la section « Orienté, non orienté, pondéré » :
aucune structure ne stocke une arête, on en pose donc les deux sens ensemble.
Le détail du contenu : voisins[0] = {1, 3},
voisins[1] = {0, 2}, voisins[2] = {1},
voisins[3] = {0, 4}, voisins[4] = {3},
voisins[5] vide. Soit 2 + 2 + 1 + 2 + 1 + 0 = 8, c'est-à-dire
2m avec m = 4.
Ce qu'il faut surtout remarquer, c'est que la fonction elle-même est le seul endroit du programme qui « sait » que le graphe est non orienté. Une fois le remplissage fait, plus rien ne distingue cette structure de celle d'un graphe orienté à huit arcs, et tous les algorithmes du module la traiteront de la même façon.
Dense ou creux : le choix qui décide
Le choix entre les deux formes n'est pas affaire de goût, et il se tranche par un calcul qui prend dix secondes. Voici d'abord ce que chacune coûte, opération par opération.
| Opération | Matrice | Listes |
|---|---|---|
| Mémoire occupée | Θ(n²) | Θ(n + m) |
| L'arc (u,v) existe-t-il ? | Θ(1) | Θ(degré de u) |
| Énumérer les voisins de u | Θ(n) | Θ(degré de u) |
| Ajouter un arc | Θ(1) | Θ(1) |
| Un parcours complet | Θ(n²) | Θ(n + m) |
La dernière ligne est celle qui décide, parce qu'un parcours complet est ce que font presque tous les algorithmes du chapitre. Et pour la lire, il faut un vocabulaire de plus. Un graphe est dense quand m approche son plafond n², et creux quand m reste de l'ordre de n.
Un réseau routier : 10 000 intersections, 30 000 routes. Chiffre les deux formes.
La matrice occupe 10 000 × 10 000 = 100 millions de cases. À quatre octets l'entier, c'est 400 Mo — pour stocker 30 000 routes. Autrement dit, 99,97 % des cases valent zéro.
Les listes occupent n + m = 10 000 + 30 000 = 40 000 entrées. Deux mille cinq cents fois moins.
Et le parcours suit la même proportion : Θ(n²) contre Θ(n + m), soit cent millions d'étapes contre quarante mille. Ce n'est pas une optimisation, c'est la différence entre un programme qui répond et un programme qui ne répond pas.
Pourquoi 30 000 et pas un million ? Parce qu'une intersection réelle a trois ou quatre routes, jamais dix mille — et c'est vrai de presque tous les graphes du monde réel. Un sommet a un nombre borné de voisins, si bien que m croît comme n et non comme n². Le web, les réseaux sociaux, les réseaux de transport, les dépendances entre tâches : tous creux.
D'où la règle pratique, et le fait qu'elle penche franchement d'un côté : les listes d'adjacence sont le choix par défaut, et la matrice ne se justifie que sur un graphe vraiment dense, ou quand le programme passe son temps à demander « cet arc précis existe-t-il ? » sans jamais énumérer de voisins. C'est aussi le choix du cours : l'énoncé du deuxième travail pratique impose d'implanter son réseau aérien « en utilisant un vecteur de listes d'adjacence ».
Ce fragment tourne sur un graphe creux de 10 000 sommets rangé en matrice. Combien de cases lit-il ?
for (int u = 0; u < N; u++) {
for (int v = 0; v < N; v++) {
if (matrice[u][v]) traiter(u, v);
}
}
Cent millions, soit N² — et le mot « creux » de l'énoncé n'y change absolument rien. C'est le fait qui surprend : avec une matrice, le coût d'un parcours ne dépend pas du nombre d'arcs. Qu'il y en ait trente mille ou zéro, les deux boucles imbriquées visitent toutes les cases.
Le même parcours en listes d'adjacence lit 10 000 + 30 000 = 40 000 entrées. Le rapport est de 2 500 pour 1, et il s'aggrave quand le graphe grandit : doubler n quadruple le premier coût et double seulement le second.
La lecture à retenir : if (matrice[u][v]) à l'intérieur d'une double
boucle est la signature visuelle d'un Θ(n²) — celui qu'on
paie même quand il n'y a rien à traiter.
L'onde qui se propage : le parcours en largeur
Le graphe est rangé ; il faut maintenant le traverser. « Traverser » veut dire une chose précise : visiter chaque sommet atteignable depuis un départ, une fois et une seule. Et cette dernière exigence est plus subtile qu'elle n'en a l'air — dans une liste chaînée, on ne peut pas repasser deux fois au même endroit, alors qu'un graphe avec un cycle y conduit tout seul.
D'où la pièce commune aux deux parcours, celle qu'il faut voir avant les algorithmes eux-mêmes : un tableau de booléens, un par sommet, qui note ce qu'on a déjà rencontré. Sans lui, un graphe cyclique fait tourner n'importe quel parcours indéfiniment.
Jette une pierre dans un étang. L'onde s'éloigne en cercles concentriques : d'abord tout ce qui est à un mètre, puis tout ce qui est à deux, puis à trois. Elle n'atteint jamais un point à trois mètres avant d'avoir couvert tout ce qui est à deux.
Le parcours en largeur — breadth-first search, BFS — fait exactement ça sur un graphe. Depuis le départ, il visite d'abord tous les voisins directs, puis tous les voisins de ceux-là, et ainsi de suite. Il avance par couches, et cette régularité est la source de sa propriété la plus utile.
Pour avancer par couches, il faut se souvenir des sommets rencontrés mais pas encore traités, et les reprendre dans l'ordre où on les a rencontrés. C'est exactement le contrat d'une file — premier entré, premier sorti — que le module Les structures linéaires a détaillée. Rien à réapprendre : on l'emploie telle quelle.
void parcoursLargeur(int depart, const vector<vector<int>>& voisins) {
int n = voisins.size();
// vector<bool> visite(n, false) — n booléens, tous initialisés à false.
// Le second argument du constructeur est la VALEUR de remplissage.
vector<bool> visite(n, false);
queue<int> file; // la file de la STL : push, front, pop
visite[depart] = true; // marqué AVANT d'entrer dans la file
file.push(depart);
while (!file.empty()) {
int u = file.front(); // front() LIT, il ne retire pas
file.pop(); // pop() retire, il ne rend rien
traiter(u);
for (int v : voisins[u]) {
if (!visite[v]) {
visite[v] = true; // marqué ici, pas au défilement
file.push(v);
}
}
}
}
const vector<vector<int>>& reprend la référence constante des
Les éléments de C++ : on évite de
recopier tout le graphe à chaque appel, sans s'autoriser à le modifier.
Lire ce code ligne à ligne ne suffit pas à savoir ce qu'il produit. Déroulons-le sur le graphe de référence, départ en A. La colonne qui compte est celle de la file : c'est elle qui décide de tout.
| Tour | On défile | La file devient | Ce qu'on vient de marquer |
|---|---|---|---|
| départ | — | A | A |
| 1 | A | B · D | B, D |
| 2 | B | D · C · E | C, E |
| 3 | D | C · E | rien — E est déjà marqué |
| 4 | C | E · F | F |
| 5 | E | F | rien — F est déjà marqué |
| 6 | F | vide | rien — F n'a aucun voisin |
Ordre de traitement : A, B, D, C, E, F. Les couches se lisent directement dedans : A est à distance 0 ; B et D à distance 1 ; C, E et F à distance 2. Le parcours a bien fini la couche 1 avant d'entamer la couche 2.
Regarde le tour 3 et le tour 5 : le parcours ne fait rien. C'est là que le tableau
visite travaille — sans lui, E serait entré une seconde fois dans la file
au tour 3, F au tour 5, et chacun aurait été traité deux fois.
C'est la propriété qu'on répète partout sans la justifier, et elle se démontre en trois lignes à partir de la trace ci-dessus.
La file ne contient jamais que des sommets de deux couches consécutives — regarde le tour 2 : D est à distance 1, C et E à distance 2, et rien d'autre. C'est une conséquence directe du fonctionnement : on ne peut enfiler qu'en traitant, et traiter un sommet de la couche k n'enfile que des sommets de la couche k + 1.
Une file rendant ses éléments dans l'ordre d'arrivée, les sommets sont donc
traités par distance croissante. Et comme un sommet n'est marqué
qu'une seule fois — la toute première fois qu'on l'atteint —, cette première fois
est forcément par un chemin le plus court. Il suffit alors de retenir, au
moment du marquage, la distance du sommet qui l'a atteint plus un :
distance[v] = distance[u] + 1.
Deux limites à garder : cette propriété vaut pour la longueur en nombre de liens, et seulement là. Dès que les arcs portent des poids, le raisonnement s'effondre — un chemin de deux arcs légers peut coûter moins qu'un arc lourd —, et c'est précisément le problème que le module suivant devra résoudre.
Marquer un sommet au moment où on le défile, plutôt qu'au moment où on l'enfile. C'est l'erreur la plus commune du BFS, parce que la version fautive paraît plus naturelle : « je le marque quand je le traite ».
Reprends le tour 3 de la trace. D examine son voisin E. Si E n'était marqué qu'au défilement, il ne le serait pas encore — il attend dans la file — et D l'y enfilerait une seconde fois. Le sommet serait alors traité deux fois, et sur un graphe un peu dense la file enfle jusqu'à contenir un nombre d'entrées de l'ordre du nombre d'arcs.
Le réflexe de diagnostic : dans un BFS correct, visite[v] = true et
file.push(v) sont collés l'un à l'autre, dans cet ordre.
S'ils sont séparés par autre chose que rien, relis. Et l'oubli complet du marquage du
départ avant le premier push produit la même faute, en pire :
sur un graphe cyclique, le parcours ne s'arrête plus.
La plongée et le retour : le parcours en profondeur
L'autre parcours renverse la stratégie. Au lieu d'explorer tout ce qui est proche avant d'aller plus loin, il choisit une direction et s'y enfonce le plus loin possible, quitte à ne revenir qu'une fois bloqué.
Tu explores un réseau de galeries. À chaque embranchement, tu prends un tunnel et tu plonges jusqu'au bout. Quand tu arrives à une impasse — ou à une salle où tu es déjà passé —, tu reviens au dernier embranchement et tu prends le tunnel suivant.
C'est le parcours en profondeur, depth-first search, DFS. Et la comparaison avec le précédent tient en une image : le BFS, c'est envoyer cent explorateurs qui avancent tous d'un pas à chaque tour ; le DFS, c'est un seul explorateur qui va au bout de tout.
Revenir au dernier embranchement, c'est reprendre les sommets dans l'ordre inverse de leur découverte. C'est le contrat d'une pile — dernier entré, premier sorti. Et il se trouve qu'on dispose déjà d'une pile sans avoir à l'écrire : la pile d'appels de la récursion en est une, et c'est ce qui rend la version récursive si courte.
// `visite` est passé par référence NON constante : chaque appel récursif
// doit écrire dans le MÊME tableau que son appelant. Une copie ferait
// repartir le marquage de zéro à chaque descente, et le parcours
// tournerait sans fin sur un graphe cyclique.
void parcoursProfondeur(int u, const vector<vector<int>>& voisins,
vector<bool>& visite) {
visite[u] = true;
traiter(u);
for (int v : voisins[u]) {
if (!visite[v]) {
parcoursProfondeur(v, voisins, visite); // on PLONGE ici
}
}
// Rien après la boucle : arriver ici, c'est avoir épuisé tous les
// voisins de u. La fonction rend la main, et l'appelant reprend son
// propre `for` là où il l'avait laissé. C'est ça, le retour arrière.
}
L'appel initial demande le tableau de marquage :
vector<bool> visite(n, false); parcoursProfondeur(depart, voisins, visite);
Ce code est plus court que celui du BFS et bien plus difficile à dérouler, parce que la structure qui gouverne tout — la pile — n'y apparaît nulle part. Voici donc la même trace, avec la pile d'appels rendue visible. Départ en A, toujours sur le graphe de référence.
| Geste | Pile d'appels | Traité |
|---|---|---|
| on entre dans A | A | A |
| son voisin B est neuf, on plonge | A · B | B |
| son voisin C est neuf, on plonge | A · B · C | C |
| son voisin F est neuf, on plonge | A · B · C · F | F |
| F n'a aucun voisin, on ressort | A · B · C | — |
| C a fini ses voisins, on ressort | A · B | — |
B reprend son for : E est neuf | A · B · E | E |
| F est déjà marqué, E ressort | A · B | — |
| B a fini, on ressort | A | — |
A reprend son for : D est neuf | A · D | D |
| E est déjà marqué, D ressort | A | — |
| A a fini, le parcours se termine | vide | — |
Ordre de traitement : A, B, C, F, E, D. Compare-le à celui du BFS — A, B, D, C, E, F. Le même graphe, le même départ, deux ordres franchement différents : D est visité en deuxième position par l'un et en dernière par l'autre.
Regarde surtout la ligne où B « reprend son for ». C'est le geste
que le code ne montre pas et que la trace rend visible : quand un appel rend la
main, l'appelant ne recommence pas, il continue sa boucle à l'élément suivant.
Tout le retour arrière du DFS tient dans ce mécanisme, et il est offert par la récursion.
On peut aussi écrire le DFS avec une pile explicite, ce qui est utile quand le graphe est profond au point de saturer la pile d'appels. Mais il faut savoir ce qu'on y perd.
void parcoursProfondeurIteratif(int depart, const vector<vector<int>>& voisins) {
vector<bool> visite(voisins.size(), false);
stack<int> pile; // la pile de la STL : push, top, pop
pile.push(depart);
while (!pile.empty()) {
int u = pile.top();
pile.pop();
// On marque au DÉPILEMENT, pas à l'empilement : contrairement au
// BFS, un même sommet peut avoir été empilé plusieurs fois avant
// d'être atteint. Ce `continue` écarte les entrées en double.
if (visite[u]) continue;
visite[u] = true;
traiter(u);
for (int v : voisins[u]) {
if (!visite[v]) pile.push(v);
}
}
}
Même graphe, même départ : cette version traite A, D, E, F, B, C. Ce n'est pas l'ordre de la version récursive.
Croire que la version itérative reproduit l'ordre de la version récursive. Elle n'en donne pas moins un parcours en profondeur valide, mais l'ordre diffère, et une question d'examen qui demande « l'ordre de visite du DFS » attend celui de la version récursive.
La cause tient en une ligne. Le premier tour empile les voisins de A dans l'ordre — B, puis D. Une pile rend le dernier entré : le tour suivant dépile donc D, alors que la récursion serait descendue dans B. Le parcours itératif explore les voisins en ordre inverse, à chaque sommet.
Le remède, si l'ordre importe : empiler les voisins à l'envers. Le réflexe de diagnostic : devant un DFS itératif, regarde toujours dans quel sens la boucle d'empilement parcourt les voisins — c'est cette ligne, et elle seule, qui fixe l'ordre.
Ce parcours en profondeur tourne sur le graphe de référence, départ en A. Combien de sommets traite-t-il ? Et qu'affiche-t-il de plus que la version de la section ci-dessus ?
void explorer(int u, const vector<vector<int>>& voisins,
vector<bool>& visite) {
visite[u] = true;
for (int v : voisins[u]) {
if (!visite[v]) explorer(v, voisins, visite);
}
cout << u << " "; // <-- APRÈS la boucle, pas avant
}
Voir le corrigé
Les six mêmes sommets — déplacer l'affichage ne change ni le marquage, ni les appels, donc ni les sommets atteints. Ce qui change, c'est quand chacun s'affiche.
La version de la section affiche un sommet en y entrant : c'est l'ordre préfixe, A B C F E D. Celle-ci l'affiche en en sortant, c'est-à-dire une fois tous ses descendants terminés : c'est l'ordre suffixe — ou post-ordre. En reprenant la trace ligne à ligne, F sort le premier, puis C, puis E, puis B, puis D, puis A, soit F C E B D A.
Ce déplacement d'une seule ligne mérite qu'on s'y arrête, parce que le module suivant en fera un usage constant. Sortir d'un sommet garantit que tout ce qu'il peut atteindre a déjà été traité. C'est cette garantie — et non l'ordre préfixe — qui permet de trier des tâches par dépendance, et d'identifier les groupes de sommets qui se rejoignent mutuellement.
Ce que chaque parcours trouve
Les deux parcours visitent exactement les mêmes sommets — tout ce qui est atteignable depuis le départ, ni plus ni moins. Ils ne diffèrent que par l'ordre, et ce qui suit dit ce que chacun achète avec le sien.
Le parcours marqué
marquer(depart);
attente.ajouter(depart);
while (!attente.vide()) {
u = attente.retirer(); // <-- LA SEULE LIGNE QUI DIFFÈRE
traiter(u);
for (int v : voisins[u]) {
if (!marque(v)) {
marquer(v);
attente.ajouter(v);
}
}
}
// attente = file -> parcours en LARGEUR
// attente = pile -> parcours en PROFONDEUR
Le patron le plus rentable du chapitre, parce qu'il n'y en a qu'un. BFS et DFS ne sont pas deux algorithmes : c'est un algorithme, paramétré par la nature de la structure d'attente. Retiens-le sous cette forme et tu n'auras plus jamais deux codes à mémoriser — seulement la question « qui sort en premier ? ». Le module suivant le rappellera trois fois : la détection de cycle, le tri topologique et les composantes fortement connexes sont ce même squelette, avec un geste de plus au moment de traiter.
Reste à chiffrer ce que coûte un parcours, et la dérivation vaut d'être faite une fois plutôt que retenue. En listes d'adjacence, chaque sommet est mis dans l'attente au plus une fois — le marquage le garantit —, ce qui donne un travail proportionnel à n. Et pour chaque sommet traité, on parcourt sa liste de voisins : en sommant sur tous les sommets, on parcourt donc chaque arc exactement une fois, d'où un travail proportionnel à m. Total : Θ(n + m), c'est-à-dire Θ(V + E).
En matrice, la première moitié ne change pas, mais la seconde devient Θ(n) par sommet traité — il faut balayer une ligne entière pour trouver les voisins — soit Θ(n²) au total. C'est l'écart chiffré dans la section « Dense ou creux », et le voici expliqué : il ne vient pas du parcours, il vient de la façon dont on demande ses voisins à un sommet.
| En largeur (BFS) | En profondeur (DFS) | |
|---|---|---|
| Structure d'attente | une file | une pile, souvent celle des appels |
| Ordre d'exploration | par couches, du plus proche au plus loin | au plus loin d'abord, puis retour arrière |
| Ce qu'il donne gratuitement | la distance minimale en nombre de liens | l'ordre suffixe, et la trace du chemin courant |
| Coût en listes | Θ(n + m) | Θ(n + m) |
| Mémoire au pire | la file peut contenir toute une couche | la pile suit la plus longue branche |
Ce que chacun permet de calculer se déduit de la troisième ligne, et c'est le programme du module suivant. Le BFS sert partout où la distance en nombre de liens compte : le plus court trajet dans un réseau non pondéré, tous les sommets à moins de k pas, le test de bipartition. Le DFS sert partout où l'ordre de sortie compte : détecter un cycle, trier des tâches par dépendance, identifier les groupes de sommets qui se rejoignent mutuellement.
Et il reste une question que ni l'un ni l'autre ne sait traiter : si les arcs portaient des poids, aucun de ces deux ordres ne donnerait le chemin le moins coûteux. C'est là que le module suivant commence.
Un seul mot change entre ce fragment et le BFS de ce module. Que devient le parcours ?
stack<int> attente; // c'était : queue<int> file
attente.push(depart);
visite[depart] = true;
while (!attente.empty()) {
int u = attente.top(); // c'était : file.front()
attente.pop();
traiter(u);
for (int v : voisins[u]) {
if (!visite[v]) { visite[v] = true; attente.push(v); }
}
}
Il devient un parcours en profondeur — et c'est exactement ce qu'affirme 📖 Le parcours marqué : remplacer la file par une pile suffit à basculer d'un parcours à l'autre, sans toucher à rien d'autre.
Attention toutefois : ce n'est pas le DFS récursif de ce module. Le marquage a lieu ici à l'empilement, pas au dépilement, ce qui donne encore un troisième ordre de visite. Un sommet marqué tôt mais dépilé tard est traité à un rang qui ne correspond ni à la récursion ni à la version itérative vue plus haut.
Ce que le fragment démontre vraiment : la famille du parcours tient à la structure d'attente, mais l'ordre exact tient en plus à l'endroit où l'on marque. Deux réglages, pas un — et c'est pourquoi une question d'examen sur l'ordre de visite précise toujours quelle version elle vise.
Cette matière est celle de la semaine 5 — « Les graphes, 1ʳᵉ partie » au semainier — et elle est évaluée à l'intra. Le plan de cours en délimite la portée sans ambiguïté : l'examen couvre l'introduction à l'algorithmique, les structures de données de base, et les graphes en deux parties. Les graphes sont donc la dernière matière avant l'intra, et la seule que tu auras vue trois semaines d'affilée.
Sur la feuille manuscrite que l'examen autorise, ce module mérite trois choses et pas une de plus : le tableau matrice contre listes, le squelette de 📖 Le parcours marqué, et la ligne « marquer à l'enfilement ». Les traces, elles, ne se recopient pas — elles se refont.
« Donnez l'ordre de visite d'un parcours en largeur puis d'un parcours en profondeur sur ce graphe, à partir du sommet A, en montrant l'état de la structure d'attente à chaque étape. »
C'est la question de graphes, et elle vaut des points sur la méthode autant que sur le résultat. Ce qui se corrige : le tableau, pas la réponse finale. Dresse quatre colonnes — tour, sommet traité, contenu de l'attente après, sommets nouvellement marqués — et remplis-les ligne à ligne.
Trois points s'y perdent régulièrement. L'ordre des voisins : suis celui de la liste d'adjacence donnée dans l'énoncé, et si l'énoncé donne un dessin, prends l'ordre alphabétique en l'écrivant explicitement. Le moment du marquage : à l'enfilement pour le BFS, sinon la file gonfle et la trace devient fausse. Le sens des arcs : dans un graphe orienté, un arc qui arrive sur un sommet ne permet pas d'en repartir.
Et si l'énoncé ne précise pas la version du DFS attendue, écris en une phrase celle que tu prends — récursive, en pré-ordre — avant de commencer. Ça ne coûte rien et ça protège toute la trace.
« Un graphe a 50 000 sommets et 150 000 arêtes. Justifiez le choix d'une représentation. »
Le réflexe attendu est de chiffrer, pas de citer une règle. La matrice occuperait 50 000² = 2,5 milliards de cases pour 300 000 entrées utiles (150 000 arêtes × 2, le graphe étant non orienté) : elle serait vide à plus de 99,98 %. Les listes occupent n + 2m = 350 000 entrées.
La justification complète nomme ensuite le critère : m est ici de l'ordre de 3n, donc très loin du plafond n² ; le graphe est creux, et tout parcours passe de Θ(n²) à Θ(n + m). Le facteur est de l'ordre de 7 000.
Le complément qui rapporte : dire dans quel cas on choisirait quand même la matrice. Si l'application ne fait qu'interroger « l'arc (u,v) existe-t-il ? » sans jamais énumérer de voisins, le Θ(1) de la matrice bat le Θ(degré) des listes — et c'est la seule chose que la matrice fasse mieux.
Un graphe est ce qui reste quand on retire la contrainte « un seul voisin de chaque côté », et deux nombres suffisent à le mesurer : n sommets, m liens. Deux rangements existent, et le choix se chiffre au lieu de se débattre : la matrice paie Θ(n²) de mémoire pour répondre en Θ(1) à « cet arc existe-t-il ? », les listes paient Θ(degré) sur cette question-là pour ne coûter que Θ(n + m) partout ailleurs. Les graphes réels étant creux, les listes gagnent presque toujours.
Et il n'y a pas deux parcours à mémoriser, il y en a un — 📖 Le parcours marqué — dont la structure d'attente est le seul réglage. Une file donne le parcours par couches, donc la distance minimale en nombre de liens ; une pile donne la plongée, donc l'ordre de sortie. Le tableau de marquage, lui, n'est pas un détail d'optimisation : c'est ce qui fait terminer l'algorithme sur un graphe cyclique, et l'instant où l'on marque décide de l'ordre autant que la structure elle-même.
Et ailleurs : les structures arborescentes des semaines suivantes sont des graphes sans cycle, et leurs parcours sont littéralement ceux-ci — le parcours en profondeur d'un arbre binaire est le code de ce module appliqué à un graphe qui n'a ni cycle ni retour. Hors du cours, ce patron est partout : un ramasse-miettes marque les objets atteignables depuis les racines, un gestionnaire de paquets suit les dépendances, un compilateur explore un graphe d'appels. Chaque fois, la même boucle et le même tableau de marquage.
- Ce que n et m désignent, et le plafond de m
- n = |V| sommets, m = |E| liens. m ≤ n(n−1)/2 dans un graphe non orienté simple, donc m est au plus de l'ordre de n²
- Ce que vaut la somme de tous les degrés
- 2m, jamais m — chaque arête compte pour ses deux extrémités
- Le mot pour un lien réciproque, et pour un lien à sens unique
- arête
{u, v}, accolades donc sans ordre ; arc(u, v), parenthèses donc ordonné. Un cycle orienté s'appelle aussi un circuit - Combien d'entrées occupe une arête non orientée en mémoire
- deux — aucune structure ne stocke une arête, on pose les deux arcs ensemble
- Les quatre coûts de la matrice d'adjacence
- mémoire Θ(n²) · test d'un arc Θ(1) · voisins de u Θ(n) · parcours complet Θ(n²)
- Les quatre coûts de la liste d'adjacence
- mémoire Θ(n+m) · test d'un arc Θ(degré de u) · voisins de u Θ(degré de u) · parcours complet Θ(n+m)
- Ce que « dense » et « creux » veulent dire, et lequel choisir
- dense : m proche de n² → matrice. Creux : m de l'ordre de n → listes. Les graphes réels sont creux, et le travail pratique du cours impose les listes
- La seule différence entre le code du BFS et celui du DFS
- la structure d'attente :
queueavecfront()contrestackavectop(). Tout le reste est identique - Où se place
visite[v] = truedans un BFS - juste avant
file.push(v), collé. Marquer au défilement fait entrer un sommet plusieurs fois dans la file - Ce que le BFS donne gratuitement, et sa limite
- la distance minimale en nombre de liens depuis le départ, parce que la file ne contient que deux couches consécutives. Faux dès que les arcs sont pondérés
- Ce que change l'affichage placé après la boucle d'un DFS récursif
- on passe du pré-ordre au post-ordre : un sommet ne sort qu'une fois tous ses descendants terminés
- Pourquoi le DFS itératif ne rend pas l'ordre du DFS récursif
- la pile rend le dernier voisin empilé : les voisins sont explorés en ordre inverse à chaque sommet. Empiler à l'envers rétablit l'ordre
- Les deux complexités d'un parcours, selon la représentation
- Θ(n+m) en listes, Θ(n²) en matrice. L'écart ne vient pas du parcours mais du coût d'énumérer les voisins