Adloun

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

Définition 13.1Pile

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 ?
Définition 13.2File

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.

AttentionLe piège de la file en liste Python

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

Définition 13.3Parcours en profondeur

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)
ImportantLe marquage, cœur de tout parcours

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

Définition 13.4Parcours en largeur

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.

Exemple 13.5Les deux parcours sur le même graphe

Avec G = {&quot;a&quot;: [&quot;b&quot;, &quot;c&quot;], &quot;b&quot;: [&quot;a&quot;, &quot;d&quot;], &quot;c&quot;: [&quot;a&quot;, &quot;e&quot;], &quot;d&quot;: [&quot;b&quot;, &quot;f&quot;], &quot;e&quot;: [&quot;c&quot;, &quot;f&quot;], &quot;f&quot;: [&quot;d&quot;, &quot;e&quot;]} : 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

◆Théorème 13.6Le parcours en largeur calcule 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

Exemple 13.7Tester la connexité, lister les composantes

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)

Exemple 13.8Un cycle, ou pas ?

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

Proposition 13.9Arbres

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)

Exercice 1 : Pile ou file, à la main

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

Exercice 2 : Dérouler les deux parcours

Sur le graphe hexagonal du cours, dérouler pas à pas parcours_largeur(G, &quot;a&quot;) : é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.)

Exercice 3 : Accessible ?

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

Exercice 4 : Mesurer le piège de la file-liste

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

Exercice 5 : Sortir du labyrinthe

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

Exercice 6 : Le graphe est-il un arbre ?

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

Exercice 7 : Le graphe biparti, ou le coloriage en deux couleurs

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)

Exercice 8 : L'échelle des mots

Relier rame à cage en changeant une lettre à la fois, chaque mot intermédiaire devant appartenir au lexique [&quot;rame&quot;, &quot;race&quot;, &quot;rage&quot;, &quot;cage&quot;, &quot;came&quot;, &quot;cape&quot;, &quot;rape&quot;]. 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.)

Exercice 9 : Les arbres, preuve du théorème

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

Exercice 10 : Six degrés de séparation — mesurer un petit monde

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

Synthèse du chapitre (à retenir)
  • Pile (LIFO : append/pop, la liste Python suffit, ) ; file (FIFO : collections.deque, append/popleft en ) ; piège nommé au programme : la file en liste via pop(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 set pour 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

B. Parcours

C. Connexité, cycles, distances

D. Études

Continuer sur Adloun : animation, QCM, fiches, exercices