Twenty lines that answer “are these two things connected?” in effectively constant time, and keep answering it correctly as new connections keep arriving.
DFS also finds connected components, in O(V + E). Union-Find wins when the graph is dynamic: edges arrive one at a time and you must answer after each one. DFS would have to start over every time. Union-Find just absorbs the edge.
| Signal | What it looks like |
|---|---|
| Connectivity | “how many connected components”, “are A and B connected”, “is the graph a tree”. |
| Streaming edges | “after each new edge, report…”. This is the case DFS cannot do cheaply. |
| Cycle detection, undirected | An edge whose endpoints are already together closes a cycle. One line. |
| Merging identities | “merge accounts sharing an email”, “group equivalent variables”, “same-person records”. |
| Kruskal | Minimum spanning tree: sort the edges, add one if it joins two different components. |
| Union-Find | DFS or BFS | |
|---|---|---|
| Edges known up front | Works | Works, and is simpler |
| Edges arrive over time | Works, O(α) per edge | Needs a full re-run per edge |
| Gives the actual path | No | Yes |
| Handles deletions | No, merges are permanent | Yes |
| Directed graphs | No | Yes |
find(x) walks up to the root. Two elements are in the same set exactly when find returns the same root. union(a, b) hangs one root under the other, which merges two whole trees in one pointer write.Naive Union-Find degenerates into a linked list and gives O(n) per operation. Two small heuristics fix it, and you need both to get the famous bound.
| Heuristic | What it does | Alone gives |
|---|---|---|
| Union by size (or rank) | Always attach the smaller tree under the larger one, so the result never gets deeper than it must. | O(log n) |
| Path compression | During find, re-point the nodes you walk past directly at the root. The walk was happening anyway, so it is free. | O(log n) amortised |
| Both together | — | O(α(n)) |
α is the inverse Ackermann function. It grows so slowly that α(n) ≤ 4 for any n that could be written down, so the operations are constant time for every practical purpose. Tarjan proved this bound is tight, so it cannot be improved. The full proof is in CLRS chapter 19.Both work and both give the same bound. Size counts the elements in the set, and rank is an upper bound on the tree height. Prefer size: the number is meaningful on its own, so problems asking “how big is this group” get the answer for free, and it is harder to get subtly wrong.
class DisjointSet:
"""Union-Find over 0 .. n-1, with path compression and union by size.
Attributes:
count: The number of disjoint sets remaining.
"""
def __init__(self, n: int) -> None:
self.parent = list(range(n)) # everyone starts as their own root
self.size = [1] * n
self.count = n
def find(self, x: int) -> int:
"""Root of x's set, compressing the path on the way up."""
while self.parent[x] != x:
# Path halving: point x at its grandparent, then step up two.
# Same effect as full compression, in one iterative pass.
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a: int, b: int) -> bool:
"""Merge the sets holding a and b.
Returns:
True if they were separate and are now merged. False if they
were already together, which means this edge closes a cycle.
"""
root_a, root_b = self.find(a), self.find(b)
if root_a == root_b:
return False
# Attach the smaller tree under the larger one.
if self.size[root_a] < self.size[root_b]:
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a
self.size[root_a] += self.size[root_b]
self.count -= 1
return True
def connected(self, a: int, b: int) -> bool:
"""True if a and b are in the same set."""
return self.find(a) == self.find(b)
Write this once and reuse it. Every problem below is this class plus a few lines of mapping.
bool return from union is the most useful line in the class. False means the two endpoints were already connected, so the edge is redundant and closes a cycle. That single return value solves Redundant Connection outright and drives Kruskal’s algorithm.parent[x] = find(parent[x]), flattens the path completely but costs stack depth. Path halving, shown above, points each node at its grandparent while walking. It halves the path length per pass, achieves the same amortised bound, and never recurses. Prefer it in Python.a == b instead of find(a) == find(b). Only roots identify a set.parent[b] = a instead of parent[root_b] = root_a. Linking non-roots corrupts the structure.1..n, size the arrays at n + 1 and ignore slot 0.count. Decrement only on a real merge, inside the branch that actually links.Given n nodes labelled 0 to n-1 and a list of undirected edges, return the number of connected components.
n components, one per node. Every edge that joins two different components reduces the count by one. Edges inside a component change nothing.count field does the whole job, so there is no final pass to count distinct roots.def count_components(n: int, edges: list[list[int]]) -> int:
"""Number of connected components in an undirected graph.
Args:
n: Node count. Nodes are labelled 0 .. n-1.
edges: Undirected edges as [a, b] pairs.
Returns:
The number of connected components.
Example:
>>> count_components(5, [[0, 1], [1, 2], [3, 4]])
2
"""
dsu = DisjointSet(n)
for a, b in edges:
dsu.union(a, b) # only a real merge decrements dsu.count
return dsu.count
n = 5, edges [[0,1], [1,2], [3,4]]:
| edge | merged? | count |
|---|---|---|
| start | — | 5 |
| [0, 1] | yes | 4 |
| [1, 2] | yes | 3 |
| [3, 4] | yes | 2 |
Components are {0, 1, 2} and {3, 4}.
Effectively O(E). Quoting the α factor and then saying “which is at most 4, so effectively linear” is the complete answer.
def valid_tree(n: int, edges: list[list[int]]) -> bool:
"""A graph is a tree when it is connected and has exactly n-1 edges.
Equivalently: n-1 edges and no edge is ever redundant.
"""
if len(edges) != n - 1:
return False
dsu = DisjointSet(n)
return all(dsu.union(a, b) for a, b in edges)
If any union returns False, that edge closed a cycle, so it is not a tree. With exactly n - 1 edges and no cycle, connectivity follows automatically. That is a neat two-fact argument worth having ready.
n.1.[2, 2]: also a no-op. Correct.n = 0: the answer is 0.A tree on n nodes had one extra edge added, creating exactly one cycle. Given the edge list, return the edge that can be removed to restore a tree. If several answers exist, return the one that appears last in the input.
bool from union is the entire solution. This problem exists to test whether you have that return value.1..n, so size the structure at n + 1.def find_redundant_connection(edges: list[list[int]]) -> list[int]:
"""The edge that closes the single cycle in a tree plus one extra edge.
Args:
edges: Undirected edges over nodes 1..n, in the order they were added.
Returns:
The edge to remove, the last one in the input that closes a cycle.
Raises:
ValueError: If no edge closes a cycle.
Example:
>>> find_redundant_connection([[1, 2], [1, 3], [2, 3]])
[2, 3]
"""
# Nodes are 1-indexed, so allocate one extra slot and ignore index 0.
dsu = DisjointSet(len(edges) + 1)
for a, b in edges:
# union returns False when a and b already share a root, which
# means this edge creates a cycle.
if not dsu.union(a, b):
return [a, b]
raise ValueError("no redundant edge: the graph is already a tree")
[[1,2], [1,3], [2,3]]:
| edge | find(a) | find(b) | outcome |
|---|---|---|---|
| [1, 2] | 1 | 2 | different, merge |
| [1, 3] | 1 | 3 | different, merge |
| [2, 3] | 1 | 1 | same, so return [2, 3] |
len(edges) + 1A tree on n nodes has n - 1 edges, and this graph has one more, so len(edges) == n. Nodes run from 1 to n, so the arrays need n + 1 slots. Slot 0 is allocated and never touched. Getting this wrong gives an IndexError on the highest-numbered node, which is easy to miss on a small test.
[[1,2],[2,1]]: caught on the second edge.union(x, x) returns False immediately, so it is reported. Ask whether self-loops can appear.union returning false is the answer. Scanning forwards also satisfies the last-in-input rule, because there is only one extra edge.”Each account is a name followed by a list of emails. Two accounts belong to the same person if they share at least one email. Merge them, and return each merged account as the name followed by its emails in sorted order. The same name may belong to different people.
find(owner). Each bucket is one person.from collections import defaultdict
def accounts_merge(accounts: list[list[str]]) -> list[list[str]]:
"""Merge accounts that share any email address.
Args:
accounts: Each entry is [name, email1, email2, ...].
Returns:
One entry per person: [name, ...emails sorted ascending].
Example:
>>> accounts_merge([["A", "x@z"], ["B", "y@z"], ["A", "x@z", "w@z"]])
[['A', 'w@z', 'x@z'], ['B', 'y@z']]
"""
dsu = DisjointSet(len(accounts))
owner_of: dict[str, int] = {} # email -> index of the first account seen with it
for index, account in enumerate(accounts):
for email in account[1:]:
if email in owner_of:
dsu.union(index, owner_of[email])
else:
owner_of[email] = index
# Bucket every email under the root of the account that owns it.
groups: defaultdict[int, set[str]] = defaultdict(set)
for email, index in owner_of.items():
groups[dsu.find(index)].add(email)
# Every account in a group carries the same name, so the root's will do.
return [
[accounts[root][0], *sorted(emails)]
for root, emails in groups.items()
]
Accounts [["A","x@z"], ["B","y@z"], ["A","x@z","w@z"]]:
| account | action | |
|---|---|---|
| 0 | x@z | new, owner 0 |
| 1 | y@z | new, owner 1 |
| 2 | x@z | seen, union(2, 0) |
| 2 | w@z | new, owner 2 |
Accounts 0 and 2 now share a root, so their emails bucket together. Account 1 stands alone even though nothing else was needed to separate it.
E is the total number of emails. The Union-Find work is effectively linear, so the sort dominates. Naming the sort as the bottleneck, rather than the merging, is the sharp answer here.
union(i, i) is a no-op.You start with an m × n grid of water. Land is added one cell at a time. After each addition, report the current number of islands. Return the list of counts.
(r, c) is index r * n + c. That lets the standard integer class work unchanged, with no coordinate keys.count at zero, because there is no land yet. This is the one place the class needs adjusting.count optimistically, treating the new cell as its own island. Then union it with each of the four neighbours that is already land. Every successful union decrements count, so the arithmetic settles itself.DIRECTIONS: tuple[tuple[int, int], ...] = ((1, 0), (-1, 0), (0, 1), (0, -1))
def num_islands2(m: int, n: int, positions: list[list[int]]) -> list[int]:
"""Island count after each land addition to an initially empty grid.
Args:
m: Number of rows.
n: Number of columns.
positions: Cells [row, col] turned into land, in order.
Returns:
The island count after each addition.
Example:
>>> num_islands2(3, 3, [[0, 0], [0, 1], [1, 2], [2, 1]])
[1, 1, 2, 3]
"""
dsu = DisjointSet(m * n)
dsu.count = 0 # the grid starts as all water
is_land = [[False] * n for _ in range(m)]
answer: list[int] = []
for r, c in positions:
if is_land[r][c]:
answer.append(dsu.count) # repeat position: nothing changes
continue
is_land[r][c] = True
dsu.count += 1 # a brand new island, for the moment
for dr, dc in DIRECTIONS:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and is_land[nr][nc]:
# Each successful merge takes the count back down by one.
dsu.union(r * n + c, nr * n + nc)
answer.append(dsu.count)
return answer
m = 3, n = 3, positions [[0,0], [0,1], [1,2], [2,1]]:
| position | count after +1 | merges | reported |
|---|---|---|---|
| [0, 0] | 1 | no land neighbours | 1 |
| [0, 1] | 2 | joins (0,0), so −1 | 1 |
| [1, 2] | 2 | none adjacent | 2 |
| [2, 1] | 3 | none adjacent | 3 |
Notice [1,2] is diagonal to [0,1], and diagonals do not connect.
k is the number of additions. Each one does at most four unions, so the work per addition is constant.
+1 -3, a net -2, which is right: three islands plus a new cell became one. Getting this for free is why union should return whether it merged.[1].[].m × n is huge but k is small, allocating the full grid may be wasteful. A dictionary keyed by coordinate avoids it, at some constant-factor cost. Worth a sentence.find(a) == find(b) is the connectivity test.α(n) ≤ 4 for any real input, so treat the operations as constant time and say why.union return whether it merged. That one boolean gives cycle detection, component counting and Kruskal.r * cols + c, and accounts become their index.That is all sixteen patterns. What is left is not more patterns, it is the mechanics around them. The four guides cover that: the Python toolkit you write them with, how to read a constraint as a hint, the script to follow in the room, and how to test your own code before the interviewer does.