Union-Find and Minimum Spanning Trees
After this lesson, you will be able to:
- Explain the parent array and trace find and union by hand on a small example
- Say what path compression and union by rank each do, and show with counted steps why the two together are almost O(1) per operation
- State in plain words what the inverse Ackermann function is, and where the formal amortized argument lives
- Use Union-Find for a stream of new links, for a cycle in an undirected graph, for grid islands and for merging accounts
- Say what a spanning tree is, state the cut property in plain words, and say why it makes greedy choices safe
- Write Kruskal with sort and Union-Find, write Prim with heapq, give the time and space of each, and choose between them
- Explain why the weight of a minimum spanning tree is unique, when the tree itself is unique, and how single-linkage clustering is Kruskal in disguise
Before You Start
#Two Questions About Connections
Think of islands joined by bridges. Someone asks: can I walk from island A to island B? Then a new bridge opens, and someone asks again. A graph search such as breadth-first search answers each question by walking the whole map. That is fine once. It is slow if the map changes and the questions never stop.
#Quick Recap: Graphs and Heaps
Five lines of what you need.
- A graph has V nodes and E edges. An edge can carry a weight.
- An edge list is a plain list of
(weight, u, v)triples. An adjacency list gives each node its neighbours (Graphs). - A cycle is a path that leaves a node and returns to it without reusing an edge.
- A heap hands you the smallest value in O(log n), and
heapqis Python's version (Heaps). sortedcosts O(n log n) time and O(n) extra space (Sorting Algorithms).
#The Parent Array Idea
parent. Item i has a parent link, a pointer that is just another item's number, stored in parent[i]. At the start nobody has a real parent, so each item points at itself: parent = [0, 1, 2, 3, 4, 5]. An item that points at itself is a root. A root names its group.| Step | Call | parent after the call | Groups |
|---|---|---|---|
| start | [0, 1, 2, 3, 4, 5] | six groups of one | |
| 1 | union(0, 1) | [0, 0, 2, 3, 4, 5] | 1 goes under 0 |
| 2 | union(2, 3) | [0, 0, 2, 2, 4, 5] | 3 goes under 2 |
| 3 | union(1, 3) | [0, 0, 0, 2, 4, 5] | root of 1 is 0, root of 3 is 2, so 2 goes under 0 |
0 4 5
/ \
1 2
|
3
find that climbs, and a union that links two roots.parent = list(range(6)) # every item starts as its own root
def find(x):
"""Walk up the parent links until we reach a root."""
while parent[x] != x:
x = parent[x]
return x
def union(a, b):
"""Join the groups of a and b by hanging one root under the other."""
root_a, root_b = find(a), find(b)
if root_a != root_b:
parent[root_b] = root_a
union(0, 1)
print(parent)
union(2, 3)
print(parent)
union(1, 3)
print(parent)
print(find(3), find(4))
print(find(1) == find(3), find(1) == find(4))
# Output:
# [0, 0, 2, 3, 4, 5]
# [0, 0, 2, 2, 4, 5]
# [0, 0, 0, 2, 4, 5]
# 0 4
# True FalseThe printed lists match the table row by row. Item 3 climbs 3, then 2, then 0, so its root is 0. Item 4 never moved, so it is its own root.
Start from parent = [0, 0, 2, 2, 4, 5] (the state after step 2 above). The code is the plain version: it hangs the second root under the first. What is parent after union(3, 5)?
#Why the Plain Version Gets Slow
find costs as many steps as the tree is tall. Nothing in the plain code keeps trees short. Join item 1 to item 0, then 2 to that group, then 3, and so on, always hanging the old group under the new item. The group becomes one long chain, and find(0) must climb the whole chain.def chain_hops(n):
"""Plain union only. Join item i+1 to the group of item i, for every i."""
parent = list(range(n))
def find(x):
hops = 0
while parent[x] != x:
x = parent[x]
hops += 1
return x, hops
for i in range(n - 1):
root_a, _ = find(i + 1)
root_b, _ = find(i)
if root_a != root_b:
parent[root_b] = root_a
return find(0)[1]
for n in (10, 100, 1_000):
print(n, chain_hops(n))
# Output:
# 10 9
# 100 99
# 1000 999find took n-1 steps. So the plain structure costs O(n) per operation in the worst case, no better than a graph search. Two small tricks fix this, and each attacks a different cause of tall trees.#Trick One: Path Compression
find reaches the root, it already knows the root. So it walks the path a second time and points every item on the way straight at the root. This is path compression. The next find on any of those items takes one step.parent = [1, 2, 3, 4, 4] # a chain: 0 -> 1 -> 2 -> 3 -> 4, and 4 is the root
def find(x):
"""Find the root, then point every item on the path straight at it."""
root = x
while parent[root] != root: # pass 1: walk up to the root
root = parent[root]
while parent[x] != root: # pass 2: re-point each item on the path
parent[x], x = root, parent[x]
return root
print(parent)
print(find(0))
print(parent)
# Output:
# [1, 2, 3, 4, 4]
# 4
# [4, 4, 4, 4, 4]find(0) paid for the long climb, and in return the whole chain became flat. We wrote find with two loops and not with recursion, so a very tall tree cannot hit Python's recursion limit. Compression costs only a second walk over a path you just walked, so it at most doubles the cost of one find.#Trick Two: Union by Rank
We build 4,096 items into one group by joining equal-size trees: items in pairs, then the pairs in pairs, and so on. Union by rank decides every link, and there is no path compression. How many steps does the deepest item need to reach its root?
def deepest(n, pairs):
"""Union by rank, no compression. Return how many steps the deepest item is from its root."""
parent = list(range(n))
rank = [0] * n
def find(x):
while parent[x] != x:
x = parent[x]
return x
for a, b in pairs:
ra, rb = find(a), find(b)
if ra == rb:
continue
if rank[ra] < rank[rb]:
ra, rb = rb, ra
if rank[ra] == rank[rb]:
rank[ra] += 1
parent[rb] = ra
depth = 0
for x in range(n):
steps, y = 0, x
while parent[y] != y:
y, steps = parent[y], steps + 1
depth = max(depth, steps)
return depth
def pairs_of_equal_trees(n):
"""Join items in pairs, then join the pairs, then join those, and so on."""
pairs, step = [], 1
while step < n:
pairs += [(i, i + step) for i in range(0, n, 2 * step)]
step *= 2
return pairs
for n in (16, 256, 4096):
print(n, deepest(n, pairs_of_equal_trees(n)))
# Output:
# 16 4
# 256 8
# 4096 12find cost O(log n) in the worst case.#The Finished UnionFind Class
parent list, a rank list, a count of components, and the methods find, union and connected. union returns True when it really merged two groups, and False when they were already one. That return value powers most of the uses below.class UnionFind:
"""Groups of items numbered 0 to n-1, with path compression and union by rank."""
def __init__(self, n: int) -> None:
"""Start with n groups of one item each."""
self.parent: list[int] = list(range(n)) # each item is its own root
self.rank: list[int] = [0] * n # upper bound on tree height
self.components: int = n # how many groups there are now
def find(self, x: int) -> int:
"""Return the root of x's group, flattening the path on the way."""
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # path compression
self.parent[x], x = root, self.parent[x]
return root
def union(self, x: int, y: int) -> bool:
"""Merge the groups of x and y. Return True if they were different groups."""
root_x, root_y = self.find(x), self.find(y)
if root_x == root_y:
return False
if self.rank[root_x] < self.rank[root_y]: # union by rank:
root_x, root_y = root_y, root_x # the taller tree stays on top
self.parent[root_y] = root_x
if self.rank[root_x] == self.rank[root_y]:
self.rank[root_x] += 1
self.components -= 1
return True
def connected(self, x: int, y: int) -> bool:
"""Say whether x and y are in the same group."""
return self.find(x) == self.find(y)
uf = UnionFind(5)
print(uf.parent, uf.components)
uf.union(0, 1)
print(uf.parent, uf.components)
uf.union(2, 3)
print(uf.parent, uf.components)
uf.union(1, 3)
print(uf.parent, uf.rank, uf.components)
print(uf.find(3), uf.parent)
print(uf.connected(1, 3), uf.connected(1, 4))
print(uf.union(0, 3))
# Output:
# [0, 1, 2, 3, 4] 5
# [0, 0, 2, 3, 4] 4
# [0, 0, 2, 2, 4] 3
# [0, 0, 0, 2, 4] [2, 0, 1, 0, 0] 2
# 0 [0, 0, 0, 0, 4]
# True False
# False1 and 3, their roots were 0 and 2. Both had rank 1, so the tie goes to the first, 0 stays on top, and its rank rises to 2. Then find(3) climbed 3, 2, 0 and pointed item 3 straight at 0. The last line shows union(0, 3) returning False because they were already together.find and union take almost constant time per call, explained next. The extra space per operation is O(1), because both loops are iterative.#How Fast Is It Really?
find per item.def total_hops(n, pairs, compress, by_rank):
"""Do all the unions, then one find per item. Count every step up a parent link."""
parent = list(range(n))
rank = [0] * n
hops = 0
def find(x):
nonlocal hops
root = x
while parent[root] != root:
root = parent[root]
hops += 1
if compress:
while parent[x] != root:
parent[x], x = root, parent[x]
return root
for a, b in pairs:
ra, rb = find(a), find(b)
if ra == rb:
continue
if by_rank:
if rank[ra] < rank[rb]:
ra, rb = rb, ra
if rank[ra] == rank[rb]:
rank[ra] += 1
parent[rb] = ra
for x in range(n):
find(x)
return hops
n = 4096
chain = [(i + 1, i) for i in range(n - 1)]
mixed = [(i, (i * 7919 + 13) % n) for i in range(n)]
print("tricks chain mixed")
for label, compress, by_rank in (("neither", False, False),
("compression only", True, False),
("rank only", False, True),
("both", True, True)):
print(f"{label:<16} {total_hops(n, chain, compress, by_rank):>7} {total_hops(n, mixed, compress, by_rank):>7}")
print()
print("both tricks, mixed workload")
for n in (1_000, 10_000, 100_000):
pairs = [(i, (i * 7919 + 13) % n) for i in range(n)]
hops = total_hops(n, pairs, True, True)
print(n, hops, f"{hops / (3 * n):.2f} hops per find")
# Output:
# tricks chain mixed
# neither 8386560 26553
# compression only 8189 12019
# rank only 8188 18565
# both 8188 11983
#
# both tricks, mixed workload
# 1000 2426 0.81 hops per find
# 10000 27721 0.92 hops per find
# 100000 295450 0.98 hops per findfind. That flat line is what "almost constant" looks like.Now the plain-words version of the famous bound.
Amortized means averaged over the whole run, so one call may cost more while the average stays tiny. Robert Tarjan proved this bound in the 1970s. We will not fake a proof here. The formal argument uses the potential method, and the asymptotics and amortized analysis lesson builds the tools for it. The counted steps above are the evidence you can check today.
You keep path compression but remove union by rank, so the root of the second group always goes under the root of the first. Which statement is true about the cost of an operation?
#Using Union-Find: Four Classic Jobs
All four jobs below use the same class. Each time, ask: what are the items, and what counts as a link?
#Groups That Change Over Time
Items are people, links are friendships. Friendships arrive one at a time, and after each we want the number of groups. A fresh breadth-first search after every link costs O(V + E) each time. With Union-Find, each link costs almost O(1).
uf = UnionFind(6)
for a, b in [(0, 1), (2, 3), (1, 3), (4, 5), (0, 2)]:
merged = uf.union(a, b)
print((a, b), "merged" if merged else "already together", uf.components)
# Output:
# (0, 1) merged 5
# (2, 3) merged 4
# (1, 3) merged 3
# (4, 5) merged 2
# (0, 2) already together 2The last link joined two people who were already in one group, so the count stayed at 2. For m links over n people, the total is O((n + m) α(n)) time and O(n) space.
#A Cycle in an Undirected Graph
union returning False is exactly a cycle.def has_cycle(n, edges):
"""True if the undirected graph has a cycle. O(n + E * alpha(n)) time, O(n) space."""
uf = UnionFind(n)
for u, v in edges:
if not uf.union(u, v): # both ends already joined, so this edge closes a loop
return True
return False
print(has_cycle(4, [(0, 1), (1, 2), (2, 3)]))
print(has_cycle(4, [(0, 1), (1, 2), (2, 3), (3, 1)]))
print(has_cycle(3, [(0, 1), (1, 1)]))
# Output:
# False
# True
# True#Grid Islands
row * cols + col, and the links are land cells that touch. Every cell starts as its own group, so water cells are groups too. We subtract them at the end.def count_islands(grid):
"""Count groups of connected '1' cells (up, down, left, right)."""
rows, cols = len(grid), len(grid[0])
uf = UnionFind(rows * cols)
land = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] != "1":
continue
land += 1
if r + 1 < rows and grid[r + 1][c] == "1":
uf.union(r * cols + c, (r + 1) * cols + c)
if c + 1 < cols and grid[r][c + 1] == "1":
uf.union(r * cols + c, r * cols + c + 1)
water = rows * cols - land
return uf.components - water
grid = ["11000",
"11000",
"00100",
"00011"]
print(count_islands(grid))
# Output:
# 3We only look down and right, so each touching pair is joined once. The cost is O(R times C times α) time and O(R times C) space for R rows and C columns. A depth-first search does the same in O(R times C), but Union-Find wins when land cells are added one at a time and you want the island count after each.
#Accounts Merge
A site has several accounts, each with a name and some emails. Two accounts belong to one person if they share any email. The items are accounts. For each email we remember the first account that owned it, and every later account with that email is linked to that first one.
def merge_accounts(accounts):
"""accounts: [name, email, email, ...]. Merge accounts that share an email."""
uf = UnionFind(len(accounts))
owner = {} # email -> first account that had it
for i, (_name, *emails) in enumerate(accounts):
for email in emails:
if email in owner:
uf.union(i, owner[email])
else:
owner[email] = i
groups = {}
for email, i in owner.items():
groups.setdefault(uf.find(i), []).append(email)
result = [[accounts[root][0]] + sorted(emails) for root, emails in groups.items()]
return sorted(result)
accounts = [
["John", "john@a.com", "j@b.com"],
["Mary", "mary@c.com"],
["John", "j@b.com", "john2@d.com"],
["John", "other@e.com"],
]
for row in merge_accounts(accounts):
print(row)
# Output:
# ['John', 'j@b.com', 'john2@d.com', 'john@a.com']
# ['John', 'other@e.com']
# ['Mary', 'mary@c.com']j@b.com, so they merged. The third John has no shared email, so he stays alone, even though the name matches. With L emails in total, the unions cost O(L α) and the final sorting costs O(L log L), so the sort dominates. Space is O(L).#Spanning Trees and the Cut Property
(weight, u, v).A-B 1 A-C 3 B-C 4 B-D 2 C-D 5
C-E 7 D-E 6 D-F 8 E-F 9
Nine edges, so a spanning tree picks 5 of them. We can check every choice by brute force, using Union-Find to test each one. There are 126 ways to pick 5 edges from 9. Which of them are real spanning trees?
from itertools import combinations
EDGES = [(1, 0, 1), (3, 0, 2), (4, 1, 2), (2, 1, 3), (5, 2, 3),
(7, 2, 4), (6, 3, 4), (8, 3, 5), (9, 4, 5)] # (weight, u, v)
def is_spanning_tree(n, chosen):
"""True if the chosen edges join all n nodes with no cycle."""
uf = UnionFind(n)
return all(uf.union(u, v) for _w, u, v in chosen) and uf.components == 1
trees = [c for c in combinations(EDGES, 5) if is_spanning_tree(6, c)]
weights = sorted(sum(w for w, _u, _v in t) for t in trees)
print(len(list(combinations(EDGES, 5))), len(trees))
print(weights[:5], weights[-1])
print(weights.count(weights[0]))
# Output:
# 126 55
# [20, 21, 21, 21, 22] 31
# 1Of 126 choices, 55 are spanning trees. Their weights run from 20 to 31, and exactly one tree has the smallest weight, 20. Brute force costs C(E, V-1) tests, which grows explosively, so we need a smarter way. It comes from one fact.
The cut property
Why? The idea in plain words. Suppose some MST left that edge out. Add the edge to that tree. This creates a cycle, and a cycle that crosses the cut once must cross it again through some other edge. That other edge costs at least as much. Swap it for the cheap edge, and you get a tree that is no worse. So taking the cheapest crossing edge never hurts.
#Kruskal's Algorithm
union returning True or False.Why is a taken edge safe? At that moment, take the cut "the group of u against every other node". No edge taken so far crosses it, and every edge cheaper than this one has already been handled, so this is the cheapest edge crossing that cut. By the cut property, it belongs to an MST.
Kruskal has taken A-B (1), B-D (2) and A-C (3) on the town graph. The next edge in sorted order is B-C with weight 4. Does Kruskal take it or skip it?
NAMES = "ABCDEF"
def kruskal(n, edges):
"""Return (total weight, chosen edges). edges are (weight, u, v)."""
uf = UnionFind(n)
chosen, total = [], 0
for w, u, v in sorted(edges):
took = uf.union(u, v)
print(f" {w} {NAMES[u]}{NAMES[v]}", "take" if took else "skip (cycle)")
if took:
chosen.append((w, u, v))
total += w
if len(chosen) == n - 1:
break # a spanning tree has n-1 edges
return total, chosen
print(kruskal(6, EDGES))
# Output:
# 1 AB take
# 2 BD take
# 3 AC take
# 4 BC skip (cycle)
# 5 CD skip (cycle)
# 6 DE take
# 7 CE skip (cycle)
# 8 DF take
# (20, [(1, 0, 1), (2, 1, 3), (3, 0, 2), (6, 3, 4), (8, 3, 5)])The result matches the brute-force minimum, 20, from five edges. The loop stopped after edge 8 and never looked at edge 9.
The cost. Sorting E edges is O(E log E) time. Since E is at most V times V, log E is at most 2 log V, so that is the same as O(E log V). The loop adds at most E union calls at O(α(V)) each, which is smaller. So Kruskal is O(E log V) time, dominated by the sort. Space is O(V) for Union-Find plus O(E) for the sorted copy of the edges.
#Prim's Algorithm
Prim grows one tree. Start at any node. Look at all edges from the tree to nodes outside it, take the cheapest, and add that new node. Repeat until every node is in. The cut is always "inside the tree" against "outside", so the cut property proves each choice safe.
(weight, from, to) triples. When we pop a pair whose node is already inside, it is stale, so we skip it. This is the same trick you saw in Dijkstra's algorithm.import heapq
def prim(n, edges, start=0):
"""Return (total weight, chosen edges). edges are (weight, u, v)."""
adj = [[] for _ in range(n)]
for w, u, v in edges:
adj[u].append((w, v))
adj[v].append((w, u))
seen = [False] * n
heap = [(0, start, start)] # (weight, from, to); the start has no real edge
chosen, total = [], 0
while heap:
w, u, v = heapq.heappop(heap)
if seen[v]:
print(f" pop {w} {NAMES[u]}{NAMES[v]} skip")
continue
seen[v] = True
total += w
if u != v:
chosen.append((w, u, v))
print(f" pop {w} {NAMES[u]}{NAMES[v]} take")
for w2, x in adj[v]:
if not seen[x]:
heapq.heappush(heap, (w2, v, x))
return total, chosen
print(prim(6, EDGES))
# Output:
# pop 1 AB take
# pop 2 BD take
# pop 3 AC take
# pop 4 BC skip
# pop 5 DC skip
# pop 6 DE take
# pop 7 CE skip
# pop 8 DF take
# pop 9 EF skip
# (20, [(1, 0, 1), (2, 1, 3), (3, 0, 2), (6, 3, 4), (8, 3, 5)])Same tree and same weight as Kruskal. Notice the order of the taken edges is the same here, but that is a coincidence of this graph. Prim always grows from the start, while Kruskal may build several pieces that join later.
seen is O(V).len(chosen) == n - 1 before you trust the answer.#Kruskal or Prim?
Both give a tree of the same minimum weight, and with these implementations both cost O(E log V). So the choice is about the shape of your data and what you need.
| Question | Kruskal | Prim |
|---|---|---|
| Natural input | an edge list | an adjacency list |
| Works from | all edges, sorted | one growing tree |
| Needs | sort and Union-Find | heapq and seen |
| Disconnected graph | gives a minimum spanning forest | reaches only the start's group |
| Early stop | can stop after V-1 edges are taken | stops when all nodes are in |
On a dense graph, E log V is about V² log V. A Prim version that drops the heap, and instead scans a plain array of "cheapest known edge to each outside node" in every round, costs O(V²), and that is better when E is close to V². Running that version on the town graph also gives 20.
The next cell counts the work on a sparse and a dense graph with 40 nodes. Kruskal's sort always touches all E edges, but the loop can stop early. Prim's heap gets each edge once.
import heapq
def kruskal_work(n, edges):
"""Return (total weight, edges looked at after sorting)."""
uf = UnionFind(n)
total = looked = taken = 0
for w, u, v in sorted(edges):
looked += 1
if uf.union(u, v):
total += w
taken += 1
if taken == n - 1:
break
return total, looked
def prim_work(n, edges):
"""Return (total weight, heap pushes)."""
adj = [[] for _ in range(n)]
for w, u, v in edges:
adj[u].append((w, v))
adj[v].append((w, u))
seen = [False] * n
heap = [(0, 0)]
total = pushes = 0
while heap:
w, v = heapq.heappop(heap)
if seen[v]:
continue
seen[v] = True
total += w
for w2, x in adj[v]:
if not seen[x]:
heapq.heappush(heap, (w2, x))
pushes += 1
return total, pushes
n = 40
dense = [((i * 31 + j * 17) % 97 + 1, i, j) for i in range(n) for j in range(i + 1, n)]
sparse = [((i * 31 + 5) % 97 + 1, i, i + 1) for i in range(n - 1)]
sparse += [((i * 13) % 97 + 1, i, (i + 7) % n) for i in range(n) if i < (i + 7) % n]
for label, edges in (("sparse", sparse), ("dense", dense)):
print(label, len(edges), kruskal_work(n, edges), prim_work(n, edges))
# Output:
# sparse 72 (1001, 50) (1001, 72)
# dense 780 (115, 47) (115, 780)Read each line as: edge count, then (weight, work) for Kruskal, then (weight, work) for Prim. The weights agree. On the dense graph Kruskal looked at 47 of 780 sorted edges, but it still paid to sort all 780. Prim pushed all 780 edges.
#Is the Minimum Spanning Tree Unique?
def kruskal_edges(n, edges):
"""Kruskal on edges (weight, u, v). Return the chosen (u, v) pairs. Ties keep input order."""
uf = UnionFind(n)
return [(u, v) for _w, u, v in sorted(edges, key=lambda e: e[0]) if uf.union(u, v)]
triangle = [(1, 0, 1), (1, 1, 2), (1, 0, 2)]
print(kruskal_edges(3, triangle))
print(kruskal_edges(3, triangle[::-1]))
# Output:
# [(0, 1), (1, 2)]
# [(0, 2), (1, 2)]Both trees weigh 2.
Now the other direction. If all edge weights are different, there is exactly one MST. The reason, in short. Suppose two MSTs differ, and look at the cheapest edge that is in one but not the other, say in the first. Add it to the second tree. That makes a cycle, and the cycle has some edge that is not in the first tree. That edge is strictly dearer, because weights differ. Swapping it out gives a tree cheaper than the second, which was supposed to be minimum. That is a contradiction, so there are not two.
On the town graph all nine weights differ, and the brute-force count above found exactly one tree of weight 20.
#In Machine Learning: Single-Linkage Clustering
components equals k. The tree Kruskal builds along the way is the MST of the points, and the distances at which merges happen are its edge weights. Libraries such as SciPy offer single linkage as one of their hierarchical clustering methods.import math
points = [(1.0, 1.0), (1.5, 2.0), (2.0, 1.2), (8.0, 8.0),
(8.5, 7.5), (9.0, 8.4), (15.0, 1.0), (15.5, 1.8)]
n = len(points)
edges = sorted((math.dist(points[i], points[j]), i, j)
for i in range(n) for j in range(i + 1, n))
def single_linkage(k):
"""Merge the two closest clusters until k remain. Return the clusters and merge distances."""
uf = UnionFind(n)
merges = []
for d, i, j in edges:
if uf.components == k:
break
if uf.union(i, j):
merges.append(round(d, 2))
groups = {}
for i in range(n):
groups.setdefault(uf.find(i), []).append(i)
return sorted(groups.values()), merges
for k in (3, 2):
print(k, single_linkage(k)[0])
print(single_linkage(1)[1])
# Output:
# 3 [[0, 1, 2], [3, 4, 5], [6, 7]]
# 2 [[0, 1, 2, 3, 4, 5], [6, 7]]
# [0.71, 0.94, 0.94, 1.02, 1.03, 8.85, 9.03]The last line is all seven MST edges. Five are tiny, and then come two big jumps, 8.85 and 9.03. Asking for 3 clusters is the same as cutting the two heaviest MST edges. A big jump between merge distances is a common hint about how many clusters the data has.
The cost for n points is high on purpose: there are n(n-1)/2 distances, so sorting them is O(n² log n) time and O(n²) space. Single linkage also has a known weakness. A thin chain of points between two blobs can glue them into one cluster, because only the closest pair matters.
#When Not to Use Union-Find
Union-Find only joins. It cannot split a group apart or delete a link, because compression has thrown the old shape away. If links can be removed, use another tool, or process the changes backwards in time so every removal becomes an addition.
It also gives a yes or no, not a path. It tells you two towns are connected and not how to get from one to the other. Use BFS or DFS for a path, and Dijkstra when edges have weights and you want the cheapest route between two nodes. A spanning tree is not that: it is the cheapest way to join everything, and the path inside it between two towns is usually not the shortest one.
For a graph that never changes and you only need the components once, one DFS in O(V + E) is just as good, and simpler. Finally, the MST in this lesson is for undirected graphs. A directed graph needs a different algorithm.
#Common Mistakes
In Prim's code, the first line after `heappop` is `if seen[v]: continue`. What would go wrong without it?
#Try It: The Five-Problem Ladder
Five problems, easy to hard. The class from the lesson is at the top of the cell. Problem 1 changes one thing about the island example. Problem 2 is a small variation on cycle detection. Problem 3 combines Union-Find with sorting to build a network. Problem 4 has two bugs to find. Problem 5 is slow, and you make it fast and say its Big-O. Predict each printed line before you run.
Tests · Correct output, line by line: '4 2'; '(3, 1)'; 'None'; '8 -1'; '3 True False'; '20 20'; '115 115'. With the TODOs left as they are, you see '5 5', None, None, '0 0', '2 False False', '20 None', '115 None'.
Costs for the ladder, with reasons. Problem 1 is O(R times C times α) time, because each cell does at most 8 unions, and O(R times C) space for the parent list.
Problem 2 is O(n + E α) time and O(n) space. Problem 3 is O(E log E) time for the sort and O(n + E) space. Problem 4 is a correctness fix only. Problem 5 drops from O(V times E), because each of V rounds scans E edges, to O(E log V) time and O(V + E) space.
#Recap
parent list. find climbs to a root, and union hangs one root under another. Path compression flattens every path that find climbs. Union by rank hangs the shorter tree under the taller one, so a tree is never more than log n levels tall. Together they make each operation O(α(n)) amortized, where the inverse Ackermann function α stays below 5 for any real input, so it acts like a constant.It answers dynamic components, cycles in undirected graphs, grid islands and merged accounts. A spanning tree joins every node with V-1 edges and no cycle. The cut property says the cheapest edge across any cut is safe to take. Kruskal sorts edges and uses Union-Find, in O(E log V) time. Prim grows a tree with a heap, also in O(E log V). The MST weight is unique, and single-linkage clustering is Kruskal with a stop.
With the plain parent array and no tricks, what does this print? parent = [0, 0, 2, 2, 4, 5] root_a = 3 while parent[root_a] != root_a: root_a = parent[root_a] print(root_a)
Key Takeaways
- Union-Find stores groups in a parent list: find climbs to a root, and union hangs one root under another
- Path compression flattens each path find climbs, and union by rank limits height to log n; together they give O(α(n)) amortized per operation
- Use it for streams of new links, cycles in undirected graphs, grid islands and merged accounts, and remember that it cannot split a group
- A spanning tree of V nodes has V-1 edges, and the cut property says the cheapest edge across any cut is safe to take
- Kruskal sorts the edges and keeps an edge when union returns True, in O(E log V) time and O(V + E) space
- Prim grows one tree with a heap and skips stale entries, in O(E log V) time and O(V + E) space; the MST weight is unique even when the tree is not