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.

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

ProblemHow
connected components, offlineunite all edges
Kruskaladd an edge iff unite succeeds
cycle in an undirected graphthe first edge that fails to unite
connectivity with deletions, offlinereverse 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 percolationcells as elements, plus virtual nodes for borders
equivalence with contradictionsparity / weighted DSU
offline LCATarjan’s algorithm (LCA)
Kruskal reconstruction treenew node per union (KRT)
number of componentsmaintain a counter, decremented on every successful unite
component sizessize(find(v))
image / grid connected componentsan 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
};

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.

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.