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).
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!
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.
Invariant & State
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;
};
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.