Union Find (Disjoint Set)
- Implement Union Find with path compression and union by rank
- Solve connected component problems efficiently
- Recognize when Union Find beats DFS/BFS
Edges show up one at a time, and after each one someone wants to know whether two nodes now sit in the same group. Answer that with DFS or BFS and you re-traverse the entire graph on every single edge, which collapses the moment the graph gets big. Union Find is the data structure that handles the streaming version of the question. It tracks connected components as the edges arrive, and once you bolt on path compression and union by rank, every query and every merge costs almost nothing, effectively O(1).
With path compression and union by rank, operations are nearly O(1), technically O(α(n)) where α is the inverse Ackermann function, effectively constant.
The Core Idea
The whole structure is a pile of disjoint sets and two operations. One tells you which set an element lives in, the other fuses two sets into one:
Operations:
- find(x): Return the root of x's set
- union(x, y): Merge the sets containing x and y
Example: Connect 1-2, then 2-3, then 4-5
Initial: {1} {2} {3} {4} {5} (each is its own set)
union(1,2): {1,2} {3} {4} {5}
union(2,3): {1,2,3} {4} {5}
union(4,5): {1,2,3} {4,5}
find(1) == find(3)? Yes (same set)
find(1) == find(4)? No (different sets)
Basic Implementation
Think of a forest where every element begins as its own one-node tree, pointing at itself. The parent array is the whole thing: parent[x] = x means x is the root of its own tree. find(x) follows parent pointers upward until it reaches a node that points to itself, and that self-pointing node is the root, the representative for the entire group.
union(x, y) finds the root of each element's tree and points one root at the other, welding the two trees into one. Once that happens, every element in both trees traces its parent chain back to the same root, which is exactly how the structure decides they belong together. What trips people up on a first read is that you never touch the individual elements. Connect the two roots and every node underneath comes along for free:
class UnionFind: def __init__(self, n): self.parent = list(range(n)) # Each element is its own root def find(self, x): while self.parent[x] != x: # Follow parent pointers x = self.parent[x] return x # Reached the root def union(self, x, y): root_x = self.find(x) root_y = self.find(y) if root_x != root_y: self.parent[root_x] = root_y # Connect the two treesThis is correct, and it's also slow. Nothing stops the chains from growing long, and a long chain makes find O(n). Two optimizations kill that off, and you almost always want both.
Optimization 1: Path Compression
Path compression is the optimization that earns Union Find its reputation. Once find(x) has walked the whole chain from x up to the root, leaving that chain intact for the next call to re-walk is pure waste. You already hold the root in your hand, so point every node you passed straight at it. One slow traversal buys you O(1) on every future find for any of those nodes.
Concretely: if the path is 5 -> 4 -> 3 -> 2 -> 1 (root), after find(5) with path compression, the parent array changes from [1, 1, 2, 3, 4] to [1, 1, 1, 1, 1]. Nodes 2, 3, 4, and 5 all point directly to 1. The next call to find(3) doesn't walk 3 -> 2 -> 1. It goes straight to 1 in one step.
This is amortization in its purest form. The first find on a deep chain pays O(depth), but it flattens the tree permanently, so every find after it on those nodes is O(1). You spend one expensive operation to make a crowd of future ones cheap. Run a handful of operations and the trees are nearly flat anyway, most nodes sitting one hop from their root.
def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) # Flatten path to root return self.parent[x] # Return rootRecursion depth can bite you here, since Python caps out around 1000 frames and a big input will blow past that. The iterative version gets the same flattening in two explicit passes: walk up once to locate the root, then walk the path a second time rewriting every parent pointer to point straight at it:
def find_iterative(self, x): root = x while self.parent[root] != root: # First pass: find root root = self.parent[root] while self.parent[x] != root: # Second pass: compress path next_x = self.parent[x] self.parent[x] = root # Point directly to root x = next_x return rootOnce the path is compressed, every node on it points straight at the root and future finds are O(1). The flattening, step by step:
Optimization 2: Union by Rank/Size
Compression cleans up tall trees after they form. Union by rank keeps them from forming at all. Left to its own devices, a naive union will happily bolt the bigger tree onto the smaller one and grow a chain. Merge elements 1 through 5 in order and watch: union(1,2) points 2 at 1, union(1,3) points 3 at 1, and you've got a tidy star. Do union(2,3), union(3,4), union(4,5) instead and you've built the chain 2 -> 3 -> 4 -> 5, which drags find back to O(n) until compression has a chance to fire.
Rank fixes the ordering by always hanging the shorter tree under the taller one. If tree A stands 3 tall and tree B stands 1, attaching B under A's root leaves A's height untouched, since B's nodes just gain a level above them and the deepest path is still 3. Go the other way, A under B, and you'd manufacture depth 4 for no reason.
Height only has to grow in one situation: two trees of equal rank. If both are height 2, whichever goes under the other, you end up at height 3, so you pick one arbitrarily and bump its counter. A tree therefore gets taller only when two equal-rank trees merge, and that can happen at most O(log n) times because each bump requires the tree to double in size. That single fact is why rank alone, with no compression at all, already guarantees O(log n) per find.
Stack the two together and the amortized cost per operation drops to O(α(n)), the inverse Ackermann function. Nobody expects you to prove that in an interview, and you shouldn't try. The intuition is enough: a slow find down a tall chain compresses it and speeds up everything after, while rank makes sure the chains rarely get tall to begin with. They cover each other's blind spots. Rank prevents the worst case, compression repairs whatever tall structure sneaks through.
class UnionFind: def __init__(self, n): self.parent = list(range(n)) # Each node is its own root self.rank = [0] * n # Approximate depth of each tree def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) # Path compression return self.parent[x]Union splits into three cases, all falling out of that one observation about height. Taller tree wins: hang the shorter one under it and the overall height doesn't budge, because the taller side's depth still dominates. Only when the ranks tie does the result come out strictly taller than either input, and only then do you increment:
def union(self, x, y): root_x, root_y = self.find(x), self.find(y) if root_x == root_y: return False # Already in same group if self.rank[root_x] < self.rank[root_y]: self.parent[root_x] = root_y # Shorter under taller elif self.rank[root_x] > self.rank[root_y]: self.parent[root_y] = root_x # Shorter under taller else: self.parent[root_y] = root_x # Same height — pick one self.rank[root_x] += 1 # Tree got one level deeper return True # Successfully mergedWith both path compression and union by rank:
- find: O(α(n)) ≈ O(1) amortized
- union: O(α(n)) ≈ O(1) amortized
α(n) is the inverse Ackermann function. For any practical input size, α(n) ≤ 4.
Count Connected Components
Counting connected components is the problem Union Find was born for. You're handed a list of friendships and asked how many separate friend groups exist. Assume everyone starts as a stranger, so you begin with n groups. Every friendship might merge two groups: if the two people already share a group, nothing moves, and if they don't, one merge fires and the count drops by one.
The code is almost embarrassingly short. Run union on every edge, then count the distinct roots left standing, one per connected component:
def count_components(n, edges): uf = UnionFind(n) for u, v in edges: uf.union(u, v) # Merge connected nodes # Count unique roots = number of components return len(set(uf.find(i) for i in range(n)))Watch the parent array evolve as we process edges for n=5, edges=[[0,1], [1,2], [3,4]]:
Start: parent = [0, 1, 2, 3, 4]. Every node is its own root. 5 components.
union(0, 1): find(0)=0, find(1)=1. Different roots. Attach 1 under 0: parent = [0, 0, 2, 3, 4]. Now find(1) returns 0. 4 components.
union(1, 2): find(1) follows 1->0, returns 0. find(2)=2. Different roots. Attach 2 under 0: parent = [0, 0, 0, 3, 4]. Path compression: if we called find(1), parent[1] already points to 0, just one step. 3 components.
union(3, 4): find(3)=3, find(4)=4. Attach 4 under 3: parent = [0, 0, 0, 3, 3]. 2 components.
Final state: Nodes 0, 1, 2 all trace to root 0. Nodes 3, 4 trace to root 3. Two components. The find call on any node is at most one hop thanks to path compression.
You can also keep the count inside the UnionFind itself: start at n and decrement on each successful union. Either way it's O(E × α(n)). Cycle detection is the same idea read backwards. When find(u) == find(v) already holds before you union, the edge would close a loop, since u and v are in one component and connecting them again just completes a cycle. Grids are the same problem in disguise: flatten 2D coordinates to 1D with row * cols + col, then union adjacent cells that share a value or meet whatever condition the problem sets.
When to Use Union Find vs DFS/BFS
The question that decides it: are edges being added over time, or is the graph fixed?
For a static graph and one query ("are A and B connected?"), reach for DFS or BFS. It's simpler and just as fast, O(V + E) for a single traversal. The moment you have to answer that question repeatedly as edges keep arriving, DFS and BFS fall apart, because each new edge can change connectivity and you'd rebuild the whole traversal every time. Ten thousand edges means ten thousand full traversals, O(E × (V + E)) in total.
Union Find shrugs this off. Each new edge is a single union(), O(α(n)) amortized, so ten thousand edges cost you ten thousand near-constant operations. At any moment, find(a) == find(b) answers the connectivity query, also O(α(n)). Nothing gets rebuilt and nothing gets re-traversed.
Here's what you give up. Union Find tells you WHETHER two nodes are connected, never HOW. It can't hand you the path between them. So the moment a problem wants the shortest path, or nodes visited in BFS order, or a path of length exactly k, Union Find is the wrong tool and you should reach for a traversal. It answers one question: does a route exist. That's all it knows.
- Dynamic connectivity (edges added over time)
- Need to answer many "are X and Y connected?" queries
- Counting/tracking connected components as edges added
- MST algorithms (Kruskal's)
- Redundant edge detection
Use DFS/BFS when:
- Need the actual path between nodes
- Static graph with single traversal
- Level-order or distance information needed
- Visiting all nodes in specific order
- Problems with additional constraints during traversal
Common Applications
Count connected components
Union all edges, return count
Track count in UF structure
Is graph connected?
Union all edges, check count == 1
Single component = connected
Find redundant edge
Union returns false = redundant
Edge between already-connected nodes
Earliest time all connected
Process edges in time order
Return time when count == 1
Accounts merge / synonyms
Union equivalent items
Group items with same root
Kruskal's MST
Sort edges, union if not connected
Skip edges that create cycles
Practice
Start with n components. Each successful union decreases the count by 1.
Try it yourself
Number of Connected ComponentsGiven n nodes labeled from 0 to n-1 and a list of undirected edges, return the number of connected components in the graph.
Examples
Input: n = 5, edges = [[0,1],[1,2],[3,4]]
Output: 2
Components: {0,1,2} and {3,4}
Input: n = 5, edges = [[0,1],[1,2],[2,3],[3,4]]
Output: 1
All nodes connected
Number of Connected Components
Click "Run" to execute your code
Test Cases
5, [[0,1],[1,2],[3,4]]Expected: 25, [[0,1],[1,2],[2,3],[3,4]]Expected: 13, []Expected: 34, [[0,1],[2,3]]Expected: 2Solution
View Solution
Take some time to think about the problem before revealing the solution. Try to write the code yourself first - that's where the real learning happens.
Nearly Constant-Time Connectivity
With path compression and union by rank, Union Find answers connectivity in essentially O(1) per operation. When a problem is about grouping elements or tracking components as they merge, this is the structure to reach for.
The template barely fills a screen, and that same handful of lines covers graph connectivity, redundant edge detection, and the dynamic grouping problems stacked on top of them.
Key Takeaways
- Union Find answers "same group?" in O(α(n)) ≈ O(1)
- Path compression: flatten tree during find
- Union by rank: attach smaller tree under larger
- Track component count by decrementing on successful union
- Use for dynamic connectivity, DFS/BFS for paths