In Chapter 60 we built the graph — the structure that stores every relationship as an edge and lets us walk them. But one question we kept asking it, are these two things connected?, 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. Here's the plan. We'll meet a structure that stores no paths at all, only who is grouped with whom. 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. You'll explain why it runs in effectively constant time. And you'll recognise the surprising number of famous problems that are secretly just "merge groups and ask whether two things landed in the same one."
★ YOU ALREADY RUN THIS · the-two-tablesone introduction, and two circles of strangers become one — permanently
Table four, somewhere between the speeches and the cake. Eight of you, sat as two clumps: your lot down one end, the couple's cousins down the other, nobody crossing. Then Aisha turns round, says oh — you two should know each other, and puts Marco in front of you. Nobody re-introduces everybody to everybody. One handshake, and it is one table. An hour later somebody asks how you know the bride and you do not say through Aisha, who knows Priya, who… — you say “I'm with Priya's lot”, because that is the only thing that survived the evening. The route is gone. The label is all anyone kept.
one introduction, and the two clumps are one table→union(a, b) — find each circle's central figure, point one at the other. ONE write merges everybody, not everybody-to-everybody
“how do you two know each other?” and you both answer Priya→find(x) climbs the parent chain to the root. Same root = same circle, so the whole question is one comparison, never a search
after one party you stop retelling the chain and just say “Priya's lot”→path compression — the climb you had to do anyway re-points everyone you passed straight at the root. Free, and it stays flat
the small clump joins the big one, never the other way round→union by size: hang the smaller tree under the bigger root and the climb can never grow past log n. One if on a size dict is the whole fix
you could not reconstruct who introduced whom if you tried→the route was never stored. Union-Find keeps the label and throws the path away — that amnesia is the speed
pin it: a circle does not remember how it formed — only who it answers to. Forget the route, keep the label, and “are these two connected?” stops being a search.
iolinked · chapter 61 — the checkpoints6 steps
$ sections covered in Union-Find — are these two connected? —
›01One question, asked a billion times
›02The parent array — a forest hiding in a flat array
›03find and union — follow the parents, repoint a root
›04Path compression — flatten the trail on the way up
›05Union by size — never hang the big tree off the small one
›06α(n) — the cost that never reaches 5
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 in 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 interleaved with the merges, someone keeps asking: are these two particular things now in the same island? That's it. That's the entire job. The formal name for the groups is 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.
Where does this actually show up? Three places you have already used. When Photoshop's paint bucket floods a region, it is merging touching pixels into one blob and asking which blob a pixel belongs to. When a network tool checks whether two machines can still reach each other after cables are added one by one, that is merge-and-ask. And Kruskal's algorithm for the cheapest set of roads that connects every town adds a road only when it would join two separate groups — a "same group?" test on every single step. The surface stories look nothing alike. The shape underneath is identical: things merge, and you keep asking who ended up together.
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. Each time someone asks, run a BFS from A and 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.
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, aimed up at the "representative" of its group. Follow those parents and you climb a tree. The element at the very top points at itself, and that self-pointer marks the root, whose own index is the group's label. Every element in a group climbs to that same root, so the question "same group?" becomes "same root?". A single group is one tree, and the whole collection is a forest of these upward-pointing trees.
Now the memory move. A tree usually means scattered heap nodes joined by real pointers. That's 28-byte object headers and cache-missing pointer-chasing, the whole tax we paid for the linked list and the graph. Union-Find pays none of it. 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. It's 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.
Why does one flat array beat a tree of heap nodes so badly? It comes down to how the CPU actually fetches memory. Hardware never pulls a single byte. It pulls a whole cache line, 64 bytes at once, into fast on-chip memory. In an array('i') of 4-byte ints, one line holds 16 consecutive elements, so reading parent[i] often drags parent[i+1] and its neighbours along for free. Heap nodes scatter across pages instead, and each pointer-hop risks a cache miss — a stall of a hundred-plus cycles while the CPU waits on RAM. The forest-in-an-array keeps the climb close and cache-warm, and that locality is half of why Union-Find feels instant.
Let's make that array concrete with six elements, 0 through 5. Say parent = [0, 0, 1, 1, 4, 4]. To find 2's root, read parent[2], which is 1. Then read parent[1], which is 0. Then parent[0] is 0 — a self-pointer — so 0 is the root. Element 3 climbs the same way and also lands on 0, so 2 and 3 share a group. But 5 reads parent[5] = 4, then parent[4] = 4, so 5's root is 4, a different group. The whole membership question came down to a few array reads and one comparison. No walking, no traversal, just following numbers up a ladder.
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). That 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, or 4.09 B each. There are no boxed int objects to reference now, 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.
⚠ MOST BEGINNERS THINK…that the parent array is an index sitting on top of the edges
The sentence you just read drops a claim and walks on: no per-edge storage. It is easy to nod at and surprisingly hard to actually believe, because every other structure in this volume keeps what you put into it. Section 01 already sold you the time half of the deal — a BFS re-walks the blob on every question, Union-Find compares two integers — and if that is all you took away, the natural next picture is that Union-Find must be a fast index over the graph: the edges still live in an adjacency list somewhere, and the parent array is a lookup table laid over them, the way an index sits on top of a table. Every index you have ever met works exactly like that, and nothing in a small demo forces you to notice which structure is actually holding the edges — both fit in memory, both answer correctly. So the belief is that to keep answering “are these two connected?” you have to keep the edges. Put both on the scales. Feed the same 100,000 edges into an adjacency list and into the parent array side by side, and read the meter three times on the way through.
TYPE THIS — 10 SECONDS
import sys, random
from array import array
from collections import defaultdict, deque
random.seed(61)
V, E = 50_000, 100_000
edges = [(random.randrange(V), random.randrange(V)) for _ in range(E)]
def gbytes(d): # the dict, plus every list it owns
return sys.getsizeof(d) + sum(sys.getsizeof(v) for v in d.values())
adj = defaultdict(list) # the graph way: keep every edge
parent = array('i', range(V)) # the union-find way: one int per element
def root(x): # the climb section 02 just taught
while parent[x] != x: x = parent[x]
return x
for n, (a, b) in enumerate(edges, 1):
adj[a].append(b); adj[b].append(a) # graph: 2 entries per edge, kept forever
ra, rb = root(a), root(b)
if ra != rb: parent[ra] = rb # union-find: ONE write, edge never stored
if n in (1, 10_000, 100_000):
print(f"{n:>7,} edges in: adjacency {gbytes(adj):>9,} B parent {sys.getsizeof(parent):>8,} B")
def bfs(a, b): # the graph's answer, walked
seen, q = {a}, deque([a])
while q:
x = q.popleft()
if x == b: return True
for y in adj[x]:
if y not in seen: seen.add(y); q.append(y)
return False
lonely = next(v for v in range(V) if v not in adj)
for a, b in [(0, 1), (123, 45_678), (0, lonely)]:
print(f" connected({a}, {b})? BFS {bfs(a, b)} root(a)==root(b) {root(a)==root(b)}")
1 edges in: adjacency 408 B parent 209,420 B
10,000 edges in: adjacency 2,041,872 B parent 209,420 B
100,000 edges in: adjacency 7,604,424 B parent 209,420 B
connected(0, 1)? BFS True root(a)==root(b) True
connected(123, 45678)? BFS True root(a)==root(b) True
connected(0, 209)? BFS False root(a)==root(b) False
Union-Find never keeps an edge. It consumes each one and drops it, holding one parent per element and nothing else, so its footprint is a function of V alone and does not move no matter how many edges you pour through it. Read the right-hand column downward and that is the whole argument: 209,420, 209,420, 209,420. The same number three times, after one edge, after ten thousand, after a hundred thousand. The left column meanwhile climbs from 408 bytes to 7,604,424 — a factor of 36.3 by the last row — because an adjacency list is a promise to remember every relationship it was ever shown. The parent array made no such promise. It read each edge, climbed two short ladders, wrote at most one integer, and let the edge go. And the last three lines are there so the saving cannot be mistaken for a shortcut: both structures answer the same three questions the same way, including a genuine negative — vertex 209 was never named in a single one of the 100,000 edges, and both say False.
Four labelled refinements, so none of this reads as more than it is. First, what the meter counted. gbytes adds up the dict and the lists it owns, and skips the integer objects those lists point at — an omission that flatters the graph, not Union-Find. Counted honestly, the adjacency lists hold references to 199,229 distinct int objects, another 5,578,412 bytes, which puts the graph side at 13,182,836 B against the array's 209,420: 62.9× rather than 36.3×. An array('i') keeps no Python objects at all — the numbers are the bytes. Second, Union-Find is not universally smaller, and the very first row of the run says so out loud: after one edge the graph is 408 bytes and the parent array is already 209,420. It pays for V upfront whatever happens; the graph pays per edge. On this vertex count the two cross near the 800th edge (measured, sampling every fifty). Below that crossing the graph is the lighter one and the question is idle anyway; above it the gap only opens — push the same 50,000 vertices to a million edges and the adjacency reaches 23,379,520 B while the parent array is still 209,420. Flatness, not smallness, is the property.Third, this is a claim about one question and no others. Union-Find is not a cheaper graph; it is the answer to “same group?” and to nothing else. If your program also wants a vertex's neighbours, its degree, or a route, you keep the adjacency list anyway and Union-Find becomes 4.19 B per element on top of it — still an easy trade when the connectivity question is hot, but an addition rather than a swap. Fourth, the merge in the test is the naive one — climb to each root, point one at the other — because that is all section 02 has given you, and it is about to be improved twice. Neither improvement touches this story: path compression only rewrites cells that already exist, and union by size adds a second n-length array, taking 4.19 to roughly 8.4 B per element. Still flat in E.
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 run the whole structure, and the flat array makes both of them 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. That element is the root, and you return it. union(a, b) merges two groups by finding a's root, finding b's root, and, if they differ, setting one root's parent to the other. That single write, parent[rootA] = rootB, staples two trees into one, and instantly every element under rootA climbs to rootB. "Connected?" is then just find(a) == find(b).
Trace one union on that same array. Right now the two trees have roots 0 and 4, sitting apart. Call union(3, 5). First find(3) returns root 0, and find(5) returns root 4. The roots differ, so we staple one under the other with a single write: parent[4] = 0. Now the array is [0, 0, 1, 1, 0, 4], and everything — 2, 3, 4, and 5 — climbs to 0. One assignment merged two whole groups at once. Ask connected(2, 5) a moment later and both climb to root 0, so the answer is now yes where seconds ago it was no. That is the entire data structure working.
One more question falls out for free: how many separate groups are left? Just count the roots, the indices where parent[i] == i. Back on parent = [0, 0, 1, 1, 4, 4], only 0 and 4 point at themselves, so there are exactly two groups. Better still, you can track the count without ever scanning. Start it at n, since every element begins as its own group, and drop it by one on each union that actually merges. Here is why that must hold: a successful union turns one root into a child, erasing exactly one self-pointer. So merging 6 singletons down to a single group takes precisely 5 unions, the same 5 edges any tree on 6 nodes needs. Connected-component counting, a whole classic problem, is just this counter read off at the end.
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 togetherdef connected(parent, a, b):
return find(parent, a) == find(parent, b)
That is a fully working Union-Find already. find is a loop that follows parents until it hits the self-pointer. union calls it twice and does one assignment when the roots differ, and connected is a one-liner on top. It is correct, but read find again with a suspicious eye. Its cost is the height of the tree — the number of parent-hops from x up to the root — and nothing here controls that height. If unions keep stapling roots into a straight line, say 0 under 1 under 2 under 3, the tree becomes a chain of length n. Then find degrades to O(n), a linked list wearing a forest's clothes.
When does that dreaded chain actually form? You do not need an adversary — a natural order is enough. Watch naive union merge lone elements in sequence. union(0, 1) points 0 at 1. union(1, 2) finds 1's root and points it at 2, dragging 0 along beneath. Do union(2, 3), then union(3, 4), and each step hangs the growing tree under the fresh lone element. After n such merges you have one long chain, 0 at the bottom and the newest element on top. A find on the bottom now pays the full O(n) climb. Nothing tricky happened. Merging in the most obvious order was all it took to build the worst case, and that is exactly the leak the next two sections plug.
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 yourself. Click any two circles below to union their groups, and watch the parent-arrows rewire as the parent array updates live. Click two that already sit in the same tree and it tells you they were already connected. That is connected() answering the question without doing a single merge.
InteractiveClick two elements to union — watch the forest merge
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.
Make it concrete on a five-node chain: parent = [0, 0, 1, 2, 3], so 4 sits under 3 under 2 under 1 under root 0. Call find(4). It climbs 4 → 3 → 2 → 1 → 0, four hops to reach the root. Compression then rewrites every node it passed to point straight at 0, leaving parent = [0, 0, 0, 0, 0]. Now 2, 3, and 4 are each one hop from the root. The next find(4) costs a single read instead of four, and you paid nothing extra for it — you were already walking that path on the way up. That is the trick in one image: the climb you had to do anyway leaves the tree permanently flatter behind you.
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. Here's 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 for yourself. Below is a deliberately tall chain, with 5 hanging under 4 under 3 and so on down to root 0. Press find(5) and the path folds flat as every node on it re-points directly at 0. The hop-count for a future find(5) drops from 5 all the way to 1.
Interactivefind(5) — watch the path compress to the root
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, in the same spirit. Alone, it guarantees no tree is ever taller than O(log n). 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.
Here is why that height cap is airtight, not hand-waving. Under union by size, a tree only grows taller when it hangs beneath a root at least as big as itself, so its height can rise only when its element count at least doubles. Count it out: a tree of height 1 needs at least 2 elements, height 2 needs at least 4, height 3 needs at least 8. Every extra level at least doubles the population underneath it. So a tree holding n elements can be at most log₂n tall. With a million elements that is a height of about 20, not a million. The O(n) comb becomes structurally impossible the moment the size array gets a vote.
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:
returnif 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. That doubles 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.
Watch it defeat the exact sequence that beat us before. Those same merges — union(0,1), union(1,2), union(2,3), union(3,4) — built a height-4 chain under naive union. Now run them with size in charge. The first merge makes a tree of size 2 rooted at 0. Every later element arrives as a lone singleton of size 1, so it loses the tie and hangs directly under root 0. The array ends as [0, 0, 0, 0, 0]: a flat star, not a chain. A find that paid 4 hops before now pays exactly 1. The worst input from two sections ago just collapsed into the best case, and all it took was one look at the size array.
THE STDLIB TOOLBELT · the honest card — there is no import unionfindfifteen lines you will re-type for the rest of your life — find, union by size, and the cycle it catches
# there is no union-find in the stdlib. these fifteen lines ARE the tool.
parent, size = {}, {} # element -> its parent, root -> its headcountdef find(x):
root = x
while parent[root] != root: # pass 1: climb to the root
root = parent[root]
while parent[x] != root: # pass 2: bend the whole trail flat
parent[x], x = root, parent[x]
return root
def union(a, b):
ra, rb = find(a), find(b)
if ra == rb: return False # already together - this edge closes a CYCLEif size[ra] < size[rb]: ra, rb = rb, ra # make ra the BIGGER circle
parent[rb] = ra # the small table joins the big one
size[ra] += size[rb]
return True
connected = lambda a, b: find(a) == find(b) # the entire query
parent, size = {}, {}A dict, not the chapter's flat array — because real elements are names, pixels, and account ids, not the integers 0..n−1. You trade the array's cache-warm climb for the freedom to union anything hashable. Same algorithm, same α(n); a fatter constant.
parent[x], x = root, parent[x]The whole of pass two. The right-hand side is built before either name is rebound, so the old parent is safely in hand as the new one is written — the walk survives its own rewrite. Write it as two statements in the wrong order and the loop eats itself.
if ra == rb: return FalseNot an early exit — a discovery. The two were already connected, so this edge closes a cycle. Returning that boolean is exactly Kruskal's reject test (Vol 3), and it is how you count the merges that actually merged.
size[ra] < size[rb] → swapUnion by size. Its cousin, union by rank, is the same line over a rank dict that only ever increments on a tie: if rank[ra] == rank[rb]: rank[ra] += 1. Size is easier to reason about and hands you the component's headcount for free.
itertools.count() for labelsRoots are indices, not names. To hand each component a stable 0,1,2… label at the end: ids = defaultdict(count().__next__), then ids[find(x)] per element. Do it after the last union — see the third tripwire.
INPUTparent, size = {}, {}
def add(x):
parent.setdefault(x, x) # a newcomer is their own circle
size.setdefault(x, 1)
def find_plain(x): # NO compression - the naive climb
while parent[x] != x:
x = parent[x]
return x
def find(x): # WITH path compression
root = x
while parent[root] != root: # pass 1: climb to the root
root = parent[root]
while parent[x] != root: # pass 2: bend the trail to the root
parent[x], x = root, parent[x]
return root
def union(a, b, f=find_plain): # union BY SIZE
add(a); add(b)
ra, rb = f(a), f(b)
if ra == rb:
return False # already the same circle
if size[ra] < size[rb]:
ra, rb = rb, ra # ra is the BIGGER circle
parent[rb] = ra # the smaller table joins the bigger
size[ra] += size[rb]
return True
table_A = [("Nikhil", "Priya"), ("Aisha", "Dan"), ("Priya", "Aisha")]
table_B = [("Lena", "Marco"), ("Jo", "Ben"), ("Marco", "Jo")]
for a, b in table_A + table_B:
union(a, b)
print("two tables, nobody has crossed the room yet")
print(" parent:", parent)
print(" sizes :", {k: v for k, v in size.items() if parent[k] == k})
print(" Dan and Nikhil same circle?", find_plain("Dan") == find_plain("Nikhil"))
print(" Dan and Ben same circle?", find_plain("Dan") == find_plain("Ben"))
print(" circles left:", sum(1 for k in parent if parent[k] == k))
print("\nAisha turns round and introduces Marco. ONE union.")
merged = union("Aisha", "Marco")
print(" merged something new?", merged)
print(" parent:", parent)
print(" Dan and Ben same circle?", find_plain("Dan") == find_plain("Ben"))
print(" circles left:", sum(1 for k in parent if parent[k] == k))
def climb(x):
trail = [x]
while parent[x] != x:
x = parent[x]; trail.append(x)
n = len(trail) - 1
return " -> ".join(trail) + f" ({n} hop{'s' if n != 1 else ''})"
for who in ("Dan", "Ben", "Jo"):
print(f" {who:6} climbs:", climb(who))
print("\nask Ben ONCE, with compression - the climb pays itself forward")
print(" find('Ben') ->", find("Ben"))
print(" parent:", parent)
for who in ("Dan", "Ben", "Jo"):
print(f" {who:6} climbs:", climb(who))
print(" same answers? Ben~Dan:", find("Ben") == find("Dan"),
" Ben~Nikhil:", find("Ben") == find("Nikhil"))
OUTPUTtwo tables, nobody has crossed the room yet
parent: {'Nikhil': 'Nikhil', 'Priya': 'Nikhil', 'Aisha': 'Nikhil', 'Dan': 'Aisha', 'Lena': 'Lena', 'Marco': 'Lena', 'Jo': 'Lena', 'Ben': 'Jo'}
sizes : {'Nikhil': 4, 'Lena': 4}
Dan and Nikhil same circle? True
Dan and Ben same circle? False
circles left: 2
Aisha turns round and introduces Marco. ONE union.
merged something new? True
parent: {'Nikhil': 'Nikhil', 'Priya': 'Nikhil', 'Aisha': 'Nikhil', 'Dan': 'Aisha', 'Lena': 'Nikhil', 'Marco': 'Lena', 'Jo': 'Lena', 'Ben': 'Jo'}
Dan and Ben same circle? True
circles left: 1
Dan climbs: Dan -> Aisha -> Nikhil (2 hops)
Ben climbs: Ben -> Jo -> Lena -> Nikhil (3 hops)
Jo climbs: Jo -> Lena -> Nikhil (2 hops)
ask Ben ONCE, with compression - the climb pays itself forward
find('Ben') -> Nikhil
parent: {'Nikhil': 'Nikhil', 'Priya': 'Nikhil', 'Aisha': 'Nikhil', 'Dan': 'Aisha', 'Lena': 'Nikhil', 'Marco': 'Lena', 'Jo': 'Nikhil', 'Ben': 'Nikhil'}
Dan climbs: Dan -> Aisha -> Nikhil (2 hops)
Ben climbs: Ben -> Nikhil (1 hop)
Jo climbs: Jo -> Nikhil (1 hop)
same answers? Ben~Dan: True Ben~Nikhil: True
TRIPWIRES
Drop the compression and the chain eats you. Measured on a 10,000-element chain (9,999 hops from the deepest element to the root): a naive find of that element ran 353.5 µs, every single time you asked. The first compressing find cost more — 1,020 µs, because it pays the full climb and rewrites the trail — but every find after it ran in 0.195 µs with the hop count down from 9,999 to 1. That is about 1,813× per later query, bought once.
parent[a] = b is not a union. It only merges correctly while a happens to still be a root, which is why the bug ships: run parent['a']='b'; parent['c']='d'; parent['b']='d' and you get the right answer by luck. Change the last line to parent['a']='d' — a was no longer a root — and the array reads {'a': 'd', 'b': 'b', 'c': 'd', 'd': 'd'}: b has been silently evicted from its own group, connected('a','b') answers False, and the component count says 2 where the correct answer is 1. Always repoint a root.
A root is not a permanent name. Cached find('Priya') as 'Nikhil'; then four more guests joined Lena's circle, and the next union hung the smaller tree — Priya's — under the bigger. find('Priya') now answers 'Lena', and the cached label is stale. Union by size is why: the winner is decided by headcount, not by who arrived first. Compare find(a) == find(b) live, and only freeze labels once the last union is in.
The deeper cut — why the two tricks together beat either alone
Union by size alone gives O(log n) per operation, and path compression alone also gives roughly O(log n) amortized, but neither one is constant on its own. The magic is that the two compound. With both switched on, a sequence of m operations on n elements costs O(m · α(n)) total, where α is the inverse Ackermann function from the next section. That total is indistinguishable from linear. The intuition is short. Union-by-size keeps every tree short, so a climb is already cheap, and every climb that does happen pays it forward by flattening the path it walked. That leaves the tree structurally harder to make deep again. Tarjan's 1975 analysis proved this bound is not merely an average but genuinely tight.
Line the three regimes up on a concrete n = 1,000,000 and the whole arc snaps into focus. With no help at all, a find on the pathological chain climbs the full 1,000,000 hops. Add union by size alone and the tree can be at most log₂(1,000,000) tall. That is about 20 hops, since 2²⁰ ≈ 1.05 million. Switch on path compression too and the measured climb drops to roughly 1.11 hops. A million, then twenty, then basically one: that is the same operation, three times, as each trick does its share of the work.
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 and needs no second pass. It achieves the same α(n) bound, and it's 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.
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 real proof is in a stopwatch, not a formula. Here is a full Union-Find with both tricks, run on a million elements and 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. 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, so yours will differ in absolute terms. The ratio and the flatness are the structural facts.
Sit with that 1.11 for a second, because it is the whole point wearing a number. It means a real find, after all the compression and size-balancing, almost always reaches the root in one or two hops. The tree is essentially flat. Double the elements to two million and the figure stays near 1.11. Grow them ten-fold and it barely twitches. That flatness — not any single microsecond timing — is the promise of Union-Find: the work per question stops caring how big the problem got. A billionth query costs about what the first one did. Very few data structures can say that, and it is why this one shows up under so many famous algorithms.
WATCH AN EXPERT THINKhow many separate groups are there right now — asked again after every edge
Edges arrive in a stream and never leave. Cables get patched into a rack one at a time; two neighbouring pixels are judged the same colour; a confirmation lands and two people are now linked. After each arrival somebody wants a single number back: how many separate groups are there now? The connected-component count, live, over a feed with no end — the classic that sits underneath cluster monitors, region-growing in image tools, and any dashboard with the words “still reachable” on it. Three labels first, so this reads as exactly what it is. One: if the stream is finite and the number is only due once at the end, there is no problem here at all — one sweep of chapter 60's BFS answers it and you go home. What makes this a problem is that the report is due every time. Two: you have already been told the answer, twice. Section 03 hands you the arithmetic in a sentence — start the count at n, drop it by one on each union that actually merged — and the drill at the end of this chapter has you type it. So the destination will not surprise you, and it is not meant to. What being told an answer cannot give you is the rest of it: the situation in which somebody who knows that sentence perfectly well still writes the slow thing, what the slow thing costs on a clock, and the one input that kills the fast thing outright. Three: both versions below are exact. Neither is an estimate, and the run checks them against each other at every single step, not just the last one.
FIRST INSTINCT
You already own a component counter, and you wrote it last chapter. Keep an adjacency list, append the new edge to both endpoints, then sweep: for every vertex not yet seen, start a BFS, mark everything it reaches, add one to the count. Print the count. It is correct at every step and for every shape the stream can produce — disconnected, cyclic, one giant blob, a field of untouched vertices — and it needs nothing at all from this chapter. That correctness is worth more than it looks, because it earns the instinct a second job below as the referee every faster answer has to match. There is not much to sharpen, either. The sweep is O(V+E), and you cannot count what you have not looked at… if you insist on looking at all of it.
WHY IT STALLS
The cost has nothing to do with what changed. Measured on a small stream first, small enough that both versions can be run to completion side by side: 2,000 vertices, 4,000 edges, the count reported after every edge. Re-walking cost 1,765.5 ms; maintaining it cost 2.4 ms — 735× — and the two agreed on all 4,000 reports, not merely the final one. Now take it to a size worth having: 200,000 vertices, 400,000 edges. A single recount of that graph measured 264.0 ms (timed as 40 recounts totalling 10,558.5 ms, one on every ten-thousandth edge). The maintained version answered after every one of the 400,000 arrivals in 225.1 ms altogether — less than one recount. Doing what the instinct literally asks, one recount per edge, is 400,000 × 264 ms: about 1,760 minutes, twenty-nine hours, and that figure is a projection from the measured per-recount cost rather than a run anyone sat through. Then look at what the recount is for. Edge number 400,000 can change the answer by at most one, and to discover whether it did, the sweep re-derives all 3,830 components from nothing.
THE TELL
Stop asking what the number is and ask what the stream can do to it. Edges only ever arrive. An arriving edge either fuses two groups into one, or it lands inside a group that was already whole. There is no third case and there is no way back, so the count is monotone: it starts at V, it never rises, and every edge moves it by exactly 0 or 1. Which of the two? Union already worked that out and handed it back. The boolean the toolbelt card calls “not an early exit — a discovery” is this exact quantity: True when two different roots were stapled, so one component just died; False when the edge closed a cycle, so nothing died. Keep a counter, start it at V, decrement on each True. Nothing was invented and no algorithm appeared — the recount was never computing a new number, it was re-deriving one whose change the union had already told us and we threw away. Cost per edge drops from O(V+E) to O(α(n)), and reading the answer stops being a computation at all: it is a field read.
Two honest edges, both visible in the run. First, and this one decides whether you are allowed to use it: everything above rests on edges only ever arriving. Delete one and the counter is dead, because there is no un-union. Part 3 builds a six-node example, deletes the edge (0, 1), and asks which single write to parent undoes it — none does, and the printed array shows why. Node 2 arrived on the edge (1, 2), but union by size hung it straight off root 0, so [0, 0, 0, 3, 3, 5] no longer records that 2 belongs through 1. There is no one cell to change: the smallest repair is two writes — parent[1]=1 and parent[2]=1 — and the only way to know it is those two is to consult the edge list the structure threw away. That is the limitation in a sentence: the array stores who answers to whom, never which edge caused it, and path compression flattens away what little shape survives. The standard escape is to stop being online: if the whole schedule of arrivals and departures is known in advance, run time backwards, because a deletion read in reverse is an insertion. Part 3 does that too — the replay reports 4 components after the delete and 3 before it, and a from-scratch walk of both graphs agrees on both numbers. If the deletions arrive live and unannounced, this structure is the wrong one and you are in genuinely harder territory. Second, the instinct is not beaten in general, only in this workload. If the number is due once, the walk is one 264 ms sweep and the maintained counter buys you nothing worth the extra code. It is the repetition that decides — the same trade the chapter opened with about “connected?”, which is worth recognising as one shape rather than two separate facts.
import random, time
from array import array
from collections import defaultdict, deque
def recount(V, adj): # FIRST INSTINCT: re-walk the whole graph
seen, comps = bytearray(V), 0
for s in range(V):
if seen[s]: continue
comps += 1; seen[s] = 1
q = deque([s])
while q:
x = q.popleft()
for y in adj[x]:
if not seen[y]: seen[y] = 1; q.append(y)
return comps
class Live: # THE TELL: components = V - merges
def __init__(self, V):
self.p = array('i', range(V))
self.sz = array('i', [1]) * V
self.comps = V # everybody starts on their own island
self.merges = 0
def find(self, x):
p, r = self.p, x
while p[r] != r: r = p[r]
while p[x] != r: p[x], x = r, p[x] # compress on the way back
return r
def add_edge(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False # closes a cycle - nothing died
if self.sz[ra] < self.sz[rb]: ra, rb = rb, ra
self.p[rb] = ra; self.sz[ra] += self.sz[rb]
self.merges += 1; self.comps -= 1 # exactly one component just died
return True
# --- 1. the referee: both ways, after EVERY edge, on a stream small enough ---
random.seed(61)
V, E = 2_000, 4_000
stream = [(random.randrange(V), random.randrange(V)) for _ in range(E)]
adj, slow = defaultdict(list), []
t0 = time.perf_counter()
for a, b in stream:
adj[a].append(b); adj[b].append(a)
slow.append(recount(V, adj))
slow_ms = (time.perf_counter() - t0) * 1e3
live, fast = Live(V), []
t0 = time.perf_counter()
for a, b in stream:
live.add_edge(a, b)
fast.append(live.comps)
fast_ms = (time.perf_counter() - t0) * 1e3
print(f"referee V = {V:,}, {E:,} edges, the count reported after EVERY edge")
print(f" all {E:,} reports identical, not just the last one -> {slow == fast}")
print(f" reports 1-6: {fast[:6]} ... final: {fast[-1]}")
print(f" V - merges = {V:,} - {live.merges:,} = {V - live.merges:,}"
f" arithmetic matches the walk -> {V - live.merges == slow[-1]}")
print(f" re-walk {slow_ms:9.1f} ms maintained {fast_ms:6.1f} ms ({slow_ms/fast_ms:,.0f}x)")
# --- 2. the same stream, 100x bigger -----------------------------------------
random.seed(610)
V, E = 200_000, 400_000
stream = [(random.randrange(V), random.randrange(V)) for _ in range(E)]
live = Live(V)
t0 = time.perf_counter()
for a, b in stream:
live.add_edge(a, b)
_ = live.comps # read the answer, every single edge
maintained_ms = (time.perf_counter() - t0) * 1e3
adj, checks, c = defaultdict(list), 0, None
t0 = time.perf_counter()
for i, (a, b) in enumerate(stream, 1):
adj[a].append(b); adj[b].append(a)
if i % 10_000 == 0: # recount on 1 edge in 10,000 only
c = recount(V, adj); checks += 1
sampled_ms = (time.perf_counter() - t0) * 1e3
each = sampled_ms / checks
print(f"\nstream V = {V:,}, {E:,} edges")
print(f" maintained, answer readable after every edge : {maintained_ms:9.1f} ms")
print(f" re-walk on 1 edge in 10,000 ({checks} recounts) : {sampled_ms:9.1f} ms"
f" ({each:.1f} ms per recount)")
print(f" final count agrees: walk {c:,} vs maintained {live.comps:,} -> {c == live.comps}")
print(f" PROJECTION (not run): one recount per edge = {E:,} x {each:.1f} ms"
f" = {E * each / 6e4:,.0f} minutes")
# --- 3. the honest edge: an edge that LEAVES ---------------------------------
V, edges = 6, [(0, 1), (1, 2), (3, 4)]
uf = Live(V)
for a, b in edges: uf.add_edge(a, b)
print(f"\ndeletion {V} nodes, edges {edges} -> {uf.comps} components")
print(f" parent = {list(uf.p)}. now delete (0, 1): which write undoes it? none.")
def adj_of(V, es):
g = defaultdict(list)
for a, b in es: g[a].append(b); g[b].append(a)
return g
surviving = [(1, 2), (3, 4)] # the graph AFTER the deletion
back = Live(V)
for a, b in surviving: back.add_edge(a, b)
after = back.comps
back.add_edge(0, 1) # replay the deleted edge, backwards
before = back.comps
print(f" reverse-time replay: after-delete {after}, before-delete {before}")
print(f" the walk agrees: after-delete {recount(V, adj_of(V, surviving))},"
f" before-delete {recount(V, adj_of(V, edges))}")
referee V = 2,000, 4,000 edges, the count reported after EVERY edge
all 4,000 reports identical, not just the last one -> True
reports 1-6: [1999, 1998, 1997, 1996, 1995, 1994] ... final: 32
V - merges = 2,000 - 1,968 = 32 arithmetic matches the walk -> True
re-walk 1765.5 ms maintained 2.4 ms (735x)
stream V = 200,000, 400,000 edges
maintained, answer readable after every edge : 225.1 ms
re-walk on 1 edge in 10,000 (40 recounts) : 10558.5 ms (264.0 ms per recount)
final count agrees: walk 3,830 vs maintained 3,830 -> True
PROJECTION (not run): one recount per edge = 400,000 x 264.0 ms = 1,760 minutes
deletion 6 nodes, edges [(0, 1), (1, 2), (3, 4)] -> 3 components
parent = [0, 0, 0, 3, 3, 5]. now delete (0, 1): which write undoes it? none.
reverse-time replay: after-delete 4, before-delete 3
the walk agrees: after-delete 4, before-delete 3
trigger: reach for a maintained counter when the quantity you keep asking for can only move one way, and the update you are already doing tells you whether it moved — here union's own return value is the delta, so recomputing from scratch is paying full price for a number somebody already handed you. The moment updates can move it both ways — a deletion, a split — the counter is void and you are either replaying time backwards or reaching for a different structure entirely.
That same flatness powers a trick worth its own name: cycle detection. When you call union(a, b) and find(a) already equals find(b), the two were already connected, so the new edge closes a loop. Take a triangle: edges (0,1), (1,2), (2,0) on three nodes. The first two merge 0, 1, and 2 into one group. The third edge, (2,0), finds them already sharing a root, so it is flagged as a cycle and skipped. That is precisely the reject test inside Kruskal's algorithm from the opening, keeping its growing road network a tree. And it runs in the same near-constant time as every other query.
InteractiveDrag n toward every atom in the universe — watch α(n) refuse to grow
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
There's an honest baseline worth stating first. To keep checking whether two things are connected as relationships pour in, you could just store a graph and BFS on each question, and it's simple and correct.
Reality
But that costs O(V+E) on every question, and it can never reuse the work it did between merges. Union-Find instead answers both "connected?" and "merge!" in effectively O(1) each, amortized. It becomes the right tool the moment the workload is incremental grouping with connectivity queries. It is not a graph replacement so much as the specialist built for that one access pattern.
One honest limit, so you reach for it correctly. Union-Find merges groups and never splits them. There is no cheap un-union, because a group is just "everyone who climbs to this root," and once paths are compressed the old shape is gone for good. It also will not list a group's members or hand you the route between two elements. It threw those away on purpose, and that discarding is exactly why it's fast. So if your problem needs deletions, membership lists, or actual paths, you want the graph, not this. Knowing what a structure refuses to do is half of knowing when to use it.
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 here's 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.
NOW COUNT THE CIRCLESa class, ten guests, and the number that falls out for free
The drill. Wrap the two functions into a UnionFind class, then answer the classic: given a list of “these two know each other” pairs, how many separate circles are in the room? Your class needs find with path compression, union by size, connected, and a live groups counter.
The counter is the trick, and it costs nothing. Start it at the number of guests, because everyone begins as a circle of one. Then drop it by exactly one on every union that actually merges — a successful union turns one root into a child, erasing exactly one self-pointer. Have union return True when it merged and False when the two were already together, so the redundant pairs announce themselves. Never scan the dict to count roots; you already know the answer.
The check. Feed it two four-person circles closed into rings (so the last pair in each ring is redundant), one pair off to the side, and a duplicate. Ten guests, ten pairs, but only seven of those pairs should report merged — the other three closed a cycle, which is Kruskal's reject test firing. You should land on 3 circles. Then run the one introduction that crosses the room and watch it become 2, in a single write.
show the solution
class UnionFind:
"""Groups that merge and never split. Two dicts, ~15 lines, no import."""
def __init__(self, items=()):
self.parent = {x: x for x in items} # a newcomer is their own circle
self.size = {x: 1 for x in items}
self.groups = len(self.parent) # every element starts alone
def add(self, x):
if x not in self.parent:
self.parent[x] = x
self.size[x] = 1
self.groups += 1
def find(self, x):
self.add(x)
root = x
while self.parent[root] != root: # pass 1: climb to the root
root = self.parent[root]
while self.parent[x] != root: # pass 2: bend the trail flat
self.parent[x], x = root, self.parent[x]
return root
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 # the SMALLER circle joins the bigger
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
def circles(self):
out = {}
for x in self.parent:
out.setdefault(self.find(x), []).append(x)
return {r: sorted(v) for r, v in out.items()}
friendships = [
("Nikhil", "Priya"), ("Priya", "Aisha"), ("Aisha", "Dan"), ("Dan", "Nikhil"),
("Marco", "Lena"), ("Lena", "Jo"), ("Jo", "Ben"), ("Ben", "Marco"),
("Sofia", "Theo"), ("Theo", "Sofia"),
]
guests = sorted({p for pair in friendships for p in pair})
uf = UnionFind(guests)
print("guests:", len(guests), "- so", uf.groups, "circles before anyone speaks")
merged = 0
for a, b in friendships:
did = uf.union(a, b)
merged += did
print(f" union({a:6}, {b:6}) -> {'merged' if did else 'already together'};"
f" circles now {uf.groups}")
print("\ncircles:", uf.groups)
for root, members in sorted(uf.circles().items()):
print(f" {root:6}: {members}")
print("\nintroductions that changed anything:", merged, "of", len(friendships))
print("the rest closed a cycle - Kruskal's exact reject test")
print("Dan ~ Priya?", uf.connected("Dan", "Priya"),
" Dan ~ Ben?", uf.connected("Dan", "Ben"))
print("\nAisha turns round and introduces Marco:")
uf.union("Aisha", "Marco")
print(" circles now:", uf.groups)
print(" Dan ~ Ben?", uf.connected("Dan", "Ben"))
for root, members in sorted(uf.circles().items()):
print(f" {root:6}: {members}")
print(" parent, fully compressed:", uf.parent)
# ---------------- OUTPUT, verbatim (CPython 3.12.7) ----------------
# guests: 10 - so 10 circles before anyone speaks
# union(Nikhil, Priya ) -> merged; circles now 9
# union(Priya , Aisha ) -> merged; circles now 8
# union(Aisha , Dan ) -> merged; circles now 7
# union(Dan , Nikhil) -> already together; circles now 7
# union(Marco , Lena ) -> merged; circles now 6
# union(Lena , Jo ) -> merged; circles now 5
# union(Jo , Ben ) -> merged; circles now 4
# union(Ben , Marco ) -> already together; circles now 4
# union(Sofia , Theo ) -> merged; circles now 3
# union(Theo , Sofia ) -> already together; circles now 3
#
# circles: 3
# Marco : ['Ben', 'Jo', 'Lena', 'Marco']
# Nikhil: ['Aisha', 'Dan', 'Nikhil', 'Priya']
# Sofia : ['Sofia', 'Theo']
#
# introductions that changed anything: 7 of 10
# the rest closed a cycle - Kruskal's exact reject test
# Dan ~ Priya? True Dan ~ Ben? False
#
# Aisha turns round and introduces Marco:
# circles now: 2
# Dan ~ Ben? True
# Nikhil: ['Aisha', 'Ben', 'Dan', 'Jo', 'Lena', 'Marco', 'Nikhil', 'Priya']
# Sofia : ['Sofia', 'Theo']
# parent, fully compressed: {'Aisha': 'Nikhil', 'Ben': 'Nikhil', 'Dan': 'Nikhil', 'Jo': 'Nikhil', 'Lena': 'Nikhil', 'Marco': 'Nikhil', 'Nikhil': 'Nikhil', 'Priya': 'Nikhil', 'Sofia': 'Sofia', 'Theo': 'Sofia'}
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. →
SAY IT BACKthe chapter in five breaths
Union-Find answers are these two connected? by keeping the label and throwing the route away: every group elects a representative, “same group?” becomes “same root?”, and a question that cost a full O(V+E) traversal collapses into comparing two integers — and because it never stores an edge, its memory is a function of V alone and does not move as the stream grows.
The whole forest is one flat array of indices — parent[i] holds i's parent, a root is any i where parent[i] == i, and the root's own index is the group's label — so there are no heap nodes and no real pointers to chase, just 4.09 B per element of contiguous integers the CPU can stream through cache.
Two operations run everything: findclimbs until the self-pointer, and union reduces both arguments to their roots and, when they differ, writes one root's parent to the other — a single array write re-labels an entire subtree at once, which is also why the write must aim at a root: re-point a non-root and you silently evict it from its own group while everything still looks fine.
The cost is the tree's height, not its size, and merging in the most obvious order is enough to build the O(n) comb — so path compression bends every node on the climb straight at the root (free, because you walked that path anyway) and union by size hangs the smaller tree under the bigger root, capping height at log n; together they drive the amortized cost to O(α(n)), measured at 1.11 hops per find on a million elements.
The boolean union hands back is worth as much as the merge itself: False means the edge closed a cycle, which is exactly Kruskal's reject test, and counting the Trues gives the live component count as V minus successful unions — two famous problems falling out of a return value most people discard.
reach for this when… your workload is things merging, plus “are these two in the same group?” — and nothing else. Every word of that is load-bearing, and each near neighbour wants something this structure refuses to give. If you need the route between two elements, the members of a group, a vertex's neighbours or its degree, keep chapter 60's graph — Union-Find discarded precisely that, and the discarding is why it is fast. If groups ever have to split, it cannot help you: there is no un-union, because the parent array records who answers to whom and never which edge caused it — unless you know the whole schedule of arrivals and departures in advance, in which case run time backwards and every deletion becomes an insertion. If the count is due once, on a graph that has stopped changing, one BFS sweep is O(V+E) and is the simpler right answer — 264 ms on 200,000 vertices, measured; it is repetition that buys this structure, not size. If your elements are names, pixel coordinates or account ids rather than 0..n−1, swap the two arrays for two dicts: same α(n), fatter constant, and you give up the cache-warm climb. And the Python-shaped caveat that decides what you actually type: there is no import unionfind — the standard library ships nothing here, the fifteen lines on the toolbelt card are the tool, and the one line you must not get clever with is find, written as a loop; the recursive one-liner dies at depth 1,000 on exactly the deep chain a compressing find would have flattened.
You already owned the pieces: chapter 60 gave you the graph — every relationship stored as an edge, walked by BFS — and this chapter kept the one question that graph answered most often and threw away everything else needed to answer it. Chapter 58 gave you the move that makes it cheap: a heap is a tree folded into a flat array, and the parent array is the same trick again, a whole forest living in one contiguous run of integers with indices standing in for pointers. Chapter 49 supplied the block itself — contiguous memory, O(1) indexing, the cache line that arrives 16 neighbours at a time — which is why a climb here costs so much less than a climb through scattered nodes. Chapter 50 returns as the villain rather than the tool: the comb that naive union builds is a linked list wearing a forest's clothes, and recognising that shape by sight is what makes the danger legible before the profiler finds it. Chapter 54's hash map is what you reach for when the elements have names instead of indices, since parent and size become plain dicts and not a line of the algorithm changes. And Volume 1 is underneath all of it, twice: it explained why a list of a million elements costs 8.00 B each as references while array('i') costs 4.09 B as raw integers, and it taught the object header that this structure pays exactly none of.
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.