Retour au cours

backend / algorithmes-structures-donnees

Structures avancées : Union-Find (disjoint set)

Leçon 221 exercice

Explication

Ce que vous allez apprendre

  • Expliquer le problème que résout Union-Find : suivre dynamiquement des groupes connectés sans refaire un parcours de graphe complet
  • Implémenter les deux opérations de base : find (trouver la racine/représentant) et union (fusionner deux groupes)
  • Appliquer l'optimisation "union par rang ou par taille" pour éviter des arbres déséquilibrés
  • Appliquer la "compression de chemin" pour accélérer les recherches futures
  • Justifier pourquoi ces deux optimisations combinées donnent une complexité quasi constante par opération

Dans quel contexte ?

Un réseau social doit répondre en temps réel à la question « ces deux comptes appartiennent-ils au même groupe d'amis connectés ? », alors que de nouvelles relations d'amitié sont ajoutées en continu. Refaire un BFS ou un DFS complet à chaque nouvelle relation serait bien trop lent sur un graphe de millions d'utilisateurs. Union-Find répond à cette même question en quasi O(1) par opération, ce qui en fait la structure de référence pour ce type de suivi dynamique de connectivité — c'est aussi exactement ce qu'utilise l'algorithme de Kruskal vu à la leçon 16.

D'abord, un problème que BFS/DFS résout mal ici

Imagine ajouter des connexions réseau une par une entre des machines, en voulant savoir à chaque étape si deux machines précises sont dans le même réseau. Refaire un parcours de graphe complet à chaque nouvelle connexion serait beaucoup trop coûteux.

Ce que Union-Find propose à la place

Union-Find (aussi appelé "disjoint set") maintient des groupes d'éléments et répond très vite à deux questions : "x et y sont-ils dans le même groupe ?" et "fusionne le groupe de x avec celui de y".

Étape 1 : comment un groupe est représenté

Chaque élément pointe vers un "parent". En suivant les parents de proche en proche, on arrive à la racine de l'arbre, qui sert de représentant unique pour tout le groupe.

Étape 2 : répondre à "même groupe ?"

Deux éléments sont dans le même groupe si et seulement si suivre leurs parents respectifs mène à la même racine.

Étape 3 : fusionner deux groupes

Fusionner deux groupes revient simplement à faire pointer la racine de l'un vers la racine de l'autre — une seule opération, peu importe la taille des groupes.

OpérationRôleComplexité (avec optimisations)
find(x)Trouver la racine / représentant du groupe de xQuasi O(1) amorti
union(x, y)Fusionner les groupes de x et yQuasi O(1) amorti
Sans optimisationArbre pouvant dégénérer en chaîneO(n) au pire cas

Piège fréquent

Fusionner systématiquement en attachant la racine de x sous celle de y, sans jamais regarder la taille des groupes, peut créer une longue chaîne dégénérée après de nombreuses fusions. L'union par rang (ou par taille), qui attache toujours le plus petit arbre sous le plus grand, évite ce piège.

Il reste un problème : des arbres qui s'allongent trop

Sans précaution, ces arbres peuvent devenir très déséquilibrés, une longue chaîne, ce qui rend chaque recherche de racine lente.

Astuce

La compression de chemin fait pointer directement chaque élément visité pendant un find vers la racine trouvée, au lieu de garder la chaîne intermédiaire. Combinée à l'union par rang, elle rend chaque opération quasiment constante en pratique, même sur des millions d'éléments.

La première optimisation : la compression de chemin

En cherchant la racine, on rattache directement chaque élément visité à cette racine, pour que la prochaine recherche depuis ce même élément soit immédiate.

La seconde optimisation : l'union par rang

On accroche toujours le plus petit arbre sous le plus grand, jamais l'inverse, ce qui évite de créer des chaînes inutilement longues.

Un signal très utile qui en découle

Quand union(x, y) échoue, cela signifie que x et y étaient déjà dans le même groupe : ajouter cette connexion créerait un cycle, un test à la base de l'algorithme de Kruskal vu plus tôt dans ce cours.

Vers la suite

Après avoir vu de nombreuses structures et algorithmes isolément, la prochaine leçon prend du recul pour repérer, dans du code réel, où se cachent vraiment les lenteurs en pratique.

Commandes & code

Structures avancées : Union-Find (disjoint set)

python
# Union-Find (aussi appelé Disjoint Set Union) gère des ensembles disjoints avec 2 opérations :
# trouver(x) : identifier l'ensemble auquel x appartient
# union(x, y) : fusionner les ensembles de x et y
# Avec compression de chemin + union par rang : quasi O(1) amorti par opération (O(alpha(n))).

class UnionFind:
    def __init__(self, elements: list) -> None:
        self.parent = {e: e for e in elements}   # chaque élément est initialement son propre représentant
        self.rang = {e: 0 for e in elements}       # rang = hauteur approximative de l'arbre
        self.nb_composantes = len(elements)

    def trouver(self, x) -> "object":
        # Compression de chemin : rattache x DIRECTEMENT à la racine lors du parcours
        racine = x
        while self.parent[racine] != racine:
            racine = self.parent[racine]
        # deuxième passe : aplatit tout le chemin vers la racine trouvée
        while self.parent[x] != racine:
            self.parent[x], x = racine, self.parent[x]
        return racine

    def union(self, x, y) -> bool:
        racine_x, racine_y = self.trouver(x), self.trouver(y)
        if racine_x == racine_y:
            return False   # déjà connectés -- fusion inutile (indique un cycle si utilisé sur un graphe)

        # union par rang : le plus petit arbre est accroché SOUS le plus grand, garde l'arbre plat
        if self.rang[racine_x] < self.rang[racine_y]:
            racine_x, racine_y = racine_y, racine_x
        self.parent[racine_y] = racine_x
        if self.rang[racine_x] == self.rang[racine_y]:
            self.rang[racine_x] += 1

        self.nb_composantes -= 1
        return True

    def connectes(self, x, y) -> bool:
        return self.trouver(x) == self.trouver(y)

uf = UnionFind(["A", "B", "C", "D", "E"])
uf.union("A", "B")
uf.union("B", "C")
assert uf.connectes("A", "C") is True
assert uf.connectes("A", "D") is False
assert uf.nb_composantes == 3   # {A,B,C}, {D}, {E}

# --- Application : détecter un cycle dans un graphe non orienté ---
def graphe_contient_cycle(sommets: list, aretes: list[tuple]) -> bool:
    uf = UnionFind(sommets)
    for a, b in aretes:
        if not uf.union(a, b):   # union() retourne False si a et b étaient déjà connectés
            return True           # cette arête referme un cycle
    return False

assert graphe_contient_cycle(["A", "B", "C"], [("A", "B"), ("B", "C"), ("C", "A")]) is True
assert graphe_contient_cycle(["A", "B", "C"], [("A", "B"), ("B", "C")]) is False

# --- Application : compter les composantes connexes (ex: îles dans une grille, réseaux sociaux) ---
def nombre_iles(grille: list[list[int]]) -> int:
    lignes, colonnes = len(grille), len(grille[0])
    cellules_terre = [(i, j) for i in range(lignes) for j in range(colonnes) if grille[i][j] == 1]
    uf = UnionFind(cellules_terre)

    for i, j in cellules_terre:
        for di, dj in [(0, 1), (1, 0)]:   # ne vérifier que droite et bas évite les doublons
            ni, nj = i + di, j + dj
            if ni < lignes and nj < colonnes and grille[ni][nj] == 1:
                uf.union((i, j), (ni, nj))

    return uf.nb_composantes if cellules_terre else 0

grille = [
    [1, 1, 0, 0],
    [1, 0, 0, 1],
    [0, 0, 1, 1],
]
assert nombre_iles(grille) == 3

# --- Application : Kruskal (voir leçon MST) utilise directement cette structure ---
# --- Application : connectivité dynamique en réseau (ajout de connexions au fil du temps) ---
class ReseauDynamique:
    def __init__(self, machines: list[str]):
        self.uf = UnionFind(machines)

    def connecter(self, machine_a: str, machine_b: str) -> None:
        self.uf.union(machine_a, machine_b)

    def meme_reseau(self, machine_a: str, machine_b: str) -> bool:
        return self.uf.connectes(machine_a, machine_b)

reseau = ReseauDynamique(["srv1", "srv2", "srv3", "srv4"])
reseau.connecter("srv1", "srv2")
assert reseau.meme_reseau("srv1", "srv2") is True
assert reseau.meme_reseau("srv1", "srv3") is False

Résumé

  • Union-Find gère des ensembles disjoints avec trouver/union en quasi-O(1) amorti grâce à la compression de chemin et l'union par rang.
  • union() renvoyant False (racines déjà identiques) est le pattern standard pour détecter un cycle dans un graphe non orienté.
  • Compter les composantes connexes (îles, réseaux, clusters) se fait naturellement en suivant nb_composantes après chaque union.
  • C'est la structure de données centrale de l'algorithme de Kruskal pour le calcul d'un arbre couvrant minimum.

Exercices pratiques

1 disponible
1

Mission : détecter les boucles de câblage avant qu'elles ne coûtent cher

Objectif : Utiliser Union-Find pour détecter un cycle et compter des composantes connexes, puis en expliquer la complexité amortie.

Contexte

Une équipe réseau ajoute des connexions entre serveurs une par une : ("srv1","srv2"), ("srv2","srv3"), ("srv4","srv5"), ("srv3","srv1"). Avant de valider chaque nouvelle connexion, il faut vérifier si elle referme une boucle redondante (un cycle), et à la fin, savoir combien de réseaux isolés (composantes) subsistent parmi 5 serveurs (srv1 à srv5).

Résoudre l’exercice →