Retour au cours

backend / algorithmes-structures-donnees

Parcours de graphes : BFS et DFS

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Différencier BFS (file, couche par couche) et DFS (pile ou récursion, profondeur d'abord)
  • Justifier pourquoi BFS garantit le plus court chemin en nombre d'arêtes sur un graphe non pondéré
  • Utiliser DFS pour détecter les composantes connexes et calculer un tri topologique
  • Distinguer un sommet "en cours d'exploration" d'un sommet "terminé" pour détecter un cycle correctement
  • Choisir entre BFS et DFS selon la question posée : plus court chemin ou exploration structurelle

Dans quel contexte ?

Un réseau social veut calculer la distance en nombre de relations entre deux utilisateurs pour proposer "vos amis en commun" (une fonctionnalité "à 2 degrés de connexion"). BFS, en explorant couche par couche depuis l'utilisateur de départ, garantit de trouver le chemin le plus court en nombre de relations dès la première fois qu'il atteint un sommet, exactement ce qu'il faut ici. Un gestionnaire de paquets comme npm, lui, doit déterminer dans quel ordre installer des dépendances qui dépendent les unes des autres : c'est un tri topologique calculé via DFS, et une dépendance circulaire (A dépend de B qui dépend de A) est détectée précisément grâce à la distinction entre sommet "en cours" et sommet "terminé".

D'abord, la question à se poser face à un graphe inconnu

Une fois un graphe représenté en mémoire, il faut pouvoir le parcourir pour visiter tous ses sommets. Deux stratégies radicalement différentes existent pour cela.

Prérequis

Cette leçon s'appuie sur la représentation en liste d'adjacence vue à la leçon précédente, ainsi que sur les piles et files (leçon 3) et la récursivité (leçon 4).

Étape 1 : le parcours en largeur (BFS)

BFS avance "en cercles concentriques" depuis le point de départ : il visite d'abord tous les voisins directs, puis tous leurs voisins, et ainsi de suite. Cette progression couche par couche est rendue possible par une file (FIFO).

Pourquoi BFS garantit le plus court chemin

En visitant les sommets par distance croissante, BFS découvre chaque sommet pour la toute première fois via l'un de ses chemins les plus courts possibles depuis le départ, sur un graphe SANS pondération.

CritèreBFSDFS
Structure de donnéesFile (FIFO)Pile (ou récursion)
ProgressionCouche par coucheUne branche jusqu'au bout
Garantit le plus court chemin (non pondéré)OuiNon
Cas d'usage typiqueDistance minimale, plus proche voisinComposantes connexes, tri topologique, cycles

Étape 2 : le parcours en profondeur (DFS)

DFS fonce au contraire le plus loin possible dans une direction avant de faire demi-tour à la première impasse, en utilisant naturellement la récursivité (ou une pile explicite).

Ce que révèle cette stratégie différente

En s'enfonçant profondément avant de revenir en arrière, DFS révèle la structure récursive du graphe : quels sommets sont accessibles depuis lesquels (composantes connexes), et dans quel ordre des dépendances doivent être satisfaites (tri topologique).

Un piège à connaître : détecter un cycle

Sur un graphe orienté, un simple ensemble "déjà visité" ne suffit pas à détecter un cycle. Il faut distinguer un sommet "en cours d'exploration sur le chemin actuel" d'un sommet "totalement terminé".

Piège fréquent

Sur un graphe orienté, retomber sur un sommet "en cours d'exploration sur le chemin actuel" (souvent marqué en gris) signale un cycle. Retomber sur un sommet "totalement terminé" (marqué en noir) est parfaitement normal : cela signifie juste qu'il existe deux chemins distincts vers la même destination, pas un cycle.

Pourquoi cette distinction est nécessaire

Un cycle n'existe que si on retombe sur un sommet encore en cours d'exploration. Retomber sur un sommet déjà totalement terminé est parfaitement normal : cela signifie simplement qu'il existe deux chemins distincts vers la même destination.

Vers la suite

BFS et DFS sont les deux briques fondamentales de presque tous les algorithmes de graphes plus avancés. La prochaine leçon généralise directement BFS pour gérer des arêtes qui ont un coût différent les unes des autres : l'algorithme de Dijkstra.

Commandes & code

Parcours de graphes : BFS et DFS

python
from collections import deque

graphe = {
    "A": ["B", "C"],
    "B": ["A", "D", "E"],
    "C": ["A", "F"],
    "D": ["B"],
    "E": ["B", "F"],
    "F": ["C", "E"],
}

# --- BFS (Breadth-First Search) : parcours en largeur, avec une FILE ---
# Garantit le plus court chemin en NOMBRE D'ARÊTES sur un graphe non pondéré
def bfs(graphe: dict, depart: str) -> list[str]:
    visites = {depart}
    file = deque([depart])
    ordre = []
    while file:
        sommet = file.popleft()
        ordre.append(sommet)
        for voisin in graphe.get(sommet, []):
            if voisin not in visites:
                visites.add(voisin)   # marquer visité DÈS l'ajout en file, pas à l'extraction
                file.append(voisin)
    return ordre

assert bfs(graphe, "A") == ["A", "B", "C", "D", "E", "F"]

# BFS pour le plus court chemin (non pondéré) avec reconstruction du chemin
def plus_court_chemin_bfs(graphe: dict, depart: str, arrivee: str) -> list[str] | None:
    if depart == arrivee:
        return [depart]
    visites = {depart}
    file = deque([depart])
    parent = {depart: None}
    while file:
        sommet = file.popleft()
        for voisin in graphe.get(sommet, []):
            if voisin not in visites:
                visites.add(voisin)
                parent[voisin] = sommet
                if voisin == arrivee:
                    # reconstruire le chemin en remontant les parents
                    chemin = [arrivee]
                    while parent[chemin[-1]] is not None:
                        chemin.append(parent[chemin[-1]])
                    return chemin[::-1]
                file.append(voisin)
    return None   # aucun chemin trouvé

assert plus_court_chemin_bfs(graphe, "A", "F") == ["A", "C", "F"]

# --- DFS (Depth-First Search) récursif : parcours en profondeur ---
def dfs_recursif(graphe: dict, sommet: str, visites: set | None = None, ordre: list | None = None) -> list[str]:
    if visites is None:
        visites, ordre = set(), []
    visites.add(sommet)
    ordre.append(sommet)
    for voisin in graphe.get(sommet, []):
        if voisin not in visites:
            dfs_recursif(graphe, voisin, visites, ordre)
    return ordre

assert dfs_recursif(graphe, "A") == ["A", "B", "D", "E", "F", "C"]

# --- DFS itératif avec une pile explicite (évite la limite de récursion sur grands graphes) ---
def dfs_iteratif(graphe: dict, depart: str) -> list[str]:
    visites = set()
    pile = [depart]
    ordre = []
    while pile:
        sommet = pile.pop()
        if sommet not in visites:
            visites.add(sommet)
            ordre.append(sommet)
            # inverser pour visiter les voisins dans le même ordre que la version récursive
            for voisin in reversed(graphe.get(sommet, [])):
                if voisin not in visites:
                    pile.append(voisin)
    return ordre

# --- Détecter les composantes connexes d'un graphe non orienté ---
def composantes_connexes(graphe: dict) -> list[list[str]]:
    visites = set()
    composantes = []
    for sommet in graphe:
        if sommet not in visites:
            composante = dfs_recursif(graphe, sommet, visites, [])
            composantes.append(composante)
    return composantes

# --- Détecter un cycle dans un graphe ORIENTÉ (3 couleurs : blanc/gris/noir) ---
def a_un_cycle_oriente(graphe: dict) -> bool:
    BLANC, GRIS, NOIR = 0, 1, 2
    couleur = {sommet: BLANC for sommet in graphe}

    def visiter(sommet) -> bool:
        couleur[sommet] = GRIS   # en cours de visite (sur la pile d'appels actuelle)
        for voisin in graphe.get(sommet, []):
            if couleur.get(voisin, BLANC) == GRIS:
                return True       # arête vers un sommet EN COURS de visite -> cycle
            if couleur.get(voisin, BLANC) == BLANC and visiter(voisin):
                return True
        couleur[sommet] = NOIR   # visite terminée, définitivement sûr
        return False

    return any(visiter(s) for s in graphe if couleur[s] == BLANC)

graphe_cyclique = {"A": ["B"], "B": ["C"], "C": ["A"]}
assert a_un_cycle_oriente(graphe_cyclique) is True

# --- Tri topologique (DFS) : ordonner les sommets d'un DAG (graphe orienté acyclique) ---
def tri_topologique(graphe: dict) -> list[str]:
    visites = set()
    pile_resultat = []

    def visiter(sommet):
        visites.add(sommet)
        for voisin in graphe.get(sommet, []):
            if voisin not in visites:
                visiter(voisin)
        pile_resultat.append(sommet)   # ajouté APRÈS avoir visité tous ses successeurs

    for sommet in graphe:
        if sommet not in visites:
            visiter(sommet)
    return pile_resultat[::-1]   # inverser : les "sources" doivent apparaître en premier

dag = {"habillage": ["chaussures"], "sous_vetements": ["habillage"], "chaussures": []}
assert tri_topologique(dag) == ["sous_vetements", "habillage", "chaussures"]

Résumé

  • BFS explore niveau par niveau via une file : garantit le plus court chemin en nombre d'arêtes sur un graphe non pondéré.
  • DFS explore en profondeur (récursif ou pile explicite) : utile pour la connexité, la détection de cycles, le tri topologique.
  • La détection de cycle sur un graphe orienté nécessite 3 états (blanc/gris/noir), pas un simple ensemble "visité".
  • Le tri topologique n'existe que sur un DAG (pas de cycle) : il ordonne les sommets pour respecter toutes les dépendances.

Exercices pratiques

1 disponible
1

Mission : réparer le gestionnaire de dépendances qui boucle à l'infini

Objectif : Choisir le bon parcours pour une question précise, puis diagnostiquer une dépendance circulaire dans un tri topologique.

Contexte

Un gestionnaire de paquets interne doit installer des modules qui dépendent les uns des autres, et doit aussi proposer une fonctionnalité "distance minimale de collaboration" entre deux employés dans l'organigramme des mentorats. Après l'ajout d'un nouveau paquet, l'installation se bloque indéfiniment, et personne ne sait si c'est un bug de parcours ou une vraie dépendance circulaire.

Résoudre l’exercice →