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 # 1: each set holds just its root
self.count = n # n: one set per element at the start
def find(self, x: int) -> int:
"""Root of x's set, compressing the path on the way up."""
# Invariant: x is always on the path from the start node to the root.
# A root is the one node that is its own parent.
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 # same root: already one set
# Attach the smaller tree under the larger one.
# This keeps trees shallow, so find stays fast.
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] # root_a now holds both sets
self.count -= 1 # -1: two sets became one
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) # same root means same set
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.find is shorter.Reading the figure. Arrows point from a node to its parent. The green node 0 is the root. Amber nodes are the ones find(4) stands on as it jumps two levels at a time. On the right, the two green arrows are the pointers it rewrote. Nodes 1 and 3 keep their old parents, yet every node is now closer to the root.
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) # n nodes, so n separate sets to start
# Invariant: dsu.count = components among the edges seen so far.
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}.
Reading the figure. Arrows point from a node to its parent. Green nodes are roots, and each root names one set. Blue nodes point to a parent. The thick green arrow is the link made in that step. Count the green nodes in any frame and you get count.
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.
"""
# n - 1: a tree on n nodes has exactly one fewer edge than nodes.
if len(edges) != n - 1:
return False
dsu = DisjointSet(n) # n nodes, each its own set
# union is False on a cycle edge. With n - 1 edges and no cycle,
# the graph must be connected.
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.
# n nodes and n edges here, so len(edges) + 1 gives slots 0..n.
dsu = DisjointSet(len(edges) + 1)
# Invariant: the edges before this one form a forest with no cycle.
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] |
Reading the figure. Each box adds one input edge. A green edge joined two different sets. Gray edges were added in earlier steps. The dashed red edge connects 2 and 3, which already share root 1. union returns False there, and that edge is the answer.
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)) # one set per account to start
owner_of: dict[str, int] = {} # email -> index of the first account seen with it
for index, account in enumerate(accounts):
# account[1:]: skip index 0, which is the name. The rest are emails.
for email in account[1:]:
if email in owner_of:
# A shared email means the same person: join the two accounts.
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.
# [0]: the name is the first entry of an account.
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.
Reading the figure. Boxes on the left are accounts. Boxes in the middle are emails. Gray lines are emails seen for the first time, so they just record an owner. The amber line is x@z showing up a second time, which triggers union(2, 0). Same color on the left means same root, and the right side shows the merged output.
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.# The four neighbours of a cell: down, up, right, left.
# Each pair is (row change, column change). 1 moves forward, -1 back, 0 stays.
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) # m * n: one slot per grid cell
dsu.count = 0 # the grid starts as all water
is_land = [[False] * n for _ in range(m)] # m rows of n cells, all water
answer: list[int] = []
# Invariant: dsu.count = number of islands among the land cells so far.
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 # the neighbour cell
# 0 <= ... < m and 0 <= ... < n keep the neighbour inside the grid.
if 0 <= nr < m and 0 <= nc < n and is_land[nr][nc]:
# Each successful merge takes the count back down by one.
# r * n + c flattens (row, col) to one id: n cells per row.
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.
Reading the figure. Each grid is the state after one addition. The cell with the amber border is the one just added. Cells of the same color belong to the same island. In frame 2 the new cell touches (0,0). One union succeeds, so the count goes up 1 and back down 1. In frame 3 the new cell only touches (0,1) at a corner, so it starts a new island.
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 closes the five classical parts. Part VI adds seven more shapes, starting with Pattern 17, Greedy. After that, the guides cover the mechanics around the patterns: the Python toolkit you write them with, how to read a constraint as a hint, the script to follow in the room, how to test your own code before the interviewer does, the advanced language features a senior loop probes, and the ML and data-processing layer on top.