3.5.15 · D2Graphs

Visual walkthrough — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

2,515 words11 min readBack to topic

We only assume you can read a picture of dots-and-arrows. Everything else — the word "root", the array parent[], the number "rank", even "" — is built here.


Step 1 — A set is a picture of arrows pointing home

WHAT. Draw every element as a dot. Draw one arrow out of each dot: "who is my boss?" Follow arrows and you always arrive at one special dot whose arrow loops back to itself. That self-pointing dot is the root (also called the representative — the one name that stands for the whole group).

WHY this picture and not a list? Because merging two groups must be cheap. If a group were a plain list of names, merging two lists of size costs copying (recall is the total dot count). With arrows, merging is a single arrow flip — point one root at the other. That is the entire reason we choose a tree-of-arrows over a list.

PICTURE. Look at figure s01. Three separate groups. Inside each, follow any dot's arrow upward and you reach its root. The one array we store is parent[]:

Read it term by term: parent[x] is the dot at the tip of x's arrow; when that tip is x again, the arrow is a self-loop, so x is a root. That single equality is our entire test for "am I the leader?"

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 2 — find(x): walk the arrows until one loops home

WHAT. To answer "who is x's leader?", start at x and keep hopping along arrows until you hit a dot whose arrow loops to itself.

def find(x):
    while parent[x] != x:   # not a root yet: arrow points elsewhere
        x = parent[x]       # hop one arrow up
    return x                # loop-to-self reached: this is the root

WHY. parent[x] != x is literally "my arrow does not loop back to me" — so I am not yet the leader, keep climbing. The moment the loop-to-self is true, we stop. Nothing clever yet; this is the honest, slow version.

PICTURE. Figure s02 traces find on a tall chain. The red path is the sequence of hops. Count the hops: in a chain of height , find costs arrow-follows. If an adversary built a straight chain of all dots, one find costs hops. This is the disaster we must prevent — and the next steps do exactly that.

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 3 — Union by rank: the short tree bows to the tall one

WHAT. To merge two groups we flip one root's arrow onto the other root. But which onto which? Give every root a number rank[x] — an upper bound on the tree's height (how many arrow-hops from the deepest dot to the root). All dots start at rank = 0. Rule: hang the smaller-rank root under the larger-rank root.

def union(x, y):
    rx, ry = find(x), find(y)
    if rx == ry: return             # already one group — stop
    if rank[rx] < rank[ry]:
        rx, ry = ry, rx             # make rx the taller (or equal) root
    parent[ry] = rx                 # flip smaller root's arrow onto bigger
    if rank[rx] == rank[ry]:
        rank[rx] += 1               # a tie: new height is one taller

WHY. Look at what "hang short under tall" does to height. If the tall tree has height and the short one height , gluing the short root just under the tall root makes total height

Term by term: is the taller tree's height; is the shorter tree's dots pushed one level deeper by the new arrow; the picks the winner — and the tall one still wins. The tree got no taller. Only when the two heights are equal () does exceed , so height rises by exactly — and that is the one case where we do rank[rx] += 1.

PICTURE. Figure s03 shows both cases side by side: the unequal merge (green — height unchanged, no rank bump) and the tie merge (yellow — height grows by one, rank bumps).

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 4 — Why rank stays tiny: each rank hides dots

WHAT. We now prove the payoff of Step 3: a root of rank owns at least dots.

WHY this matters. If rank- needs dots, then rank can never exceed (you simply run out of dots — remember is all the dots there are). Since height rank, every find costs at most hops — the tall-chain disaster of Step 2 is impossible.

The argument, drawn. A rank climbs from to only in the tie case (Step 3). Before that tie, each of the two merging roots was rank , so — by the same claim one level down — each already owned dots. Glue them:

Base case: a lone dot has rank and dot. ✔ The induction rolls upward forever.

Read the chain: a rank- tree needs dots; those dots all fit inside the total, so ; take of both sides and can be at most .

PICTURE. Figure s04 stacks the doubling: rank 0 = 1 dot, rank 1 = 2, rank 2 = 4, rank 3 = 8 — a perfect binary pyramid. To reach rank 30 you'd need over a billion dots.

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 5 — Path compression: flatten the trail while you read it

WHAT. Union by rank stops trees getting tall. Path compression makes them get short again for free, every time you look. During a find, after reaching the root, repoint every dot you passed straight at the root.

def find(x):
    if parent[x] == x:      # already a root: nothing above me
        return x
    root = find(parent[x])  # recurse up: root is the leader
    parent[x] = root        # adopt the leader directly — flatten
    return root

Or the loop version with path halving (each node adopts its grandparent):

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]  # skip one level: point at grandparent
        x = parent[x]
    return x

WHY. The first find on a deep dot has to pay for the climb — but why waste that work? On the way back we already hold the root's name, so writing it into every visited dot costs nothing extra and makes every future find on those dots a single hop. The line parent[x] = parent[parent[x]] reads: "let x's arrow skip its parent and point to its grandparent," halving the remaining distance on the spot.

PICTURE. Figure s05: a 4-deep chain before find(bottom) on the left, and after on the right — every dot on the red path now points directly at the root, a flat starburst.

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 6 — The two together: rank stops climbing, so cost collapses to α(n)

WHAT. Let = ==the total number of find/union calls you make== over the whole run (the length of your sequence of operations). Union by rank caps rank at (Step 4). Path compression keeps deleting the height that made ranks meaningful (Step 5). Spreading the rare expensive climbs across the many cheap ones — an amortized argument — gives, over those operations:

Here is the inverse of the monstrously fast Ackermann function: it grows so slowly that for any you could ever store.

WHY α and not merely — the potential-function picture. Here is the intuition without the full proof. Give every non-root dot a "level" based on how its rank compares to its parent's rank. Ranks along any path are strictly increasing (a child always has smaller rank than its parent), so as you climb, ranks jump upward fast. Group the possible ranks into a handful of bands where each band is exponentially wider than the last — band 0 is , then , then , then , and so on, each boundary an Ackermann-style leap.

The key bookkeeping trick: every time path compression re-points a dot, that dot's parent jumps to a strictly higher rank. A dot can only be "re-pointed cheaply" so many times within its own band before its parent's rank crosses into the next band — and the number of bands needed to cover all ranks up to is exactly . Figure s06 draws these widening bands: because bands grow at Ackermann speed, it takes only of them to swallow every rank, so each dot pays only ~ across its whole lifetime. That is why the rate is inverse-Ackermann and not the plain you'd get from rank alone.

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

Step 7 — The degenerate cases (never skipped)

WHAT & WHY — walk each corner so no scenario surprises you:

  • union(x, x) / union of two already-joined dots. find(x) == find(y), so the early return fires. Why it must be there: without it you'd flip a root's arrow onto itself or wrongly bump a rank — self-corruption. (See the parent note's mistake box.)
  • A lone dot. parent[x] = x, rank[x] = 0. find(x) returns instantly (loop-to-self on hop zero). The base case of Step 4.
  • All-equal-rank merges (worst case for height). Every union is a tie, so rank climbs as fast as it ever can — yet Step 4 proves that even this caps at , and Step 5 flattens it back down on the first read.
  • A giant flat star. After heavy compression a rank- root may point directly to thousands of dots while its rank value is still, say, . That stale-but-safe gap is not a bug — it is the α(n) speed.

PICTURE. Figure s07 shows the self-union no-op (arrow stays put) and a lone dot beside a flattened star, so you see both extremes at once.

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)

The one-picture summary

Figure s08 is the whole derivation on one canvas: loose dots → arrows to roots (Step 1) → find climbs (Step 2) → union-by-rank keeps it short, pyramid caps rank at (Steps 3–4) → compression flattens on read (Step 5) → both together give the flat α(n) line (Step 6).

Figure — Disjoint Set Union (Union-Find) — path compression + union by rank → α(n)
Recall Feynman: the whole walkthrough in plain words

Every dot points at its boss; follow the arrows and you reach the one dot that is its own boss — the leader. Finding your leader means climbing arrows. Two tricks keep that climb short. When two groups merge, the shorter tree's leader bows under the taller one, so the combined tree never grows unless they were exactly the same height — and even then only by one. Because a tree of "height-number " must hide at least dots, the height-number can never pass of the total number of dots — you'd run out of dots. And every time you climb to find a leader, you make everyone you passed point straight at the leader, so next time it's one hop. Do both and asking "who's my leader?" is essentially free — never more than about four questions, even for billions of dots.


Recall

Which single equality means "x is a root"?
— the arrow loops back to itself.
In a tie merge, why must rank increase by exactly 1?
Both trees had equal height ; gluing one under the other gives height , and rank (an upper bound on height) must reflect that.
Why does "short bows to tall" leave height unchanged when ranks differ?
New height since .
How many dots must a rank- tree contain, and why?
; a rank rises only when two rank- trees merge, doubling .
What do and stand for in the complexity claims?
= total number of dots (elements); = total number of find/union calls made.
Why never lower rank after path compression?
Rank is only an upper bound used to pick merges; a stale over-estimate is safe and is what the amortized proof assumes.
What does the early return in union protect against?
Flipping a root's arrow onto itself or wrongly bumping rank when the two dots share a root.