backend / algorithmes-structures-donnees
Structures avancées : Union-Find (disjoint set)
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) etunion(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ération | Rôle | Complexité (avec optimisations) |
|---|---|---|
find(x) | Trouver la racine / représentant du groupe de x | Quasi O(1) amorti |
union(x, y) | Fusionner les groupes de x et y | Quasi O(1) amorti |
| Sans optimisation | Arbre pouvant dégénérer en chaîne | O(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)
# 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 FalseRésumé
- Union-Find gère des ensembles disjoints avec
trouver/unionen quasi-O(1) amorti grâce à la compression de chemin et l'union par rang. union()renvoyantFalse(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_composantesaprè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
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).