Topics › Graphs › MinimumSpanningTree/Disjoint Set and Problems
Disjoint Set
Java source ↗ Edit this article ↗
Jump to code:
Problem
Maintain n elements 0..n-1, initially each in its own group, under two operations:
union(u, v): merge the group containinguwith the group containingv.find(u): return a representative ofu’s group, so thatuandvare in the same group exactly whenfind(u) == find(v).
The input is a sequence of operations [UNION, u, v] and [QUERY, u, v], and the output is the list of answers to the queries (“are u and v connected?”). This structure is called a disjoint set union (DSU) or union-find. It is the standard tool whenever a graph gains edges over time and you keep asking whether two vertices are connected. Rerunning BFS or DFS after every new edge would cost O(V + E) per question.
Example 1
n = 8
union(1,2) union(2,3) union(4,5) union(6,7) union(5,6)
query(3,7) -> false // groups: {1,2,3} {4,5,6,7}
union(3,7)
query(3,7) -> true // now one group {1,...,7}
query(1,4) -> true
Example 2
n = 4, no unions
query(0,1) -> false, query(2,3) -> false // every element is alone
All three approaches store each group as a rooted tree in a parent array. The root is the group’s representative and satisfies parent[root] == root. find walks up to the root, and union makes one root the child of the other. They differ only in how much effort they spend keeping the trees short.
The Java file defines each version as a nested class behind a small UnionFind interface, and one shared driver applies the operations.
interface UnionFind {
int find(int x);
void union(int a, int b);
}
static List<Boolean> run(UnionFind dsu, int[][] ops) {
List<Boolean> answers = new ArrayList<>();
for (int[] op : ops) {
if (op[0] == UNION) dsu.union(op[1], op[2]);
else answers.add(dsu.find(op[1]) == dsu.find(op[2]));
}
return answers;
}
Approach 1: Naive parent array
Intuition. The simplest version: find follows parent pointers until it reaches a root, and union finds both roots and points the first at the second. It is correct, because two elements share a root exactly when they were linked by some chain of unions. Nothing stops the trees from becoming long paths, though.
Algorithm.
- Start with
parent[i] = i. find(x): whileparent[x] != x, move toparent[x]. Returnx.union(a, b): iffind(a) != find(b), setparent[find(a)] = find(b).
static class NaiveDSU implements UnionFind {
final int[] parent;
NaiveDSU(int n) {
parent = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
public int find(int x) {
while (parent[x] != x) x = parent[x];
return x;
}
public void union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra != rb) parent[ra] = rb;
}
}
static List<Boolean> naive(int n, int[][] ops) {
return run(new NaiveDSU(n), ops);
}
Complexity. find costs the depth of the tree, which can reach n - 1. The sequence union(0,1), union(1,2), …, union(n-2, n-1) builds exactly that chain, with 0 at the bottom, so each later find(0) walks the whole path. Over m operations this is O(m · n) in the worst case. Space O(n).
Approach 2: Union by rank with path compression
Intuition. Two independent ideas each fix a different cause of tall trees.
Union by rank. Keep a rank for every root, an upper bound on its tree’s height. When merging, hang the root with the smaller rank under the one with the larger rank. The height of the taller tree does not change. Only when the ranks are equal does the new root’s rank grow by one. A root of rank r therefore has at least 2^r elements in its tree (two rank r - 1 trees were needed to make it), so ranks, and heights, never exceed log2 n. The Java tests build a perfectly balanced sequence of merges on 1024 elements and check that the final rank is exactly 10.
Path compression. After find(x) discovers the root, point every node on the path from x directly at the root. The recursive version does it in one line: parent[x] = find(parent[x]). Later finds from any of those nodes take one step.
Together they give an amortised cost of O(α(n)) per operation, where α is the inverse Ackermann function. It is at most 4 for any n that fits in memory, so in practice every operation is constant time.
Algorithm.
- Start with
parent[i] = iandrank[i] = 0. find(x): ifxis a root return it, otherwise setparent[x] = find(parent[x])and return that.union(a, b): find the rootsraandrb. If they are equal, stop. Otherwise attach the lower-rank root to the higher one; on a tie attachrbunderraand incrementrank[ra].
static class DSUByRank implements UnionFind {
final int[] parent, rank;
DSUByRank(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
public int find(int x) {
if (parent[x] == x) return x;
return parent[x] = find(parent[x]); // path compression: point straight at the root
}
public void union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return;
if (rank[ra] < rank[rb]) {
parent[ra] = rb;
} else if (rank[ra] > rank[rb]) {
parent[rb] = ra;
} else {
parent[rb] = ra; // equal ranks: the new root grows by one
rank[ra]++;
}
}
}
static List<Boolean> byRank(int n, int[][] ops) {
return run(new DSUByRank(n), ops);
}
Complexity. Amortised O(α(n)) per operation, so O(m · α(n)) for m operations. Space O(n) for two arrays.
Rank is only an upper bound once compression starts. Compression shortens paths without updating ranks, and that is fine: the bound is all the analysis needs.
Approach 3: Union by size with path compression
Intuition. Instead of rank, track size[root], the number of elements in the group, and hang the smaller group under the larger. The same argument applies: an element’s depth increases only when its group is merged into one at least as large, which at least doubles the size of its group. That can happen at most log2 n times. The complexity is identical to union by rank. The bonus is that size[find(x)] tells you how large x’s group is, which many problems need (largest island, accounts merge, counting pairs across components).
Algorithm.
- Start with
parent[i] = iandsize[i] = 1. find(x): the same path-compressing find.union(a, b): find both roots. If they differ, attach the root of the smaller group under the other and add its size to the new root.
static class DSUBySize implements UnionFind {
final int[] parent, size;
DSUBySize(int n) {
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
public int find(int x) {
if (parent[x] == x) return x;
return parent[x] = find(parent[x]);
}
public void union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return;
if (size[ra] < size[rb]) { // hang the smaller tree under the larger one
parent[ra] = rb;
size[rb] += size[ra];
} else {
parent[rb] = ra;
size[ra] += size[rb];
}
}
}
static List<Boolean> bySize(int n, int[][] ops) {
return run(new DSUBySize(n), ops);
}
Dry run of Example 1 with union by size (n = 8):
| operation | roots compared | result | groups (root: size) |
|---|---|---|---|
| union(1,2) | 1 (1) vs 2 (1) | parent[2] = 1 | 1: 2 |
| union(2,3) | 1 (2) vs 3 (1) | parent[3] = 1 | 1: 3 |
| union(4,5) | 4 (1) vs 5 (1) | parent[5] = 4 | 1: 3, 4: 2 |
| union(6,7) | 6 (1) vs 7 (1) | parent[7] = 6 | 1: 3, 4: 2, 6: 2 |
| union(5,6) | 4 (2) vs 6 (2) | parent[6] = 4 | 1: 3, 4: 4 |
| query(3,7) | find(3) = 1, find(7) = 4 | false | |
| union(3,7) | 1 (3) vs 4 (4) | parent[1] = 4 | 4: 7 |
| query(3,7) | find(3) = 4, find(7) = 4 | true |
During the last find(3), the path 3 → 1 → 4 is compressed so that parent[3] = 4.
Complexity. Amortised O(α(n)) per operation; space O(n).
Edge cases and pitfalls
- Always compare roots.
unionmust linkfind(a)andfind(b), notaandb. Linking a non-root detaches it from its own group. - Self-union and repeated unions.
union(x, x)and merging two elements already together must do nothing. Thera == rbcheck handles both, and the tests include them. - Rank versus size. Pick one. Mixing them (comparing ranks, then adding sizes) still works, but it confuses later readers.
- Recursion depth. With union by rank or size, trees have height at most
log2 n, so the recursivefindis safe. The naive version with a recursivefindcould overflow the stack on a long chain, which is why itsfindis a loop. - Path compression alone (without rank or size) is already
O(log n)amortised. Union by rank alone givesO(log n)worst case. Use both. - One-based inputs. Many problems number nodes from
1; allocaten + 1slots, as Example 1 does withn = 8for elements1..7. - Counting components. Start a counter at
nand decrement it on every union that actually merges two roots.
Related problems
- Find the MST weight uses a DSU to skip edges that would close a cycle (Kruskal’s algorithm).
- Number of operations to make network connected counts components and redundant edges with a DSU.
- Accounts merge groups emails into people with union-find.
- Number of islands II is the online version of island counting, a classic DSU problem.
- Making a large island relies on union by size to know each component’s area.