Module L05 · Partie F · Coder comme un professionnel
Graphes : tout est réseau.
Une carte routière, les dépendances d’un projet, les amis d’un réseau social, les états d’un robot, les pages du web, les neurones d’un cerveau : des sommets reliés par des arêtes. Une fois qu’un problème est un graphe, une poignée d’algorithmes le résout. Ce module les implémente tous, avec leurs preuves et leurs pièges.
Durée : 3 séances · Prérequis : L03 (files, tas), L04. Objectifs : représentations, BFS/DFS et leurs applications, Dijkstra, A*, Bellman-Ford, tri topologique, composantes fortement connexes, Union-Find et Kruskal, Prim, Floyd-Warshall, une introduction aux flots.
Ce que vous saurez faire à la fin
- Choisir liste d’adjacence ou matrice selon la densité.
- Implémenter 8 algorithmes de graphes et connaître leur complexité.
- Modéliser un problème concret (planification de robot, réseau de capteurs, dépendances) en graphe.
Références : CLRS chapitres 22-26, programme MPI « graphes », Sedgewick Algorithms partie 4.
Fiche de cours · Définitions
Le vocabulaire des graphes
Fiche de cours · Formules
Formules et complexités à connaître
| Algorithme | Problème | Complexité | Structure clé |
|---|---|---|---|
| BFS | Plus court chemin (non pondéré), composantes, biparti | O(n + m) | File |
| DFS | Cycles, tri topologique, composantes fortement connexes | O(n + m) | Pile / récursion |
| Dijkstra | Plus court chemin, poids ≥ 0 | O((n + m) log n) | Tas de priorité |
| Bellman-Ford | Plus court chemin, poids négatifs, détection de cycle négatif | O(n·m) | n − 1 relaxations de toutes les arêtes |
| Floyd-Warshall | Toutes les paires | O(n³) | PD sur les sommets intermédiaires |
| Kruskal | ACM | O(m log m) | Tri + union-find |
| Prim | ACM | O((n + m) log n) | Tas |
| A* | Plus court chemin avec heuristique | dépend de h | Tas sur g + h (L17, L21) |
Fiche de cours · Théorèmes et démonstrations
Démonstrations à savoir refaire (1/2)
Fiche de cours · Théorèmes et démonstrations
Démonstrations à savoir refaire (2/2)
Fiche de cours · Méthodes
Méthodes et pièges
pred[v] = u quand v est découvert (ou relaxé) depuis u ; remonter de t à s ; inverser.Pièges : Dijkstra avec des poids négatifs ; oublier les sommets isolés en itérant seulement sur les arêtes ; matrice d’adjacence pour un graphe creux (mémoire n²) ; graphe orienté traité comme non orienté (arêtes en un seul sens) ; ACM ≠ arbre des plus courts chemins.
Fiche de cours · Exercices corrigés
Exercices corrigés
01 / Représenter
Listes d’adjacence, matrice, et une classe qui sert pour tout le module
Quelle représentation ?
| Liste d’adjacence | Matrice | |
|---|---|---|
| Mémoire | O(S + A) | O(S²) |
| « u et v sont-ils voisins ? » | O(deg u) ou O(1) avec un dict | O(1) |
| Parcourir les voisins de u | O(deg u) | O(S) |
| Adapté à | graphes creux (routes, web, réseaux sociaux) | graphes denses, calcul matriciel (Floyd, puissances de matrice) |
Un graphe routier de 107 sommets a ~2·107 arêtes : la matrice pèserait 1014 cases. La liste d’adjacence est le choix par défaut.
02 / Parcours
BFS : le plus court chemin en nombre d’arêtes
Pourquoi BFS trouve le plus court chemin : la file traite les sommets par distance croissante (invariant : à tout instant, la file contient des sommets à distance d puis d+1, jamais plus). La première fois qu’on atteint un sommet, c’est donc par un plus court chemin. C’est un argument à savoir rédiger.
02 / Parcours
DFS : composantes, cycles, tri topologique
Gris = « en cours » : la détection de cycle
Un sommet gris est sur la pile d’appels courante. Rencontrer une arête vers un sommet gris, c’est revenir sur un ancêtre : un cycle. Le tri topologique est l’ordre inverse des dates de fin : quand u finit, tous ses descendants ont fini avant lui. Applications : make, gestionnaires de paquets, ordonnancement de calculs (PyTorch construit et trie topologiquement le graphe des opérations pour la rétropropagation, module L14). La version récursive dépasse la limite de pile pour ~1000 sommets : en pratique on écrit une version itérative avec une pile explicite.
03 / Plus courts chemins
Dijkstra : le plus court chemin pondéré
L’invariant de Dijkstra
Quand un sommet u sort du tas avec la distance d, aucune autre voie ne peut être plus courte : tout autre chemin vers u passe par un sommet encore dans le tas, dont la distance est ≥ d, plus des poids ≥ 0. Cette preuve échoue avec des poids négatifs (ici, Dijkstra fixe B à 5 avant de découvrir A→C→B = −2 ; par chance la version « paresseuse » ci-dessus se corrige, mais ce n’est pas garanti en général). Dijkstra est un glouton dont l’argument d’échange repose sur la positivité. Pour un GPS : contraction hierarchies, A* et pré-calculs rendent Dijkstra 1000 fois plus rapide sur un graphe continental.
03 / Plus courts chemins
A* : Dijkstra guidé par une heuristique
Admissible, consistante, et le piège de la sur-estimation
A* trie par f = g + h : coût déjà payé + estimation du reste. Si h ne sur-estime jamais (admissible), le premier chemin trouvé est optimal. Si de plus h(u) ≤ coût(u, v) + h(v) (consistante, inégalité triangulaire), chaque sommet est sorti une seule fois. La distance euclidienne est admissible pour tout déplacement ; l’octile est plus serrée pour 8 directions, donc explore moins. Une heuristique qui sur-estime (h × 1,5) va plus vite mais peut rater l’optimum : c’est le « weighted A* » des jeux vidéo, où la vitesse prime. Sur un robot, A* tourne sur une grille d’occupation (module L20) ; en 3D et avec des contraintes cinématiques, on passe aux RRT (module L21).
04 / Connexité et arbres couvrants
Union-Find et Kruskal : relier au moindre coût
Preuve de Kruskal (propriété de la coupe) : l’arête la plus légère qui traverse une coupe quelconque appartient à un arbre couvrant minimal ; l’argument d’échange du module L04. Union-Find sert aussi à la détection de cycles, aux composantes connexes dynamiques et à la segmentation d’images.
04 / Connexité et arbres couvrants
Floyd-Warshall et composantes fortement connexes
Les composantes fortement connexes regroupent les sommets mutuellement accessibles : dans un graphe de dépendances, ce sont les cycles à casser ; dans le web, les « communautés » ; dans une machine à états, les régions d’où on ne peut plus sortir (un piège à détecter dans un robot !). Le graphe réduit des CFC est toujours acyclique.
05 / Flots
Flot maximal : combien peut passer ?
Max-flot / min-coupe
Le flot maximal égale la capacité de la coupe minimale (théorème de Ford-Fulkerson) : le goulot d’étranglement d’un réseau est exactement son débit maximal. Applications : bande passante, affectation (couplage biparti), segmentation d’images (graph cuts), planification de projets, et beaucoup de problèmes qui ne ressemblent pas à des flots mais s’y réduisent — reconnaître ces réductions est un art de concours.
Cours
Cours 1 — Définitions et propriétés de base des graphes
| Notion | Définition | Fait utile |
|---|---|---|
| Graphe G = (S, A) | Sommets S, arêtes A ⊆ S×S ; orienté (arcs) ou non | |A| ≤ |S|(|S|−1)/2 (non orienté simple) |
| Degré | Nombre d’arêtes incidentes (entrant/sortant si orienté) | Σ deg(v) = 2|A| ⇒ le nombre de sommets de degré impair est pair (« lemme des poignées de main ») |
| Chemin / cycle | Suite d’arêtes consécutives ; fermée pour un cycle | Un chemin simple ne repasse pas par un sommet |
| Connexe | Tout couple de sommets est relié par un chemin | Un graphe connexe à n sommets a ≥ n−1 arêtes |
| Arbre | Connexe et sans cycle | Exactement n−1 arêtes ; un unique chemin entre deux sommets ; ajouter une arête crée exactement un cycle |
| DAG | Orienté sans cycle | Admet un ordre topologique ; a au moins un sommet sans prédécesseur |
| Biparti | Sommets colorables en 2 couleurs sans arête monochrome | ⇔ aucun cycle de longueur impaire ; testable par BFS |
| Planaire | Dessinable sans croisement | Euler : S − A + F = 2 ; donc A ≤ 3S − 6 |
Exemple travaillé (poignées de main). Dans un tournoi de robotique, 7 équipes affirment chacune avoir rencontré exactement 3 autres équipes. Possible ? Σ deg = 21, impair : impossible, car chaque rencontre compte deux fois. Ce genre d’argument de parité est la première chose à essayer sur un problème de graphe « existe-t-il… ».
Cours
Cours 2 — Modéliser : reconnaître un graphe dans un énoncé
La compétence clé n’est pas d’implémenter Dijkstra (c’est fait, dans toutes les bibliothèques), mais de voir qu’un problème est un plus court chemin. Cinq modélisations classiques :
- Espace d’états : sommet = configuration (position + orientation + objets portés…), arête = action. Le puzzle du taquin, le loup-chèvre-chou, le robot avec cap (défi ★★) : BFS donne le nombre minimal d’actions.
- Dépendances : sommet = tâche, arc = « doit précéder ». Tri topologique pour l’ordre ; plus long chemin dans le DAG pour la durée minimale du projet (chemin critique).
- Similarité : sommet = objet, arête = « ressemblent » (mots à une lettre près, images proches). Composantes pour regrouper ; BFS pour transformer l’un en l’autre.
- Réseau de flux : capacités, source, puits. Affectations, bande passante, couplages.
- Grille : cases = sommets, adjacence = 4 ou 8 voisins, poids = coût de traversée. Cartes de robots (L20), images (segmentation).
Cours
Cours 3 — Preuves de correction : tri topologique et Kruskal, rédigées
Tri topologique par DFS. Énoncé : dans un DAG, l’ordre inverse des dates de fin d’un DFS est un ordre topologique. Preuve : soit un arc u → v. Quand le DFS examine cet arc depuis u, trois cas : (a) v est blanc : on visite v, qui finit avant u ; (b) v est noir : v a déjà fini, donc avant u ; (c) v est gris : v est un ancêtre de u sur la pile, donc il existe un chemin v ⇝ u, et u → v ferme un cycle — impossible dans un DAG. Dans tous les cas fin(v) < fin(u), donc u précède v dans l’ordre inverse des fins. ∎ Corollaire : le cas (c) détecte les cycles.
Kruskal (propriété de la coupe). Lemme : soit une partition (X, S∖X) des sommets et e l’arête la plus légère qui la traverse ; alors e appartient à un arbre couvrant minimal (ACM). Preuve : soit T un ACM sans e. Ajouter e à T crée un cycle, qui traverse la coupe au moins une autre fois par une arête e′ avec poids(e′) ≥ poids(e). T + e − e′ est un arbre couvrant de poids ≤ poids(T), donc un ACM contenant e. ∎ Kruskal : chaque arête acceptée est la plus légère traversant la coupe entre sa composante et le reste (toutes les plus légères ont déjà été examinées) ; par le lemme, elle est « sûre » ; par récurrence l’ensemble final est un ACM. ∎
Structure commune : identifier un invariant (« l’ensemble des arêtes choisies est inclus dans un ACM ») et montrer que chaque étape le conserve. C’est le module L09 appliqué aux graphes ; à l’oral, on attend exactement ce niveau de rédaction.
TP guidé
TP — Bibliothèque de graphes et planificateur sur carte réelle (sur PC, 2 h 30)
- Bibliothèque.
graphes/:Graphe(liste d’adjacence, chargement/sauvegarde JSON),parcours.py(BFS, DFS itératif, composantes, tri topologique),chemins.py(Dijkstra, A*, Bellman-Ford),arbres.py(Kruskal + UnionFind, Prim). Tests : comparer Dijkstra à Floyd-Warshall sur 200 graphes aléatoires ; Kruskal à Prim ; BFS à Dijkstra avec poids 1. - Données réelles.
Exportez sommets (id, lat, lon) et arêtes (u, v, longueur) vers votre format JSON.pip install osmnx networkx matplotlib import osmnx as ox G = ox.graph_from_place("Massy, France", network_type="drive") # ou votre ville ox.save_graphml(G, "ville.graphml") print(len(G.nodes), len(G.edges)) - Itinéraire. Dijkstra puis A* (heuristique : distance à vol d’oiseau via la formule de haversine) entre deux adresses ; comptez les sommets explorés par chacun ; tracez le chemin sur la carte (
ox.plot_graph_route). - Validation. Comparez votre longueur à
networkx.shortest_path_length(G, u, v, weight="length"): écart < 1 m. Mesurez les temps : votre Python vs NetworkX vs A*. - Robustesse. Retirez 5 % des arêtes au hasard (travaux) : le chemin existe-t-il toujours ? Calculez les composantes fortement connexes du graphe routier (sens uniques !) : combien y en a-t-il, et que représente la plus grande ?
- Livrable. Dépôt, carte avec l’itinéraire,
RESULTATS.md(sommets explorés Dijkstra vs A*, temps, CFC).
Exercices
Exercices auto-corrigés — parcours
Exercice 1 — Biparti, et plus courte transformation
a) est_biparti(adj) par BFS 2-coloration (adj : dict sommet → liste de voisins). b) echelle(depart, arrivee, mots) : plus courte suite de mots du dictionnaire où deux mots consécutifs diffèrent d’une lettre (BFS ; renvoyer la liste, ou None).
Correction
def est_biparti(adj):
couleur = {}
for s in adj:
if s in couleur: continue
couleur[s] = 0; file = deque([s])
while file:
u = file.popleft()
for v in adj[u]:
if v not in couleur: couleur[v] = 1 - couleur[u]; file.append(v)
elif couleur[v] == couleur[u]: return False
return True
def echelle(depart, arrivee, mots):
D = set(mots) | {depart}; pred = {depart: None}; file = deque([depart])
while file:
u = file.popleft()
if u == arrivee: break
for i in range(len(u)):
for c in "abcdefghijklmnopqrstuvwxyz":
v = u[:i] + c + u[i + 1:]
if v in D and v not in pred: pred[v] = u; file.append(v)
if arrivee not in pred: return None
c, u = [], arrivee
while u: c.append(u); u = pred[u]
return c[::-1]Exercices
Exercices auto-corrigés — chemins et arbres
Exercice 2 — Chemin critique dans un DAG
Tâches avec durées et dépendances. duree_projet(taches) renvoie la durée minimale totale (plus long chemin dans le DAG, calculé en ordre topologique) et la liste des tâches critiques (celles dont tout retard retarde le projet).
Correction
def duree_projet(taches):
ordre, vus = [], set()
def visiter(t):
if t in vus: return
vus.add(t)
for p in taches[t][1]: visiter(p)
ordre.append(t)
for t in taches: visiter(t)
debut = {}
for t in ordre: debut[t] = max((debut[p] + taches[p][0] for p in taches[t][1]), default=0)
fin_projet = max(debut[t] + taches[t][0] for t in taches)
tard = {} # au plus tard : en ordre inverse
for t in reversed(ordre):
succ = [s for s in taches if t in taches[s][1]]
tard[t] = min((tard[s] for s in succ), default=fin_projet) - taches[t][0]
return fin_projet, sorted(t for t in taches if debut[t] == tard[t])Exercice 3 — Dijkstra avec contrainte et Kruskal de mémoire
a) chemin_moins_de_k_aretes(g, s, t, k) : plus court chemin pondéré utilisant au plus k arêtes (état = (sommet, nombre d’arêtes utilisées)). b) kruskal(n, aretes) de mémoire : renvoie le poids total de l’ACM (arêtes : liste de (u, v, p), sommets 0..n−1) ou None si le graphe n’est pas connexe.
Correction
def chemin_moins_de_k_aretes(adj, s, t, k):
dist = {(s, 0): 0}; tas = [(0, s, 0)]
while tas:
d, u, n = heapq.heappop(tas)
if u == t: return d
if d > dist.get((u, n), float("inf")) or n == k: continue
for v, p in adj[u]:
if d + p < dist.get((v, n + 1), float("inf")):
dist[(v, n + 1)] = d + p; heapq.heappush(tas, (d + p, v, n + 1))
return None
def kruskal(n, aretes):
parent = list(range(n))
def trouver(x):
while parent[x] != x: parent[x] = parent[parent[x]]; x = parent[x]
return x
total, nb = 0, 0
for u, v, p in sorted(aretes, key=lambda a: a[2]):
ru, rv = trouver(u), trouver(v)
if ru != rv: parent[ru] = rv; total += p; nb += 1
return total if nb == n - 1 else None06 / Défis
Défi ★ — Le graphe des mots
Consigne
Deux mots de même longueur sont voisins s’ils diffrent d’une lettre. Avec le petit dictionnaire fourni, trouvez par BFS la plus courte échelle de froid à chaud (chaque étape est un mot du dictionnaire). Combien de composantes connexes a ce graphe ? Quel est le mot le plus « central » (excentricité minimale) ?
Correction
class G:
def voisins(self, m): return voisins(m)
def sommets(self): return mots
g2 = G()
dist, pred = bfs(g2, "froid")
print(chemin(pred, "chaud") if "chaud" in dist else "pas de chemin")
print("composantes :", composantes_connexes(g2))
exc = {m: max(bfs(g2, m)[0].values()) for m in mots if len(bfs(g2, m)[0]) > 1}
print(min(exc, key=exc.get), exc)Construire le graphe explicitement coûterait O(n²·L) comparaisons ; pour un vrai dictionnaire (300 000 mots), on hache les motifs « fr_id » pour trouver les voisins en O(L) par mot.
06 / Défis
Défi ★★ — Planificateur de robot avec coûts de rotation
Consigne
Sur la grille du labyrinthe (slide BFS), le robot a une orientation (N, E, S, O). Avancer coûte 1, tourner de 90° coûte 2. Modélisez l’état comme (case, orientation), construisez les arêtes à la volée et lancez Dijkstra pour trouver le trajet de coût minimal de S (orienté E) à E. Comparez avec le chemin BFS : le nombre de virages diminue-t-il ?
Correction
def successeurs(etat):
i, j, d = etat
di, dj = DIRS[d]
a, b = i + di, j + dj
if 0 <= a < len(labyrinthe) and 0 <= b < len(labyrinthe[0]) and labyrinthe[a][b] != "#":
yield (a, b, d), 1
k = ORDRE.index(d)
yield (i, j, ORDRE[(k + 1) % 4]), 2
yield (i, j, ORDRE[(k - 1) % 4]), 2
def dijkstra_etats(depart, est_but):
dist, pred, tas = {depart: 0}, {depart: None}, [(0, depart)]
while tas:
d, u = heapq.heappop(tas)
if d > dist[u]: continue
if est_but(u):
c = []
while u: c.append(u); u = pred[u]
return d, c[::-1]
for v, p in successeurs(u):
if d + p < dist.get(v, float("inf")):
dist[v], pred[v] = d + p, u; heapq.heappush(tas, (d + p, v))
cout, traj = dijkstra_etats((0, 0, "E"), lambda e: e[:2] == (4, 8))
print("coût :", cout, "| virages :", sum(1 for a, b in zip(traj, traj[1:]) if a[2] != b[2]))
print([e[:2] for e in traj if True][::3])L’espace d’états est 4 fois plus grand que la grille, mais Dijkstra reste instantané. C’est exactement ainsi qu’un planificateur de robot (ROS Nav2, module L21) intègre les contraintes cinématiques : l’état n’est pas la position mais (position, cap, vitesse…).
06 / Défis
Défi ★★★ — Esprit prépa : 2-SAT par les composantes fortement connexes
Consigne
Une formule 2-SAT est une conjonction de clauses à deux littéraux (x ∨ ¬y) ∧ (…). Construisez le graphe d’implication : chaque clause (a ∨ b) donne les arêtes ¬a → b et ¬b → a. La formule est satisfiable si et seulement si aucune variable x n’est dans la même CFC que ¬x. Implémentez, testez sur une formule satisfiable et une insatisfiable, puis extrayez une affectation (ordre topologique des CFC). Complexité : O(n + m), alors que 3-SAT est NP-complet — pourquoi la frontière passe-t-elle entre 2 et 3 ?
Correction
def deux_sat(clauses):
g = Graphe(oriente=True)
for a, b in clauses:
g.ajouter_arete(-a, b); g.ajouter_arete(-b, a)
for a, b in clauses:
for x in (a, b, -a, -b): g.adj[x]
comps = kosaraju(g)
cfc = {}
for i, c in enumerate(comps):
for x in c: cfc[x] = i
for x in list(cfc):
if x > 0 and -x in cfc and cfc[x] == cfc[-x]: return None
# Kosaraju renvoie les CFC en ordre topologique du graphe : x vrai si cfc[x] > cfc[-x]
return {x: cfc[x] > cfc[-x] for x in cfc if x > 0}
print(deux_sat(clauses_ok)); print(deux_sat(clauses_ko))
aff = deux_sat(clauses_ok)
val = lambda l: aff[abs(l)] if l > 0 else not aff[abs(l)]
print("vérification :", all(val(a) or val(b) for a, b in clauses_ok))Avec 2 littéraux, une clause est une implication et le problème devient un problème de graphe. Avec 3, il n’y a plus de structure d’implication simple : 3-SAT est le problème NP-complet de référence (Cook-Levin, module L23). Cette frontière — un problème facile, son voisin immédiat difficile — est l’un des phénomènes centraux de l’informatique théorique.
07 / Vérification
Pour trouver le plus court chemin (en nombre d’arêtes), on remplace la file du BFS par une pile. Résultat ?
Deux questions supplémentaires
1. Pourquoi Dijkstra utilise-t-il un tas et non une file ? Parce que les poids diffèrent : il faut toujours traiter le sommet de distance minimale, ce que la file (ordre d’arrivée) ne garantit pas.
2. Une heuristique A* valant toujours 0 donne quoi ? Exactement Dijkstra.
Référence
Les algorithmes à retenir
| Algorithme | Problème | Complexité |
|---|---|---|
| BFS | Plus court chemin non pondéré, niveaux | O(S + A) |
| DFS | Composantes, cycles, tri topologique, CFC | O(S + A) |
| Dijkstra | Plus court chemin, poids ≥ 0 | O((S + A) log S) |
| Bellman-Ford | Poids négatifs, cycles négatifs | O(S·A) |
| A* | Plus court chemin guidé | dépend de h |
| Floyd-Warshall | Toutes les paires | O(S³) |
| Kruskal / Prim | Arbre couvrant minimal | O(A log A) |
| Union-Find | Ensembles disjoints | ~O(1) amorti (α(n)) |
| Kosaraju / Tarjan | Composantes fortement connexes | O(S + A) |
| Edmonds-Karp | Flot maximal, couplage | O(S·A²) |
Pour continuer
Vous modélisez en graphes
Module suivant : les outils du développeur — terminal, Git, organisation d’un projet, tests automatisés en intégration continue — pour travailler comme une équipe et pas comme un fichier unique.
À faire chez soi
- Réécrire Dijkstra et Kruskal de mémoire, avec tests par propriétés (comparer à Floyd-Warshall sur des graphes aléatoires).
- Implémenter Tarjan (une seule passe DFS) pour les CFC.
- Lire le sujet X-ENS informatique 2021 (« chemins ») et le résoudre avec les outils du module.