0Pricing
DSA Interview Prep · Leçon

DSU avec compression de chemin

Implémentez find avec compression de chemin afin que tous les nœuds du chemin pointent directement vers la racine, pour obtenir un coût amorti proche de O(1) par recherche.

DSU avec compression de chemin est une leçon DSA Interview Prep gratuite sur CoddyKit. Ceci est la leçon 1 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage DSA Interview Prep, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours DSA Interview Prep comprend 4 leçons au total.

Qu'est-ce que l'union-find ?

Disjoint Set Union (DSU), également appelé union-find, est une structure de données qui gère une collection d'ensembles disjoints (sans chevauchement). Elle prend en charge deux opérations essentielles : find (à quel ensemble l'élément x appartient-il ?) et union (fusionner les ensembles contenant x et y). DSU est idéal pour les problèmes de connectivité dynamique, où les groupes fusionnent au fil du temps sans jamais être séparés.

Chaque élément commence dans son propre ensemble. À mesure que nous traitons les arêtes ou les relations, nous fusionnons les ensembles. Le défi consiste à effectuer ces opérations efficacement : les implémentations naïves sont en O(n) par opération, mais avec des optimisations, nous approchons O(1) en coût amorti.

# Naive DSU without optimisations
class DSU:
    def __init__(self, n):
        self.parent = list(range(n))  # each node is its own parent

    def find(self, x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

Le problème de la recherche naïve

Dans le DSU naïf, find(x) remonte la chaîne des parents jusqu'à atteindre un nœud qui pointe vers lui-même (la racine). Si l'arbre est équilibré, cela donne O(log n). Mais si nous effectuons toujours l'opération union en reliant la seconde racine sous la première, nous pouvons créer une chaîne (arbre dégénéré) de longueur n, ce qui rend chaque appel à find en O(n).

Considérons les opérations union successives 0→1→2→3→4. L'appel à find du nœud 0 doit parcourir toute la chaîne. Avec la compression de chemin, nous éliminons ce problème en faisant pointer directement vers la racine chaque nœud visité pendant l'opération find elle-même.

# Worst case without compression: a chain
# parent = [1, 2, 3, 4, 4]  => find(0) takes 4 steps
# After path compression: parent = [4, 4, 4, 4, 4]  => find(0) takes 1 step

parent = [1, 2, 3, 4, 4]
print('Before:', parent)
# Simulate find(0) with naive approach
x = 0
steps = 0
while parent[x] != x:
    x = parent[x]
    steps += 1
print('Root:', x, 'Steps taken:', steps)

Compression de chemin : récursive en un seul passage

La compression de chemin modifie l'opération find afin qu'après avoir trouvé la racine, chaque nœud du chemin soit mis à jour pour pointer directement vers la racine. Les futurs appels à find sur ces nœuds deviennent O(1). La version récursive réalise cela élégamment en un seul passage.

L'idée essentielle est la suivante : lorsque l'appel récursif renvoie la racine, nous définissons self.parent[x] = root avant de retourner cette racine. Cela aplatit l'arbre : tous les nœuds du chemin de recherche pointent désormais directement vers la racine. Cela ne modifie pas l'ensemble auquel appartient un nœud ; cela raccourcit uniquement les futurs chemins de recherche.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

dsu = DSU(5)
dsu.union(0, 1)
dsu.union(1, 2)
dsu.union(2, 3)
print('Root of 0:', dsu.find(0))
print('Parent array after compression:', dsu.parent)

Compression de chemin : itérative en deux passages

La version itérative de la compression de chemin utilise deux passages : le premier remonte jusqu'à la racine ; le second repasse par chaque nœud du chemin et met à jour son parent pour qu'il pointe directement vers la racine. Cela évite le coût de la pile de récursion et reste sûr pour les arbres très profonds proches de la limite de récursion de Python.

Dans les approches récursive et itérative, la correction reste inchangée : find renvoie toujours la même racine. La seule différence réside dans la mise à jour des pointeurs vers les parents comme effet secondaire, ce qui rend tous les futurs appels à find sur ces nœuds égaux à O(1).

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        root = x
        while self.parent[root] != root:
            root = self.parent[root]          # first pass: find root
        while self.parent[x] != root:
            nxt = self.parent[x]
            self.parent[x] = root             # second pass: compress
            x = nxt
        return root

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py
            return True
        return False  # already connected

dsu = DSU(6)
for a, b in [(0,1),(1,2),(2,3),(3,4)]:
    dsu.union(a, b)
print('Parent before find(0):', dsu.parent[:])
dsu.find(0)
print('Parent after  find(0):', dsu.parent[:])

Complexité amortie de la compression de chemin

La compression de chemin seule atteint un temps amorti de O(log n) par opération sur une séquence de m opérations. Chaque opération find peut être coûteuse la première fois qu'une chaîne est parcourue, mais elle aplatit cette chaîne, de sorte que chaque appel à find ultérieur sur ces nœuds est en O(1). Le travail total est réparti sur de nombreuses opérations.

L'analyse formelle utilise la méthode de la fonction de potentiel : le potentiel du DSU diminue chaque fois que le parent d'un nœud est rapproché, et cette diminution paie le coût du parcours. Sans union par rang, la compression de chemin seule donne un coût amorti de O(log n), ce qui constitue déjà une énorme amélioration par rapport au coût naïf O(n).

# Demonstrating amortised benefit
import time

def build_chain(n):
    parent = list(range(n))
    for i in range(n - 1):
        parent[i] = i + 1  # chain: 0->1->2->...->n-1
    return parent

n = 1000
parent = build_chain(n)

# First find on a chain: visits n nodes
x = 0
root = x
while parent[root] != root:
    root = parent[root]
# Compress
while parent[x] != root:
    nxt = parent[x]; parent[x] = root; x = nxt
print('After first find, parent[0]:', parent[0])  # should be n-1
print('Second find cost: O(1) since parent[0] is now the root')

Nombre de composantes connexes

Une application courante du DSU consiste à compter les composantes connexes d'un graphe. Nous initialisons un compteur components à n, soit une composante par nœud. Chaque opération union réussie (fusion de deux ensembles différents) décrémente le compteur de 1. À la fin, le compteur contient le nombre de composantes distinctes.

C'est plus efficace que d'exécuter BFS ou DFS pour les requêtes de connexité, en particulier lorsque les arêtes arrivent progressivement (en ligne). Le DSU traite chaque arête en un temps amorti presque égal à O(1), quel que soit le moment où elle arrive.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.components = n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False
        self.parent[px] = py
        self.components -= 1
        return True

dsu = DSU(7)
edges = [(0,1),(1,2),(3,4),(5,6)]
for u, v in edges:
    dsu.union(u, v)
print('Components:', dsu.components)  # 4: {0,1,2}, {3,4}, {5,6}, {6 alone was merged}
# Node 6 is alone => 4 total: {0,1,2},{3,4},{5,6},{6} wait
# Let me recalculate: 7 nodes, 4 edges merged 4 pairs => 7-4=3... no
# {0,1,2} one union, {3,4} one, {5,6} one => 7-3=4 components
print('Expected: 4')

DSU pour les problèmes de graphes : nombre de provinces

Le problème du nombre de provinces fournit une matrice d'adjacence n×n et demande combien de groupes de villes directement ou indirectement connectées existent. Il s'agit exactement d'un problème de composantes connexes que le DSU résout élégamment. Nous parcourons toutes les paires (i, j) pour lesquelles isConnected[i][j] == 1 et appelons union(i, j).

Après avoir traité toutes les connexions, dsu.components est la réponse. C'est plus simple et plus rapide que d'exécuter BFS depuis chaque nœud non visité, et cela traite directement la représentation matricielle sans construire d'abord une liste d'adjacence.

def find_provinces(isConnected):
    n = len(isConnected)
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        px, py = find(x), find(y)
        if px != py:
            parent[px] = py
            return True
        return False

    count = n
    for i in range(n):
        for j in range(i + 1, n):
            if isConnected[i][j] == 1:
                if union(i, j):
                    count -= 1
    return count

matrix = [[1,1,0],[1,1,0],[0,0,1]]
print(find_provinces(matrix))  # 2: cities {0,1} and {2}

Variantes de la compression de chemin : compression par moitié

Au-delà de la compression en deux passages, il existe une variante plus simple en un seul passage appelée compression par moitié du chemin : lorsque nous remontons la chaîne, nous faisons pointer chaque nœud vers son grand-parent plutôt que vers son parent. Cela réduit de moitié la longueur du chemin à chaque parcours, sans second passage, et atteint la même complexité amortie O(alpha(n)) lorsqu'elle est combinée à l'union par rang.

La compression par moitié est souvent privilégiée en programmation compétitive, car elle se résume à une seule boucle claire, sans récursion ni second parcours. Chaque étape effectue self.parent[x] = self.parent[self.parent[x]]; x = self.parent[x].

class DSUHalving:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # point to grandparent
            x = self.parent[x]
        return x

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        return True

dsu = DSUHalving(8)
for u, v in [(0,1),(2,3),(4,5),(6,7),(0,2),(4,6),(0,4)]:
    dsu.union(u, v)
print('All in one component:', dsu.find(0) == dsu.find(7))

Vérification de la connexité après les opérations union

Pour vérifier si deux nœuds sont connected (dans la même composante), appelez find(x) == find(y). Si les deux appels renvoient la même racine, les nœuds appartiennent à la même composante. Il s'agit de la requête connected ; avec la compression de chemin, elle s'exécute en un temps amorti proche de O(1).

Dans les problèmes d'entretien, les requêtes de connexité sont souvent entremêlées avec des opérations union. Le DSU gère les deux en ligne : vous pouvez alterner les opérations union et les requêtes dans n'importe quel ordre. Cela distingue le DSU des algorithmes pour graphes statiques comme BFS/DFS, qui doivent être relancés après chaque modification structurelle.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py

    def connected(self, x, y):
        return self.find(x) == self.find(y)

dsu = DSU(10)
dsu.union(0, 3)
dsu.union(3, 7)
dsu.union(1, 5)
print(dsu.connected(0, 7))   # True: 0-3-7
print(dsu.connected(0, 5))   # False: different components
print(dsu.connected(1, 5))   # True: 1-5

Erreurs courantes dans l'implémentation du DSU

Une erreur fréquente consiste à appeler find, puis à modifier incorrectement les parents. Appelez toujours find sur les deux éléments avant de vérifier leur égalité ; sinon, vous risquez de comparer incorrectement un nœud à sa propre racine. Une autre erreur consiste à oublier que union doit ne rien faire lorsque les deux éléments partagent déjà une racine.

En Python, la limite de profondeur de récursion (1000 par défaut) peut provoquer une RecursionError pour les longues chaînes avec un appel find récursif. Utilisez soit la version itérative en deux passages, soit augmentez la limite avec sys.setrecursionlimit, soit utilisez la compression par moitié de manière itérative afin d'éviter complètement une récursion profonde.

import sys
sys.setrecursionlimit(10000)  # needed for large recursive DSU

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        # Safe iterative path compression
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:
            nxt = self.parent[x]
            self.parent[x] = root
            x = nxt
        return root

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # already same component — do nothing
        self.parent[px] = py
        return True

dsu = DSU(5)
print(dsu.union(0, 1))  # True: merged
print(dsu.union(0, 1))  # False: already merged — no double-counting

Suivi de la taille des composantes

Dans certains problèmes, vous avez besoin de la taille de chaque composante, et pas seulement de sa racine. Ajoutez un tableau size initialisé à 1 partout. Lors de la fusion de deux composantes, ajoutez la taille de la racine la plus petite à celle de la racine la plus grande. Cela permet d'obtenir en O(1) la taille d'une composante après toute opération union.

Le suivi des tailles sert également de base à l'union par taille (une alternative à l'union par rang) : attachez toujours le plus petit arbre sous la racine du plus grand. Cela garantit que la hauteur de l'arbre reste en O(log n), offrant la même garantie asymptotique que l'union par rang.

class DSUWithSize:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return
        if self.size[px] < self.size[py]:
            px, py = py, px           # attach smaller under larger
        self.parent[py] = px
        self.size[px] += self.size[py]

    def get_size(self, x):
        return self.size[self.find(x)]

dsu = DSUWithSize(6)
for u, v in [(0,1),(1,2),(3,4)]:
    dsu.union(u, v)
print('Size of component containing 0:', dsu.get_size(0))  # 3
print('Size of component containing 3:', dsu.get_size(3))  # 2
print('Size of component containing 5:', dsu.get_size(5))  # 1

Vérification rapide

Testez votre compréhension des concepts de structures de données et d'algorithmes — préparation aux entretiens de programmation de cette leçon.

Récapitulatif de la leçon

Dans cette leçon, vous avez appris que : le DSU maintient des ensembles disjoints grâce aux opérations find et union, la compression de chemin aplatit l'arbre en faisant pointer directement vers la racine tous les nœuds parcourus, et cela donne à find une performance amortie proche de O(1). Ensuite, nous étudierons l'union par rang, qui maintient les arbres peu profonds de haut en bas afin d'atteindre la borne de l'inverse d'Ackermann.

Questions Fréquemment Posées

La leçon « DSU avec compression de chemin » est-elle gratuite ?

Oui — le texte complet de « DSU avec compression de chemin » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours DSA Interview Prep, passe à CoddyKit PRO. Le cours DSA Interview Prep comprend 4 leçons au total.

Qu'est-ce que j'apprendrai dans « DSU avec compression de chemin » ?

Implémentez find avec compression de chemin afin que tous les nœuds du chemin pointent directement vers la racine, pour obtenir un coût amorti proche de O(1) par recherche. Tu pratiques DSA Interview Prep avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.

Dois-je avoir de l'expérience pour commencer DSA Interview Prep ?

Aucune expérience préalable n'est requise. DSA Interview Prep sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 1 sur 4.

Combien de temps prend la leçon « DSU avec compression de chemin » ?

La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.

Peux-tu écrire et exécuter du code dans cette leçon DSA Interview Prep ?

Oui. Chaque leçon DSA Interview Prep inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.

Toutes les leçons de ce cours

  1. DSU avec compression de chemin
  2. Union par rang et borne de l’inverse d’Ackermann
  3. Connexion redondante et détection de cycles
  4. Fusion de comptes et composantes connexes
← Retour à DSA Interview Prep