AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 23
Minimum Spanning Trees and Union-Find
Connect every town with the least cable. The answer needs a structure that can answer ‘are these two already connected?’ faster than seems possible.
By the end of this chapter you can
- Define a spanning tree and a minimum spanning tree, and count its edges without thinking
- State and use the cut property — the theorem that makes greedy correct here
- Implement Kruskal's algorithm, and Prim's, and say when each is the better fit
- Build a disjoint-set forest with union by size and path compression
- Explain why its cost is called “almost constant” and what α(n) actually is
- Recognise the problems that are secretly MST or union-find questions
1Connect everything, cheaply
Not the shortest route between two places — the cheapest way to make every place reachable from every other.
Nine houses, and a quote for laying cable between each pair that is physically feasible. Every house must end up connected to the network. Cable costs money, so you want the smallest total bill.
Two observations settle the shape of the answer before you compute anything. First, you never
want a loop of cable: if the network already contains a cycle, one edge of it can be cut
and everyone is still connected. Second, with V houses you will lay exactly
V − 1 cables — the first cable connects two houses, and every cable after
it must bring in exactly one new house or it was a waste.
A connected graph with no cycles is a tree. So the answer is always a tree, and the only question is which one.
A spanning tree of a connected graph is a subset of its edges that touches every vertex and contains no cycle. A minimum spanning tree (MST) is a spanning tree whose total edge weight is as small as possible.
- An MST has exactly
V − 1edges. Always. If your answer has a different count, it is not a spanning tree. - An MST exists iff the graph is connected. A disconnected graph has a
minimum spanning forest instead — one tree per component,
V − Cedges in total. - The MST is not necessarily unique, but it is unique when all edge weights are distinct. Ties are where the multiple answers come from.
- An MST is not a shortest-path tree. These are different objectives and they usually disagree. More on this in §5.
That last point catches almost everyone once, so here it is concretely:
# A ---1--- B
# | /
# 3 1
# | /
# C ---
#
# MST: edges A-B (1) and B-C (1), total weight 2.
# In that tree the route from A to C is A -> B -> C, costing 2.
# But the graph has a direct A-C edge of weight 3... which is worse, fine.
#
# Now flip it:
# A ---1--- B ---1--- C plus a direct A-C edge of weight 1.9
# MST total = 2, and it drops the A-C edge.
# Shortest path A to C in the MST = 2. In the real graph = 1.9.
# The MST made every route slightly worse in exchange for a cheaper network.2The cut property
Greedy algorithms usually need an excuse. This one has a theorem, and it is the reason both algorithms in this chapter are correct.
A cut is any way of splitting the vertices into two non-empty groups. An edge crosses the cut if its two endpoints are in different groups.
For any cut, the cheapest edge crossing it belongs to some MST. (If that cheapest crossing edge is unique, it belongs to every MST.)
Proof by exchange. Let e be the cheapest edge crossing some cut, and
suppose an MST T does not contain it. Add e to T: since
T already connects everything, adding an edge creates exactly one cycle. That cycle
must cross the cut a second time — you cannot leave a group and stay away — so it
contains some other crossing edge f. Remove f. You still have a spanning
tree, and its weight changed by w(e) − w(f) ≤ 0, because e was
the cheapest crossing edge. So the new tree is at least as good, and it contains e.
Split the villages into two groups however you like — say, everyone north of the river and everyone south. Whatever the final network looks like, at least one cable must cross the river, or the two halves are separate networks.
So you are going to buy a river crossing. Given that, why would you buy anything but the cheapest one available? Any plan using a dearer crossing can be improved by swapping in the cheap one and dropping the dear one — that is the exchange in the proof.
The cut property says this reasoning is valid for every possible split, simultaneously. Both algorithms below are just different policies for choosing which split to reason about next.
Its mirror image is occasionally handier:
For any cycle, the single most expensive edge on it is in no MST (if it is strictly the most expensive).
Same argument, run backwards: that edge can always be removed and replaced by another edge of the cycle, and the tree gets cheaper. This is what justifies rejecting an edge, which is exactly what Kruskal spends most of its time doing.
Together the two properties give a complete decision rule: an edge is safe to take if it is the cheapest across some cut, and safe to discard if it is the dearest on some cycle. Kruskal and Prim are two ways of always having such a cut or cycle available.
3Kruskal's algorithm
Sort every edge by weight. Walk the list cheapest-first and take an edge unless its endpoints are already connected.
Kruskal ignores the shape of the graph completely and just works down a sorted list. The cut it is
implicitly reasoning about is “the component containing u, versus everything
else”: since we are taking edges in increasing weight order, the first edge that leaves that
component is the cheapest one that does, so the cut property applies.
def kruskal(n, edges):
"""edges: list of (weight, u, v). Vertices are 0..n-1."""
edges = sorted(edges) # cheapest first — the whole strategy
dsu = DSU(n)
total, tree = 0, []
for w, u, v in edges:
if dsu.union(u, v): # True if they were in different components
total += w
tree.append((w, u, v))
if len(tree) == n - 1: # a spanning tree is complete; stop early
break
return (total, tree) if len(tree) == n - 1 else None # None => disconnectedThe sort is O(E log E). Everything else in the loop is a union-find operation, and
those are so nearly free that the sort dominates the whole algorithm. Which raises the obvious
question: how does dsu.union decide, in almost no time, whether two vertices are already
connected?
You could ask “is v already reachable from u?” with a
BFS. That is O(V + E) per edge and O(E²) overall — correct but
hopeless.
The structure in the next section answers the same question in effectively constant time, and it is the real content of this chapter. Kruskal is four lines; union-find is the idea.
4Union-find (disjoint-set union)
Two operations: 'are these in the same group?' and 'merge these two groups'. Both, after two small tricks, cost essentially nothing.
Everyone belongs to a club. Each club has one president, and the way you find your club's president is to ask the person who recruited you, who asks the person who recruited them, and so on until you reach someone who recruited themselves. That person is the president, and the president is the club's identity.
Are we in the same club? Both of us walk up to our presidents and compare names.
Merge two clubs? One president starts pointing at the other. That single change merges two clubs of any size, instantly, without telling a single member.
The whole structure is one array: parent[i] is who recruited i.
class DSU:
def __init__(self, n):
self.parent = list(range(n)) # everyone starts as their own president
self.size = [1] * n # how many members each president has
self.count = n # number of separate components
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already together — nothing to do
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # attach the SMALLER tree under the larger
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.count -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)Written naively — parent[find(b)] = find(a) with no further thought — the
structure works but degenerates. Union a chain of n elements in the wrong order and you
build a linked list of length n, making every find cost
O(n). Two independent fixes rescue it.
Always attach the smaller tree under the root of the larger one.
The depth of an element only increases when its tree is the smaller one in a merge — and
when that happens, the tree it belongs to at least doubles. An element's depth can therefore
increase at most log₂ n times, so no tree is ever taller than
log n. That alone gives O(log n) per operation.
Union by rank uses tree height instead of size. Both give the same bound; size is easier to reason about and gives you component sizes for free, which problems often want anyway.
While walking up to the root, point the nodes you pass directly at the root. The next
find on any of them is a single hop.
The version above is path halving: each node is pointed at its grandparent as you pass. It is one line, needs no second pass and no recursion, and gives the same asymptotic bound as full compression. The recursive full-compression version is prettier and blows the stack on large inputs.
Note that find is not a read-only operation. It rewrites the structure. That is
deliberate — the work is paid for by the queries that benefit from it, which is what makes the
analysis amortised.
α(n) is the inverse of a function that grows so violently that α
is below 5 for every n that could be written down using all the atoms in the observable
universe. It is not constant — the proof genuinely needs it — but you may treat it as
constant in every practical sense, and everyone does.
- Comparing
parent[a] == parent[b]instead offind(a) == find(b). Parents are not identities; roots are. Two elements in the same set very often have different parents. - Assuming
findis cheap the first time. The bound is amortised. A singlefindon a fresh tall tree really can walk a long path — it just makes sure nobody has to do it twice.
5Prim's algorithm
Grow a single tree outward. Each step, add the cheapest edge leaving the tree. If that sounds like Dijkstra, it should.
Where Kruskal builds a forest that gradually coalesces, Prim keeps one tree and grows it. The cut it reasons about is explicit: the tree so far, versus everything else. The cheapest edge crossing that cut is safe by the cut property, so take it and repeat.
import heapq
def prim(graph, start=None):
"""graph: {vertex: [(neighbour, weight), ...]}, undirected."""
if not graph:
return 0, []
start = start if start is not None else next(iter(graph))
seen = {start}
heap = [(w, start, v) for v, w in graph[start]]
heapq.heapify(heap)
total, tree = 0, []
while heap and len(seen) < len(graph):
w, u, v = heapq.heappop(heap)
if v in seen: # stale entry: v joined via a cheaper edge
continue
seen.add(v)
total += w
tree.append((w, u, v))
for nxt, nw in graph[v]:
if nxt not in seen:
heapq.heappush(heap, (nw, v, nxt))
return (total, tree) if len(seen) == len(graph) else NonePut the two inner loops side by side:
- Dijkstra pushes
dist[u] + w— total cost from the source. - Prim pushes
w— cost of this one edge.
That is the entire difference in code, and it is the entire difference in meaning. Dijkstra minimises the distance to each vertex; Prim minimises the cost of attaching each vertex. Same skeleton, different objective, different tree.
If you can only remember one thing about MSTs versus shortest paths, remember that Prim drops the accumulated distance and Dijkstra keeps it.
| Kruskal | Prim | |
|---|---|---|
| Idea | Sort all edges, merge components | Grow one tree from a start vertex |
| Needs | Union-find + a sort | A priority queue |
| Time | O(E log E) | O(E log V) with a heap; O(V²) with a scan |
| Best when | Sparse graphs; edges already sorted; you have the edge list | Dense graphs; adjacency lists; you want to stop part-way |
| Disconnected input | Naturally gives a spanning forest | Only finds the start vertex's component |
| Parallel-friendly | The sort is; the merging is not | Not really |
For a dense graph — E ≈ V², say a complete graph over points in the
plane — the heap stops helping. Prim with a plain O(V) scan for the nearest
outside vertex runs in O(V²), which beats O(V² log V) and needs
no edge list at all. That version is worth knowing:
def prim_dense(n, weight):
"""weight(i, j) returns the cost of the edge i-j. Complete graph, no edge list."""
INF = float("inf")
best = [INF] * n # cheapest known edge attaching i to the tree
best[0] = 0
inside = [False] * n
total = 0
for _ in range(n):
u = min((i for i in range(n) if not inside[i]), key=lambda i: best[i])
inside[u] = True
total += best[u]
for v in range(n):
if not inside[v] and weight(u, v) < best[v]:
best[v] = weight(u, v)
return total6Where this shows up
MST and union-find turn up in problems that mention neither trees nor sets.
Union-find on its own is the more frequently useful of the two. Reach for it any time the problem is about things becoming connected over time:
- Connected components of an undirected graph — union every edge, then count distinct roots. Cheaper to write than BFS and it handles edges arriving one at a time, which BFS does not.
- Cycle detection in an undirected graph — a
unionthat returnsFalsemeans both endpoints were already connected, so this edge closes a cycle. - Merging equivalences — duplicate accounts sharing an email, image pixels of the same colour, “these two records are the same person”.
- Percolation and flood fill on a grid — treat each cell as an element and union neighbours as they open.
- Kruskal, which is what brought us here.
There is no split operation. The structure is designed for connections that only
ever accumulate, and path compression destroys the history that a split would need.
Problems that remove edges are usually solved by processing them in reverse: run time backwards so that every removal becomes an addition. That reversal trick is worth remembering — it is the standard answer to “union-find, but with deletions”.
MST proper shows up wherever “connect everything cheaply” is the underlying question, and in a few places where it is not obvious:
- Network and utility design — the original motivation: cabling, piping, road building.
- Single-linkage clustering — build the MST, delete the
k − 1most expensive edges, and thekpieces left are exactly the clusters that maximise the minimum gap between groups. Kruskal is agglomerative clustering, run in a different order. - Approximating the travelling salesman — walking around an MST twice gives a tour at most twice the optimal length, in polynomial time.
- Minimax / bottleneck paths — the route between two vertices that minimises its largest edge always lies in the MST. So one MST answers bottleneck queries for every pair.
- Image segmentation and maze generation — a random-weight MST on a grid is a maze with exactly one route between any two cells.
Run Kruskal on your data points, weighting each edge by distance, and stop as soon as
k components remain instead of one.
You have just done single-linkage clustering, and the greedy order gives you a guarantee for
free: the very next edge you would have taken is the distance between the two nearest clusters, and
no other way of forming k groups makes that gap larger.
The algorithm never knew it was clustering. It was still just refusing to close cycles.
What to carry forward
- A spanning tree touches every vertex with no cycles and has exactly V−1 edges. The minimum one minimises total weight.
- The cut property is the licence for greed: across any split of the vertices, the cheapest crossing edge is in some MST.
- The cycle property is its mirror: the dearest edge on any cycle is in no MST. That is what justifies rejecting an edge.
- Kruskal: sort edges, take each one unless it closes a cycle.
O(E log E), dominated entirely by the sort. - Prim: grow one tree, always adding the cheapest edge leaving it. Dijkstra's code with
d + wreplaced byw. - Union-find keeps a forest where each root names a set.
findwalks to the root;unionpoints one root at another. - Union by size keeps trees shallow; path compression flattens them as a side effect of querying. Together:
O(α(n)), under 5 for any real input. - Compare roots, never parents.
find(a) == find(b), notparent[a] == parent[b]. - An MST is not a shortest-path tree. Different objective, usually a different tree.
- Union-find has no split. Problems with deletions are usually solved by processing time backwards.
- MST answers bottleneck questions too: the minimax path between any two vertices lies inside it.
>_Playground
Kruskal and Prim on the same graph, a union-find with and without its two optimisations, and MST-based clustering.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 24 — Greedy Algorithms and When They Lie
Take the best-looking option right now and never look back. Sometimes provably optimal, sometimes disastrously wrong.