What is the Disjoint-Set Data Structure?
A Disjoint-Set (or Union-Find) is a data structure that keeps track of a partition of a set into elements grouped into disjoint (non-overlapping) subsets. It supports two primary operations:
- Find: Determine which subset a particular element belongs to (returns a representative member).
- Union: Merge two subsets into a single subset.
Explanation
- The Disjoint-Set data structure is crucial for problems involving dynamic connectivity. It allows us to group elements and efficiently check if two elements belong to the same group in near-constant time.
Real-World Analogy
- Social Circles & Representatives: Imagine a convention where attendees belong to different clubs. At first, each person is their own club’s representative. When two clubs merge, one representative is chosen to speak for the combined group. To check if two people belong to the same club, we ask each person who their representative is; if they name the same representative, they belong to the same club.
- Network Routers: Routers in a local network are grouped into subnets. Union-Find determines if a packet can traverse between any two routers by checking if they are in the same connected subnet component.
How It Works
Core Mechanics
- A Disjoint-Set is represented as a forest of trees, where each tree represents a set. The root of each tree is the representative of that set.
1. Path Compression (Optimization for Find)
- During the recursive
findoperation, we traverse up the parent pointers to find the root. - Optimization: Once the root is found, we update the parent pointers of all traversed nodes to point directly to the root. This flattens the tree, ensuring subsequent queries take amortized time.
2. Union by Rank or Size (Optimization for Union)
- When merging two trees, we want to avoid creating deep trees (which degrades lookup time).
- Optimization: We attach the shorter tree (lower rank) under the root of the taller tree (higher rank). The “rank” represents an upper bound on the height of the tree.
Complexity and Inverse Ackermann Function
- Combining path compression and union by rank yields an amortized time complexity of per operation, where is the Inverse Ackermann function.
- The Ackermann function grows extremely fast; consequently, its inverse grows incredibly slowly. For all practical values of (up to ), . Hence, operations run in near-constant time.
Visual Walkthrough
Initial State (5 elements, each is its own root)
0 1 2 3 4
After Union(0, 1) and Union(2, 3)
0becomes parent of1;2becomes parent of3:
0 2 4
/ /
1 3
After Union(1, 3)
- We find representative of
1(which is0) and of3(which is2). Since both have rank 1, we attach root2under root0:
0 4
/ \
1 2
/
3
During Find(3) with Path Compression
- Tracing
3 -> 2 -> 0. We find root0. - Path compression updates
3’s parent directly to0:
0 4
/ | \
1 2 3
Time & Space Complexity
| Operation | Without Optimizations | With Path Compression Only | With Union by Rank Only | With Both (Optimized) |
|---|---|---|---|---|
| Find | ||||
| Union | ||||
| Space Complexity |
Implementation
class DisjointSet:
def __init__(self, size):
"""Initializes size elements, each in its own set."""
self.parent = list(range(size))
self.rank = [0] * size
def find(self, x):
"""Finds the representative of the set containing x with path compression."""
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # Path compression
return self.parent[x]
def union(self, x, y):
"""Merges the sets containing x and y using union by rank."""
root_x = self.find(x)
root_y = self.find(y)
if root_x != root_y:
# Union by rank: attach smaller tree under taller tree
if self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
elif self.rank[root_x] > self.rank[root_y]:
self.parent[root_y] = root_x
else:
self.parent[root_y] = root_x
self.rank[root_x] += 1
return True
return False # Already in the same set
def connected(self, x, y):
"""Checks if elements x and y belong to the same set."""
return self.find(x) == self.find(y)#include <iostream>
#include <vector>
#include <numeric>
class DisjointSet {
private:
std::vector<int> parent;
std::vector<int> rank;
public:
// Initializes size elements, each in its own set
DisjointSet(int size) {
parent.resize(size);
std::iota(parent.begin(), parent.end(), 0); // parent[i] = i
rank.assign(size, 0);
}
// Finds the representative of the set containing x with path compression
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // Path compression
}
return parent[x];
}
// Merges the sets containing x and y using union by rank
bool unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
// Union by rank: attach smaller tree under taller tree
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
return true;
}
return false; // Already in the same set
}
// Checks if elements x and y belong to the same set
bool connected(int x, int y) {
return find(x) == find(y);
}
};
int main() {
DisjointSet ds(5);
ds.unionSets(0, 1);
ds.unionSets(2, 3);
std::cout << std::boolalpha;
std::cout << "0 and 1 connected: " << ds.connected(0, 1) << "\n"; // True
std::cout << "0 and 2 connected: " << ds.connected(0, 2) << "\n"; // False
ds.unionSets(1, 2);
std::cout << "0 and 2 connected after union: " << ds.connected(0, 2) << "\n"; // True
return 0;
}
When to Use
✅ Use Disjoint-Set When:
- You need to detect cycles in an undirected graph (e.g. during Kruskal’s algorithm).
- You are finding the number of connected components in a dynamic graph where edges are added continuously.
- You are implementing grid connectivity algorithms (like percolation theory or maze generation).
❌ Do NOT Use Disjoint-Set When:
- You need to delete edges/connections. Standard Disjoint-Set does not support edge deletion (requires complex fully dynamic connectivity data structures).
- You need to find actual paths between nodes (use DFS or BFS instead).
Variations & Related Concepts
- Union by Size: Instead of storing rank (height bound), store the size (number of elements) of each component. Attach the smaller tree to the larger one.
- Persistent Disjoint-Set: A variation that keeps track of the history of unions, allowing you to query connectivity at any point in the past.
- Dinic’s Dynamic Trees / Link-Cut Trees: Self-balancing trees that can maintain connected components under both edge insertions and deletions.
Key Takeaways
- Disjoint-Set efficiently manages partitioning of elements into disjoint subsets.
- Path compression flattens tree paths on every find operation, yielding amortized lookups.
- Union by rank attaches shallow trees under deep ones to prevent degrading tree shapes.
- Together, these optimizations yield near-constant time operations.