-
Notifications
You must be signed in to change notification settings - Fork 0
012 — Union Find
Graphs (last chapter) can answer "is there a path from A to B" with a BFS or DFS — but that's a full traversal every single time you ask. Union-Find answers a narrower question — "are A and B in the same connected group" — and answers it in almost constant time, even after thousands of groups have merged together.
Picture a school with hundreds of students who keep forming and merging friend groups. Every so often, two groups discover they actually know each other and merge into one bigger group. Someone asks you constantly: "are Alex and Jordan in the same friend group?"
You could re-trace every friendship chain from scratch each time — slow, and it gets slower as groups merge into bigger ones. Or: every friend group elects one representative. To check if two people are in the same group, you just ask each of them "who's your group's representative?" — if it's the same person, they're in the same group. Merging two groups is just as cheap: one representative now points to the other.
- 👥 Each group has exactly one representative (the "root").
- 🔎
find(person)— chase pointers up until you reach the representative. - 🤝
union(personA, personB)— merge their two groups by pointing one representative at the other.
That's the entire idea. No traversal of the whole group needed — just a pointer chase to the top.
Union-Find (also called a Disjoint Set Union, or DSU) tracks a collection of elements partitioned into non-overlapping ("disjoint") groups, and supports two operations efficiently:
-
find(x)— which group doesxbelong to? (Returns that group's representative/root.) -
union(x, y)— merge the groups containingxandyinto one.
The naive version — each element stores a pointer to its parent, find walks up to the root — works, but a long chain of unions can degrade into an O(n)-deep chain, making find slow. Two independent optimizations fix that, and interviews usually expect both:
-
Path compression — while
findwalks up to the root, it rewires every node it passed through to point directly at the root. Futurefindcalls on those nodes become instant. - Union by rank (or size) — when merging two groups, always attach the shorter tree under the taller one's root, instead of arbitrarily. This keeps trees flat instead of accidentally building a long chain.
Together, these two tricks give amortized O(α(n)) per operation — α is the inverse Ackermann function, which grows so slowly that for any n you could realistically store in memory, α(n) ≤ 4. In practice: treat Union-Find operations as constant time.
Swift has no stdlib Union-Find — hand-rolled as a class wrapping a parent array and a rank array, indexed by integer.
Reach for Union-Find when:
- You need to repeatedly answer "are these two things connected" as connections keep getting added — Union-Find beats re-running BFS/DFS from scratch every time.
- You're detecting cycles in an undirected graph while building it edge by edge — a cycle exists exactly when
union(a, b)is called on two nodes that are already in the same group. - Classic use cases: Kruskal's minimum spanning tree algorithm, counting connected components, "number of provinces/islands"-style grouping problems, checking if a graph stays fully connected as edges are removed.
Skip it when the graph structure is fixed and you only need to check connectivity once or twice — a single BFS/DFS is simpler and just as fast for a one-off query.
Starting with 6 separate elements (each its own group), after union(0,1), union(1,2), union(3,4):
Before any union — 6 separate groups, each element is its own root:
0 1 2 3 4 5
│ │ │ │ │ │
0 1 2 3 4 5 (parent[i] == i for all i)
After union(0,1), union(1,2), union(3,4):
0 3 5
/ \ | |
1 (rank 1) 4 5 (still alone)
|
2
parent: [0, 0, 1, 3, 3, 5]
↑ 2's parent is 1, but find(2) walks 2 → 1 → 0
After find(2) runs once (path compression kicks in):
0
/ | \
1 2 (rank still 1 — compression doesn't touch rank)
parent: [0, 0, 0, 3, 3, 5]
↑ 2 now points DIRECTLY at 0 — future find(2) is instant
No stdlib equivalent — hand-rolled over Int indices (the standard interview form; wrap with a [T: Int] lookup if you need arbitrary hashable elements).
final class UnionFind {
private var parent: [Int]
private var rank: [Int]
private(set) var groupCount: Int
init(size: Int) {
parent = Array(0..<size) // everyone starts as their own representative
rank = Array(repeating: 0, count: size)
groupCount = size
}
func find(_ x: Int) -> Int {
if parent[x] != x {
parent[x] = find(parent[x]) // path compression: flatten the chain on the way up
}
return parent[x]
}
@discardableResult
func union(_ a: Int, _ b: Int) -> Bool {
let rootA = find(a)
let rootB = find(b)
guard rootA != rootB else { return false } // already in the same group — this IS a cycle
// union by rank: attach the shorter tree under the taller one's root
if rank[rootA] < rank[rootB] {
parent[rootA] = rootB
} else if rank[rootA] > rank[rootB] {
parent[rootB] = rootA
} else {
parent[rootB] = rootA
rank[rootA] += 1 // trees were equal height — new tree is one taller
}
groupCount -= 1
return true
}
func connected(_ a: Int, _ b: Int) -> Bool {
find(a) == find(b)
}
}Usage:
let uf = UnionFind(size: 6)
uf.union(0, 1)
uf.union(1, 2)
uf.union(3, 4)
uf.connected(0, 2) // true — 0 and 2 both trace back to the same root
uf.connected(0, 3) // false — never merged
uf.groupCount // 3 — groups are {0,1,2}, {3,4}, {5}The union function's return value doubles as a cycle detector: if union(a, b) returns false, a and b were already connected before this call — adding an edge between them would create a cycle.
| Operation | Big-O | Why |
|---|---|---|
find (with path compression) |
O(α(n)) amortized |
Each call flattens the chain it walks — so future calls on those same nodes are near-instant. α(n) is the inverse Ackermann function; for all practical n, treat this as O(1). See Big-O Notation. |
union (with union by rank) |
O(α(n)) amortized |
Dominated by two find calls, plus O(1) pointer rewiring — union by rank keeps tree height from ever growing unboundedly. |
connected |
O(α(n)) amortized |
Just two find calls compared for equality. |
find with no optimizations |
O(n) worst case |
A naive union that always attaches arbitrarily can build a straight-line chain n deep — find degenerates into a linked-list walk. |
| Space | O(n) |
Two arrays (parent, rank), each sized to the number of elements. |
Union-Find is friend groups with elected representatives — asking "same group?" is just "same rep?", and merging two groups is just pointing one rep at the other, with path compression flattening the chain every time you ask.
Suppose you implement union without union by rank — always attaching find(b)'s root under find(a)'s root, regardless of tree height. Construct a sequence of union calls on elements 0 through 4 that produces the worst possible shape (a straight chain of depth 5). Then explain how path compression alone (still no union by rank) would affect the cost of a second find call on the deepest node, versus the first.