DSA DSA In Depth
0%
lesson dsuunion-findinverse-ackermann

Disjoint Set Union (Union-Find): Union by Rank + Path Compression & O(α(N)) Proof Intuition

7 min read Module 5.2: Disjoint Sets, Minimum Spanning Trees & Shortest Paths Updated Oct 23, 2026
📄 Cheat Sheet
Union Find (Disjoint Set) Data Structure — Path Compression & Union by Rank
William Fiset Union Find (Disjoint Set) Data Structure — Path Compression & Union by Rank Watch on YouTube ↗

Visualizes parent pointer trees, union by rank/size balancing, and how path compression flattens trees to near-constant depth.

0:00 / --:--

Tree Height Invariants & The Inverse Ackermann Bound

A Disjoint Set Union (DSU) forest represents each set as a rooted tree where every node $x$ stores a parent pointer $\text{parent}[x]$, and the root $r$ of a set satisfies $\text{parent}[r] = r$ (or $\text{parent}[r] < 0$ in the negative-size encoding).

Formal Theorem — Rank Subtree Bound & Tarjan-Seidel Amortized Bound

Lemma (Rank Exponential Size Invariant): Under Union by Rank (without or with path compression), a node $x$ of rank $k$ is the root of a subtree containing at least $2^k$ nodes. Consequently, the maximum rank (and tree height) in an $N$-element DSU forest is bounded by:

$$\text{rank}(x) \le \lfloor \log_2 N \rfloor$$

Theorem (Tarjan 1975, Fredman & Saks 1989): With both Union by Rank/Size and Path Compression ($ ext{parent}[x] leftarrow ext{find}( ext{parent}[x])$), any intermixed sequence of $N$ $\text{MAKE-SET}$ and $M$ $\text{UNION}/\text{FIND}$ operations takes $\Theta(N + M \alpha(M, N))$ time, and this bound is tight in the cell-probe model.

Why $\alpha(N)$ Arises: Rank Buckets & Ackermann Tower Collapse

Why isn't the amortized cost $O(1)$ or $O(\log^* N)$? Consider what happens when find(x) traverses a path $x = v_0 \to v_1 \to \dots \to r$. Path compression rewires $v_0, v_1, \dots$ to point directly to the root $r$. Notice that along any upward path, node ranks strictly increase: $\text{rank}(v_0) < \text{rank}(v_1) < \dots < \text{rank}(r)$.

Every time find traverses an edge $(u, p)$ that is not the last edge into the root, $\text{rank}(\text{parent}[u])$ strictly increases to $\text{rank}(r)$. Using a potential function grouped by levels of the Ackermann hierarchy $A_k(j)$ (where $A_0(j) = j+1$, $A_1(j) = 2j+1$, $A_2(j) = 2^{j+1}$, $A_3(j) = \text{tower of } 2\text{s}$), charging a traversal step to the level $k$ where $\text{rank}(\text{parent}[u]) \ge A_k(\text{rank}(u))$ proves that each node can pay for at most $O(\alpha(N))$ non-terminal pointer hops across its entire lifetime!

Before find(4): Path of Length 3 After find(4): Path Compressed to Root 0 0 1 2 3 4 0 1 2 3 4
flowchart LR Find["find(x): trace ancestors to root r"] --> Compress["Rewire every visited node u: parent[u] = r"] Compress --> Union["union(a, b): ra = find(a), rb = find(b)"] Union --> Same{"ra == rb?"} Same -- "Yes" --> Noop["Already in same component (return false)"] Same -- "No" --> Attach["Attach smaller rank/size root under larger root"]
DSU Path Compression vs Undo-Stack Rollback & Dynamic Connectivity Tree
O(α(N)) Amortized vs O(log N) Rollback

Contrast standard Disjoint Set Union (Union-by-Rank + Path Compression flattening trees in O(α(N))) against Rollback DSU (Union-by-Rank ONLY + explicit undo stack [(child, oldParent, oldRank)]) walking an offline edge-lifespan segment tree.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Before find(4): Skewed chain 4 → 3 → 2 → 1 → 0 (depth 4). Calling find(4) walks up to root 0.
p[4]=3 p[3]=2 p[2]=1 p[1]=0 v0 ROOT v1 depth=1 v2 depth=2 v3 depth=3 v4 depth=4
[0]0
[1]0
[2]1
[3]2
[4]3
parent[] Array
[0, 0, 1, 2, 3]
Tree Depth
4 hops
Amortized Cost
O(α(N)) ≤ 4
[Step 1] Before find(4): Skewed chain 4 → 3 → 2 → 1 → 0 (depth 4). Calling find(4) walks up to root 0.
Hardware Reality — Single-Array Negative-Size Encoding & Path Halving

Instead of allocating two vectors parent[N] and size[N] ($8N$ bytes across two cache streams), production C++ DSU implementations use a single signed array p[N] ($4N$ bytes) initialized to -1: if p[x] < 0, $x$ is a root of size -p[x]; if p[x] >= 0, p[x] is $x$'s parent index. Furthermore, iterative Path Halving (while (p[x] >= 0 && p[p[x]] >= 0) { p[x] = p[p[x]]; x = p[x]; }) achieves the exact same $O(\alpha(N))$ amortized bound in a single top-free upward pass without function-call recursion!

Complexity Matrix

Heuristic Combination Worst-Case Single Op Amortized per Op ($M$ ops) Space Overhead
Naive Tree (No Heuristics) O(N) O(N) 4N bytes
Union by Size/Rank Only O(log N) O(log N) 4N bytes
Path Compression Only O(log N) O(log N) 4N bytes
Union by Size + Path Compression / Halving O(log N) O(α(N)) ≈ O(1) 4N bytes

Production Implementation: Single-Array Signed DSU with Path Compression

#include <vector>
#include <utility>

class DisjointSetUnion {
public:
    explicit DisjointSetUnion(int n) : parentOrNegSize(n, -1), numComponents(n) {}

    int find(int x) noexcept {
        int root = x;
        while (parentOrNegSize[root] >= 0) {
            root = parentOrNegSize[root];
        }
        while (x != root) {
            int next = parentOrNegSize[x];
            parentOrNegSize[x] = root;
            x = next;
        }
        return root;
    }

    bool unite(int a, int b) noexcept {
        a = find(a);
        b = find(b);
        if (a == b) return false;
        // Remember: more negative means LARGER component size!
        if (parentOrNegSize[a] > parentOrNegSize[b]) {
            std::swap(a, b);
        }
        parentOrNegSize[a] += parentOrNegSize[b];
        parentOrNegSize[b] = a;
        --numComponents;
        return true;
    }

    bool connected(int a, int b) noexcept { return find(a) == find(b); }
    int componentSize(int x) noexcept { return -parentOrNegSize[find(x)]; }
    int components() const noexcept { return numComponents; }

private:
    std::vector<int> parentOrNegSize;
    int numComponents;
};
Failure Mode — Updating Rank by Subtree Height During Path Compression

In Union by Rank, rank[r] is an upper bound on height, not the exact height after path compression (since recomputing exact subtree height after compressing paths would cost $O(N)$). Moreover, only increments rank[ra]++ when merging two trees of equal rank; if using Union by Size, always add size[ra] += size[rb] on every merge.

Check yourself Question 1 of 4
Concept Check Maximum Tree Height under Union by Rank recall
Section: Rank & Size Invariants

In a Disjoint Set Union (DSU) forest of $N$ elements using Union by Rank (even without Path Compression), what is the tight upper bound on the rank (height) of any root node?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

When Path Compression flattens a subtree during `find(x)`, the actual height of ancestor nodes decreases, yet the algorithm never decrements `rank[u]`. Why is `rank[u]` kept frozen as an upper bound rather than recomputed?

Key Takeaways

Combining Union by Rank/Size with Path Compression (or Path Halving) achieves O(alpha(N)) amortized time per operation, where alpha(N) <= 4 for all N < 2^65536.
Without path compression, Union by Size guarantees that every tree has depth at most floor(log_2 N) because every step up a tree at least doubles the subtree size.
Encoding both parent indices (>= 0) and negated component sizes (< 0) in a single int32 array cuts memory usage in half (4N bytes) and eliminates dual-array cache misses.
Using Path Compression alone without Union by Rank/Size still allows O(log N) amortized sequences; both heuristics are required for O(alpha(N)).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue