Union-Find
Track groups that keep merging and answer "are these two connected?" almost instantly, with union by size and path compression. Traced, in JS and Python.
On this page
You look after a small office network of six computers, numbered 0 to 5. All day, someone plugs in a new cable between two of them. In between, people ask you things like "can computer 5 talk to computer 1 yet?"
You could answer each question with a BFS or DFS from computer 5. That works, but it walks the whole network every time. With 100,000 computers and 100,000 questions, that is billions of steps. You do not need paths here. You only need to know which group each computer is in, and to merge two groups when a cable joins them. A union-find structure does exactly that, and each operation takes only a few steps.
The idea: every group has a boss
Each group picks one member to be its root, like a team captain. Every computer stores one number: its parent, the computer it reports to. A root reports to itself. At the start, nobody is connected, so everyone is their own root.
That gives you two operations:
- find(x): follow parent pointers from
xuntil you reach a computer that is its own parent. That is the root ofx's group. Two computers are connected exactly when they have the same root. - union(a, b): find the root of each. If the roots differ, make one root report to the other. One pointer change merges two whole groups.
Notice that union never touches the other members. Everyone who reported to the old root still does, and the old root now reports to the new one, so their find still ends at the right place, just one hop later.
Trace it on six computers
The array is parent: cell i holds the parent of computer i. The arrows in the picture point from each computer to its parent. Each root also keeps a size: how many computers are in its group.
find(x): if parent[x] != x: parent[x] = find(parent[x]) return parent[x]union(a, b): ra, rb = find(a), find(b) if ra == rb: return false if size[ra] < size[rb]: swap ra, rb parent[rb] = ra; size[ra] += size[rb]- parent001122334455
- groups
- 6
1No cables yet. Every computer is its own root, so
parent[i] = iand every group has size 1. - parent001022334455
- groups
- 5
- size[0]
- 2
2Cable 0–1. The roots are 0 and 1, both size 1. On a tie we keep
ra, so 1 now reports to 0. - parent001022324455
- groups
- 4
- size[0]
- 2
- size[2]
- 2
3Cable 2–3. Same story: 3 now reports to 2.
- parent001022324454
- groups
- 3
- size[0]
- 2
- size[2]
- 2
- size[4]
- 2
4Cable 4–5. Now 5 reports to 4. Three groups of two.
- parent001022324454
- ra
- 0
- rb
- 2
5Cable 1–3. Neither is a root, so first find their roots: 1 reports to 0, and 3 reports to 2. The roots differ, so the groups must merge.
- parent001020324454
- groups
- 2
- size[0]
- 4
- size[4]
- 2
6Both roots have size 2, a tie, so root 2 reports to root 0. One pointer moved, and four computers are now one group. Computer 3 is two hops from its root.
- parent001020304454
- groups
- 2
- size[0]
- 4
- size[4]
- 2
7Someone asks about computer 3.
find(3)walks 3 → 2 → 0, and on the way back it points 3 straight at the root. This is path compression: the next find from 3 takes one hop. - parent001020304454
- ra
- 0
- rb
- 4
8Cable 5–3.
find(5)is 4 (size 2) andfind(3)is 0 (size 4). Root 4's group is smaller, so the swap makesra= 0 andrb= 4: 4 will go under 0, not the other way round. - parent001020304054
- groups
- 1
- size[0]
- 6
9Root 4 now reports to 0. Only computer 5 got one hop further from its root. Had we put 0 under 4, the four computers in the bigger group would all have moved one hop further.
- parent001020304050
- groups
- 1
- union(5, 1)
- false
10Cable 5–1.
find(5)walks 5 → 4 → 0 and compresses 5 onto 0.find(1)is 0 too. Same root, sounionreturns false: this cable connects nothing new. - parent001020304050
- groups
- 1
- longest find
- 1 hop
11Six cables, one group. Every computer now points straight at root 0, so any find is one hop. The tree stayed flat because small groups went under big ones, and finds flattened what was left.
Two tricks that keep trees flat
The whole cost of union-find is the walk inside find. A short walk means fast operations. Without care, the walks get long.
Suppose you always put the first root under the second, and the cables arrive as 0–1, 1–2, 2–3, 3–4. Each union hangs the whole existing group under one new computer. You get a chain, and find(0) walks past every computer. With n computers that is n steps per find, no better than searching the graph.
Union by size. Always hang the smaller group's root under the bigger group's root. A computer only gets one hop further from its root when its group joins a group at least as big, so its group at least doubles each time. A group of n can only double about log₂ n times, so no walk is longer than log₂ n hops. For a million computers, that is 20.
Path compression. Every time find walks up a path, point every computer on it straight at the root. You did the walk anyway, so this costs nothing extra, and the next find from any of them takes one hop.
The code
class UnionFind {
constructor(n) {
this.parent = Array.from({ length: n }, (_, i) => i); // everyone is their own root
this.size = new Array(n).fill(1);
this.groups = n;
}
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]); // path compression
}
return this.parent[x];
}
union(a, b) {
let rootA = this.find(a);
let rootB = this.find(b);
if (rootA === rootB) return false; // already in the same group
if (this.size[rootA] < this.size[rootB]) [rootA, rootB] = [rootB, rootA];
this.parent[rootB] = rootA; // smaller tree goes under the bigger one
this.size[rootA] += this.size[rootB];
this.groups--;
return true;
}
}class UnionFind:
def __init__(self, n):
self.parent = list(range(n)) # everyone is their own root
self.size = [1] * n
self.groups = n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # path compression
return self.parent[x]
def union(self, a, b):
root_a, root_b = self.find(a), self.find(b)
if root_a == root_b:
return False # already in the same group
if self.size[root_a] < self.size[root_b]:
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a # smaller tree goes under the bigger one
self.size[root_a] += self.size[root_b]
self.groups -= 1
return TrueTo ask "are a and b connected?", compare find(a) with find(b). The groups counter tells you how many separate pieces the network is in, without any search. It drops by one only when a union really merges two groups.
The return value of union is useful on its own. When it returns false, the two computers were already connected, so the new cable closes a loop. That is how you detect a cycle in an undirected graph while reading its edges one at a time, and it is the heart of Kruskal's algorithm in Minimum Spanning Trees.
| Approach | Time | Space | Why |
|---|---|---|---|
| Re-run BFS or DFS per question | O(V + E) per question | O(V) | Walks the whole network every time. |
| Union-find, no tricks | O(n) per operation | O(n) | Trees can turn into chains. |
| Union by size only | O(log n) per operation | O(n) | A group doubles each time a node gets deeper, so depth stays under log₂ n. |
| Union by size + path compression | O(α(n)) amortized | O(n) | α is the inverse Ackermann function. It is at most 4 for any input you could ever store, so treat it as constant. |
"Amortized" means averaged over a long run of operations. One find can still take a few hops, but it flattens the path it walked, so later finds are cheaper.
Common mistakes
Check yourself
parent = [0, 0, 1, 3, 3]. Which computers are in the same group as 2?
Root A has a group of 10 and root B has a group of 3. With union by size, what does union do?
You read the edges of an undirected graph one at a time and call union(u, v) on each. One call returns false. What does that tell you?
What to practise next
Union-find returns in Minimum Spanning Trees, where it decides which cheap cables are worth laying. First, though, graphs get weights: Dijkstra's Algorithm finds the fastest route when every road has a travel time.