Parcours de graphes
Cours complet · informatique (tronc commun des prépas scientifiques), chapitre 13 · prépas scientifiques, tronc commun
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>13.1 Introduction et motivation
Un graphe en mémoire ne livre rien de lui-même : pour savoir si deux stations de métro sont reliées, si un labyrinthe a une sortie, si un logiciel a des dépendances circulaires, il faut explorer — partir d'un sommet et visiter, de proche en proche, tout ce qui est accessible. C'est le parcours de graphe, l'algorithme fondamental du domaine, dont presque tout le reste (connexité, cycles, plus courts chemins) n'est qu'une instrumentation.
Or il n'y a essentiellement que deux façons d'explorer, et elles diffèrent par un seul choix : quand plusieurs directions s'offrent, traite-t-on d'abord la dernière découverte — on s'enfonce, c'est le parcours en profondeur — ou la première — on rayonne par cercles, c'est le parcours en largeur ? Ce choix est exactement celui d'une structure de données : une pile ou une file, que ce chapitre introduit pour l'occasion — avec un détour obligé par leur implémentation, car la file naïve en liste Python cache un piège de complexité que le programme tient à montrer. À l'arrivée : la connexité et ses composantes, la détection de cycles, et les distances en nombre d'arêtes — le plus court chemin non pondéré, marchepied du chapitre 14.
13.2 Piles et files
13.2.1 Deux disciplines d'attente
Une pile (stack) est une collection où l'on insère et retire du même côté — le sommet. Discipline LIFO (last in, first out) : le dernier entré sort le premier, comme une pile d'assiettes. Opérations : empiler, dépiler, tester la vacuité — toutes exigées en . En Python, la liste fait une pile parfaite :
pile = []
pile.append(x) # empiler : O(1)
x = pile.pop() # dépiler (le DERNIER ajouté) : O(1)
len(pile) == 0 # vide ?
Une file (queue) insère d'un côté et retire de l'autre. Discipline FIFO (first in, first out) : le premier entré sort le premier — la file d'attente du guichet. Opérations : enfiler, défiler, vacuité, en aussi.
La tentation est d'écrire la file avec une liste : file.append(x) pour enfiler, file.pop(0) pour défiler. C'est correct — et c'est un piège de complexité : pop(0) retire la première case, et la liste décale toutes les autres — coût à chaque défilement ! Un parcours qui défile sommets devient en silence. La solution du programme : le module dédié collections.deque (double-ended queue), conçu pour des insertions et retraits en aux deux bouts :
from collections import deque
file = deque()
file.append(x) # enfiler à droite : O(1)
x = file.popleft() # défiler à gauche : O(1)
C'est le « choix éclairé des collections » du chapitre 10, version structure : même contrat, complexités opposées — et l'écart se mesure (exercice 4).
13.3 Le parcours en profondeur
13.3.1 S'enfoncer d'abord
Le parcours en profondeur (DFS, depth-first search) explore depuis un sommet source en suivant un chemin aussi loin que possible, et ne revient en arrière que bloqué. Version itérative, avec une pile et un ensemble de sommets visités :
def parcours_profondeur(G: dict, source) -> list:
"""Renvoie la liste des sommets accessibles depuis source,
dans l'ordre de leur visite."""
visites = []
vus = {source} # marqués : dans la pile ou déjà visités
pile = [source]
while pile: # variant : nb de sommets jamais empilés... voir preuve
s = pile.pop()
visites.append(s)
for v in G[s]:
if v not in vus:
vus.add(v)
pile.append(v)
return visites
La version récursive, équivalente, confie la pile à la machine (chapitre 6 — c'est la pile d'appels) :
def dfs(G: dict, s, vus: set, visites: list) -> None:
vus.add(s)
visites.append(s)
for v in G[s]:
if v not in vus:
dfs(G, v, vus, visites)
Sans l'ensemble vus, le moindre cycle — et même une simple arête non orientée, qui se lit dans les deux sens — relance l'exploration indéfiniment : boucle infinie (itératif) ou débordement de pile (récursif). Tout parcours de graphe marque ce qu'il a rencontré ; et l'on marque à l'empilement, pas au dépilement — sinon un même sommet, atteint par deux chemins avant d'être traité, entrerait deux fois dans la pile. L'ensemble set offre l'ajout et le test en (c'est le dictionnaire du chapitre 2, réduit à ses clés).
Démonstration (Correction et terminaison du parcours)
Terminaison : chaque sommet entre au plus une fois dans la pile (on n'empile que des non-marqués, et l'on marque en empilant) ; chaque tour de boucle dépile un élément. Le nombre total de dépilements est donc borné par : variant (nombre de sommets jamais empilés) (taille de la pile), qui décroît strictement à chaque tour. Correction — l'invariant à double face : (i) tout sommet marqué est accessible depuis la source (on ne marque que des voisins de sommets accessibles : récurrence immédiate) ; (ii) à la fin, tout sommet accessible est marqué — sinon, sur un chemin de la source vers un sommet non marqué, il existerait une première arête avec marqué et non marqué ; or marqué a été dépilé (la pile finit vide) et son voisin aurait alors été marqué : contradiction. Le parcours visite exactement l'ensemble accessible. (Cette preuve, une fois acquise, vaut pour tous les parcours : elle n'utilise nulle part l'ordre de la pile.)
Complexité : Coût d'un parcours
Chaque sommet accessible est dépilé une fois, et sa liste de voisins parcourue une fois : le coût total est — linéaire en la taille du graphe en listes d'adjacence. (Avec une matrice d'adjacence, énumérer les voisins coûte par sommet : parcours en — la raison d'être des listes, annoncée au chapitre 12.)
13.4 Le parcours en largeur
13.4.1 Rayonner par cercles
Le parcours en largeur (BFS, breadth-first search) visite d'abord la source, puis tous ses voisins, puis tous les voisins de ceux-ci, etc. — par cercles concentriques. Une seule différence de code avec la version itérative du DFS : la pile devient une file :
from collections import deque
def parcours_largeur(G: dict, source) -> list:
visites = []
vus = {source}
file = deque([source])
while file:
s = file.popleft() # SEULE différence : FIFO au lieu de LIFO
visites.append(s)
for v in G[s]:
if v not in vus:
vus.add(v)
file.append(v)
return visites
Mêmes preuve, même coût : tout ce qui a été établi pour le parcours générique tient. Ce qui change est l'ordre des visites — et c'est tout l'intérêt.
Avec G = {"a": ["b", "c"], "b": ["a", "d"], "c": ["a", "e"], "d": ["b", "f"], "e": ["c", "f"], "f": ["d", "e"]} : le parcours en largeur depuis donne — les cercles , , , dans l'ordre. Le parcours en profondeur itératif donne : il dépile (le dernier empilé), s'enfonce jusqu'à , puis remonte. Deux stratégies, deux ordres — un seul ensemble visité.
13.4.2 La propriété maîtresse : les distances
Dans un graphe non pondéré, la distance de la source à un sommet est la longueur minimale d'un chemin (en nombre d'arêtes). Le parcours en largeur visite les sommets par distance croissante, et la variante suivante calcule toutes les distances depuis la source :
def distances(G: dict, source) -> dict:
"""Renvoie {sommet: distance depuis source} pour les sommets accessibles."""
dist = {source: 0}
file = deque([source])
while file:
s = file.popleft()
# Invariant : dist est exacte pour tous les sommets marqués, et la file
# contient des sommets de distances d ou d+1, en ordre croissant
for v in G[s]:
if v not in dist:
dist[v] = dist[s] + 1
file.append(v)
return dist
Démonstration (Esquisse)
Par récurrence sur : tous les sommets à distance entrent dans la file avant tout sommet à distance , et avec la bonne étiquette. Pour : la source. Si la propriété vaut jusqu'à : un sommet à distance a, par définition, un voisin à distance ; quand — correctement étiqueté, et défilé avant tout sommet de distance — examine ses voisins, il marque avec s'il ne l'est pas déjà ; et ne peut pas avoir été marqué plus tôt avec une étiquette plus grande (les marquages se font par étiquettes croissantes, ordre FIFO). (C'est l'ordre premier-entré-premier-sorti qui porte toute la preuve : avec une pile, les étiquettes seraient fausses — le DFS ne calcule pas les distances, et c'est le contre-exemple à connaître.)
Méthode : Retrouver le chemin lui-même
Pour exhiber un plus court chemin (et pas seulement sa longueur), on mémorise, au moment du marquage, d'où l'on vient : un dictionnaire pere[v] = s. Le chemin se reconstruit alors à rebours, de la cible vers la source, puis se renverse :
def chemin(pere: dict, source, cible) -> list:
if cible not in pere and cible != source:
return None # inaccessible
c = [cible]
while c[-1] != source:
c.append(pere[c[-1]])
c.reverse()
return c
L'arbre des pères — chaque sommet pointant vers son découvreur — est le sous-produit le plus utile de tout parcours ; le chapitre 14 le réutilisera tel quel.
13.5 Connexité et cycles
13.5.1 Composantes connexes
Un graphe non orienté est connexe si un parcours depuis n'importe quel sommet atteint tout le monde ; et relancer le parcours depuis chaque sommet encore non visité énumère les composantes :
def composantes(G: dict) -> list:
"""Renvoie la liste des composantes connexes (listes de sommets)."""
vus = set()
resultat = []
for s in G:
if s not in vus:
compo = parcours_profondeur(G, s) # ou largeur : indifférent ici
vus.update(compo)
resultat.append(compo)
return resultat
def est_connexe(G: dict) -> bool:
return len(composantes(G)) <= 1
Coût total : — chaque sommet et chaque arête ne sont touchés que par le parcours de leur composante. (Petite incise d'implémentation : pour réutiliser parcours_profondeur telle quelle, on lui fait redécouvrir sa composante avec un marquage neuf ; passer l'ensemble vus en paramètre partagé évite ce double travail — facteur constant, même .)
13.5.2 Détection de cycles (graphes non orientés)
Dans un graphe non orienté, un parcours détecte les cycles par une observation simple : si, en explorant depuis , on rencontre un voisin déjà visité qui n'est pas le sommet d'où l'on vient, c'est qu'un second chemin y mène — un cycle existe.
def a_un_cycle_depuis(G: dict, source) -> bool:
vus = {source}
pile = [(source, None)] # (sommet, son père dans le parcours)
while pile:
s, pere = pile.pop()
for v in G[s]:
if v not in vus:
vus.add(v)
pile.append((v, s))
elif v != pere: # déjà vu, et pas le père : CYCLE
return True
return False
def a_un_cycle(G: dict) -> bool:
vus = set()
for s in G: # toutes les composantes !
if s not in vus:
if a_un_cycle_depuis(G, s):
return True
vus.update(parcours_profondeur(G, s))
return False
Le test v != pere est toute la subtilité : l'arête étant stockée dans les deux sens (chapitre 12), revoir son père n'est pas un cycle — c'est l'aller-retour sur la même arête. (Honnêteté de spécification : tel quel, le test père échoue sur le cycle minimal à deux sommets reliés par... rien — il n'y en a pas sans multi-arêtes, exclues du programme ; et une boucle est bien détectée : dès le premier examen.)
Pour un graphe non orienté à sommets, il y a équivalence entre : connexe et sans cycle ; connexe avec exactement arêtes ; sans cycle avec arêtes. Un tel graphe est appelé un arbre (complément classique, hors vocabulaire officiel du programme) — la structure des hiérarchies, des arbres des pères du cours, et de bien des chapitres à venir. (Preuve esquissée en exercice 9.)
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>13.6 Exercices résolus
Niveau (Application directe du cours)
On effectue : insérer , insérer , retirer, insérer , insérer , retirer, retirer. Donner la suite des éléments retirés et l'état final si la collection est (a) une pile ; (b) une file.
Démonstration (Solution)
(a) Pile (LIFO) : retirés (dernier entré), puis , puis ; état final . (b) File (FIFO) : retirés (premier entré), puis , puis ; état final . (Le même scénario, deux histoires : la pile traite l'urgence du moment, la file honore l'ancienneté — exactement la différence DFS/BFS qui s'ensuit.)
Sur le graphe hexagonal du cours, dérouler pas à pas parcours_largeur(G, "a") : état de la file, sommet défilé, marquages — et vérifier l'ordre annoncé. Donner ensuite, sans dérouler, l'ordre du parcours en profondeur récursif (qui examine les voisins dans l'ordre des listes) et le comparer à l'itératif .
Démonstration (Solution)
Largeur : file ; défiler , marquer-enfiler ; défiler , voisin vu, marquer ; défiler , marquer ; défiler , marquer ; défiler ( déjà vu) ; défiler . Ordre : . ✓
Profondeur récursive : depuis , premier voisin ; depuis , voisin ; depuis , voisin ; depuis , voisin ; depuis , voisin ; tout remonte. Ordre : — différent de l'itératif (), qui dépile le dernier voisin empilé et explore donc les listes à rebours. (Deux DFS légitimes : « un » parcours en profondeur n'est pas unique, seul l'ensemble visité l'est — toute question d'ordre doit préciser la variante, et les deux satisfont la même preuve.)
Écrire existe_chemin(G, u, v) pour un graphe orienté, par parcours arrêté dès que la cible est atteinte ; donner le coût, et expliquer pourquoi l'arrêt anticipé ne change pas le pire cas.
Démonstration (Solution)
def existe_chemin(G: dict, u, v) -> bool:
if u == v:
return True
vus = {u}
pile = [u]
while pile:
s = pile.pop()
for w in G[s]:
if w == v:
return True # arrêt anticipé
if w not in vus:
vus.add(w)
pile.append(w)
return False
Pire cas : si est inaccessible (ou découvert en dernier), tout l'accessible est exploré avant de conclure — l'arrêt anticipé améliore les cas favorables, jamais la garantie, exactement comme le drapeau du tri à bulles (chapitre 3). (Le cas traité d'emblée est le cas limite du partitionnement, chapitre 10 — et la convention « chemin de longueur 0 » que la spécification doit trancher.)
Niveau (Application avec raisonnement intermédiaire)
Chronométrer le parcours en largeur avec la file en deque puis avec la file en liste (pop(0)), sur deux graphes de tailles comparables : (a) l'étoile à branches — un compte célèbre et ses abonnés ; (b) la grille (chapitre 12, exercice 6), sommets. Expliquer les deux verdicts par un calcul de complexité.
Démonstration (Solution)
import time
n = 100_000
etoile = {0: list(range(1, n + 1))} # le hub...
for i in range(1, n + 1):
etoile[i] = [0] # ... et ses abonnés
debut = time.perf_counter()
parcours_largeur(etoile, 0) # version deque
t_deque = time.perf_counter() - debut # 0,01 s
# même code avec : file = [source] ... s = file.pop(0)
t_liste = ... # 0,9 s : facteur 80 !
(a) L'étoile : dès le premier tour, les voisins du hub entrent dans la file ; chaque pop(0) décale alors éléments — coût total , mesuré : facteur , et il croît linéairement avec . (b) La grille, verdict surprenant : les deux versions font quasiment jeu égal ! La raison tient dans la longueur de la file : lors d'un parcours en largeur, la file contient la frontière de l'exploration — sur une grille, un « cercle » d'au plus cases (quelques centaines), et décaler quelques centaines de cases est trop rapide pour se voir. Le coût du pop(0) est : tout dépend du graphe — file courte, piège invisible ; file longue (hubs, graphes denses — et les réseaux réels ont des hubs), parcours quadratique. (La leçon mûre : une mauvaise complexité peut rester indolore sur mille entrées et exploser sur la mille-et-unième — comme on ne choisit pas le graphe qu'on recevra, on choisit deque, dont la garantie ne dépend de rien.)
Un labyrinthe est donné en chaînes de caractères (# mur, . libre), avec une entrée et une sortie . Calculer la longueur du plus court chemin de à et reconstituer ce chemin (graphe-grille du chapitre 12 distances pères).
Démonstration (Solution)
def resoudre_labyrinthe(plan: list, E: tuple, S: tuple):
"""plan : liste de chaînes. Renvoie (distance, chemin) ou (None, None)."""
p, q = len(plan), len(plan[0])
dist, pere = {E: 0}, {}
file = deque([E])
while file:
(i, j) = file.popleft()
if (i, j) == S:
break # la première atteinte est la bonne
for (ni, nj) in [(i-1, j), (i+1, j), (i, j-1), (i, j+1)]:
if 0 <= ni < p and 0 <= nj < q and plan[ni][nj] == "." \
and (ni, nj) not in dist:
dist[(ni, nj)] = dist[(i, j)] + 1
pere[(ni, nj)] = (i, j)
file.append((ni, nj))
if S not in dist:
return (None, None)
return (dist[S], chemin(pere, E, S))
Le graphe n'est même pas construit : les voisins se calculent à la volée depuis le plan — un graphe implicite, économie majeure quand la grille est grande. La largeur garantit que la première fois que sort de la file, sa distance est minimale (théorème du cours) : l'arrêt est licite. (Tout y est : la grille du chapitre 12, le théorème des distances, les pères, l'arrêt anticipé — le labyrinthe est l'examen blanc du parcours en largeur, et la version en profondeur trouverait un chemin, mais pas le plus court : tester les deux sur un labyrinthe à deux routes inégales est l'expérience à faire.)
Écrire est_un_arbre(G) pour un graphe non orienté, de deux façons : (a) connexe et sans cycle (les fonctions du cours) ; (b) connexe et (le critère de comptage). Vérifier l'accord des deux sur des exemples et contre-exemples.
Démonstration (Solution)
def est_un_arbre(G: dict) -> bool: # (a)
return est_connexe(G) and not a_un_cycle(G)
def est_un_arbre_compte(G: dict) -> bool: # (b)
nb_aretes = sum(len(G[s]) for s in G) // 2 # chaque arête comptée 2 fois
return est_connexe(G) and nb_aretes == len(G) - 1
chaine = {0: [1], 1: [0, 2], 2: [1]}
triangle = {0: [1, 2], 1: [0, 2], 2: [0, 1]}
foret = {0: [1], 1: [0], 2: []}
for g in (chaine, triangle, foret):
assert est_un_arbre(g) == est_un_arbre_compte(g)
assert est_un_arbre(chaine) and not est_un_arbre(triangle) and not est_un_arbre(foret)
Les deux fonctions implémentent les deux caractérisations équivalentes de la proposition du cours ; la version (b) remplace une détection de cycle par un comptage — plus simple, à condition d'avoir déjà la connexité (la forêt à deux composantes a bien arêtes… non : arête pour sommets — et c'est la connexité qui la rejette). (Tester l'équivalence de deux implémentations indépendantes : le test croisé du chapitre 1, ici au service d'un théorème — chaque assertion qui passe est une instance vérifiée de la proposition.)
Un graphe non orienté est biparti (complément, hors vocabulaire officiel) si ses sommets se partagent en deux camps sans arête interne à un camp (modèle : élèves/options, antagonismes). Montrer qu'un parcours en largeur décide : colorier chaque sommet selon la parité de sa distance, puis vérifier les arêtes. Implémenter et tester sur un cycle pair et un cycle impair.
Démonstration (Solution)
def est_biparti(G: dict) -> bool:
couleur = {}
for depart in G: # toutes les composantes
if depart in couleur:
continue
couleur[depart] = 0
file = deque([depart])
while file:
s = file.popleft()
for v in G[s]:
if v not in couleur:
couleur[v] = 1 - couleur[s]
file.append(v)
elif couleur[v] == couleur[s]:
return False # arête dans un camp : impossible
return True
cycle4 = {i: [(i - 1) % 4, (i + 1) % 4] for i in range(4)}
cycle5 = {i: [(i - 1) % 5, (i + 1) % 5] for i in range(5)}
assert est_biparti(cycle4) and not est_biparti(cycle5)
Justification : si le graphe est biparti, les deux camps alternent le long de tout chemin — la couleur doit être la parité de la distance, et le coloriage du parcours est le seul candidat ; s'il échoue (deux voisins de même couleur), aucun partage n'existe. Le cycle pair alterne sans heurt ; le cycle impair se mord la queue — son tour complet rend une couleur contradictoire. (Au passage, un théorème célèbre se touche du doigt : un graphe est biparti si et seulement s'il n'a aucun cycle impair — le parcours en largeur en est la preuve algorithmique, et le contre-exemple minimal est le triangle.)
Niveau (Raisonnement subtil ou plusieurs étapes)
Relier rame à cage en changeant une lettre à la fois, chaque mot intermédiaire devant appartenir au lexique ["rame", "race", "rage", "cage", "came", "cape", "rape"]. Modéliser (chapitre 12, banque), résoudre par parcours en largeur, et donner la suite de mots.
Démonstration (Solution)
Modèle : sommets mots du lexique ; arête si les mots diffèrent d'exactement une lettre (même longueur). Construction du graphe en comparant toutes les paires ( pour mots de lettres) :
def different_d_une_lettre(u: str, v: str) -> bool:
return len(u) == len(v) and sum(1 for a, b in zip(u, v) if a != b) == 1
lexique = ["rame", "race", "rage", "cage", "came", "cape", "rape"]
G = {m: [v for v in lexique if different_d_une_lettre(m, v)] for m in lexique}
Résolution : parcours en largeur depuis rame avec pères, puis reconstruction. Les distances calculées : race et rage et came et rape à ; cage à (via rage) — chemin : , de longueur , et le théorème garantit qu'aucune échelle plus courte n'existe. (Le jeu de Lewis Carroll, devenu cas d'école : un problème de mots sans aucun « graphe » apparent, entièrement résolu par modélisation BFS — la chaîne complète des compétences du programme sur sept mots. Avec un vrai lexique de mots, la construction par paires devient le goulot : l'astuce des classes r_me — remplacer chaque lettre par un joker et grouper par dictionnaire, chapitre 2 ! — la ramène au linéaire.)
Démontrer l'équivalence du cours : pour non orienté à sommets, (i) connexe sans cycle (ii) connexe à arêtes (iii) sans cycle à arêtes (i). (On pourra admettre et utiliser : un graphe connexe à sommets a au moins arêtes ; un graphe sans cycle en a au plus .)
Démonstration (Solution)
Établissons d'abord les deux lemmes admis-utilisables, qui font tout le travail. Lemme A (connexe arêtes) : un parcours depuis un sommet quelconque atteint les autres, et chacun est découvert via une arête nouvelle (celle de son père) — arêtes distinctes au moins. Lemme B (sans cycle arêtes) : par récurrence sur ; un graphe sans cycle non vide a un sommet de degré (suivre un chemin sans répéter de sommet — possible sans cycle — jusqu'à un cul-de-sac) ; le retirer ôte au plus une arête et préserve l'acyclicité : au plus arêtes.
(i) (ii) : connexe donne (A), sans cycle donne (B) : exactement . (ii) (iii) : si un cycle existait, retirer une arête du cycle préserverait la connexité (tout chemin par cette arête se détourne par le reste du cycle) — un graphe connexe à arêtes contredirait le lemme A. (iii) (i) : notons le nombre de composantes connexes ; chaque composante, connexe et sans cycle, possède (par (i)(ii) appliqué à elle-même) arêtes ; le total doit valoir : , connexe. (Le théorème dit que les arbres vivent sur un fil : une arête de plus crée un cycle, une de moins déconnecte — c'est cette rigidité qui en fait la structure des réseaux minimaux, des hiérarchies, et des arbres de parcours eux-mêmes : les pères d'un BFS forment précisément un arbre à arêtes.)
Sur le graphe « petit monde » du chapitre 12 (cycle voisins à distance , puis d'arêtes recâblées au hasard, ) : écrire distance_moyenne(G) (moyenne des distances entre paires accessibles, estimée par échantillonnage de sources), comparer avant et après recâblage, et commenter le phénomène.
Démonstration (Solution)
import random
def distance_moyenne(G: dict, nb_sources: int = 50) -> float:
total, compte = 0, 0
for s in random.sample(list(G), nb_sources): # échantillon de sources
d = distances(G, s) # un BFS complet : O(n + m)
total += sum(d.values())
compte += len(d) - 1 # sans la source elle-même
return total / compte
Mesures typiques : sur le cycle régulier (, voisins à distance ), un tour peut coûter jusqu'à pas — distance moyenne . Après recâblage de des arêtes seulement : distance moyenne — division par cinq pour un changement d'un centième (et chaque point de recâblage supplémentaire l'écrase davantage) ! Les quelques arêtes longues servent de raccourcis planétaires que le parcours en largeur emprunte aussitôt. C'est le phénomène du petit monde : les réseaux réels (sociaux, web, neuronaux) mêlent un fort voisinage local et de rares liens lointains, d'où les fameux « six degrés de séparation » — des distances moyennes minuscules dans des graphes géants. (Méthodologiquement : la distance moyenne exacte exigerait parcours, — l'échantillonnage de sources en donne une estimation honnête pour un centième du prix, et la validation consiste à vérifier la stabilité quand on double l'échantillon : les techniques du chapitre 4 — simulation, estimation, contrôle — appliquées aux graphes ; les vrais mesureurs du web ne font pas autrement.)
- Pile (LIFO :
append/pop, la liste Python suffit, ) ; file (FIFO :collections.deque,append/popleften ) ; piège nommé au programme : la file en liste viapop(0)décale tout — par défilement, parcours quadratique en silence. - Parcours générique : marquer la source, tant que la réserve n'est pas vide — extraire, traiter, marquer-et-ranger les voisins non vus ; marquer à l'insertion (sinon doublons) ; ensemble
setpour le marquage (). Preuve : variant (chaque sommet entre au plus une fois), invariant double (marqués accessibles, et tout accessible finit marqué — l'argument de la « première arête frontière »). Coût en listes d'adjacence ( en matrice). - Profondeur (DFS) : réserve pile (ou récursion — attention à la profondeur, chapitre 6) ; s'enfonce puis revient ; l'ordre dépend de la variante, l'ensemble visité non.
- Largeur (BFS) : réserve file ; visite par distance croissante — calcule les distances en nombre d'arêtes (le plus court chemin non pondéré) ; preuve par récurrence sur les cercles, portée par l'ordre FIFO ; pères pour reconstruire le chemin (à rebours puis renverser) ; le DFS ne calcule PAS les distances.
- Applications : composantes connexes (relancer le parcours depuis chaque non-vu, total) ; cycles en non orienté (voisin déjà vu père) ; arbres : connexe sans cycle connexe à arêtes sans cycle à arêtes ; biparti coloriage par parité des distances réussit aucun cycle impair ; labyrinthe grille implicite BFS.
- Graphes implicites : voisins calculés à la volée (grille, mots) — pas besoin de matérialiser le graphe ; échantillonner les sources pour estimer les distances moyennes (petit monde : quelques raccourcis effondrent les distances).
13.7 Exercices d'entraînement
Cette banque d'exercices, classée par thème, couvre l'intégralité du chapitre. La numérotation prolonge celle des dix exercices résolus. Légende : application directe, raisonnement intermédiaire, approfondissement ; le symbole signale un classique incontournable.
A. Piles et files
- () Implémenter
renverse_pile(p)(renverser une liste en la dépilant dans une autre) et l'utiliser pour vérifier qu'empiler-dépiler renverse l'ordre, deux fois le restaure. - ( ) Le contrôle des parenthésages : vérifier qu'une expression équilibre ses
()[]\{\} à l'aide d'une pile (empiler les ouvrantes, apparier les fermantes) — le grand classique de la pile. - () Évaluer une expression en notation polonaise inverse (
"3 4 + 2 *"vaut ) avec une pile d'opérandes. - () Simuler une file avec deux piles (l'une pour entrer, l'autre pour sortir, basculée quand elle se vide) et montrer que chaque élément n'est déplacé que deux fois : le coût amorti du défilement est .
- () Chronométrer
pop(0)contrepopleft()sur des files de à éléments ; tracer en log-log (chapitre 4) et lire les pentes.
B. Parcours
- () Dérouler les deux parcours depuis sur le graphe — ordres de visite, arbre des pères.
- () Écrire
accessibles(G, s)(l'ensemble atteignable) etnb_accessiblespour un graphe orienté ; compareraccessibles(G, u)etaccessibles(inverse(G), u)(chapitre 12, banque) — qui atteint ? - ( ) Écrire la version du parcours en profondeur qui passe l'ensemble
vusen paramètre, et l'utiliser pour descomposantessans double marquage ; vérifier le coût au chronomètre sur un graphe à sommets. - () Le DFS récursif sur une chaîne de sommets lève
RecursionError(chapitre 6 !) : le constater, et conclure sur le choix itératif/récursif selon la forme du graphe. - () Compter les sommets à distance exactement de la source (les « cercles » du BFS) et tracer l'histogramme des distances pour la grille — la forme en losange des cercles de la grille.
- () L'ordre de fin de traitement du DFS récursif (ajouter le sommet à la liste après la boucle des voisins) : l'implémenter, et vérifier sur un graphe orienté sans cycle que l'ordre inverse des fins place toujours avant quand l'arc existe — le tri topologique, en avance sur le programme de seconde année.
C. Connexité, cycles, distances
- () Écrire
meme_composante(G, u, v)ettaille_plus_grande_composante(G). - ( ) Le labyrinthe complet : générer un plan aléatoire ( de murs), résoudre par BFS, afficher le plan avec le chemin marqué
*— et mesurer la fréquence des labyrinthes sans solution. - () Détecter si un graphe non orienté contient un cycle passant par un sommet donné (adapter la détection du cours en partant de et en exigeant le retour à ).
- () L'excentricité d'un sommet (sa plus grande distance aux autres), le diamètre (la plus grande excentricité) : les calculer par BFS répétés sur un petit graphe, et estimer le diamètre du petit monde de l'exercice résolu 10.
- () Le nombre de plus courts chemins : adapter le BFS pour compter, pour chaque sommet, combien de plus courts chemins y mènent (cumuler les comptes des pères à distance ) ; vérifier sur la grille que le coin opposé en reçoit .
D. Études
- () Le cavalier des échecs : sur un échiquier , graphe implicite des mouvements du cavalier — nombre minimal de coups d'un coin à l'autre (réponse : ), et la case la plus longue à atteindre.
- ( ) Les composantes d'une image binaire (chapitre 8 !) : compter les taches de pixels noirs -connexes d'une matrice — le parcours de graphe au service de la vision, sans construire le graphe.
- () Le verre d'eau : avec deux récipients de L et L, obtenir exactement L. Modéliser les états en graphe implicite (remplir, vider, transvaser), résoudre par BFS, donner la suite d'opérations — la recherche d'état, ancêtre des solveurs de jeux.
- ( ) Dossier complet « réseau social jouet » : charger un graphe d'amitiés depuis un fichier (chapitre 12, banque), calculer composantes, distances moyennes par échantillonnage, histogramme des degrés, suggestions d'amis (distance 2, triées par nombre d'amis communs — chapitre 2 !) — rédigé selon les six compétences du programme.