python/volume-4/chapter-61ch 61 / 64 · 10 min

61Union-Find — are these two connected?

In Chapter 60 we built the graph — the structure that stores every relationship as an edge so we can walk them. But one question we kept putting to it, are these two things connected?, really deserves its own specialist. A graph answers it by re-walking the whole blob every single time, then throwing the answer away when it's done. So here's the plan. We'll meet a structure that stores no paths at all — only who is grouped with whom — and it answers two questions faster than anything else can: are A and B in the same group? and merge A's group into B's. The whole way through, we keep asking the one thing that matters: how do you remember that two things are connected without remembering how? By the end you'll build the entire thing in a dozen lines, explain why it runs in effectively constant time, and start spotting the surprising number of famous problems that are secretly just "merge groups and ask whether two things landed in the same one."

One question, asked a billion times The parent array — a forest hiding in a flat array find and union — follow the parents, repoint a root Path compression — flatten the trail on the way up
scroll to begin

01One question, asked a billion times

Let's start with the one question this whole chapter is built to answer fast. Picture a set of things — pixels, computers, people, cities — that start out as n separate islands, each a group of one. Now relationships arrive one at a time: this pixel matches that pixel, this wire connects those two machines, these two people are friends. Each relationship merges two islands into one. And in between the merges, someone keeps asking: are these two particular things now in the same island? That's it — that's the entire job. The groups have a formal name, disjoint sets: a family of sets with no overlap, where every element belongs to exactly one. The structure that maintains them is the disjoint-set union, or Union-Find after its two operations.

Watch what the obvious approach costs. You could answer "connected?" with a graph traversal — the very tool we built last chapter. Store every relationship as an edge, and each time someone asks, run a BFS from A to see if you reach B. But that's O(V+E) per question. You re-walk the whole component every single time, then throw the answer away when you're done. Union-Find makes a different deal with memory. It doesn't remember how A and B are connected — no path, no route — only that they're in the same group, boiled down to one number. Forget the route, keep the label, and the question that cost a full traversal collapses to comparing two integers.

graph way: BFS re-walks the blob (O(V+E) each ask) A · · · B visit node, scan neighbours, repeat… every time Union-Find way: each group wears one label group label = 2 A · B label(A) == label(B)? → one comparison
Fig — the whole idea. A traversal answers "connected?" by re-exploring the blob. Union-Find pre-collapses each blob to a single label, so the same question becomes "do these two carry the same label?" — a comparison, not a walk.

So each group needs a single label every member agrees on. Where does that label come from, and how does it live in memory? The answer is the cleverest small idea in this volume: a whole forest of trees, folded flat into one integer array. →

02The parent array — a forest hiding in a flat array

Here's the trick that makes the label cheap: don't store a label at all — store a parent. Give every element one pointer, up toward the "representative" of its group. Follow those parents and you climb a tree; the element at the very top — the one that points at itself — is the root, and the root's own index is the group's label. Every element in a group climbs to the same root, so "same group?" becomes "same root?". A group is a tree; the whole collection of groups is a forest of these upward-pointing trees.

Now the memory move. A tree usually means scattered heap nodes joined by real pointers — 28-byte object headers, cache-missing pointer-chasing, the whole tax we paid for the linked list and the graph. Union-Find pays none of it, because the elements are the integers 0..n-1, and an integer can be an index. So the entire forest collapses into one flat array, parent, where parent[i] holds the index of i's parent. No nodes, no heap pointers — the "pointer up the tree" is just a number you use to index back into the same array. A tree living inside a contiguous block, exactly the way the heap chapter folded a tree into an array. The root is any i where parent[i] == i.

the forest (a drawing) 2 0 1 3 root (label 2) 5 4 6 root (label 5) 7 alone: 7→7 …is really just this one contiguous array in RAM 0123 4567 2 2 2 0 5 5 5 7 parent[2]=2 → root parent[5]=5 → root parent[7]=7 → root
Fig — the memory layout. Three groups, drawn as upward trees, are one 8-cell array: parent[i] is i's parent index. Roots point at themselves (highlighted). No heap nodes, no real pointers — the whole forest is a flat run of integers you index into.
up.pypython
import sys
from array import array

n = 1_000_000
parent = list(range(n))          # start: everyone is their own root (n islands)
sys.getsizeof(parent)            # -> 8,000,056   bytes  (8.00 B per element)

parent = array('i', range(n))    # same forest, packed 4-byte ints
sys.getsizeof(parent)            # -> 4,091,948   bytes  (4.09 B per element)

Line by line: the initial state is list(range(n)) — every element is its own parent, so every element is a root and the forest is n single-node trees. As a plain list it's a contiguous array of 8-byte references (Vol 1) — measured 8,000,056 bytes for a million elements, a flat 8.00 B per element. Swap in array('i') and the same information packs into raw 4-byte integers — 4,091,948 bytes, 4.09 B each — because there are no boxed int objects to reference, just the numbers themselves. Either way the cost is O(n), one slot per element, and that's the whole structure: no per-edge storage, no node headers. Union-Find is one of the lightest containers you'll ever meet.

A forest, not a tree — and the root names the group
There are as many trees as there are groups, and they share one array. The only thing that makes an element "special" is parent[i] == i: that self-loop marks a root, and the root's index is the group's identity. Merging two groups will be nothing more than making one root point at the other — one array write.

One array, one rule ("roots point at themselves"). From those, both operations fall out almost for free — and one of them has a hidden performance trap that a person had to be clever to escape. →

03find and union — follow the parents, repoint a root

Two operations, and the array makes both obvious. find(x) answers "which group?" by climbing: start at x, hop to parent[x], then parent[parent[x]], and keep going until you reach the element that points at itself — the root. Return that root. union(a, b) merges two groups: find a's root, find b's root, and if they differ, set one root's parent to the other. That single write — parent[rootA] = rootBstaples two trees into one, and instantly every element under rootA now climbs to rootB. "Connected?" is just find(a) == find(b).

ops.pypython
def find(parent, x):
    while parent[x] != x:        # climb until a self-pointer (the root)
        x = parent[x]
    return x

def union(parent, a, b):
    ra, rb = find(parent, a), find(parent, b)
    if ra != rb:                 # different groups?
        parent[ra] = rb          # one write staples them together

def connected(parent, a, b):
    return find(parent, a) == find(parent, b)

That's a fully working Union-Find. find is a loop that follows parents until the self-pointer; union calls it twice and, if the roots differ, does one assignment; connected is a one-liner on top. It's correct — but read find again with a suspicious eye. Its cost is the height of the tree: how many parent-hops from x up to the root. Nothing here controls that height. If unions keep stapling roots into a straight line — 0 under 1 under 2 under 3 — the tree becomes a chain of length n, and find degrades to O(n), a linked list wearing a forest's clothes.

Naive union can build a comb — and then find is O(n)
Because union blindly does parent[ra] = rb, an adversarial (or just unlucky) order of merges can grow one long spindly chain. Every find then walks the whole chain. On a real run of a 200,000-element chain, one find of the deepest node measured about 3.8 ms — millions of times slower than it needs to be. The layout is perfect; the shape is the enemy.

Play with it. Click any two circles below to union their groups; the parent-arrows rewire and the parent array updates live. Click two that are already in the same tree and it tells you they were already connected — that's connected() answering without a single merge.

InteractiveClick two elements to union — watch the forest merge
Click an element…
groups: 8
Same root = same group. union() staples two roots together; connected() just compares the two roots.
↺ The thing people get backwards
Union-Find feels like a graph, so people expect it to tell them how two elements connect — the path between them, or the list of everyone in a group. It won't, cheaply. The parent pointer aims at a representative, not at the neighbour you merged with; the tree's shape is an accident of merge order, not the real relationships. Union-Find deliberately forgets the route and keeps only the label. That amnesia is the whole point — it's why the answer is one comparison instead of a traversal. Need the actual paths or members? You want the graph of d12, not this.

A person stared at that O(n) chain and asked: on the way up to the root, I touch every node on the path anyway — why not fix them while I'm here? That one thought is worth a 19,000× speedup. →

04Path compression — flatten the trail on the way up

Here's the first great optimization, and it's almost embarrassingly cheap. When find(x) climbs to the root, it walks past every node on the path from x upward. Those nodes now know who the root is. So on the way back, re-point every one of them straight at the root. The next time you call find on any of them, it's one hop. You did the walk anyway; flattening the path is free work that permanently shortens the tree. This is path compression, and it turns a tall spindly tree into a flat bush after a single query.

compress.pypython
def find(parent, x):
    root = x
    while parent[root] != root:      # pass 1: climb to the root
        root = parent[root]
    while parent[x] != root:         # pass 2: bend every node on the path to root
        parent[x], x = root, parent[x]
    return root

Pass one finds the root as before. Pass two walks the same path a second time and rewrites each node's parent to be the root directly — the tuple assignment saves the old parent before overwriting it so the walk can continue. Two cheap passes instead of one, and the tree collapses. The payoff, measured on that same 200,000-node chain: the first compressed find still pays O(n) to climb (about 10 ms, one time), but it flattens everything, so every subsequent find of that node drops to about 0.20 µs — versus 3,793 µs naive. That's roughly a 19,000× speedup per query, and it came from noticing you were already standing on those nodes.

Write find as a loop, not recursion
The tempting one-liner return x if parent[x]==x else find(parent, parent[x]) is elegant and crashes in production. Before compression kicks in, a tree can be tens of thousands deep, and Python's default recursion limit is just 1000 — a recursive find on a depth-4999 chain raises RecursionError (verified). The iterative two-pass version above has no such ceiling. This is a real bug that ships: the structure is provably shallow eventually, but the very first deep find — the one that would have flattened it — is exactly the call that blows the stack.

Watch it happen. Below is a deliberately tall chain, 5 hanging under 4 under 3 … under root 0. Press find(5) and the path folds flat — every node on it re-points directly at 0. The hop-count for a future find(5) drops from 5 to 1.

Interactivefind(5) — watch the path compress to the root
A chain of depth 5. find(5) must climb 5 hops.
hops: 5
You walk the path to answer the query anyway — so bend every node straight to the root while you're there. Free.

Compression fixes trees after they grow tall. But there's a second, complementary idea that stops them growing tall in the first place — and it's just as simple. →

05Union by size — never hang the big tree off the small one

The second optimization attacks the problem at union time. When you merge two trees, you choose which root becomes the child of the other — and that choice decides the new height. Attach the bigger tree's root on top and hang the smaller under it, and the combined tree stays as short as possible; do it backwards and you risk stacking height on height. Keep a size array (how many elements each root owns) and always point the smaller root at the larger. This is union by size (its cousin, union by rank, tracks tree height instead — same spirit). Alone, it guarantees no tree is ever taller than O(log n), because a tree can only get taller when two equal-size trees merge, and that can happen at most log₂n times on the way from 1 to n.

✗ small root on top → tall r height grows every merge → O(n) ✓ big root on top → flat R height ≤ log₂n, always
Fig — the union you choose decides the height. Hang the small tree under the big root (right) and depth stays logarithmic; do it backwards (left) and you can build the O(n) comb. One if on the size array is the whole fix.
r_b.pypython
def union(parent, size, a, b):
    ra, rb = find(parent, a), find(parent, b)
    if ra == rb:
        return
    if size[ra] < size[rb]:          # ensure ra is the LARGER root
        ra, rb = rb, ra
    parent[rb] = ra                  # small tree hangs under big root
    size[ra] += size[rb]             # big root absorbs the count

The extra machinery is one comparison and one addition. If ra's tree is smaller, swap the labels so ra is always the bigger root; then hang rb under ra and fold the sizes together. The cost is one more n-length array (the size counts), doubling the structure's footprint to about 8 B/element as two packed int arrays — still gloriously light. And now, crucially, height never exceeds log n on its own.

The deeper cut — why the two tricks together beat either alone

Union by size alone gives O(log n) per operation. Path compression alone also gives roughly O(log n) amortized. Neither is constant. The magic is that they compound: with both, a sequence of m operations on n elements costs O(m · α(n)) total, where α is the inverse Ackermann function (next section) — indistinguishable from linear. The intuition: union-by-size keeps trees short so a climb is cheap; and every climb that does happen pays it forward by flattening the path, so the tree gets structurally harder to make deep again. Tarjan's 1975 analysis proved this bound is not just an average — it's tight.

There's also a slicker one-pass compression called path halving: as you climb, set each node's parent to its grandparent (parent[x] = parent[parent[x]]; x = parent[x]). It halves the path in a single loop, needs no second pass, and achieves the same α(n) bound — the version most production libraries ship.

Union by size caps height at log n; compression crushes it further toward 1. Put them together and the per-operation cost sinks to a number so small it has a famous, almost unbelievable name. →

06α(n) — the cost that never reaches 5

Wait —
if the cost still technically grows with n, how can anyone call it constant? Because the thing it grows as is so slow that "grows" loses its teeth. Meet the slowest-rising function that ever earned a Big-O.

With both optimizations, a Union-Find operation runs in O(α(n)) amortized time, where α is the inverse Ackermann function. The Ackermann function A(m,n) is the standard example of something that grows faster than any tower of exponentials — it explodes so violently that its inverse crawls upward almost imperceptibly. How slowly? α(n) stays ≤ 4 for every n up to a number with roughly 19,729 digits. The observable universe has about 10⁸⁰ atoms — an 81-digit number. So for any Union-Find you will ever build — indeed for any that could physically exist — α(n) is a small constant no bigger than 4. It is not literally O(1), but the gap is philosophical, not practical.

Ackermann explodes → its inverse α(n) barely moves A(m,n) — grows past all towers A(2,4) = 11 A(3,4) = 125 A(4,1) = 65,533 A(4,2) = 2⁶⁵⁵³⁶ − 3 (19,729 digits long) universe ≈ 10⁸⁰ (81 digits) α(n) — the inverse, per operation n ≤ 3 ....... α = 1 n ≤ 7 ....... α = 2 n ≤ 2047 .... α = 3 n ≤ (19729 digits) .. α = 4 → effectively constant, forever
Fig — why "almost O(1)" is honest. Ackermann outruns every exponential (A(4,2) already has 19,729 digits); its inverse α therefore stalls at 4 across every input the physical universe permits. Verified in Python: A(4,1)=65,533, and 2⁶⁵⁵³⁶ prints 19,729 digits.

The proof is in a stopwatch. Here's a full Union-Find with both tricks, run on a million elements, counting the real parent-hops per operation:

alpha.pypython
# n = 1,000,000 elements, 1,000,000 random unions, both optimizations on
avg_parent_hops_per_find  = 1.11     # after all unions, averaged over n finds
amortized_hops_per_union  = 1.34     # total climbs / total unions across the run

# the same worst-case chain of 200,000 nodes:
naive_find_deepest        = 3793.0   # microseconds, O(n)
compressed_find_after     = 0.20     # microseconds, effectively O(1)
speedup_per_find          = 18965    # times faster

These are measured, not quoted. After a million random unions on a million elements, an average find climbs just 1.11 parent-hops to reach its root — and that number barely budged (it was 1.11 at n=100,000 too), which is exactly what "independent of n" looks like in the wild. The amortized cost across a whole run is about 1.34 hops per union. And on the pathological chain, compression turns a 3,793 µs climb into a 0.20 µs one — the ~19,000× we promised. (Timings are machine-dependent — yours will differ in absolute terms; the ratio and the flatness are the structural facts.)

InteractiveDrag n toward every atom in the universe — watch α(n) refuse to grow
PER-FIND COST · A NAIVE CHAIN vs BOTH TRICKS 10⁸⁰ pointer-hops · naive find 4 pointer-hops · both tricks n = 10⁸⁰ — every atom in the observable universe naive · O(n) 10⁸⁰ union by size · O(log n) 266 both tricks · O(α(n)) 4 1 10²⁰ 10⁴⁰ 10⁶⁰ 10⁸⁰ ← number of pointer-hops per find (log scale) → Drag n up: the red bar rockets to 10⁸⁰ hops — the green bar ticks to 4 and stops. α reaches 4 and then never reaches 5 — not for any n with fewer than ~19,729 digits.
n = 10⁸⁰
Every set you could ever store fits on this chart. A naive find on a degenerate chain costs the count itself — up to 10⁸⁰ pointer-chases on a universe-sized set. Union-by-size alone caps a find at log₂n (≈266 even at 10⁸⁰). Add path compression and the amortized cost is α(n), the inverse Ackermann function: on this scale it ticks 3 → 4 (and is just 1 or 2 for a handful of tiny sets), then never reaches 5. That flat green bar is what "effectively constant" means — the rare cost that stops growing before the universe runs out of atoms.
Why the metal loves this structure
Two n-integer arrays, contiguous. A find after compression touches a handful of adjacent-ish cells that mostly sit in cache — the win Volume 3 taught us to chase — no 28-byte object headers, no heap pointer-chasing, no cache-missing node hops like the linked list or the dict-of-lists graph. The forest is logically a tree but physically a flat array, so the CPU streams it. Light in space and friendly to cache — the rare structure that wins on both axes.

Myth

To keep checking whether two things are connected as relationships pour in, just store a graph and BFS on each question — it's simple and correct.

Reality

That's O(V+E) every question and it can't reuse work between merges. Union-Find answers "connected?" and "merge!" in effectively O(1) each, amortized — the right tool the moment the workload is incremental grouping with connectivity queries. It's not a graph replacement; it's the specialist for that one access pattern.

Where you meet this — more places than you'd guess
Kruskal's algorithm for the Minimum Spanning Tree (Vol 3, greedy) sorts edges and uses Union-Find to skip any edge whose endpoints are already connected — that's how it avoids cycles. Image segmentation merges neighbouring pixels of similar colour into regions (the classic Felzenszwalb algorithm is Union-Find under the hood). Percolation physics, maze generation (merge cells until the grid is one connected maze), "friend circles" / connected components in social graphs, network reliability ("is the grid still one piece after this cable fails?"), and type unification in compilers (merge two type variables that must be equal) are all this structure. You've almost certainly used a photo tool or a map router whose internals ran a find.

And the 1% move, the transferable one: "good at data structures" is mostly matching a structure to an access pattern on sight. When you see a problem that's incremental merging plus "same group?" queries — and only that, no paths, no members, no deletions — the reflex should fire: this is Union-Find. Recognising it saves you from reaching for a heavyweight graph and re-traversing forever. The skill isn't memorising α(n); it's feeling the shape of "merge and ask" and knowing there's a near-free tool built for exactly it.

Thirteen structures down — arrays, lists, stacks, queues, deques, hash maps, matrices, trees, BSTs, heaps, tries, graphs, and this connectivity specialist. Each was the right answer to one access pattern. The last chapter steps back and turns that whole collection into a single decision: given a new problem, how do you pick the right container on sight? Next: the chooser's map — matching the operation you do most to the structure that makes it cheap. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 61, in working code

Here's the container that answers "are these two things connected?" not by re-walking a maze but by giving every group a single name — and we'll build it from one flat array of integers, then watch two tiny tricks make it so fast the cost has a famous, almost unbelievable name.

The parent array — a forest in a flat array
Every element stores one number: the index of its parent. Roots point at themselves, and a root's index IS its group's label — the whole forest is one contiguous run of integers you index into.
find and union — climb, then repoint a root
find(x) climbs to the root; union(a,b) finds both roots and, if they differ, staples one under the other with a single array write. Correct and tiny — but a blind union can grow an O(n) comb.
Path compression — flatten the trail on the way up
You already walk the path to the root to answer a query — so bend every node you pass straight at the root. A tall chain becomes a flat bush after one find; path halving does the same in a single pass and is what production libraries ship.
Union by size — keep the trees short
At merge time, always hang the smaller tree under the larger root. One extra size array and one comparison cap every tree's height at O(log n), so it can never grow into the comb.
Union-Find at work — components, cycles, islands
The payoff. Counting connected components, catching the edge that closes a cycle (Kruskal's exact trick), and counting islands in a grid — each one a near-linear sweep over the same flat parent array.
end of chapter 61 · six sections
iolinked.com
Written by Ajai Raj