0Pricing
Coding Interview Prep · Lezione

DSU con compressione dei cammini

Implementi find con la compressione dei cammini, in modo che tutti i nodi del cammino puntino direttamente alla radice, ottenendo un costo ammortizzato vicino a O(1) per find.

DSU con compressione dei cammini è una lezione Coding Interview Prep gratuita su CoddyKit. Questa è la lezione 1 di 4. Puoi leggere la lezione completa qui gratuitamente — poi esercitati direttamente nel browser con un editor di codice integrato e un tutor IA disponibile 24/7. Fa parte del percorso di apprendimento Coding Interview Prep, e i tuoi progressi si sincronizzano tra il web e l'app CoddyKit. Il corso Coding Interview Prep include 4 lezioni in totale.

Che cos'è la Disjoint Set Union?

Disjoint Set Union (DSU), chiamata anche Union-Find, è una struttura dati che gestisce una collezione di insiemi disgiunti (non sovrapposti). Supporta due operazioni fondamentali: find (a quale insieme appartiene l'elemento x?) e union (unisce gli insiemi che contengono x e y). DSU è ideale per i problemi di connettività dinamica, in cui i gruppi si uniscono nel tempo ma non vengono mai separati.

Ogni elemento inizia come insieme indipendente. Durante l'elaborazione di archi o relazioni, gli insiemi vengono uniti. La difficoltà consiste nell'eseguire queste operazioni in modo efficiente: le implementazioni ingenue richiedono O(n) per operazione, ma con le ottimizzazioni ci si avvicina a O(1) ammortizzato.

# 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

Il problema della ricerca ingenua

Nella DSU ingenua, find(x) risale la catena dei padri finché raggiunge un nodo che punta a sé stesso (la radice). Se l'albero è bilanciato, il costo è O(log n). Tuttavia, se si esegue sempre l'unione collegando la seconda radice sotto la prima, è possibile creare una catena (un albero degenere) di lunghezza n, rendendo ogni operazione find O(n).

Si consideri l'unione in sequenza di 0→1→2→3→4. La chiamata a find sul nodo 0 deve attraversare l'intera catena. Con la compressione dei cammini, si elimina questo problema facendo puntare direttamente alla radice ogni nodo visitato durante la stessa operazione find.

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

Compressione dei cammini: ricorsiva in un solo passaggio

La compressione dei cammini modifica l'operazione find in modo che, dopo aver trovato la radice, ogni nodo lungo il cammino venga aggiornato affinché punti direttamente alla radice. Le chiamate find successive su questi nodi diventano O(1). La versione ricorsiva ottiene questo risultato elegantemente in un solo passaggio.

L'intuizione fondamentale è la seguente: quando la chiamata ricorsiva restituisce la radice, si assegna self.parent[x] = root prima di restituire il risultato. In questo modo si appiattisce l'albero: tutti i nodi del cammino di ricerca puntano ora direttamente alla radice. Questo non cambia l'insieme di appartenenza del nodo; accorcia soltanto i cammini delle ricerche successive.

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)

Compressione dei cammini: iterativa in due passaggi

La versione iterativa della compressione dei cammini usa due passaggi: nel primo si risale l'albero per trovare la radice; nel secondo si visita nuovamente ogni nodo del cammino e si aggiorna il suo padre affinché punti direttamente alla radice. In questo modo si evita il sovraccarico dello stack delle chiamate ricorsive ed è sicuro anche per alberi molto profondi, vicini al limite di ricorsione di Python.

Sia nell'approccio ricorsivo sia in quello iterativo la correttezza rimane invariata: find restituisce sempre la stessa radice. L'unica differenza consiste nell'aggiornamento dei puntatori ai padri come effetto collaterale, che rende O(1) tutte le operazioni find successive su quei nodi.

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[:])

Complessità ammortizzata della compressione dei cammini

La sola compressione dei cammini raggiunge un tempo ammortizzato di O(log n) per operazione su una sequenza di m operazioni. La prima operazione find può essere costosa quando attraversa una catena, ma la appiattisce, così ogni operazione find successiva su quei nodi ha costo O(1). Il lavoro totale viene distribuito su molte operazioni.

L'analisi formale utilizza il metodo della funzione potenziale: il potenziale della DSU diminuisce ogni volta che il padre di un nodo si avvicina, e questa diminuzione copre il costo dell'attraversamento. Senza l'unione per rango, la sola compressione dei cammini offre un costo ammortizzato di O(log n): un miglioramento già enorme rispetto al costo ingenuo 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')

Conteggio delle componenti connesse

Una delle applicazioni più comuni della DSU è il conteggio delle componenti connesse di un grafo. Si inizializza un contatore components uguale a n, uno per ogni nodo. Ogni operazione union eseguita con successo (che fonde due insiemi diversi) decrementa il contatore di 1. Al termine, il contatore contiene il numero di componenti distinte.

È più efficiente che eseguire BFS o DFS per le interrogazioni di connettività, soprattutto quando gli archi arrivano progressivamente (online). La DSU elabora ogni arco in un tempo ammortizzato quasi O(1), indipendentemente dal momento in cui arriva.

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 per i problemi sui grafi: Number of Provinces

Il problema Number of Provinces fornisce una matrice di adiacenza n×n e chiede quante gruppi di città collegate direttamente o indirettamente esistano. Si tratta esattamente di un problema sulle componenti connesse, che la DSU risolve in modo diretto. Si iterano tutte le coppie (i, j) per cui isConnected[i][j] == 1 e si chiama union(i, j).

Dopo aver elaborato tutte le connessioni, dsu.components contiene la risposta. È più semplice e veloce che eseguire BFS a partire da ogni nodo non visitato e consente di gestire direttamente la rappresentazione tramite matrice, senza dover prima costruire una lista di adiacenza.

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}

Varianti della compressione dei cammini: dimezzamento

Oltre alla compressione in due passaggi, esiste una variante più semplice in un solo passaggio chiamata dimezzamento dei cammini: mentre si risale la catena, si fa puntare ogni nodo al proprio nonno invece che al proprio padre. In questo modo la lunghezza del cammino si dimezza a ogni attraversamento, senza un secondo passaggio, e si ottiene la stessa complessità ammortizzata O(alpha(n)) quando la tecnica è combinata con l'unione per rango.

Il dimezzamento dei cammini è spesso preferito nella programmazione competitiva perché consiste in un unico ciclo semplice, senza ricorsione né una seconda visita. A ogni passaggio si esegue 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))

Verifica della connettività dopo le unioni

Per verificare se due nodi sono connessi (appartengono alla stessa componente), si chiama find(x) == find(y). Se entrambe le chiamate restituiscono la stessa radice, i nodi appartengono alla stessa componente. Questa è l'interrogazione di connettività e, con la compressione dei cammini, ha un tempo ammortizzato quasi O(1).

Nei problemi dei colloqui tecnici, le interrogazioni di connettività compaiono spesso intercalate alle operazioni union. La DSU gestisce entrambe online: è possibile alternare unioni e interrogazioni in qualsiasi ordine. Questa caratteristica distingue la DSU dagli algoritmi per grafi statici come BFS/DFS, che devono essere rieseguiti dopo ogni modifica strutturale.

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

Errori comuni nell'implementazione della DSU

Un errore frequente consiste nel chiamare find e poi modificare parent in modo errato. Si chiami sempre find su entrambi gli elementi prima di verificarne l'uguaglianza; in caso contrario, si potrebbe confrontare erroneamente un nodo con la propria radice. Un altro errore comune è dimenticare che union deve essere un'operazione nulla quando entrambi gli elementi condividono già la stessa radice.

In Python, il limite di profondità della ricorsione (1000 per impostazione predefinita) può causare un RecursionError per catene lunghe quando si usa una versione ricorsiva di find. Si può usare la versione iterativa in due passaggi, aumentare il limite con sys.setrecursionlimit oppure usare iterativamente il dimezzamento dei cammini per evitare del tutto la ricorsione profonda.

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

Tracciamento delle dimensioni nella DSU

In alcuni problemi è necessaria la dimensione di ogni componente, non soltanto la sua radice. Si aggiunga un array size inizializzato con tutti valori pari a 1. Quando si fondono due componenti, si aggiunga la dimensione della radice più piccola a quella della radice più grande. Questo consente di ottenere in O(1) la dimensione di una componente dopo qualsiasi unione.

Il tracciamento delle dimensioni è anche alla base dell'unione per dimensione (un'alternativa all'unione per rango): si colleghi sempre l'albero più piccolo alla radice dell'albero più grande. In questo modo l'altezza dell'albero rimane O(log n), fornendo la stessa garanzia asintotica dell'unione per rango.

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

Verifica rapida

Verifichi la propria comprensione dei concetti di Data Structures & Algorithms — Coding Interview Prep presentati in questa lezione.

Riepilogo della lezione

In questa lezione ha imparato che: la DSU mantiene insiemi disgiunti con le operazioni find e union, la compressione dei cammini appiattisce l'albero facendo puntare direttamente alla radice tutti i nodi attraversati e ciò offre prestazioni ammortizzate quasi O(1) per find. Prossimamente esamineremo l'unione per rango, che mantiene gli alberi poco profondi dall'alto verso il basso e raggiunge il limite dato dalla funzione inversa di Ackermann.

Domande Frequenti

La lezione «DSU con compressione dei cammini» è gratuita?

Sì — il testo completo di «DSU con compressione dei cammini» è gratuito qui sul web. Per esercitarvi in modo interattivo (un editor di codice integrato e un tutor IA 24/7) e sbloccare il resto del corso Coding Interview Prep, passa a CoddyKit PRO. Il corso Coding Interview Prep include 4 lezioni in totale.

Cosa imparerò in «DSU con compressione dei cammini»?

Implementi find con la compressione dei cammini, in modo che tutti i nodi del cammino puntino direttamente alla radice, ottenendo un costo ammortizzato vicino a O(1) per find. Eserciti Coding Interview Prep con codice pratico che esegui direttamente nel browser, e un tutor IA 24/7 risponde alle tue domande mentre lavori sulla lezione.

Ho bisogno di esperienza per iniziare Coding Interview Prep?

Non è richiesta alcuna esperienza precedente. Coding Interview Prep su CoddyKit è strutturato per principianti e studenti avanzati, quindi puoi iniziare da qui o dall'inizio e procedere al tuo ritmo. Questa è la lezione 1 di 4.

Quanto tempo richiede la lezione «DSU con compressione dei cammini»?

La maggior parte delle lezioni CoddyKit richiede circa 5–10 minuti. Ogni lezione è breve e interattiva, quindi fai progressi costanti e riprendi esattamente da dove hai lasciato su web e app.

Posso scrivere ed eseguire codice in questa lezione Coding Interview Prep?

Sì. Ogni lezione Coding Interview Prep include un editor di codice integrato, quindi scrivi ed esegui codice reale direttamente nel tuo browser e ricevi feedback istantaneo dall'IA — nessuna configurazione locale necessaria.

Tutte le lezioni di questo corso

  1. DSU con compressione dei cammini
  2. Union per rango e limite dell'inversa di Ackermann
  3. Connessione ridondante e rilevamento dei cicli
  4. Unione di account e componenti connesse
← Torna a Coding Interview Prep