Ch 23 / 30 Minimum Spanning Trees and Union-Find 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 23

Part 5 · Graphs · 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.

Reading
CLRS Ch. 21, 23
Focus
Greedy with a proof
Cost
O(E log E) · find ~O(1)
Needs
Heaps, sorting

By the end of this chapter you can

  1. Define a spanning tree and a minimum spanning tree, and count its edges without thinking
  2. State and use the cut property — the theorem that makes greedy correct here
  3. Implement Kruskal's algorithm, and Prim's, and say when each is the better fit
  4. Build a disjoint-set forest with union by size and path compression
  5. Explain why its cost is called “almost constant” and what α(n) actually is
  6. 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.

Cabling a village

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.

Four facts to have ready
  • An MST has exactly V − 1 edges. 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 − C edges 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:

not_the_same.pyMST is not a shortest-path tree
#      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.

The cut property

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.

Two islands, one bridge

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:

The cycle property

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.

kruskal.py
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 => disconnected

The 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?

Why not BFS to check connectivity?

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.

Clubs with a president

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.

dsu.pyunion by size + path halving
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.

Fix 1: union by size (or rank)

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.

Fix 2: path compression

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.

Naive union, naive find
O(n) per op
Union by size only
O(log n) per op
Path compression only
O(log n) amortised
Both together
O(α(n)) 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.

Two easy mistakes
  • Comparing parent[a] == parent[b] instead of find(a) == find(b). Parents are not identities; roots are. Two elements in the same set very often have different parents.
  • Assuming find is cheap the first time. The bound is amortised. A single find on 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.

prim.py
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 None
Prim vs. Dijkstra: one word

Put the two inner loops side by side:

  • Dijkstra pushes dist[u] + wtotal cost from the source.
  • Prim pushes wcost 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.

KruskalPrim
IdeaSort all edges, merge componentsGrow one tree from a start vertex
NeedsUnion-find + a sortA priority queue
TimeO(E log E)O(E log V) with a heap; O(V²) with a scan
Best whenSparse graphs; edges already sorted; you have the edge listDense graphs; adjacency lists; you want to stop part-way
Disconnected inputNaturally gives a spanning forestOnly finds the start vertex's component
Parallel-friendlyThe sort is; the merging is notNot 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:

prim_dense.pyO(V^2), no heap, no edge list
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 total

6Where 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 union that returns False means 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.
Union-find cannot un-union

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 − 1 most expensive edges, and the k pieces 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.
Clustering by cutting cables

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 + w replaced by w.
  • Union-find keeps a forest where each root names a set. find walks to the root; union points 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), not parent[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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →