Risolve il problema di minimizzazione di trovare un albero di copertura minimo (MST) di un grafo pesato. È un algoritmo basato sulla tecnica greedy, in quanto seleziona a ogni passo l’arco con il peso minimo che non forma cicli con quelli già scelti.
Algoritmo
- Si ordinano tutti gli archi del grafo in ordine crescente rispetto al peso.
- Si itera su ogni arco finché il MST contiene archi:
- Si verifica se l’aggiunta di al sottoinsieme di archi scelti genera un ciclo.
- Se non si formano cicli, l’arco viene aggiunto al MST.
Dimostrazione di correttezza
Per assurdo supponiamo che l’algoritmo non produca un MST.
Ipotesi per assurdo: Sia l’albero generato da Kruskal e un MST diverso da , scelto tra quelli che differiscono da nel minor numero di archi.
Contraddizione: Consideriamo il primo arco .
- Se aggiungiamo a si forma un ciclo, perché non è stato scelto nella costruzione di .
- In questo ciclo esiste almeno un arco che non è in .
- Poiché Kruskal ha scelto prima di segue che .
Costruiamo un albero rimuovendo da e aggiungendo :
Dato che si ha .
Ma per definizione è un MST, quindi .
Tuttavia, ricordando che (per definizione) , osserviamo che:
- per vale che ,
- per vale che ,
quindi è più simile a che , perché ha più archi in comune a .
Questo contraddice l’ipotesi per assurdo, perché non è il MST più simile a .
La struttura dati Union-Find
Il passo più costoso dell’algoritmo, da ottimizzare quanto possibile, è la verifica della creazione o meno di un ciclo dopo l’aggiunta di un arco.
Per questo compito si usa la struttura dati Union-Find (o Insiemi Disgiunti). Essa mantiene una partizione degli elementi (in questo caso i nodi) in insiemi disgiunti e offre due operazioni:
find(i)restituisce l’ID dell’insieme a cui appartiene l’elemento .union(i, j)unisce i due gruppi a cui appartengono gli elementi e .
Un ciclo si verifica quando si prova ad unire due nodi che sono già nello stesso insieme, cioè quando si vorrebbe fare
union(i, j)ma i ha chefind(i) == find(j).
Implementazioni di Union-Find
Con array diretto
crea()inizializza un array di dimensione dove , cioè l’ID di ogni elemento è l’elemento stesso.find(u)restituisce , l’ID dell’elemento .union(ID_u, ID_v)unisce gli insiemi scorrendo tutto l’array: per tutti gli elementi che hanno riassegna a .
class UnionFind():
def __init__(self, n):
self.n = n
self.C = None
def crea():
self.C = [i for i in range(self.n)]
def find(u):
return self.C[u]
def union(ID_u, ID_v):
for i in range(len(self.C)):
if C[i] == ID_v:
C[i] = ID_u- Crea:
- Find:
- Union:
Con vettore dei padri
Si rappresentano gli insiemi come una foresta di alberi. L’array memorizza il genitore di ogni elemento e la radice di ogni albero è l’ID del suo insieme.
crea()inizializza un array di dimensione dove , cioè l’ID di ogni elemento è l’elemento stesso.find(u)risale l’albero a partire da fino a raggiungere l’elemento che “punta se stesso” (), cioè la radice, la quale è l’ID dell’insieme di .union(ID_u, ID_v)unisce gli insiemi a cui appartengono e rendendo la radice di figlia della radice di .
class UnionFind():
def __init__(self, n):
self.n = n
self.C = None
def crea():
self.C = [i for i in range(self.n)]
def find(u):
while u != self.C[u] # nodo != padre:
u = self.C[u]
return u
def union(ID_u, ID_v):
self.C[ID_v] = ID_u # il padre di ID_v (vecchia radice dell'insieme v) diventa ID_u (nuova radice degli insiemi u e v)Complessità temporale:
- Crea:
- Find: nel caso peggiore di un albero sbilanciato.
- Union: Per essere utilizzata nell’algoritmo di Kruskal richiede prima due chiamate a
findper ottenere gli ID dei due nodi, quindi la complessità nella pratica è di .
Ottimizzazione con il bilanciamento per rango
Consiste nel modificare il comportamento di union per collegare la radice dell’albero più piccolo a quella dell’albero più grande, producendo alberi bilanciati e minimizzando i livelli dell’albero mentre si massimizza il numero di nodi per livello.
In questa implementazione cambia anche che ogni di ha come ID una coppia [padre, rango] dove rango è il numero di nodi nel sottoalbero di (compreso stesso).
class UnionFind():
def __init__(self, n):
self.n = n
self.C = None
def crea():
self.C = [[i, 1] for i in range(self.n)] # List[]
def find(u):
while u != self.C[u][0] # nodo != padre:
u = self.C[u][0]
return u
def union(ID_u, ID_v):
if ID_u != ID-v:
if self.C[ID_u][1] <= self.C[ID_v][1]:
piccolo, grande = ID_u, ID_v,
else:
piccolo, grande = ID_v, ID_u
C[piccolo][0] = C[grande][0]
C[grande][1] += C[piccolo][1]Complessità temporale:
- Crea:
- Find:
- Union: Per essere utilizzata nell’algoritmo di Kruskal richiede prima due chiamate a
findper ottenere gli ID dei due nodi, quindi la complessità nella pratica è di .
Dimostrazione della correttezza del bilanciamento per rango
In una struttura Union-Find con fusione per rango ogni albero alto ha almeno nodi.
Dimostrazione per induzione:
- caso base: Se l’albero ha un solo nodo, quindi contiene nodi.
- passo induttivo: Supponiamo che la proprietà valga per tutti gli alberi con altezza . Un albero alto può formarsi solo unendo due alberi alti .
Per ipotesi induttiva, ciascuno dei due sottoalberi contiene almeno nodi. Dopo la fusione il nuovo albero contiene nodi.
Segue che l’altezza massima di un albero bilanciato è .
Implementazione dell’algoritmo di Kruskal
def kruskal(grafo):
archi = [
(costo, u, v)
for u in range(len(grafo))
for v, costo in grafo[u] if u < v
] # O(m)
archi.sort() # ordina gli archi per costo: O(m*logm) = O(m*logn)
MST = [[] for _ in range(n)] # inizializza l'albero di copertura, implementato con le liste di adiacenza: O(n)
union_find = UnionFind()
union_find.crea()
for costo, u, v in archi: # O(m*logn)
ID_u = union_find.find(u) # O(logn)
ID_v = union_find.find(v) # O(logn)
if ID_u != ID_v:
MST[u].append(v)
MST[v].append(u)
union_find.union(ID_u, ID_v) # O(1)
return TLa complessità temporale totale è .