Union-Find
One-liner: keep track of which items belong to the same group, merge two groups in almost constant time, and ask "are these two together?" without walking the whole graph.
The analogy
Picture a party where people keep discovering they are friends. You do not want a list of every friendship. You only want a group leader for each circle of friends. To ask "are Ana and Bo in the same circle?", you find each one's leader and compare. When two circles meet, one leader simply starts reporting to the other. That is Union-Find: a leader per group, and merging is one reassignment.
Recognition signals
Reach for Union-Find when you see:
- Items that get connected over time, such as edges added one by one, or pairs of "same group" facts.
- Questions about connectivity: how many groups, are these two connected, or which edge first creates a loop.
- You do not need the path between two nodes. You only need to know whether they are connected.
- A graph search would be repeated many times as new connections arrive.
Step-by-step walkthrough
Five nodes 0..4 and edges [0,1], [1,2], [3,4]:
- Start with five groups. Each node is its own leader.
- Union
0and1: they become one group. Four groups. - Union
1and2:1is already with0, so2joins them. Three groups. - Union
3and4: two groups left,{0,1,2}and{3,4}. - If
[0,2]arrived next,Find(0)andFind(2)give the same leader. Union returnsfalse: this edge would close a cycle.
Code template
A parent slice plus two operations. The comments mark the four ideas that make it fast.
// UF tracks which items belong to the same group. Find answers "which group?", Union merges two groups.
type UF struct {
parent []int
size []int
groups int
}
func NewUF(n int) *UF {
u := &UF{parent: make([]int, n), size: make([]int, n), groups: n}
for i := range u.parent {
u.parent[i] = i // 1. everyone starts as their own group's root
u.size[i] = 1
}
return u
}
// Find returns the root of x's group, flattening the path as it goes.
func (u *UF) Find(x int) int {
for u.parent[x] != x {
u.parent[x] = u.parent[u.parent[x]] // 2. path compression: point at the grandparent
x = u.parent[x]
}
return x
}
// Union merges the groups of a and b. It reports false if they were already together.
func (u *UF) Union(a, b int) bool {
ra, rb := u.Find(a), u.Find(b)
if ra == rb {
return false // 3. already connected: this edge would close a cycle
}
if u.size[ra] < u.size[rb] {
ra, rb = rb, ra
}
u.parent[rb] = ra // 4. attach the smaller tree under the larger
u.size[ra] += u.size[rb]
u.groups--
return true
}Why each part exists:
u.parent[i] = iEvery item starts as its own leader. A leader is any item whose parent is itself.
u.parent[x] = u.parent[u.parent[x]]Path compression. While walking up to the leader, point each item at its grandparent. The next lookup is shorter, so chains flatten over time instead of growing into a long list.
if ra == rb { return false }Two items with the same leader are already connected. Returning false here is how you detect a cycle, or count a redundant edge.
u.parent[rb] = ra // smaller under largerUnion by size. Always hang the smaller tree under the bigger one, so trees stay shallow. Together with path compression, each operation is close to constant time (inverse Ackermann).
The real solutions
Number of connected components — start with n groups and subtract one for every successful union:
// CountComponents: number of connected components in an undirected graph of n nodes.
func CountComponents(n int, edges [][]int) int {
u := NewUF(n)
for _, e := range edges {
u.Union(e[0], e[1])
}
return u.groups
}Redundant connection — the first edge whose two ends are already connected is the one that closes the loop:
// FindRedundantConnection: the edge that closes a cycle in a graph that was a tree plus one extra edge.
// The input has n nodes and n edges, so nodes 1..n fit in n+1 slots. If both ends are already connected, this edge makes the cycle.
func FindRedundantConnection(edges [][]int) []int {
u := NewUF(len(edges) + 1) // nodes are labelled 1..n
for _, e := range edges {
if !u.Union(e[0], e[1]) {
return e
}
}
return nil
}Number of provinces — the same idea over an adjacency matrix:
// FindCircleNum: number of friend groups in an adjacency matrix.
func FindCircleNum(connected [][]int) int {
n := len(connected)
u := NewUF(n)
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
if connected[i][j] == 1 {
u.Union(i, j)
}
}
}
return u.groups
}Valid tree — a tree has exactly n - 1 edges and no cycle:
// ValidTree: n nodes form a tree if there are exactly n-1 edges and no edge closes a cycle.
func ValidTree(n int, edges [][]int) bool {
if len(edges) != n-1 {
return false
}
u := NewUF(n)
for _, e := range edges {
if !u.Union(e[0], e[1]) {
return false
}
}
return true
}For graph searches that need paths, distances or an ordering, see Graphs.
Complexity
| Approach | Time | Space |
|---|---|---|
| Re-run DFS after every new edge | O(E · (V + E)) | O(V + E) |
| Union-Find (path compression + union by size) | O(E · α(V)) ≈ O(E) | O(V) |
α is the inverse Ackermann function. It is below 5 for any input that fits in memory, so treat each operation as constant.
Common mistakes
Practice ladder
Ordered Easy → Hard. For each, decide: do connections arrive over time, and do I need the path or only the yes/no?
- 1.Find if Path Exists in GraphEasyOnly a yes/no connectivity question.
- 2.Number of ProvincesMedium
- 3.Number of Connected Components in an Undirected GraphMedium
- 4.Graph Valid TreeMediumHow many edges does a tree on n nodes have?
- 5.Redundant ConnectionMediumWhich edge joins two items already together?
- 6.Accounts MergeMediumTreat each shared email as a connection.
- 7.Number of Islands IIHardLand is added over time.
Which pattern? Drills
Unlabeled problems — pick the pattern, then read why.
You are given the number of computers and a list of cables that connect pairs of them. How many separate networks are there?
Which pattern?