Union-find maintains a partition under “merge two sets” and “which set is in”. With union by size and path compression, each operation is amortized, which is optimal for pointer machines (Tarjan). Variants carry relative values, support undo, or keep history.
Standard
struct DSU {
vector<int> p; // p[x] < 0: root with size -p[x]
DSU(int n) : p(n, -1) {}
int find(int x) { return p[x] < 0 ? x : p[x] = find(p[x]); }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false;
if (p[a] > p[b]) swap(a, b);
p[a] += p[b]; p[b] = a;
return true;
}
int size(int x) { return -p[find(x)]; }
};Union by size alone gives depth. Path compression alone gives amortized. Together: . For a path of unions, the recursive find can be deep before compression happens; use an iterative find if the stack is tight.
Proof: union by size alone bounds depth by
unitealways attaches the smaller-size root under the larger-size root, so every time a node’s depth increases by one (it moves to being a child of a new root during a union), the size of the tree it is now part of is at least double what its old tree’s size was — it was in the tree being attached (the smaller one, size ) and the resulting tree has size . Since size is bounded by , a node’s depth can increase at most times over the whole run, so every tree has depth and everyfind(even with no compression) costs .
Proof (cited): path compression alone is amortized
Without union-by-size, a worst-case adversarial union order can build a tree of depth before any compression happens (e.g. unite one at a time, always making the new element a child of the existing chain’s root — no rank information to prevent this). Path compression on a single
finddown a depth- chain is to flatten but makes every subsequentfindon those nodes , and the standard amortized argument (charging the cost of eachfindagainst how much shallower it leaves the tree, formalised with a potential function over node depths) gives amortized per operation — a real but somewhat involved argument not reproduced in full here; the key fact worth remembering is that path compression alone, without union by size, is already enough to avoid the worst case per operation.
Combined, union by size and path compression give the famous amortized bound (Tarjan, 1975) — optimal for any pointer-based structure — via a weight-function argument assigning each node to one of “levels” based on how fast an Ackermann-like function grows relative to subtree size; not reproduced here, since for any that fits in memory and the practical takeaway (“effectively constant”) is what matters.
Uses
| Problem | How |
|---|---|
| connected components, offline | unite all edges |
| Kruskal | add an edge iff unite succeeds |
| cycle in an undirected graph | the first edge that fails to unite |
| connectivity with deletions, offline | reverse time |
| ”paint but skip painted cells”, “next free slot” | DSU as a pointer to the next unpainted index: each cell is touched once |
| merging intervals, grid percolation | cells as elements, plus virtual nodes for borders |
| equivalence with contradictions | parity / weighted DSU |
| offline LCA | Tarjan’s algorithm (LCA) |
| Kruskal reconstruction tree | new node per union (KRT) |
| number of components | maintain a counter, decremented on every successful unite |
| component sizes | size(find(v)) |
| image / grid connected components | an alternative to flood fill: unite every pair of adjacent same-colour cells |
DSU on a grid: map to and unite as usual; add one virtual node for “outside the grid” when the problem asks about connections to the border (common in percolation and island problems).
Next free slot:
// nxt[i] = smallest unused index >= i (n is a sentinel)
int get(int i) { return nxt[i] == i ? i : nxt[i] = get(nxt[i]); }
for (int i = get(l); i <= r; i = get(i + 1)) { paint(i); nxt[i] = i + 1; }Weighted (potential) DSU
Store each node’s value relative to its parent and accumulate along compression. It handles constraints over any group (sums, XOR, modular arithmetic, even permutations composed).
struct WeightedDSU { // maintains pot[x] = value(x) - value(root)
vector<int> p; vector<ll> pot;
WeightedDSU(int n) : p(n, -1), pot(n, 0) {}
int find(int x) {
if (p[x] < 0) return x;
int r = find(p[x]); pot[x] += pot[p[x]]; return p[x] = r;
}
// add constraint value(b) - value(a) = d; returns false on contradiction
bool unite(int a, int b, ll d) {
int ra = find(a), rb = find(b);
d += pot[a] - pot[b]; // now value(rb) - value(ra) = d
if (ra == rb) return d == 0;
if (p[ra] > p[rb]) swap(ra, rb), d = -d;
p[ra] += p[rb]; p[rb] = ra; pot[rb] = d;
return true;
}
ll diff(int a, int b) { return pot[b] - pot[a]; } // valid after find(a), find(b) in the same set
};Proof: weighted DSU maintains
pot[x] = value(x) - value(root)correctlyBy induction on the sequence of operations.
find(x)recurses to the root , and by the inductive hypothesispot[p[x]]already equals oncep[x]has been fully compressed to point near ; adding it to the oldpot[x]=value(x)-value(p[x])gives exactly, which is then cached as the newpot[x]whenxis repointed directly at — so compression never changes whatpot[x]logically represents, only how many hops it takes to recompute it. Inunite(a,b,d), after finding roots ,dis adjusted bypot[a]-pot[b]to convert the constraint “value(b)-value(a)=d” into “value()-value() = d” (substituting and likewise for ); if this is a redundant constraint, correctly checked against the already-implied value ( required for consistency); otherwise attaching under (or vice versa, negating ) with makes the new root relationship exactly , consistent with the derived constraint. Since everypotvalue is always expressed relative to the current root and updated consistently on every compression and union,diff(a,b) = pot[b]-pot[a]after callingfindon both equals .
Parity DSU is the special case over with XOR. Uses: online bipartiteness (an edge inside a component with equal parities closes an odd cycle), “friend/enemy” relations, checking systems , and food-chain style relations.
Distance to the root is the same accumulation trick specialised to unweighted edges: store dist[v] = number of edges to the parent, and path compression sums it exactly as pot does above. Answers “how far is from its representative” — the classic “cows in a stack” problem, and merging chains in general.
Rollback DSU
For undo (offline dynamic connectivity, segment tree over time, divide and conquer): union by size, no path compression, and a history stack.
struct RollbackDSU {
vector<int> p; vector<pair<int, int>> st; // (child root, old parent size) or (-1, -1)
RollbackDSU(int n) : p(n, -1) {}
int find(int x) { while (p[x] >= 0) x = p[x]; return x; }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) { st.push_back({-1, -1}); return false; }
if (p[a] > p[b]) swap(a, b);
st.push_back({b, p[b]}); p[a] += p[b]; p[b] = a;
return true;
}
void undo() {
auto [b, sb] = st.back(); st.pop_back();
if (b < 0) return;
int a = p[b]; p[a] -= sb; p[b] = sb;
}
int snapshot() { return sz(st); }
void rollback(int t) { while (sz(st) > t) undo(); }
};Every find is . Recording a dummy entry for failed unions keeps “one undo per apply”, which the queue-undo trick requires (Offline Techniques). Parity and weights combine with rollback the same way.
Persistent DSU
Fat nodes or a persistent array (persistent segment tree over p) with union by size and no compression: per operation, every version queryable. Used for “what were the components after the first edges” queries, and inside some parallel-binary-search solutions. Offline alternatives (sorting queries by version, a version tree DFS with rollback) are usually simpler and faster in practice: build the tree of versions, DFS it applying each version’s union on entry and undoing on exit.
Small-to-large merging of sets
Give each component a set, map or vector and always move the smaller into the larger. Each element moves times, so the total is moves ( with set). This lets components carry anything: distinct colours, sorted positions, heaps.
Proof: small-to-large moves each element times
Every time an element is moved, it is moved as part of the smaller of the two merging sets, so the set it ends up in has size at least double the size of the set it was just moved out of (size the smaller set’s size, by definition of “smaller”). An element starts in a set of size and the set containing it can have size at most , so it can be on the “moved” side at most times before its component’s size would have to exceed . Summing over all elements gives total moves; with a
vector/array each move is (append), giving total, while asetmove costs per element, giving .
What DSU can’t do
- Split a set. Use reverse time, offline deletion, or link-cut trees.
- Path information between two elements in the same set. Use a spanning forest + LCA, or a link-cut tree.
- Directed reachability: that’s SCC territory.