Matrice et listes d'adjacence, parcours en profondeur et en largeur, connexité, détection de cycle, plus court chemin en nombre d'arêtes, Dijkstra en introduction.
Combien de correspondances au minimum entre deux stations de métro ? Un ami commun existe-t-il entre deux personnes d'un réseau social ? Quelles pages web sont atteignables depuis celle-ci ? Ces trois questions n'ont ni hiérarchie ni ordre : elles portent sur des objets reliés arbitrairement, et aucune structure vue jusqu'ici ne les modélise.
Le graphe est cet objet, et il généralise tout le semestre. Une liste est un graphe où chaque sommet a un successeur ; un arbre, un graphe sans cycle avec une racine. Retirer ces contraintes ouvre un champ immense — et introduit une difficulté nouvelle : on peut revenir sur ses pas.
Vocabulaire
Un graphe est un ensemble de sommets et un ensemble d'arêtes reliant des paires de sommets.
Il est orienté quand les liens ont un sens — on parle alors d'arcs : les liens hypertexte, les dépendances entre tâches, les relations « suit » d'un réseau social. Il est non orienté quand ils n'en ont pas : un réseau routier, une amitié réciproque.
Il est pondéré quand chaque arête porte un nombre — distance, durée, coût, capacité. C'est ce qui distinguera le chapitre en deux moitiés : sans poids, la longueur d'un chemin est son nombre d'arêtes ; avec poids, c'est la somme.
| Terme | Définition |
|---|---|
| Degré d'un sommet | nombre d'arêtes qui y aboutissent |
| Chemin | suite de sommets reliés deux à deux |
| Cycle | chemin qui revient à son point de départ |
| Connexe | tout sommet est atteignable depuis tout autre |
| Composante connexe | morceau maximal connexe d'un graphe qui ne l'est pas |
| Acyclique | sans aucun cycle |
Deux notations utiles : pour le nombre de sommets, pour le nombre d'arêtes. Un graphe est dit dense quand approche — presque tout est relié — et creux quand est de l'ordre de . Cette distinction décide de la représentation.
Deux représentations
La matrice d'adjacence est un tableau où la case vaut 1 s'il existe une arête de vers — ou le poids, s'il y en a un.
A B C D A ── B A 0 1 1 0 │ │ B 1 0 1 0 C ───┘ C 1 1 0 1 │ D 0 0 1 0 DTester l'existence d'une arête est en , ce qui est imbattable. Mais la mémoire est quel que soit le nombre d'arêtes, et surtout, énumérer les voisins d'un sommet demande de parcourir toute une ligne, soit — même s'il n'a que deux voisins.
Les listes d'adjacence associent à chaque sommet la liste de ses voisins.
A : B, CB : A, CC : A, B, DD : CLa mémoire est , et énumérer les voisins d'un sommet coûte exactement son degré. En revanche, tester une arête précise demande de parcourir une liste.
| Matrice | Listes | |
|---|---|---|
| Mémoire | ||
| Tester une arête | ||
| Énumérer les voisins |
Le choix se tranche par la densité, et par ce que fait l'algorithme. Les parcours de ce chapitre énumèrent les voisins en boucle et ne testent presque jamais une arête isolée : sur un graphe creux, les listes d'adjacence sont donc le bon choix — et les graphes réels sont massivement creux. Un réseau social de dix millions de comptes n'a pas relations ; il en a peut-être un milliard, et la matrice serait de toute façon impossible à loger.
Parcourir : la difficulté nouvelle
Les parcours du chapitre 7 se transposent presque tels quels — avec une modification qui n'est pas optionnelle.
Dans un arbre, on ne peut pas revenir sur un nœud déjà visité : il n'y a qu'un chemin depuis la racine. Dans un graphe, un cycle ramène sur ses pas, et un parcours naïf boucle indéfiniment. Il faut donc marquer les sommets visités, et tester ce marquage avant chaque descente. C'est la seule différence de fond avec le chapitre 7, et c'est l'oubli qui produit les boucles infinies du TD.
Le parcours en profondeur (DFS) s'enfonce aussi loin que possible avant de revenir en arrière. Il s'écrit récursivement en cinq lignes — la pile d'appels du bloc I fait le travail — ou itérativement avec une pile explicite.
fonction profondeur(s) marquer s traiter s pour chaque voisin v de s si v n'est pas marqué alors profondeur(v)Le parcours en largeur (BFS) explore par couches : d'abord les voisins immédiats, puis leurs voisins, et ainsi de suite. Il emploie une file, exactement comme au chapitre 7.
fonction largeur(s) marquer s ; enfiler s tant que la file n'est pas vide u ← défiler traiter u pour chaque voisin v de u non marqué marquer v ; enfiler vUn détail qui compte : on marque à l'enfilement, pas au défilement. Sinon un sommet accessible par deux chemins serait enfilé deux fois.
Les deux parcours coûtent avec des listes d'adjacence : chaque sommet est traité une fois, chaque arête examinée une fois (deux en non orienté).
Ce qu'un parcours résout
La connexité et les composantes. Un parcours depuis un sommet atteint exactement sa composante connexe. Pour toutes les trouver, on relance un parcours depuis chaque sommet non encore marqué, en comptant les relances : c'est le nombre de composantes, obtenu en .
La détection de cycle. En non orienté, un cycle existe si le parcours rencontre un sommet déjà marqué qui n'est pas le père immédiat — la nuance est essentielle, sans quoi toute arête serait vue comme un cycle. En orienté, c'est plus subtil : il faut repérer un arc vers un sommet encore en cours de traitement dans la pile de récursion, et non simplement déjà visité.
Le plus court chemin en nombre d'arêtes. C'est la propriété remarquable du BFS : il donne le plus court chemin, sans effort supplémentaire. La raison tient à l'ordre de visite — il explore tous les sommets à distance 1, puis tous ceux à distance 2, et ainsi de suite. Quand il atteint un sommet pour la première fois, il l'a donc fait par le chemin le plus court, et il suffit de mémoriser le prédécesseur pour reconstituer l'itinéraire. C'est la réponse à la question des correspondances de métro — à condition que toutes les arêtes se valent.
Le DFS n'a pas cette propriété : il peut atteindre un sommet voisin par un long détour, parce qu'il plonge avant d'explorer les alternatives.
Un étudiant écrit un parcours en profondeur sans marquer les sommets visités. Que se passe-t-il, et pourquoi la question ne se posait-elle pas au chapitre 7 ?
Dijkstra, en introduction
Le BFS répond aux arêtes de poids égal. Dès qu'une route est deux fois plus longue qu'une autre, il ne convient plus : le chemin ayant le moins d'arêtes n'est pas forcément le plus court en distance.
L'algorithme de Dijkstra (1959) traite le cas pondéré, à une condition : tous les poids doivent être positifs. Son principe est glouton, au sens du chapitre suivant — il fait à chaque étape le choix qui paraît le meilleur sur le moment, sans jamais revenir dessus.
distance[départ] ← 0, toutes les autres ← infinitant qu'il reste des sommets non traités u ← le sommet non traité de plus petite distance ← le choix glouton marquer u comme traité pour chaque voisin v de u si distance[u] + poids(u,v) < distance[v] alors distance[v] ← distance[u] + poids(u,v) ← relâchementDeux remarques suffisent à ce niveau.
Le sommet extrait est définitif. Quand on choisit le sommet non traité le plus proche, aucun chemin passant par les sommets restants ne pourra faire mieux — puisqu'ils sont tous plus loin et que les poids sont positifs, tout détour ne peut qu'allonger. C'est ce qui justifie de ne jamais revenir en arrière, et c'est aussi ce qui s'effondre avec un poids négatif : un arc de poids rencontré plus tard pourrait raccourcir un chemin déjà figé.
Le coût dépend de la structure choisie. Chercher le minimum en parcourant tous les sommets donne . Le prendre dans une file de priorité — le tas du chapitre 8 — donne , bien meilleur sur un graphe creux. C'est le plus bel emploi du tas du semestre : Dijkstra a besoin, à chaque étape, du minimum d'un ensemble qui change, ce qui est exactement le contrat de cette structure.
Pourquoi le parcours en largeur donne-t-il le plus court chemin en nombre d'arêtes, et pourquoi ne suffit-il plus sur un graphe pondéré ?
À vous
L'exercice construit un petit graphe en listes d'adjacence, puis enchaîne les applications : DFS et BFS, comptage des composantes connexes, détection de cycle, et plus court chemin en nombre d'arêtes avec reconstitution de l'itinéraire — c'est le tableau des prédécesseurs qui fait le travail, et c'est la partie qu'on oublie le plus souvent.
Le squelette contient un DFS sans marquage : lancez-le d'abord sur le graphe cyclique fourni, avec la garde qui l'empêche de tourner à l'infini, et regardez combien de fois chaque sommet est visité. C'est plus convaincant que la mise en garde du cours.
Une dernière partie, facultative, implémente Dijkstra avec une recherche linéaire du minimum, puis vous invite à le rebrancher sur le tas du chapitre 8.
Écrivez DFS et BFS avec marquage, comptez les composantes, détectez un cycle, puis lancez Dijkstra.
// Listes d'adjacence. Deux composantes connexes, et un cycle dans la première. const G = { A: ["B", "C"], B: ["A", "D"], C: ["A", "D"], D: ["B", "C"], // A-B-D-C-A : un cycle E: ["F"], F: ["E"], // seconde composante }; // ── Le parcours SANS marquage, pour voir ────────────────────────────────── function dfsSansMarquage(g, s, visites = {}, garde = { n: 0 }) { if (garde.n++ > 60) return visites; // sans cette garde : pile pleine visites[s] = (visites[s] ?? 0) + 1; for (const v of g[s]) dfsSansMarquage(g, v, visites, garde); return visites; } // ── Parcours en profondeur ──────────────────────────────────────────────── function dfs(g, depart, vus = new Set(), ordre = []) { // ← à écrire : marquer, traiter, puis descendre chez les voisins non vus return ordre; } // ── Parcours en largeur, avec prédécesseurs ─────────────────────────────── function bfs(g, depart) { const vus = new Set([depart]); const pere = { [depart]: null }; const file = [depart]; const ordre = []; while (file.length > 0) { const u = file.shift(); ordre.push(u); for (const v of g[u]) { // ← à écrire : marquer À L'ENFILEMENT, noter le père, enfiler } } return { ordre, pere }; } // Reconstitue le chemin en remontant les pères depuis l'arrivée. function chemin(pere, arrivee) { if (!(arrivee in pere)) return null; const c = []; for (let s = arrivee; s !== null; s = pere[s]) c.unshift(s); return c; } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Écrivez dfs() et complétez bfs(). // 2. Comptez les composantes connexes de G. // 3. Détectez le cycle : un voisin déjà vu qui n'est PAS le père immédiat. console.log("sans marquage :", JSON.stringify(dfsSansMarquage(G, "A")));
En travaux pratiques
Le calculateur d'itinéraires
Faire converger toute la bibliothèque du semestre sur un problème réel : charger un réseau de transport, le parcourir, et calculer le plus court chemin avec la file de priorité du TP 8.
- Les TP 5 à 8 : liste, file, tas, file de priorité
- Un fichier de réseau : arrêts et liaisons avec durées — le vôtre, ou celui d'une ville ouverte
- 1. Choisir la représentation
Représentez le graphe de deux façons : matrice d'adjacence et listes d'adjacence. Mesurez la mémoire de chacune sur votre réseau, et calculez sa densité.
- 2. Charger le réseau
Lisez le fichier et construisez le graphe. Comptez sommets, arêtes, et vérifiez qu'aucun arrêt cité dans une liaison n'est absent de la liste des arrêts.
- 3. Parcours en largeur
Écrivez le parcours en largeur avec votre file. Utilisez-le pour trouver l'itinéraire en un minimum de CORRESPONDANCES entre deux arrêts, et reconstituez le chemin.
- 4. Parcours en profondeur
Écrivez-le avec votre pile, puis en récursif. Utilisez-le pour compter les composantes connexes du réseau, et identifiez les arrêts isolés.
- 5. Détecter un cycle
Sur un graphe orienté de dépendances — par exemple l'ordre des travaux d'un chantier —, détectez un cycle, puis produisez un ordre topologique quand il n'y en a pas.
- 6. Dijkstra
Implémentez le plus court chemin en TEMPS, avec la file de priorité du TP 8. Comparez le résultat à celui du parcours en largeur et expliquez la différence.
- 7. Mesurer ce que le tas apporte
Écrivez aussi la version de Dijkstra qui cherche le minimum par balayage linéaire. Comparez les deux sur un réseau de mille, puis de cent mille sommets.
- 8. Le piège
Ajoutez au réseau une liaison de durée négative — une correspondance qui ferait gagner du temps. Exécutez Dijkstra et expliquez pourquoi le résultat est faux.
- Votre chargeur signale un arrêt manquant plutôt que de planter
- Le parcours en largeur et Dijkstra donnent des chemins DIFFÉRENTS, et vous savez expliquer lequel répond à quelle question
- La version à tas bat nettement la version linéaire à cent mille sommets
- Vous savez dire précisément quelle hypothèse de Dijkstra une arête négative viole
Ce que la suite en fait
Le chapitre 10 clôt le semestre en prenant de la hauteur sur les manières de chercher, et Dijkstra y servira d'exemple : c'est un algorithme glouton, dont la correction n'est garantie que sous une hypothèse précise — les poids positifs. C'est exactement ce que le chapitre dira des algorithmes gloutons en général : ils sont rapides et souvent faux, et il faut prouver qu'ils ne le sont pas.
Le parcours en profondeur y reviendra aussi, sous un autre nom : le retour sur trace est un DFS dans un arbre de choix qu'on ne construit jamais explicitement.
À retenir
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.