Union-Find

Links that only ever appear

Ten machines, numbered 0 to 9, sit on a network that grows one link at a time. The links arrive in this order: (1,2),(3,4),(1,3),(5,6),(7,8),(5,7),(0,9),(1,5). No link is ever removed. After every new link, the operators ask questions like "can machine 2 talk to machine 8?", meaning: is there a path of links between them?

#1 #2 #3 #4 #5 #6 #7 #8 0 1 2 3 4 5 6 7 8 9
The ten machines and the eight links, numbered in the order they appear. Links are never removed.

The answer for 2 and 8 is "no" after each of the first seven links and "yes" after the eighth, when link #8 joins the group {1,2,3,4} to the group {5,6,7,8}.

The same shape of problem appears whenever "joined" only grows: Kruskal's algorithm for a minimum spanning tree, which adds edges and has to skip any edge whose ends are already connected; pixels merged into regions of an image; accounts merged because they share an address. This module needs two operations and asks how cheap they can be:

  • union(a, b): merge the group containing a with the group containing b;
  • find(x): return a representative of x's group, the same one for every member, so that a and b are connected exactly when find(a) == find(b).

The prerequisite is module 07's definition of amortized cost. The rest is self-contained.

The naive approach: search again

The obvious answer is to keep the links and search. To answer "can 2 talk to 8?", run a breadth-first search from 2 over the links so far and see whether it reaches 8.

from collections import deque

LINKS = [(1, 2), (3, 4), (1, 3), (5, 6), (7, 8), (5, 7), (0, 9), (1, 5)]

def reach(n, links, u):
    """Every machine a search from u reaches over the given links."""
    adj = [[] for _ in range(n)]
    for a, b in links:
        adj[a].append(b)
        adj[b].append(a)
    seen, queue = {u}, deque([u])
    while queue:
        x = queue.popleft()
        for y in adj[x]:
            if y not in seen:
                seen.add(y)
                queue.append(y)
    return seen

answers = [8 in reach(10, LINKS[:k], 2) for k in range(1, 9)]
visited = [len(reach(10, LINKS[:k], 2)) for k in range(1, 9)]
assert answers == [False] * 7 + [True]
assert visited == [2, 2, 4, 4, 4, 4, 4, 8] and sum(visited) == 32

Each search costs Θ(n+m) for n machines and m links, and it throws its work away. After link #3 the search from 2 discovers the group {1,2,3,4}. After link #4 it discovers that group again, although link #4 didn't touch it. With a million machines and a million links, a question after every link costs up to 106×2·106=2·1012 steps. The waste is exact: we only ever need to know which group a machine is in, never the path.

A second naive approach keeps a group label on every machine and, on each link, relabels one whole group. find is then one read, but a single union can relabel n/2 machines.

The model: a forest of parent pointers

Store each group as a rooted tree. Every machine has one pointer, parent[x]. A root points at itself and is the group's representative. find(x) follows parent pointers until it reaches a root, and union(a, b) finds both roots and makes one of them point at the other. Linking two roots merges exactly two whole groups. Linking a itself under b would cut a away from the rest of its own group.

The cost model in this module is pointer steps: the number of parent pointers find follows. A union costs two finds plus O(1).

Without a rule for which root goes under which, the trees can be as bad as a list. Here is the rule-free version, run on the worst order: every new machine becomes the root above the chain built so far.

def find_steps(parent, x):
    """Walk to the root; return (root, pointer steps followed)."""
    steps = 0
    while parent[x] != x:
        x = parent[x]
        steps += 1
    return x, steps

def link_no_rule(parent, a, b):
    ra, rb = find_steps(parent, a)[0], find_steps(parent, b)[0]
    if ra != rb:
        parent[rb] = ra              # b's root always goes under a's root

n = 2000
parent = list(range(n))
for i in range(n - 1):
    link_no_rule(parent, i + 1, i)   # old chain goes under the new one
assert find_steps(parent, 0) == (n - 1, n - 1)
total = sum(find_steps(parent, 0)[1] for _ in range(n))
assert total == n * (n - 1) == 3_998_000

Machine 0 is at depth 1999, and 2000 questions about it cost 3,998,000 pointer steps. The fix takes two rules. Each is simple, and each attacks a different part of the cost.

Union by size

Rule 1: store at every root the size of its tree. On union, hang the root of the smaller tree under the root of the larger one, and add the sizes. On a tie, let a's root stay the root.

Here is the rule on the ten machines. After each link the figure shows the forest, with the size stored at each root:

Union by size on the ten machines1After (1,2), (3,4), (1,3); 0 and 5–9 still alone2After (5,6), (7,8), (5,7); 0 and 9 still alone3After (0,9), (1,5): 8 is at depth 34After find(8): 8 and 7 now point at 1
Each root is labelled with the size stored there; machines on their own are not drawn. Every link so far joins two trees of equal size, so each one deepens the lower tree by one level. The last panel is the same forest after find(8) has compressed its path.

Watch machine 8. It starts at depth 0. Link #5, (7,8), hangs it under 7 (depth 1). Link #6, (5,7), hangs 7's tree under 5 (depth 2). Link #8, (1,5), hangs 5's tree under 1 (depth 3). Each time 8 went one level deeper, the tree it belonged to doubled: from 1 to 2, then 4, then 8 machines.

That observation is the whole proof. The block below checks it on the actual parent arrays. It does not rebuild them, which is the lab's job; it takes the arrays from the figure and verifies that every link obeyed the rule and that every depth respects the bound.

import math

# The parent array after each of the eight links, read off the figure.
FORESTS = [
    [0, 1, 1, 3, 4, 5, 6, 7, 8, 9], [0, 1, 1, 3, 3, 5, 6, 7, 8, 9],
    [0, 1, 1, 1, 3, 5, 6, 7, 8, 9], [0, 1, 1, 1, 3, 5, 5, 7, 8, 9],
    [0, 1, 1, 1, 3, 5, 5, 7, 7, 9], [0, 1, 1, 1, 3, 5, 5, 5, 7, 9],
    [0, 1, 1, 1, 3, 5, 5, 5, 7, 0], [0, 1, 1, 1, 3, 1, 5, 5, 7, 0],
]

def root(parent, x):
    return find_steps(parent, x)[0]

def tree_size(parent, r):
    return sum(root(parent, v) == r for v in range(len(parent)))

before = list(range(10))
for (a, b), after in zip(LINKS, FORESTS):
    ra, rb = root(before, a), root(before, b)
    if tree_size(before, rb) <= tree_size(before, ra):
        small, big = rb, ra                  # a tie keeps a's root
    else:
        small, big = ra, rb
    # exactly one pointer changed: the smaller root now points at the larger
    changed = [v for v in range(10) if before[v] != after[v]]
    assert changed == [small] and after[small] == big
    for v in range(10):                      # the depth lemma, in every forest
        size = tree_size(after, root(after, v))
        assert find_steps(after, v)[1] <= math.log2(size)
    before = after

groups = [sorted(reach(10, LINKS, v)) for v in range(10)]
for u in range(10):                          # same root exactly when connected
    for v in range(10):
        assert (root(before, u) == root(before, v)) == (v in groups[u])
assert find_steps(before, 8) == (1, 3) and tree_size(before, 1) == 8

Invariant

Two machines share a root exactly when they are in the same group. Under union by size, a machine gets one level deeper only when the tree it belongs to at least doubles in size.

Lemma. With union by size, every node has depth at most log2n.

Proof. A node's depth changes only when its root is hung under another root. Every other link leaves its path to the root untouched. The rule hangs a root under another only if the other tree is at least as large, so at that moment the tree containing our node goes from size s to at least 2s. The node starts in a tree of size 1, and no tree ever holds more than n nodes, so its tree can double at most log2n times. Its depth therefore goes up at most log2n times. ◻

The step to be careful with is which sizes are compared: the sizes of the two trees, stored at their roots, not the sizes or depths of the subtrees under a and b. A size read from a node that has stopped being a root is stale, and with it the doubling argument silently fails.

So find now costs at most log2n pointer steps in the worst case, per call. The chain of 2000 becomes a tree of depth at most 10.

Predict: can union by size actually reach depth log2n, or is the lemma loose?

It is tight. Link equal sizes in rounds: 1 with 1, then 2 with 2, then 4 with 4, and so on. Every link doubles the tree of the lowest node and deepens it by one, so after log2n rounds some node sits at depth exactly log2n. The ten machines are this pattern in small: machine 8 reached depth 3 in a group of 8.

Path compression

Rule 2: once find(x) has walked up to the root r, walk the same path a second time and point every node on it straight at r.

In the last panel of the figure, find(8) follows 3 pointers (8→7→5→1), then re-points 8 and 7 at 1. (5 already points at 1.) The next find(8) follows 1 pointer. Nothing else changes: 4 and 6 keep their parents, because they were not on the path.

AFTER_FIND_8 = [0, 1, 1, 1, 3, 1, 5, 1, 1, 0]
assert [v for v in range(10) if AFTER_FIND_8[v] != FORESTS[-1][v]] == [7, 8]
# no group changed: every machine keeps its root
assert all(root(AFTER_FIND_8, v) == root(FORESTS[-1], v) for v in range(10))
assert find_steps(AFTER_FIND_8, 8) == (1, 1)

Compression never changes which nodes share a root, only how far they are from it. So it can be combined with either linking rule. It costs a second pass over a path the find already paid to walk, so it at most doubles the cost of the find that does it, and it makes later finds cheaper. Write it with loops. A recursive find on the 2000-long chain above would pass CPython's default recursion limit of 1000.

Predict: with both rules, can a single find still cost log2n pointer steps?

Yes. On the ten machines, find(8) cost 3 steps =log28, because nobody had searched that path yet. The bounds for both rules together are amortized: they bound the total over any sequence of operations, as in module 07, not each call.

Both rules together: log*n amortized

log*n ("log star") is the number of times you must apply log2 to n before the result is at most 1. It grows absurdly slowly: log*265536=5, because 265536→65536→16→4→2→1.

def log_star_of_power_of_two(e):
    """log* of 2**e, worked out on the exponent e."""
    count = 1                         # log2(2**e) = e
    while e > 1:
        e, count = math.log2(e), count + 1
    return count

assert log_star_of_power_of_two(16) == 4        # 65536 -> 16 -> 4 -> 2 -> 1
assert log_star_of_power_of_two(65536) == 5

Theorem (Hopcroft and Ullman, 1973). With union by size and path compression, any sequence of m operations on n elements costs O((m+n)log*n) pointer steps in total.

Here is the argument. It is a sketch: the ranks of nodes that are still roots keep growing, and that bookkeeping is skipped.

Ranks. When a node stops being a root, freeze its rank: ⌊log2⌋ of the size of its tree at that moment. A root's rank is ⌊log2⌋ of its current size.

Fact 1: ranks strictly increase toward the root. When x is hung under y, y's tree is at least as large as x's, so the merged tree, and every later tree that y roots, has at least twice x's frozen size. Its rank is at least one more. Compression only ever gives x a new parent that was higher on its path, so a higher rank still.

Fact 2: at most n/2r nodes have rank r. Each such node froze a tree of at least 2r nodes, and two nodes of the same rank froze disjoint trees. If the trees overlapped, one node would be inside the other's tree, and by Fact 1 its rank would be lower.

Blocks. Group the ranks into blocks {0,1},{2},{3,4},{5,…,16},{17,…,65536},…, where each block ends at 2t if the previous one ended at t. Ranks never exceed log2n, so there are at most log*n+1 blocks.

Charging. Each pointer step x→parent(x) of a find is paid for by one of two accounts:

  • by the find, if the parent is the root or lies in a higher block than x. Ranks rise along the path, so this happens at most once per block boundary: at most log*n+2 times per find.
  • by the node x, otherwise. Compression then gives x a new parent of strictly higher rank. If x is in the block that ends at 2t, then after at most 2t such charges its parent is in a higher block, and x is never charged again. By Fact 2, that block holds at most ∑r>tn/2r=n/2t nodes, so it absorbs at most n charges in all.

Summing: m finds pay at most m(log*n+2), and the at most log*n+1 blocks pay at most n each, so the total is O((m+n)log*n). ◻

Theorem (Tarjan, 1975; stated, not proved). The same structure performs m≥n operations in O(mα(m,n)) time, where α is an inverse of Ackermann's function. α grows far more slowly even than log*: it is at most 4 for any input that could ever be stored. Tarjan also showed that this analysis of the structure cannot be improved.

Lower bound (Fredman and Saks, 1989; stated). In the cell-probe model, every data structure for this problem, however it is built, needs Ω(α(m,n)) amortized time per operation. So union by size with path compression is optimal, not merely good.

The bounds side by side, all in pointer steps:

structure one find, worst case m operations, total
search after every link Θ(n+m) Θ(m(n+m))
forest, no rule Θ(n) Θ(mn)
union by size ≤log2n O(mlogn)
both rules ≤log2n O(mα(m,n)), amortized

Space is 2n words, a parent and a size per element. Deletions are the one thing this structure can't do: a link that disappears can split a tree, and nothing in the forest records where. That needs a different structure.

Kruskal needs exactly this

Six sites with possible links (w,u,v), weight first: (4,0,1),(8,0,2),(2,1,2),(6,1,3),(3,2,3),(9,2,4),(5,3,4),(7,3,5),(1,4,5). Kruskal's algorithm sorts the links by weight and takes each one unless its ends are already connected. The cut property says the lightest link joining two different groups is always safe. "Already connected?" is one find per end, and taking a link is one union.

Here it takes (1,4,5),(2,1,2),(3,2,3),(4,0,1),(5,3,4) for a total weight of 15. The next link, (6,1,3), is the first one skipped, because 1 and 3 are already joined through 2 by the links of weight 2 and 3. Sorting costs O(mlogm) and the finds O(mα(m,n)), so the sort dominates.

Measure the claim

A bound claims a growth rate, so count. These numbers come from a pointer-step counter on seeded random inputs at three sizes. For union by size alone it measures the deepest node after 2n random unions and the depth that the equal-size pairing input reaches. For both rules it measures the pointer steps per operation over 4n random operations, half unions and half finds:

n log2n union by size alone: random union by size alone: pairing both rules: steps per operation
1,024 10 5 10 1.21
8,192 13 6 13 1.23
65,536 16 7 16 1.23

The pairing column meets the lemma exactly, and random unions stay far below it. The last column is flat, which is consistent with the log*n and α bounds. It is a measurement on these random inputs, not a proof: the theorems cover every sequence, including ones built to be expensive.

A problem that looks different

A sign-up system holds a million user records, and each lists one or more email addresses. Two records belong to the same person if they share an address, directly or through a chain of records that each share one with the next. How many distinct people are there? Nothing in the question mentions links or trees. The lab's last problem is a different one.

Practise

The lab has you write the linking rule and watch every depth stay under log2 of its tree's size, frame by frame. You then predict, and then write, path compression on a new sequence; build the whole structure and lay cable with Kruskal's algorithm on a new network; measure the depth bound, its tight input and the flat cost for yourself; and finally solve one problem that doesn't say what it is.

Recap

  • You can now: keep a partition as a forest of parent pointers with find and union; prove that union by size keeps depth at most log2n and build the input that reaches it; add path compression and sketch why the total cost becomes O((m+n)log*n); state Tarjan's α bound and the matching lower bound; and use the structure inside Kruskal.
  • Invariant: two elements share a root exactly when they are in the same set, and under union by size a node gets deeper only when its tree at least doubles.
  • Complexity achieved: at most log2n pointer steps per find in the worst case with union by size, and O(mα(m,n)) amortized for m operations with both rules. Compare Θ(n) per find with no rule and Θ(n+m) per question when searching again.
  • Failure mode: linking the arguments instead of their roots, or comparing sizes stored at nodes that are no longer roots.
  • In real software: NetworkX's networkx.utils.UnionFind hangs the lighter root under the heavier and compresses paths, and its Kruskal routine for minimum_spanning_tree uses it to skip edges inside a component. SciPy's scipy.cluster.hierarchy.DisjointSet merges by size and shortens paths by path halving, a one-pass variant of compression.
  • Retrieval: module 07 defined amortized cost. What exactly does an amortized O(α(m,n)) bound promise about one find?

Check yourself

  1. Why does union by size keep every depth at most log2n, and which input makes that bound exact?
  2. Keep path compression but drop union by size. Give an order of unions that makes one find cost n−1 pointer steps. What does compression do to the finds after it?
  3. Compare union by size with a label on every machine, where each link relabels the smaller group. What does each cost per find, and why do n−1 links cost the labels at most nlog2n relabellings in total?

Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…