Patterns/Union Find

Union Find

Top interview pattern
85 min read
Updated June 2026
What you'll learn
  • Implement Union Find with path compression and union by rank, and explain why find modifies the parent array without changing component membership
  • Detect cycles in undirected graphs by checking whether both endpoints already share a component representative before adding the edge
  • Solve entity grouping problems using the three-phase pattern: scan for shared attributes and union, group by root, format output
  • Choose between Union Find and DFS/BFS based on whether the problem needs repeated connectivity queries on a changing graph or path information

Union Find tracks which elements belong to the same component and answers connectivity queries after incremental merges. Each merge and each query runs in effectively O(1) amortized time with both optimizations applied. The implementation is 15 lines of code. The difficulty is recognizing which problems reduce to merge and query operations on disjoint sets.

What Union Find Actually Does

Without Union Find, answering "are A and B connected?" requires a full DFS or BFS traversal from A. If you add edges one at a time and need the connectivity answer after each addition, you must re-traverse after every edge. With n edges and n queries, the total cost is O(n * (V + E)).

Union Find (also called Disjoint Set Union, or DSU) eliminates that repeated traversal. It maintains a partition of elements into non-overlapping sets. Each set has a representative (root) that identifies the set. Two elements are in the same set if and only if they have the same root. Adding an edge is a union operation. Checking connectivity is a find-and-compare operation. Both run in amortized O(1) with path compression and union by rank.

Union Find: Build a Forest

01234
parent =
0
0
1
1
2
2
3
3
4
4
components = 5

Five isolated nodes. Each node is its own root: parent[i] = i. Five components.

This is the initial state. Every element starts in its own set.

The data structure is a parent array. Each element points to its parent. The root of a set points to itself: parent[root] = root. To find which set an element belongs to, follow parent pointers until you reach the root.

Initially, every element is its own set: parent[i] = i for all i. This means n elements start in n separate singleton sets.

1# Naive Union Find (no optimizations)
2class NaiveUF:
3 def __init__(self, n):
4 self.parent = list(range(n)) # parent[i] = i: each element is its own root
5
6 def find(self, x):
7 while self.parent[x] != x: # walk up until we hit a root
8 x = self.parent[x]
9 return x
10
11 def union(self, a, b):
12 rootA = self.find(a)
13 rootB = self.find(b)
14 if rootA != rootB:
15 self.parent[rootB] = rootA # attach one root under the other

Without optimizations, this naive version can degenerate to O(n) per operation. If you always attach the second root under the first, you can build a chain: 0 1 2 ... n-1. Finding the root of element n-1 requires walking n-1 steps. The two optimizations, path compression and union by rank, prevent this degeneration.

How Components Are Tracked

Union Find maintains a forest of trees. Each tree is one component. The root of each tree is the component representative. Two elements are connected if and only if their trees share the same root.

find(x) follows parent pointers from x until it reaches a node whose parent is itself. That node is the root, and the root identifies the component. union(a, b) finds the roots of a and b, then attaches one root under the other. Every node that was reachable from either root is now reachable from the surviving root. connected(a, b) calls find on both and compares the results.

Key Insight
Why same representative means same component
If two nodes have the same representative, they are already connected through previous unions. Every union merges two trees by attaching one root under the other. After that merge, every node in both trees reaches the same root through parent pointers. find() walks to that root. So any two nodes that reach the same root must be in the same merged tree, which means they are connected.

The Union Find Invariant

Every element can reach exactly one root by following parent pointers. That root identifies the component. Two elements are in the same component if and only if they reach the same root.

This invariant constrains every operation in the data structure.

Union must operate on roots. If you write parent[a] = b instead of parent[find(a)] = find(b), you redirect a non-root node. Other nodes that previously reached the correct root through a now lose their path. The tree breaks, and find returns the wrong representative for those nodes.

The component count only changes on actual merges. If find(a) == find(b), the two elements are already in the same component. No merge happens, and decrementing the count would undercount the true number of components. The union method must check whether the roots differ before decrementing.

Find must return the root, not just any ancestor. The root is the canonical representative. Two nodes might share a common ancestor that is not the root, but that does not mean they are in the same component. Only the root comparison is valid.

First Walkthrough

Start with 5 nodes (0 through 4), each in its own component. Process edges one at a time and track the parent array and component count after each operation.

OperationParent ArrayComponentsWhat Changed
Initial state[0, 1, 2, 3, 4]5Every node is its own root
union(0, 1)[0, 0, 2, 3, 4]4Node 1's root attached under node 0
union(2, 3)[0, 0, 2, 2, 4]3Node 3's root attached under node 2
union(1, 3)[0, 0, 0, 2, 4]2find(1)=0, find(3)=2. Node 2 attached under 0. Nodes 0,1,2,3 now share root 0
connected(0, 3)[0, 0, 0, 2, 4]2find(0)=0, find(3)=0. Same root, so connected. Returns true
connected(0, 4)[0, 0, 0, 2, 4]2find(0)=0, find(4)=4. Different roots, so not connected. Returns false

Notice that union(1, 3) does not connect nodes 1 and 3 directly. It finds their roots (0 and 2) and connects the roots. After that, every node that previously pointed to root 2 now reaches root 0. The component count dropped from 3 to 2 because two distinct components merged into one.

Also notice that connected(0, 3) works even though node 3 still points to node 2 in the parent array, not to node 0. find(3) follows parent[3] = 2, then parent[2] = 0, then parent[0] = 0 (root found). The chain eventually reaches the shared root. Path compression would flatten this chain so future lookups are faster.

Path Compression

Without path compression, find can walk O(n) parent pointers on a degenerate chain. Path compression fixes this. During a find call, after reaching the root, every node on the path is updated to point directly to the root. The first call pays for the full walk, but every subsequent find on those same nodes is a single lookup.

Path Compression

Before: chain (O(n) find)find(0) = 4
01234root
parent =
0
1
1
2
2
3
3
4
4
4

A degenerate chain: 0 -> 1 -> 2 -> 3 -> 4. Root is 4. find(0) must traverse 4 edges.

Without path compression, find() is O(n) on a chain. This is the worst case.

The implementation is a single line of recursion:

1def find(self, x):
2 if self.parent[x] != x:
3 self.parent[x] = self.find(self.parent[x]) # path compression: point directly to root
4 return self.parent[x]

Walk through what happens with a chain 0 1 2 3 when we call find(3):

  1. find(3) calls find(2) because parent[3] = 2
  2. find(2) calls find(1) because parent[2] = 1
  3. find(1) calls find(0) because parent[1] = 0
  4. find(0) returns 0 because parent[0] = 0 (root)
  5. On the way back: parent[1] = 0, parent[2] = 0, parent[3] = 0

After this single find call, nodes 1, 2, and 3 all point directly to 0. Any subsequent find on these nodes is a single lookup.

There is also an iterative variant called path halving, where each node skips its parent: parent[x] = parent[parent[x]]. Both achieve the same amortized complexity.

Interview Tip
The recursive find with path compression modifies the parent array. This means find is not a pure read operation. It is safe because the logical set membership does not change, only the internal tree structure. Interviewers sometimes ask about this distinction.

Union by Rank

Without union by rank, repeatedly attaching one root under the other in the same direction can build a chain of length n. Union by rank prevents this by always attaching the shorter tree under the taller tree. The rank is an upper bound on the tree height.

Union by Rank

Naive Union (no rank)
01234567

Two trees before union. Left tree: height 1 (3 nodes). Right tree: height 2 (5 nodes).

We want to merge these trees. The order matters for performance.

Consider merging sets one at a time in order. If we always attach the new root under the existing root, we get a chain of length n. With union by rank, we always attach the smaller tree under the larger one, keeping the height logarithmic.

1def __init__(self, n):
2 self.parent = list(range(n))
3 self.rank = [0] * n # rank starts at 0 for all nodes
4
5def union(self, a, b):
6 rootA = self.find(a)
7 rootB = self.find(b)
8 if rootA == rootB:
9 return False # already in the same set
10
11 # Attach smaller rank tree under larger rank tree
12 if self.rank[rootA] < self.rank[rootB]:
13 rootA, rootB = rootB, rootA # ensure rootA has higher rank
14 self.parent[rootB] = rootA
15 if self.rank[rootA] == self.rank[rootB]:
16 self.rank[rootA] += 1 # only increment when ranks are equal
17 return True

Rank only increases when two trees of equal rank are merged. If one tree is already taller, the merged tree has the same height as the taller tree, because the shorter tree becomes a subtree that does not extend beyond the existing height. Only when both trees are the same height does attaching one under the other create a tree one level taller.

An alternative is union by size, where you track the number of elements in each set instead of rank. You attach the smaller set under the larger one. Both approaches yield the same time complexity. Union by size has the advantage that the size array always contains meaningful values (actual set sizes), while rank becomes an approximation after path compression.

Common Mistake
Updating rank of non-root nodes
A common mistake is updating rank for the nodes being merged rather than their roots. Always find the roots first, then compare and update ranks on the roots. The rank of non-root nodes is irrelevant after they are attached under another root.

The Complete Template

This is the full Union Find implementation with both optimizations. Every Union Find problem uses this same class. The problem-specific logic is always outside it: which elements to union, when to query find, and what to do with the component count.

1class UnionFind:
2 def __init__(self, n):
3 self.parent = list(range(n)) # Every element is its own root at first
4 self.rank = [0] * n # Tracks tree height so we always attach small under tall
5 self.components = n # n separate groups, decremented on each merge
6
7 def find(self, x):
8 if self.parent[x] != x:
9 self.parent[x] = self.find(self.parent[x]) # Path compression: point straight to root
10 return self.parent[x]
11
12 def union(self, a, b):
13 rootA, rootB = self.find(a), self.find(b)
14 if rootA == rootB:
15 return False # Already in the same group
16 # Attach the shorter tree under the taller one so find() stays fast
17 if self.rank[rootA] < self.rank[rootB]:
18 rootA, rootB = rootB, rootA # Ensure rootA is the taller tree
19 self.parent[rootB] = rootA
20 if self.rank[rootA] == self.rank[rootB]:
21 self.rank[rootA] += 1 # Height only grows when equal ranks merge
22 self.components -= 1
23 return True
24
25 def connected(self, a, b):
26 return self.find(a) == self.find(b) # Same root means same group

The components counter starts at n (each element is its own component). Every successful union decrements it by 1. This gives you the number of connected components at any point in O(1).

MethodTimeDescription
__init__(n)O(n)Initialize n elements in n separate sets
find(x)O(α(n))Return the root of the set containing x
union(a, b)O(α(n))Merge sets of a and b, return True if they were different
connected(a, b)O(α(n))Return True if a and b are in the same set
componentsO(1)Number of disjoint sets (read the field directly)
Interview Tip
The implementation never changes. Write it once at the top of your solution and treat it as a black box. The problem-specific decisions are which elements to union, when to query, and what to do with the result.

Why the Runtime Works

With both path compression and union by rank, every find and union operation runs in O(α(n)) amortized time, where α is the inverse Ackermann function. For any input that fits in the physical universe, α(n) 4. This means every operation is effectively O(1).

Why path compression helps: each find call pays for a tree walk once but makes all future finds on those same nodes a single lookup. The work is front-loaded. A single expensive find amortizes its cost across every subsequent find on the same path. Over a sequence of operations, the total work is nearly linear.

Why union by rank helps: it prevents the tree from growing taller than O(log n) even without path compression. A tree of rank r has at least 2^r nodes. Since there are n nodes total, the rank can never exceed log n. Combined with path compression, the height stays almost flat.

For k operations on n elements, the total time is O(k * α(n)), which is effectively O(k). This is why Union Find can handle 10^5 or 10^6 operations without any performance concern.

Connected Components

Connected component tracking is the most common Union Find problem family. The pattern is always the same: start with n components, process connections, and track the count. Each union that actually merges two different components reduces the count by exactly one. A union that finds the roots are already the same does nothing.

For static graphs, DFS or BFS is simpler. But when edges are added dynamically and you need the component count after each addition, Union Find handles this directly. Number of Islands II is the canonical example: land cells are added one at a time, and the answer is the component count after each addition.

Number of Islands II

Components
0
Land Cells
0

Empty 4x4 grid. All water. Zero components. We will add land cells one at a time and track connectivity.

Number of Islands II: each addLand call adds a cell, unions it with neighbors, and reports the component count.

The key technique for grid problems is 2D-to-1D mapping. Given a grid with rows rows and cols columns, cell (r, c) maps to index r * cols + c.

1def numIslands2(m, n, positions):
2 uf = UnionFind(m * n)
3 uf.components = 0 # start with 0 because no land yet
4 grid = set() # track which cells are land
5 result = []
6 directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
7
8 for r, c in positions:
9 if (r, c) in grid: # duplicate position
10 result.append(uf.components)
11 continue
12
13 grid.add((r, c))
14 idx = r * n + c # 2D -> 1D mapping
15 uf.components += 1 # new island starts as its own component
16
17 for dr, dc in directions:
18 nr, nc = r + dr, c + dc
19 if 0 <= nr < m and 0 <= nc < n and (nr, nc) in grid:
20 uf.union(idx, nr * n + nc) # merge with adjacent land
21
22 result.append(uf.components)
23
24 return result

When a new land cell appears, we increment the component count by 1 (it starts as its own island). Then we check all four neighbors. For each neighbor that is already land, we union our new cell with it. Each successful union decrements the component count. If the new cell connects two previously separate islands, the count goes down by the number of bridges minus one.

Time complexity: O(k * α(m*n)) for k position additions. Space: O(m*n) for the parent and rank arrays.

Common Mistake
Wrong 2D-to-1D mapping
The mapping is row * cols + col, not row * rows + col. When the grid is not square (rows != cols), using rows instead of cols causes index collisions. For a 3x5 grid, cell (1, 0) maps to 1*5+0=5, not 1*3+0=3. Double-check your dimensions.

Cycle Detection

Union Find detects cycles in undirected graphs directly. Process edges one by one. Before adding edge (u, v), check whether u and v are already in the same component by comparing find(u) and find(v). If they are already connected, this edge creates a second path between them, which is a cycle.

Cycle Detection

01234
parent[]
0
1
2
3
4
(0,1)(1,2)(2,3)(3,4)(4,0)

5 nodes, 5 edges. Process edges one by one. Before each union, check: are the endpoints already connected?

If find(u) == find(v) before union, adding edge (u,v) creates a cycle.

The Redundant Connection problem asks: given a tree with one extra edge, find the edge that creates the cycle. The approach is direct. Process edges in order, and the first edge where both endpoints are already connected is the answer.

1def findRedundantConnection(edges):
2 n = len(edges)
3 uf = UnionFind(n + 1) # nodes are 1-indexed
4
5 for u, v in edges:
6 if not uf.union(u, v):
7 return [u, v] # u and v already connected -> this edge is redundant
8
9 return [] # should not reach here per problem constraints

This works because union returns False when the two nodes are already in the same set. That is exactly the cycle condition: if two nodes are already connected through previous unions, adding another edge between them creates a cycle.

For directed graphs, Union Find alone is not sufficient. You need to handle cases where a node has two parents (in-degree 2). Redundant Connection II requires checking for both cycle and two-parent cases, making it significantly harder.

Interview Tip
When an interviewer asks about cycle detection, clarify whether the graph is directed or undirected. For undirected graphs, Union Find is a direct approach. For directed graphs, DFS with coloring (white/gray/black) is usually better.

Entity Grouping

Given a list of accounts where each has a name and one or more emails, merge every account that belongs to the same person. Two accounts belong to the same person if they share any email. If account A shares an email with B, and B shares a different email with C, all three are the same person even though A and C share nothing directly. These chains can be arbitrarily long, so simple pairwise matching will not work.

Union Find handles this directly. Each account starts as its own group, and as you scan through the emails, every time two accounts share an email you merge their groups. After union(1, 0) and union(3, 1), accounts 0, 1, and 3 are all in the same group regardless of which pairs actually shared emails. By the time every email has been processed, every account that should be merged already is.

You could union emails (strings), but then you need a HashMap as your parent structure and you still need to map back to account names at the end. It is more practical to union account indices. Maintain a map from each email to the first account index that contained it, and when a later account contains an email you have already seen, union those two account indices. Since accounts are integers in [0, n), the Union Find uses a simple array, and the account index gives you direct access to the name for the output.

Accounts Merge
1def accountsMerge(accounts):
2 n = len(accounts)
3 uf = UnionFind(n)
4
5 # Phase 1: Detect shared emails and merge the accounts that own them.
6 # email_to_account acts as a collision detector. The first time we see
7 # an email, we record which account owns it. The second time, we know
8 # two accounts share that email and must be the same person.
9 email_to_account = {}
10
11 for i, account in enumerate(accounts):
12 for email in account[1:]: # account[0] is the name
13 if email in email_to_account:
14 # Collision: this email already belongs to another account.
15 # These two accounts are the same person. Merge them.
16 uf.union(i, email_to_account[email])
17 else:
18 email_to_account[email] = i
19
20 # Phase 2: Collect emails under the root of each group.
21 # IMPORTANT: you must call find() here, not use the raw account_idx.
22 # After unions, account 3 might point to account 1 which points to
23 # account 0. Only find() walks the chain to the actual root.
24 from collections import defaultdict
25 root_to_emails = defaultdict(set)
26 for email, account_idx in email_to_account.items():
27 root = uf.find(account_idx) # find(), not account_idx!
28 root_to_emails[root].add(email)
29
30 # Phase 3: Format the output. The name comes from the root account.
31 result = []
32 for root, emails in root_to_emails.items():
33 name = accounts[root][0]
34 result.append([name] + sorted(emails))
35
36 return result
37
38# Worked example
39# accounts = [
40# ["John", "j@mail.com", "john@x.com"], # account 0
41# ["John", "j@mail.com", "jdog@mail.com"], # account 1
42# ["Mary", "mary@mail.com"], # account 2
43# ["John", "jdog@mail.com", "john@y.com"], # account 3
44# ]
45#
46# Account 0: j@mail.com -> owner=0, john@x.com -> owner=0
47# Account 1: j@mail.com collides with account 0 -> union(1, 0)
48# jdog@mail.com -> owner=1
49# Account 2: mary@mail.com -> owner=2
50# Account 3: jdog@mail.com collides with account 1 -> union(3, 1)
51# john@y.com -> owner=3
52#
53# After all unions: {0, 1, 3} are one group, {2} is alone.
54# Notice account 3 merged with 0 through account 1, even though
55# accounts 0 and 3 share no email directly. The chain resolved.
56#
57# Result: [["John", "j@mail.com", "jdog@mail.com", "john@x.com", "john@y.com"],
58# ["Mary", "mary@mail.com"]]

Time: O(n * k * α(n) + E log E) where n is accounts, k is average emails per account, and E is total unique emails. The E log E comes from sorting each group's emails at the end. Space: O(n + E) for the Union Find and the email map.

Why Union Find over DFS/BFS? With DFS you would first build an adjacency list (email to list of account indices), then traverse it to find connected components. That is two separate passes over two data structures. Union Find does it in one pass: each shared email triggers a union call, and the groups form as a side effect of scanning the input. You never build an explicit graph.

Common Mistake
Skipping find() when grouping emails by account
After unions, an account's parent might not be the root. In the example above, account 3's direct parent is 1, and account 1's parent is 0. If you group emails by account_idx instead of find(account_idx), you will put some of John's emails under index 0, others under index 1, and others under index 3. Three separate groups instead of one. Always call find() to get the actual root before grouping.

Variant: HashMap Union Find

The solution above unions account indices (integers). But some problems give you strings or objects that do not map naturally to [0, n). For those, you can build Union Find on top of a HashMap instead of an array. In Accounts Merge, this means unioning emails directly. For each account, union every email with the first email in that account. No index mapping needed.

HashMap Union Find
1class StringUF:
2 """Union Find backed by a dict instead of an array.
3 Elements can be any hashable type. New elements auto initialize."""
4 def __init__(self):
5 self.parent = {}
6
7 def find(self, x):
8 if x not in self.parent:
9 self.parent[x] = x # first time seeing x, it's its own root
10 if self.parent[x] != x:
11 self.parent[x] = self.find(self.parent[x]) # path compression
12 return self.parent[x]
13
14 def union(self, a, b):
15 rootA, rootB = self.find(a), self.find(b)
16 if rootA != rootB:
17 self.parent[rootA] = rootB
18
19def accountsMerge_hashmap(accounts):
20 uf = StringUF()
21 email_to_name = {}
22
23 for account in accounts:
24 name = account[0]
25 first_email = account[1]
26 email_to_name[first_email] = name
27 for email in account[2:]: # union every email with the first one
28 uf.union(first_email, email)
29 email_to_name[email] = name
30
31 # Group emails by their root email
32 from collections import defaultdict
33 groups = defaultdict(set)
34 for email in email_to_name:
35 groups[uf.find(email)].add(email)
36
37 return [[email_to_name[root]] + sorted(emails)
38 for root, emails in groups.items()]

If the problem gives you elements that are already integers (account indices, node IDs, array positions), use array Union Find. If the elements are strings, coordinates, or other types, use HashMap Union Find. The array version has lower constant factors because array lookups are faster than hash lookups, but functionally they are identical.

The Entity Grouping Pattern

Accounts Merge is the template for a family of problems that all have the same shape: entities that share some attribute, where sharing can chain, and you need to find all the groups. The solution is always the same three phases: scan for shared attributes and union, then group by root, then format output.

  • Sentence Similarity II: Words linked by synonym pairs. Union the words, then check if two sentences match word by word via find(). Same HashMap UF pattern.
  • Evaluate Division: Variables linked by equations with ratios. Union Find with weighted edges (store the ratio along the path). Harder variant that extends basic UF.
  • Smallest String With Swaps: Characters at positions linked by allowed swap pairs. Union the positions, group characters by component, sort within each group. Identical structure to Accounts Merge.
  • Similar String Groups: Strings linked by the "similar" relation (differ by exactly one swap). Union similar strings, count components. The challenge is efficiently checking similarity.
Key Insight
When direct matching is not enough
If grouping only required direct sharing, a HashMap would be enough. But when sharing chains (A shares with B, B shares with C, so A and C must be grouped), you need Union Find. Each union() call merges two groups, and find() always returns the root of the merged group regardless of how many intermediate links exist. The three phase pattern (scan and union, group by root, format) applies to every entity grouping problem.

When This Pattern Breaks

Union Find is designed for one operation: merging sets and querying membership. Several common problem requirements fall outside what it can do.

Splitting components. Once two sets are merged, they cannot be separated. The parent pointers encode the merged state, and there is no operation to undo a union. If you need to disconnect components, consider processing edges in reverse order (turning deletions into additions) or using a different data structure like Link-Cut Trees.

Finding paths. Union Find tells you whether two nodes are connected, but not how. It stores no path information, only component membership. If the problem asks for the actual path between two nodes, use DFS or BFS.

Directed graph connectivity. Union Find treats every edge as undirected. If you union A and B, both A and B can reach the same root regardless of which direction the edge pointed. For directed reachability, you need algorithms like Tarjan's or Kosaraju's for strongly connected components.

Shortest path queries. Union Find has no concept of distance or edge weight. It answers "are these two nodes in the same component?" but nothing about the cost of reaching one from the other. Use BFS (unweighted) or Dijkstra (weighted) for shortest paths.

Edge deletion. Standard Union Find does not support removing edges. If you need deletion, sometimes you can solve the problem offline by processing edges in reverse order. What would have been a deletion in forward order becomes an addition in reverse, and Union Find handles additions.

Union Find vs DFS/BFS

Both Union Find and DFS/BFS can solve connectivity problems. The choice depends on the problem constraints.

CriterionUnion FindDFS/BFS
Static graph, single queryWorks but overkillSimpler and preferred
Dynamic edges (add over time)Process edges incrementallyMust rebuild from scratch
Multiple connectivity queriesO(α(n)) per query after setupO(V+E) per query unless preprocessed
Need shortest pathCannot do thisBFS gives shortest unweighted path
Need all nodes in a componentMust iterate all nodes to collectDFS/BFS collects during traversal
Cycle detection (undirected)find(u) == find(v) before unionAlso works: back edge detection
10^5+ queries on same graphMuch fasterToo slow without preprocessing
Grid problems (static)Works but more codeDFS flood fill is simpler
Grid problems (dynamic)Handles edge additions directlyMust re-flood-fill each change
Minimum Spanning TreeKruskal's algorithmNot applicable

If you see dynamic connectivity (edges being added over time with queries interspersed), Union Find is almost always the right tool. If you need to find a path or process nodes in a specific order, DFS/BFS is the right tool.

Key Insight
Connectivity vs paths
Union Find answers "are A and B connected?" but says nothing about the path between them. DFS/BFS answers "are A and B connected, and here is the path." If the problem only needs yes/no connectivity and supports incremental edge additions, Union Find wins. If the problem needs the actual path, traversal order, or shortest distance, use DFS/BFS.

Debugging Union Find

When your Union Find code produces wrong answers, check these common failure points in order.

Common Mistake
Did you initialize parent[i] = i for all i?
Initializing parent = [0] * n sets parent[i] = 0 for all i, meaning every node already points to node 0. All nodes start in the same component. The correct initialization is parent = list(range(n)) so parent[i] = i and each node is its own root.
Common Mistake
Does union operate on roots, not the raw nodes?
Writing parent[a] = b instead of parent[find(a)] = find(b) breaks the data structure. Non-root nodes should not have their parent changed except through path compression. Always find the roots before performing a union.
Common Mistake
Does union return early when roots are equal?
If find(a) == find(b), the nodes are already in the same set. Performing the union anyway can corrupt the rank array and double-decrement the component count. Always check and return early.
Common Mistake
Is the 2D mapping row * cols + col?
Using row * rows + col instead of row * cols + col causes index collisions when the grid is not square. For a 3x5 grid, cell (1, 0) should map to 5, not 3.
Common Mistake
Are 1-indexed nodes handled with n+1 array size?
Many graph problems use 1-indexed nodes. If you create UnionFind(n) but nodes go from 1 to n, you need UnionFind(n + 1). Otherwise you get an index-out-of-bounds error on the last node.
Common Mistake
Does the component count only decrement on actual merges?
Only decrement the component count when the union actually merges two different components (when roots differ). If the roots are the same, the union does nothing and the component count should not change. This is why the union method returns a boolean.
Common Mistake
Are you calling find() when grouping results?
After unions, an element's direct parent might not be the root. Always call find() to get the actual root before grouping elements by component. Using raw parent values produces incorrect groups.

Practice Problems

These problems are ordered by complexity. The foundation set builds comfort with the template. The later problems test whether you can map unfamiliar scenarios to union/find operations.

Foundation

ProblemDifficultyApproach
Number of ProvincesMediumUnion cities that share a direct connection. Start with n components and decrement on each successful union. The final count is the answer.
Redundant ConnectionMediumProcess edges one at a time. The first edge where both nodes already share a root creates a cycle. That edge is the redundant one.
Number of IslandsMediumTreat each land cell as a node. Union adjacent land cells using 4 directional checks. Count remaining components. Compare with DFS flood fill for trade offs.
Longest Consecutive SequenceMediumFor each number, union it with its neighbor (num+1) if the neighbor exists in the set. Track component sizes during union and return the largest.

Intermediate

ProblemDifficultyApproach
Accounts MergeMediumUnion emails that belong to the same account. After all unions, group emails by their root representative and sort each group alphabetically. Attach the name from any account in the group.
Number of Islands IIHardStart with all water. For each land addition, create a new component and union with any adjacent land cells. Track component count dynamically after each operation.
Evaluate DivisionMediumBuild a weighted union find where edges store ratios. On query, trace both variables to their roots and compute the answer from the accumulated weights along each path.
Surrounded RegionsMediumConnect all border O cells to a virtual node. Union interior O cells with their O neighbors. Any O not connected to the virtual node is surrounded and gets flipped to X.
Satisfiability of Equality EquationsMediumProcess all == equations first by unioning the two variables. Then check each != equation. If both sides share a root, the system is unsatisfiable.

Advanced

ProblemDifficultyApproach
Redundant Connection IIHardThree cases: no double parent, double parent without cycle, double parent with cycle. Track the two candidate edges from the node with two parents and test by removing one.
Min Cost to Connect All PointsMediumSort all possible edges by Manhattan distance. Greedily add the cheapest edge connecting two different components. This is Kruskal's MST. Stop after n-1 edges.
Smallest String With SwapsMediumUnion all indices that can be swapped (transitivity means the whole group is interchangeable). Group characters by root, sort within each group, and place them back in order.
Similar String GroupsHardTwo strings are similar if they differ in exactly 0 or 2 positions. Check all pairs (or use smart bucketing for large inputs), union similar ones. Answer is the component count.
Couples Holding HandsHardModel each couple as a node. If seat neighbors belong to different couples, union those couples. Total swaps needed equals n minus the number of connected components.

Reference Templates

Compact code shapes for quick recovery. Each template includes the context that determines when to use it.

Standard array Union Find with path compression, union by rank, and component count.

Standard Union Find
1class UnionFind:
2 def __init__(self, n):
3 self.parent = list(range(n))
4 self.rank = [0] * n
5 self.components = n
6 def find(self, x):
7 if self.parent[x] != x:
8 self.parent[x] = self.find(self.parent[x])
9 return self.parent[x]
10 def union(self, a, b):
11 ra, rb = self.find(a), self.find(b)
12 if ra == rb: return False
13 if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra
14 self.parent[rb] = ra
15 if self.rank[ra] == self.rank[rb]: self.rank[ra] += 1
16 self.components -= 1
17 return True
18 def connected(self, a, b):
19 return self.find(a) == self.find(b)

HashMap Union Find for strings or object keys. Auto-initializes on first access.

HashMap Union Find
1class StringUF:
2 def __init__(self):
3 self.parent = {}
4 def find(self, x):
5 if x not in self.parent: self.parent[x] = x
6 if self.parent[x] != x:
7 self.parent[x] = self.find(self.parent[x])
8 return self.parent[x]
9 def union(self, a, b):
10 ra, rb = self.find(a), self.find(b)
11 if ra != rb: self.parent[ra] = rb

Cycle detection loop for undirected graphs. Returns the first edge that creates a cycle.

Cycle Detection
1uf = UnionFind(n + 1) # +1 if 1-indexed
2for u, v in edges:
3 if not uf.union(u, v):
4 return [u, v] # cycle-creating edge

Connected components count. Start with n components, decrement on actual merges.

Component Counting
1uf = UnionFind(n)
2for u, v in edges:
3 uf.union(u, v)
4print(uf.components) # number of connected components

2D grid mapping. Convert (row, col) to a 1D index for array-based Union Find.

Grid Mapping
1idx = row * cols + col # NOT row * rows + col
2directions = [(0,1),(0,-1),(1,0),(-1,0)]
3for dr, dc in directions:
4 nr, nc = row + dr, col + dc
5 if 0 <= nr < rows and 0 <= nc < cols:
6 uf.union(idx, nr * cols + nc)

Dynamic grid addition. Add land cells one at a time, union with adjacent land.

Dynamic Grid (Islands II)
1uf = UnionFind(rows * cols)
2uf.components = 0
3land = set()
4for r, c in positions:
5 if (r, c) in land: continue
6 land.add((r, c))
7 uf.components += 1
8 idx = r * cols + c
9 for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
10 nr, nc = r + dr, c + dc
11 if (nr, nc) in land:
12 uf.union(idx, nr * cols + nc)

Entity grouping three-phase pattern. Scan and union, group by root, format output.

Entity Grouping (Three Phases)
1# Phase 1: scan and union
2seen = {}
3for i, entity in enumerate(entities):
4 for attr in entity.attributes:
5 if attr in seen: uf.union(i, seen[attr])
6 else: seen[attr] = i
7# Phase 2: group by root
8groups = defaultdict(list)
9for attr, idx in seen.items():
10 groups[uf.find(idx)].append(attr)
11# Phase 3: format output
12result = [[entities[root].name] + sorted(attrs)
13 for root, attrs in groups.items()]

Pattern Recap

Connected component tracking starts with n components. Each edge triggers a union, and each successful union (where roots differ) decrements the count by one. The count is always available in O(1). For dynamic grids, increment the count when new land appears, then union with adjacent land to merge.

Cycle detection processes edges in order and checks whether both endpoints already share a root. If they do, the edge connects two nodes that are already in the same component, which means there is already a path between them. Adding the edge creates a second path, forming a cycle. This only works for undirected graphs.

Entity grouping follows three phases. First, scan through entities and union any pair that shares an attribute. Second, group all attributes by calling find() on each owner to get the actual root. Third, format the output using the root's identity (name, index, or key). Transitive chains resolve automatically because each union merges entire component trees, not just pairs.

The data structure is always the same 15-line class. What changes is what you union (edge endpoints, shared-attribute entities, adjacent grid cells), what triggers a union (edge processing, attribute collision, neighbor check), and what you query (component count, same-root check, group membership).

Rebuilding the Pattern

When Union Find feels unclear, start with the question: does the problem involve grouping elements where connections can chain transitively? If A connects to B and B connects to C, does that mean A and C should be in the same group? If so, the groups are components, and Union Find tracks them.

After recognizing the pattern, decide what elements to union. In graph problems, union the endpoints of each edge. In entity grouping problems, union entities that share an attribute. In grid problems, union adjacent cells that share a property (both are land, both have the same value, etc.).

Then decide what to query. If the answer is "how many groups," track a component counter that starts at n and decrements on each actual merge. If the answer is "which group does this element belong to," call find() and use the root as the group identifier. If the answer is "does adding this connection create a cycle," check whether both endpoints already share a root before performing the union.

The data structure never changes. Write the 15-line class once at the top, then focus entirely on the problem-specific decisions: what to union, what triggers a union, and what to do with the result of find or the component count.

Check Your Understanding

Use these questions to check whether you can reason through the pattern without looking at the template.

Take Assessment

Ready to test it?

12 questions covering Union Find operations, path compression, cycle detection, and component tracking.

Practice this pattern

Apply the guide to complete interview problems with explanations and code.

Learn Union Find in a guided sequence

The Interview Course connects this pattern to its prerequisites, worked lessons, and progressively harder problems.

Explore the course