Home
Day 9 · Week 2

Union by Rank + Path Compression

Level 3 · Union-Find  ·  2 hrs: ~45 min theory · ~60 min code · ~15 min notes

🧠 Mental Model

Two cheap tricks turn yesterday's O(n) structure into effectively O(1). Union by rank keeps trees shallow by always attaching the shorter tree under the taller one. Path compression flattens the tree during find by pointing every node visited straight at the root. Together they give the famous O(α(n)) bound.

The beautiful part: neither trick complicates the code much, yet together they're provably near-optimal. This is the version you should always write in interviews.

⚙️ The Two Optimizations

Union by rank (or size)

Rank is an upper bound on a tree's height. On union, attach the lower-rank root under the higher-rank root; ranks equal → pick either and increment its rank by one. This alone bounds height at O(log n).

Rank invariant: a root of rank r has a subtree of at least 2^r nodes. Hence rank ≤ log₂ n, so trees stay shallow.

Path compression

During find(x), after locating the root, re-point every node on the path directly to the root. Future finds on those nodes are O(1). The one-liner recursive form parent[x] = find(parent[x]) does this elegantly.

Union by size (track subtree counts instead of rank) is equivalent in bound and often more useful — the size doubles as "how big is this component," which many problems ask for directly.

📐 The α(n) Bound

Theorem (Tarjan, 1975) A sequence of m find/union operations on n elements, using union by rank and path compression, runs in O(m · α(n)) total time, where α is the inverse Ackermann function.
Why it's essentially constant α(n) is the inverse of the explosively-growing Ackermann function. It grows so slowly that α(n) ≤ 4 for every n that could physically be stored (well beyond the number of atoms in the universe). So amortized cost per operation is a small constant in practice. The full proof uses a potential/accounting argument assigning "credits" to nodes based on rank levels; each find charges most of its cost to nodes whose rank jumps a level, bounding total charges by O(m·α(n)). See CLRS §21.4 for the complete analysis.
OptimizationsAmortized per op
Neither (Day 8)O(n)
Union by rank onlyO(log n)
Path compression onlyO(log n) amortized
BothO(α(n)) ≈ O(1)

💻 Reference Implementation (Java)

Union by rank + path compression. Also tracking component count and sizes.

public class UnionFind {
    private final int[] parent, rank, size;
    private int count;               // number of disjoint sets

    public UnionFind(int n) {
        parent = new int[n];
        rank   = new int[n];
        size   = new int[n];
        count  = n;
        for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }
    }

    /** find with path compression (recursive one-liner). */
    public int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // flatten path
        return parent[x];
    }

    /** union by rank; returns false if already merged. */
    public boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; } // ra = taller
        parent[rb] = ra;                 // attach shorter under taller
        size[ra] += size[rb];
        if (rank[ra] == rank[rb]) rank[ra]++;  // tie → height grows by 1
        count--;
        return true;
    }

    public boolean connected(int a, int b) { return find(a) == find(b); }
    public int componentCount() { return count; }
    public int componentSize(int x) { return size[find(x)]; }
}

⚠️ Common Pitfalls

Compressing but not balancing (or vice versa): you only get the α(n) bound with both. Path compression alone still risks O(log n).
Incrementing rank on every union: rank only increases on a tie. Bumping it always inflates heights and defeats the purpose.
Updating size on the wrong root: after the swap, ra is the surviving root — add the child's size to it, not the other way.
Deep recursion in find: pre-compression the path can be long; with compression it's fine, but if worried, use the iterative two-pass form.

🎭 Problem Variances — when the optimizations matter

Once inputs get large or ask for component sizes / counts on the fly, the optimized DSU (with size tracking) is what makes these tractable:

Disguise / phrasingWhat's really askedTwist to handle
"Redundant connection / extra edge"First edge joining an existing setunion returns false → that's the answer
"Min operations to connect network"components − 1 vs spare edgesNeed componentCount(); count redundant edges
"Largest component / group size"Max subtree sizeTrack size[]; query componentSize(root)
"Number of islands as land is added"Incremental component countDecrement count on each successful union (Day 10)
"n up to 10⁵–10⁶, many unions/finds"PerformanceMUST have rank + path compression → O(α(n))
"% of population in largest friend group"Size-weighted queryUnion by size doubles as the size counter
Interview signal: if asked "how big is this group" or given tight limits, reach for the size-tracking optimized DSU — and be ready to state the α(n) bound and why both optimizations are needed.

🎯 Problems

LC 684 Redundant Connection The first edge whose two endpoints already share a root is the redundant one. Med
LC 1319 Number of Operations to Make Network Connected Count redundant edges vs (components − 1) needed to connect. Uses componentCount(). Med

📚 References