← All Posts
DSA Series · Graphs · Disjoint Set Union

Disjoint Set Union (Union-Find)

Introduction

Imagine a social network where people form friend groups. Two people are in the same group if they are connected by any chain of friendships. When two people from different groups become friends, their entire groups merge. At any point, we want to quickly answer: “Are Alice and Bob in the same friend group?”

This is exactly the problem that Disjoint Set Union (DSU), also known as Union-Find, solves. It maintains a collection of disjoint sets (non-overlapping groups) and supports two fundamental operations:

Why DSU is fundamental: Union-Find appears everywhere in competitive programming — from Kruskal’s MST algorithm to dynamic connectivity, offline query processing, and graph component counting. With two simple optimizations (path compression + union by rank), it achieves nearly O(1) amortized time per operation, making it one of the most elegant data structures in CS.

In this post we will build DSU from scratch, layer on each optimization with visual step-by-step examples, build an interactive animation, cover advanced variants (rollback, weighted, small-to-large), write production-quality C++ code, and explore competitive programming applications.

Naive Implementation

The simplest way to track which set each element belongs to is an array id[] where id[x] stores the set representative (label) of element x. Initially, each element is its own representative: id[x] = x.

Element01234567
id[]01234567

Find(x): Simply return id[x]. This is O(1).

Union(x, y): To merge the sets of x and y, scan the entire array and change every occurrence of id[y] to id[x]. This is O(n).

function find(x):
return id[x]

function union(x, y):
px = find(x), py = find(y)
if px == py: return
for i = 0 to n-1:
if id[i] == py: id[i] = px

After Union(1, 3) and Union(3, 5):

Element01234567
id[]01214167

Elements 1, 3, and 5 now share representative 1. Find is O(1) but Union is O(n), giving O(n·m) for m operations total. We need something better.

Tree-Based Representation

Instead of a flat array, represent each set as a rooted tree. Use a parent[] array where parent[x] points to x’s parent, and the root points to itself (parent[root] = root). The root serves as the set representative.

function find(x):
while parent[x] ≠ x:
x = parent[x]
return x

function union(x, y):
px = find(x), py = find(y)
if px ≠ py: parent[py] = px

Find now walks up the tree from x to its root. Union simply makes one root point to the other. Both operations take O(tree height) time.

But without care, the tree can degenerate into a chain (like a linked list), making both Find and Union O(n) in the worst case. For example, if we union 0→1, then 1→2, then 2→3, etc., we get a single chain of length n.

Worst case: degenerate chain 3 2 1 0 find(3) traverses 3 edges to reach root 0 Ideal: balanced tree 0 1 2

Two key optimizations fix this: path compression and union by rank/size.

Path Compression

Path compression is a strikingly simple optimization: during Find(x), after walking all the way up to the root, we make every node along the path point directly to the root. This flattens the tree so that future Find operations on those nodes are nearly instant.

function find(x):
if parent[x] ≠ x:
parent[x] = find(parent[x]) // recursively compress
return parent[x]

Let’s walk through a concrete example. Consider a tree with 7 nodes where node 6 is a deep leaf:

Tree before Find(6)

The tree has height 4. Finding the root of node 6 requires traversing 6 → 5 → 3 → 1 → 0.

0 1 2 3 4 5 parent: [0, 0, 0, 1, 1, 3, 5] Height = 4

(Node 6 is a child of 5, shown below 5. We omit it from the main layout for clarity and show the full tree with 6 in the “after” diagram.)

Path traversal: 6 → 5 → 3 → 1 → 0

We walk up the tree, collecting every node on the path. The root is 0.

6 5 3 1 0

After compression: all nodes point directly to root 0

Nodes 6, 5, 3, and 1 all have their parent set to 0. The tree is now flat — height 1.

0 1 2 3 4 5 6 parent: [0, 0, 0, 0, 1, 0, 0] — Height = 1

Orange nodes were re-pointed during compression. Future Find calls on any of them will be O(1).

Path compression alone (without union by rank) gives amortized O(log n) per operation. But when combined with union by rank, we get something even better.

Union by Rank / Size

The second optimization controls how we merge two trees. The idea: always attach the smaller (or shorter) tree under the root of the larger tree. This prevents the tree from growing tall.

There are two variants:

Union by Rank

Maintain a rank[] array (upper bound on height). Attach the tree with smaller rank under the tree with larger rank. If ranks are equal, pick either and increment the new root’s rank.

Union by Size

Maintain a size[] array (number of nodes). Attach the tree with fewer nodes under the tree with more nodes. Update the size of the new root.

function union(x, y):
px = find(x), py = find(y)
if px == py: return
if rank[px] < rank[py]: swap(px, py)
parent[py] = px // attach smaller under larger
if rank[px] == rank[py]: rank[px]++

Let’s trace a sequence of union operations with union by rank on 8 elements:

Initial state: 8 singletons

0 1 2 3 4 5 6 7

rank = [0, 0, 0, 0, 0, 0, 0, 0]. Every element is its own root.

Union(0,1), Union(2,3), Union(4,5), Union(6,7)

Four merges of equal-rank trees. In each case, rank of the new root increases to 1.

0 1 2 3 4 5 6 7

rank = [1, 0, 1, 0, 1, 0, 1, 0]

Union(0,2) and Union(4,6)

Equal-rank merges again. Root 0 gets rank 2. Root 4 gets rank 2.

0 1 2 3 4 5 6 7

rank = [2, 0, 1, 0, 2, 0, 1, 0]. The trees are balanced — height matches log2(size).

Union(0,4) — Final merge

Both roots have rank 2. We attach 4 under 0, and 0’s rank increases to 3. All 8 elements are now in one set.

0 1 4 2 5 6 3 7

rank = [3, 0, 1, 0, 2, 0, 1, 0]. Height is 3 = ⌈log2(8)⌉. Union by rank guarantees height ≤ log2(n).

Rank vs Height: Rank is an upper bound on the tree’s height. With path compression, the actual height can shrink below the rank, but rank is never decreased — it’s only used for union decisions.

Combined Optimizations & Complexity

When we use both path compression and union by rank/size, the amortized time per operation becomes O(α(n)), where α is the inverse Ackermann function.

The Inverse Ackermann Function

The Ackermann function A(m, n) grows extraordinarily fast — faster than any primitive recursive function. Its inverse α(n) grows correspondingly slowly:

n141665536265536
α(n)01234

For any conceivable input size in the universe (< 265536), α(n) ≤ 4. This means DSU with both optimizations is effectively O(1) per operation for all practical purposes.

Why It’s Nearly O(1) — Proof Sketch

The formal proof by Tarjan (1975) uses a potential function argument. Here’s the high-level intuition:

  1. Union by rank guarantees that a tree with rank r has at least 2r nodes. This bounds the number of distinct ranks to O(log n).
  2. Path compression flattens trees aggressively. After a Find, every node on the path becomes a direct child of the root. Subsequent Finds on those nodes are O(1).
  3. The key insight: partition the ranks into blocks using the iterated logarithm (log*). Path compression ensures that any node eventually “jumps” to a parent in a higher rank block. The total number of such jumps across all operations is bounded by O(n · α(n)).
  4. Distributing this cost across m operations gives O(α(n)) amortized per operation.
Key takeaway: Path compression + union by rank gives amortized O(α(n)) ≈ O(1) per Find/Union. This was proven optimal by Fredman and Saks (1989) — no pointer-based union-find can do better.
MethodFindUnionNotes
Naive (flat array)O(1)O(n)Simple but slow unions
Tree (no opt)O(n)O(n)Can degenerate to chain
Path compression onlyO(log n) amortO(log n) amortGood but not optimal
Union by rank onlyO(log n)O(log n)Bounded height
Both combinedO(α(n)) amortO(α(n)) amortPractically O(1)

Interactive Animation

Step through a sequence of Union and Find operations on 8 elements. Watch the forest of trees evolve, see path compression flatten the tree on Find, and observe union by rank maintaining balance. The parent and rank arrays update in real time.

Use Step for manual control, Play for auto-advance, or Reset to start over.

0 1 2 3 4 5 6 7

Rollback DSU (Persistent)

In standard DSU with path compression, operations are irreversible — once you compress a path, you can’t undo it. But some problems (especially offline divide-and-conquer on queries) require us to undo union operations.

Rollback DSU uses union by rank without path compression and maintains a stack of changes. Each union records what was changed, and a rollback simply restores those values.

Key constraint: Without path compression, Find is O(log n) instead of O(α(n)). But rollback capability is essential for offline divide-and-conquer algorithms where edges are added and removed.

How It Works

  1. Union: Before modifying parent[py] and rank[px], push the old values onto a history stack.
  2. Rollback: Pop the stack and restore the saved values. This undoes the most recent union.
  3. Checkpoint: Save the current stack size. Later, roll back all unions performed after the checkpoint by restoring entries until the stack returns to that size.
struct RollbackDSU:
parent[], rank[]
history = [] // stack of (index, old_value) pairs

function find(x):
while parent[x] ≠ x: x = parent[x] // NO path compression!
return x

function union(x, y):
px = find(x), py = find(y)
if px == py: return false
if rank[px] < rank[py]: swap(px, py)
history.push((py, parent[py]))
history.push((px, rank[px])) // save before modifying
parent[py] = px
if rank[px] == rank[py]: rank[px]++
return true

function checkpoint():
return history.size()

function rollback(cp):
while history.size() > cp:
(idx, val) = history.pop()
// restore parent or rank at idx

When to Use Rollback DSU

DSU with Additional Information

Standard DSU answers “are x and y in the same set?” But we can augment the DSU to maintain extra information along the edges, enabling it to answer more nuanced queries.

Weighted DSU (Distance to Root)

Store a weight[x] value representing the “distance” (or potential difference) from node x to its parent. This lets us answer queries like: “What is the relative difference between x and y?”

During path compression, accumulate the weights along the path:

function find(x):
if parent[x] == x: return (x, 0)
(root, w) = find(parent[x])
parent[x] = root
weight[x] += w // accumulate distance to root
return (root, weight[x])

function union(x, y, w): // weight[y] - weight[x] = w
(rx, wx) = find(x)
(ry, wy) = find(y)
if rx == ry: return (wy - wx == w) // check consistency
parent[ry] = rx
weight[ry] = wx - wy + w
return true

Application: Given constraints like “A − B = 5” and “B − C = 3”, we can infer “A − C = 8” and detect contradictions.

Parity DSU (Bipartiteness Checking)

A special case of weighted DSU where weights are in ℤ2 (0 or 1). This is perfect for checking if a graph is bipartite as edges are added online.

Each edge (u, v) means “u and v are in different partitions,” which translates to weight 1 (parity difference). If we ever find u and v in the same set with even parity, adding an edge between them would create an odd cycle — not bipartite!

// Parity DSU: weight is 0 or 1 (mod 2)
function find(x):
if parent[x] == x: return (x, 0)
(root, p) = find(parent[x])
parent[x] = root
parity[x] ^= p // XOR for mod 2
return (root, parity[x])

function add_edge(u, v): // u and v should differ
(ru, pu) = find(u)
(rv, pv) = find(v)
if ru == rv:
return (pu ^ pv) == 1 // check: must differ
parent[rv] = ru
parity[rv] = pu ^ pv ^ 1
return true

Tracking Set Sizes

The most common augmentation: store the size of each component at the root. Increment the root’s size during union. This lets you answer queries like “how many elements are in the same set as x?” in O(α(n)).

Small-to-Large Merging

Small-to-large merging (also called the weighted union heuristic or merge by size) is a general technique that extends beyond DSU: when merging two sets, always iterate over the smaller set and insert its elements into the larger set.

Why O(n log n) Total

Every time an element is moved, the set it joins is at least twice as large as the set it came from. Therefore, each element can be moved at most log2(n) times before it’s in a set of size n. Total work across all merges: O(n log n).

This is the same principle behind union by size in DSU! The “attach smaller tree under larger tree” rule is exactly small-to-large merging applied to the parent-pointer forest.

Applications Beyond DSU

Example: maintain the set of distinct colors in each component.

// Small-to-large merging with std::set
vector<set<int>> colors(n);
// Initially, colors[i] = {color_of_node_i}

void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (colors[x].size() < colors[y].size()) swap(x, y);
    // x is larger, y is smaller
    for (int c : colors[y]) colors[x].insert(c);
    colors[y].clear();
    parent[y] = x;
}
// Each element moves O(log n) times => total O(n log^2 n)

C++ Implementation

Basic DSU with Path Compression + Union by Rank

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, rank_;

    DSU(int n) : parent(n), rank_(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // path compression
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        return true;
    }

    bool connected(int x, int y) {
        return find(x) == find(y);
    }
};

int main() {
    int n = 8;
    DSU dsu(n);

    dsu.unite(0, 1);
    dsu.unite(2, 3);
    dsu.unite(0, 2);
    dsu.unite(4, 5);
    dsu.unite(6, 7);
    dsu.unite(4, 6);
    dsu.unite(0, 4);

    // All elements now in the same set
    for (int i = 0; i < n; i++)
        cout << "find(" << i << ") = " << dsu.find(i) << "\n";

    cout << "connected(1,7) = " << dsu.connected(1, 7) << "\n";  // 1
    cout << "connected(0,5) = " << dsu.connected(0, 5) << "\n";  // 1
    return 0;
}

DSU with Rollback

struct RollbackDSU {
    vector<int> parent, rank_;
    vector<pair<int*,int>> history;  // (pointer, old_value)

    RollbackDSU(int n) : parent(n), rank_(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        while (parent[x] != x) x = parent[x];  // NO path compression
        return x;
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (rank_[x] < rank_[y]) swap(x, y);
        // Save state before modification
        history.push_back({&parent[y], parent[y]});
        history.push_back({&rank_[x], rank_[x]});
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        return true;
    }

    int checkpoint() { return history.size(); }

    void rollback(int cp) {
        while ((int)history.size() > cp) {
            auto [ptr, val] = history.back();
            *ptr = val;
            history.pop_back();
        }
    }

    bool connected(int x, int y) {
        return find(x) == find(y);
    }
};

// Usage: offline divide-and-conquer
// int cp = dsu.checkpoint();
// dsu.unite(a, b);  // add edge
// ... process queries ...
// dsu.rollback(cp);  // undo all unions since checkpoint

Weighted DSU

struct WeightedDSU {
    vector<int> parent, rank_;
    vector<long long> weight;  // weight[x] = potential from x to parent[x]

    WeightedDSU(int n) : parent(n), rank_(n, 0), weight(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }

    // Returns {root, distance_from_x_to_root}
    pair<int, long long> find(int x) {
        if (parent[x] == x) return {x, 0};
        auto [root, w] = find(parent[x]);
        parent[x] = root;
        weight[x] += w;
        return {root, weight[x]};
    }

    // Add relation: potential[y] - potential[x] = w
    // Returns false if contradicts existing relations
    bool unite(int x, int y, long long w) {
        auto [rx, wx] = find(x);
        auto [ry, wy] = find(y);
        if (rx == ry) return (wy - wx) == w;  // check consistency
        if (rank_[rx] < rank_[ry]) {
            swap(rx, ry);
            swap(wx, wy);
            w = -w;
        }
        parent[ry] = rx;
        weight[ry] = wx - wy + w;
        if (rank_[rx] == rank_[ry]) rank_[rx]++;
        return true;
    }

    // Get potential[y] - potential[x], or nullopt if different sets
    optional<long long> diff(int x, int y) {
        auto [rx, wx] = find(x);
        auto [ry, wy] = find(y);
        if (rx != ry) return nullopt;
        return wy - wx;
    }
};

// Usage: "A is 5 more than B" => dsu.unite(B, A, 5)
// Query: "How much more is A than C?" => dsu.diff(C, A)

Applications in Competitive Programming

Kruskal’s MST

Sort edges by weight. Process each edge: if the endpoints are in different components (check via DSU), add the edge to the MST and union the components. This greedy algorithm runs in O(E log E) dominated by the sort — DSU operations are effectively O(1).

Dynamic Connectivity (Offline)

Given edges that are added and removed at specific times, answer connectivity queries at each time step. Use a segment tree on time intervals + rollback DSU. Each edge is added to O(log T) segments and rolled back automatically.

Online Bridge Finding

Maintain a graph as edges are added. Track which edges are bridges (removing them disconnects the graph). DSU helps maintain the 2-edge-connected components: when an edge closes a cycle, merge all components on the cycle path.

Offline Queries with Rollback

Process queries in an order different from the input using divide-and-conquer. Add edges relevant to the current segment, answer queries, then rollback. Rollback DSU makes this efficient.

More Applications

Practice Problems

Redundant Connection (LeetCode 684)

Find the edge that, when removed, makes the graph a tree. Classic DSU: process edges in order; the first edge where both endpoints are already connected is the answer.

Number of Islands (LeetCode 200)

Count connected components of ‘1’ cells in a grid. While BFS/DFS works, DSU provides an elegant alternative, especially when islands are revealed online.

GCD and MST (Codeforces 1513D)

Build an MST using a clever combination of GCD properties and DSU. Demonstrates how DSU enables efficient MST-like constructions beyond Kruskal’s.

Roads Not Only in Berland (Codeforces 25D)

Given a graph, find edges to remove and add to make it a spanning tree. Use DSU to find redundant edges (creating cycles) and missing connections (disconnected components).

Accounts Merge (LeetCode 721)

Merge accounts that share emails. Model emails as nodes, union emails belonging to the same account, then group by component. A real-world DSU application.

Decayed Bridges (AtCoder ABC120D)

Process bridge destructions in reverse (as additions). Classic offline technique: reverse the removals into unions and track connectivity changes with DSU.

Paint the Array (Codeforces 1630C)

Uses DSU with parity to check bipartiteness of constraint graphs. A great problem for practicing parity DSU.

Summary

Let’s recap the key takeaways:

  1. Disjoint Set Union maintains a partition of elements into non-overlapping sets with near-constant-time Find and Union operations.
  2. Path compression flattens trees during Find by pointing all nodes on the path directly to the root.
  3. Union by rank/size keeps trees balanced by attaching the smaller tree under the larger one.
  4. Combined, they achieve O(α(n)) amortized per operation — practically O(1) for any real-world input.
  5. Rollback DSU sacrifices path compression for undo capability, essential for offline divide-and-conquer problems.
  6. Weighted DSU stores edge weights to track relative differences between elements (useful for parity checking, potential functions).
  7. Small-to-large merging is a general O(n log n) technique that underpins union by size and extends to merging auxiliary data structures.
  8. DSU is the engine behind Kruskal’s MST, dynamic connectivity, cycle detection, and countless CP problems.
Practice tip: Implement DSU from scratch 3–4 times until it’s muscle memory. In contests, DSU is often the tool rather than the problem — recognizing when a problem reduces to union-find is the real skill. Look for keywords like “merge,” “connected,” “group,” or “same set.”