Union-Find (Disjoint Set Union)
When things keep getting connected and you keep asking "are these two in the same group?" or "how many groups are there now?", a union-find answers both in almost constant time. It's the tool for components that only ever merge.
1. What is this pattern for?
Union-find keeps a parent pointer per element. Following parents leads to the set's root, its representative.
- find(x): follow parents to the root, shortening the path on the way (path compression).
- union(a, b): point one root at the other (attach the smaller set under the larger: union by size).
- Both run in amortised
O(α(n)), effectively constant.
Typical uses: counting connected components as edges arrive, Kruskal's MST, merging accounts, islands appearing over time, grouping indices that can be swapped.
2. How it works (bisected)
- Decide what the nodes are: indices, grid cells (
r · cols + c), emails, rows and columns. - Initialise every node as its own root:
parent[i] = i;components = n. - For each connection, find both roots. If they differ, link them and decrement
components. - Answer queries by comparing roots, reading
components, or grouping nodes by root. - If connections disappear over time, process time backwards so they appear instead (union-find can't split).
3. Seeing a problem: how to spot Union-Find
Trigger words
- "connected", "same group", "friends of friends", "belong to the same person"
- "number of components / islands / provinces" after each operation
- "can be swapped any number of times", "equivalent", "transitively"
- "earliest time when everyone is connected", "last day you can still cross"
- minimum spanning tree with an edge list (Kruskal)
Shape of the input
- A list of pairs or edges, often arriving over time.
- Many connectivity queries mixed with merges.
- Brute-force smell: running BFS/DFS again after every new edge.
4. When to use it, and when not to
| Use union-find when… | Prefer something else when… |
|---|---|
| Edges arrive one by one and you query connectivity in between | The graph is fixed and you query once: one DFS/BFS is simpler |
| You need component counts or sizes as things merge | You need actual paths or distances: BFS / Dijkstra |
| Kruskal's MST, equivalence classes, swap groups | Edges get deleted: reverse time, or use another structure |
| Directed reachability is not needed | Direction matters (dependencies): topological sort / DFS |
Cost: O(α(n)) amortised per operation with path compression and union by size (α grows so slowly it's ≤ 4 for any realistic n); O(n) space.
5. The Python template (memorize this)
class DSU:
def __init__(self, n):
self.parent = list(range(n)) # every node starts as its own root
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already in the same set
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # attach the smaller tree under the larger
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.components -= 1
return True
What the code is doing, line by line:
parent[x] == xmarks a root. Every set is a small tree pointing up to its root.- Path halving (
parent[x] = parent[parent[x]]) makes every visited node skip a level, so later finds are shorter. unionreturnsFalsewhen the two are already connected: that's how cycle detection and "redundant connection" work.- Attaching the smaller tree under the larger keeps trees shallow;
sizealso answers "how big is this group?". componentsstarts atnand drops by one per successful union.
Dry run on 6 nodes with unions (0,1), (2,3), (1,3), (4,5), (0,2):
| union | roots found | action | components |
|---|---|---|---|
| (0, 1) | 0, 1 | parent[1] = 0 | 5 |
| (2, 3) | 2, 3 | parent[3] = 2 | 4 |
| (1, 3) | 0, 2 | sizes equal: parent[2] = 0 | 3 |
| (4, 5) | 4, 5 | parent[5] = 4 | 2 |
| (0, 2) | 0, 0 | same root → nothing (would close a cycle) | 2 |
6. How to remember it forever
How this hook works: what “Ask the boss” means
What the slogan means, phrase by phrase
- “Ask the boss”
- Every element points to a parent. Following parents up leads to the group's boss (the root).
find(x)is asking who your boss is. - “same boss → same group”
- Two elements are connected exactly when
find(a) == find(b). - “one boss starts reporting to the other”
- Union links one root under the other root. Nobody else changes, and no group is relabelled.
Why this picture fits the pattern: Union-find problems keep merging groups and asking "are these two together?" Relabelling a whole group on every merge is slow. Pointing one boss at another is one line, and path compression keeps the chains short.
The story → the code
| In the story | In the code |
|---|---|
| Who do you report to? | parent[x] |
| You are the boss | parent[x] == x |
| Ask the boss | find(x) |
| Shortcut the chain while asking | parent[x] = parent[parent[x]] |
| One boss reports to the other | parent[rb] = ra |
| Number of groups | components, minus 1 per real merge |
Watch it on numbers
Six people (0–5), unions (0,1), (2,3), (1,3), (4,5), (0,2).
- (0,1): different bosses, so 1 now reports to 0. (2,3): 3 reports to 2. Groups: 4.
- (1,3): ask the bosses. 1's boss is 0, 3's boss is 2. Different, so 2 reports to 0. Groups: 3.
- (4,5): 5 reports to 4. Groups: 2.
- (0,2): both bosses are 0, the same boss. Already one group, so this edge would close a cycle. Nothing changes. Final: 2 groups.
The trap it saves you from
Linking the people instead of their bosses (parent[a] = b), which splits groups. Subtracting a group when the bosses were already the same is the other mistake.
It still works when…
Redundant Connection: the first edge whose ends already share a boss. Accounts Merge: emails are the people, and accounts that share one email merge. If edges get removed over time, run the timeline backwards so groups only merge.
Picture
A forest of small trees; every question walks up to a root, and walking flattens the path behind you.
Chant
"Find the roots; different roots, link them, one fewer group."
Safety rule
"Union-find only merges." If edges disappear, run time backwards.
7. LeetCode problems, solved step by step
Medium 947. Most Stones Removed with Same Row or Column Google Amazon
Problem: A stone can be removed if another stone shares its row or column. Return the most stones that can be removed.
stones = [[0,0],[0,1],[1,0],[1,2],[2,1],[2,2]]5stones = [[0,0],[0,2],[1,1],[2,0],[2,2]]3Constraints: 1 ≤ stones.length ≤ 1000 · 0 ≤ xi, yi ≤ 10⁴ · No two stones are at the same coordinate point.
Hint 1
Which stones can end up removed together? Think of connected groups.
Hint 2
How many stones must remain in each group?
From brute force to the pattern: Trying removal orders with DFS is exponential; components give the answer in near-linear time.
Spot it: Stones sharing a row or column form connected groups, and each group can be reduced to one stone. So the answer is stones − components. Union-find on rows and columns (column ids offset to keep them distinct) finds the components.
Why not another pattern? Simulating removals in some order can get stuck; counting components gives the optimum directly.
Approach:
- For each stone, union
rowwith~col(a distinct key for columns). - Count distinct roots among the used keys.
- Return
len(stones) − roots.
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for r, c in stones:
parent[find(r)] = find(~c) # a stone links its row and its column (~c keeps columns separate)
components = len({find(x) for x in parent})
return len(stones) - components # each group keeps exactly one stone
What the code is doing, line by line:
~cis−c − 1, which can never collide with a row index ≥ 0.- Within a connected group, stones can be removed leaf-first (think of a spanning tree) until one remains.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- One stone → 0.
- All in one row.
- Stones connected only through a chain.
Complexity: O(n · α(n)) time, O(n) space.
Medium 721. Accounts Merge Google Amazon Microsoft
Problem: Accounts sharing any email belong to the same person. Merge them: name followed by sorted emails (any order of accounts).
accounts = [["John","johnsmith@mail.com","john_newyork@mail.com"],["John","johnsmith@mail.com","john00@mail.com"],["Mary","mary@mail.com"],["John","johnnybravo@mail.com"]][["John","john00@mail.com","john_newyork@mail.com","johnsmith@mail.com"],["Mary","mary@mail.com"],["John","johnnybravo@mail.com"]]accounts = [["Gabe","Gabe0@m.co","Gabe3@m.co","Gabe1@m.co"],["Kevin","Kevin3@m.co","Kevin5@m.co","Kevin0@m.co"],["Ethan","Ethan5@m.co","Ethan4@m.co","Ethan0@m.co"],["Hanzo","Hanzo3@m.co","Hanzo1@m.co","Hanzo0@m.co"],["Fern","Fern5@m.co","Fern1@m.co","Fern0@m.co"]][["Ethan","Ethan0@m.co","Ethan4@m.co","Ethan5@m.co"],["Gabe","Gabe0@m.co","Gabe1@m.co","Gabe3@m.co"],["Hanzo","Hanzo0@m.co","Hanzo1@m.co","Hanzo3@m.co"],["Kevin","Kevin0@m.co","Kevin3@m.co","Kevin5@m.co"],["Fern","Fern0@m.co","Fern1@m.co","Fern5@m.co"]]Constraints: 1 ≤ accounts.length ≤ 1000 · 2 ≤ accounts[i].length ≤ 10 · 1 ≤ accounts[i][j].length ≤ 30 · accounts[i][0] consists of English letters. · accounts[i][j] (for j > 0) is a valid email.
Hint 1
What should the nodes of the graph be?
Hint 2
Union all emails in one account; then group by root.
From brute force to the pattern: Pairwise account comparison is quadratic; union-find over emails is near-linear.
Spot it: Emails are nodes; an account links all of its emails. Connected components = people → union-find over emails.
Why not another pattern? Comparing every pair of accounts for shared emails is O(a² · e).
Approach:
- For each account, union each email with the account's first email; remember each email's name.
- Group emails by
find(email). - Output
[name] + sorted(emails)per group.
from collections import defaultdict
class Solution:
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
parent = {}
name_of = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for name, first, *rest in accounts:
name_of[first] = name
find(first)
for email in rest:
name_of[email] = name
parent[find(email)] = find(first) # same account: same person
groups = defaultdict(list)
for email in parent:
groups[find(email)].append(email)
return [[name_of[root]] + sorted(emails) for root, emails in groups.items()]
What the code is doing, line by line:
- Linking every email to the account's first email connects the whole account.
- A shared email links two accounts' components automatically.
- Names are the same within a person, so any email's name works.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Same name, different people (no shared email).
- An account with one email.
- Chains of accounts linked through different emails.
Complexity: O(E log E) time (sorting dominates), O(E) space for E emails.
Hard 1970. Last Day Where You Can Still Cross Google
Problem: Each day one cell floods. Return the last day you can still walk from the top row to the bottom row on land.
row = 2, col = 2, cells = [[1,1],[2,1],[1,2],[2,2]]2row = 2, col = 2, cells = [[1,1],[1,2],[2,1],[2,2]]1Constraints: 2 ≤ row, col ≤ 2 * 10⁴ · 4 ≤ row * col ≤ 2 * 10⁴ · cells.length == row * col · 1 ≤ ri ≤ row · 1 ≤ ci ≤ col · All the values of cells are unique.
Hint 1
Union-find can't remove connections. Can you reverse the process?
Hint 2
Add virtual top and bottom nodes so crossing becomes a connectivity check.
From brute force to the pattern: Checking every day with BFS is O((rc)²); binary search + BFS is O(rc log rc); reverse union-find is near-linear.
Spot it: Flooding only removes connections, which union-find can't undo. Run time backwards: start fully flooded and turn cells back into land from the last day. With virtual TOP and BOTTOM nodes, the first day they connect is the answer.
Why not another pattern? Binary search on the day with BFS per check is O(rc log rc) — also fine; reverse union-find is a single pass.
Approach:
- Nodes
0..r·c − 1, plusTOP = r·c,BOTTOM = r·c + 1. - For
dayfrom the last down to 1: makecells[day − 1]land; union with land neighbours; top-row cells with TOP, bottom-row with BOTTOM. - If
find(TOP) == find(BOTTOM), returnday − 1(that cell was still land on day − 1).
class Solution:
def latestDayToCross(self, row: int, col: int, cells: List[List[int]]) -> int:
TOP, BOTTOM = row * col, row * col + 1
parent = list(range(row * col + 2))
land = [[False] * col for _ in range(row)]
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
for day in range(len(cells), 0, -1): # undo the floods, latest first
r, c = cells[day - 1][0] - 1, cells[day - 1][1] - 1
land[r][c] = True
node = r * col + c
if r == 0:
union(node, TOP)
if r == row - 1:
union(node, BOTTOM)
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < row and 0 <= nc < col and land[nr][nc]:
union(node, nr * col + nc)
if find(TOP) == find(BOTTOM):
return day - 1 # on day - 1 this cell was still land, and a path existed
return 0
What the code is doing, line by line:
- Going backwards, land only appears, so connections only form: exactly what union-find supports.
- The virtual TOP and BOTTOM nodes turn "any top cell to any bottom cell" into one connectivity question.
- When restoring the cell flooded on
daycreates the connection, the land on dayday − 1has a crossing.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- One column.
- A crossing only via a winding path.
- The first flood already blocks everything.
Complexity: O(r · c · α(r · c)) time and space.
8. Common mistakes
parent[a] = b without find can split a set's tree and lose members. Always link find(a) to find(b).find can hit Python's recursion limit. Use the iterative loop.~col, rows * cols, …).9. Self-check quiz
Edges arrive one at a time; after each, report the number of connected components. Best tool?
union(a, b) finds the same root for a and b. What does that mean?
Cells flood one per day; when can you no longer cross? How does union-find help?
What makes union-find operations nearly constant time?
10. Practice list (in order)
| # | Problem | Level | Data structure | Twist |
|---|---|---|---|---|
| 947 | Most Stones Removed with Same Row or Column | Medium | Union each stone's row with its column; answer = stones − connected components | |
| 721 | Accounts Merge | Medium | Union every email of an account with its first email; group emails by root | |
| 1101 | The Earliest Moment When Everyone Become Friends | Medium | Sort logs by time; union friends and return the time when the number of groups hits 1 | |
| 1722 | Minimize Hamming Distance After Swap Operations | Medium | Allowed swaps connect indices; inside a component any arrangement is possible: match values by counts | |
| 2492 | Minimum Score of a Path Between Two Cities | Medium | Roads may be reused, so the answer is the smallest road anywhere in city 1's component | |
| 3532 | Path Existence Queries in a Graph I | Medium | nums is sorted: neighbours i, i+1 are connected iff their gap ≤ maxDiff; components are runs | |
| 547 | Number of Provinces | Medium | Solved in 11 · Depth-First Search (DFS) on Graphs: Components from an adjacency matrix | |
| 684 | Redundant Connection | Medium | Union edges in order; the first edge whose endpoints are already connected closes the cycle | |
| 990 | Satisfiability of Equality Equations | Medium | Union all '==' pairs first, then check no '!=' pair ends up in the same set | |
| 1319 | Number of Operations to Make Network Connected | Medium | Need at least n − 1 cables; then the answer is the number of components − 1 (union-find) | |
| 3607 | Power Grid Maintenance | Medium | Union-find for grids (connectivity never changes); per grid a min-heap of station ids, skipping offline ones lazily | |
| 1202 | Smallest String With Swaps | Medium | Swap pairs connect indices; inside each component, sort the characters and place them back in sorted index order | |
| 1970 | Last Day Where You Can Still Cross | Hard | Reverse time: add land cells from the last day backwards; the first moment TOP and BOTTOM connect | |
| 305 | Number of Islands II | Hard | Add land one cell at a time: +1 island, then −1 for every union with a different neighbouring island | |
| 827 | Making A Large Island | Hard | Label islands with their sizes first; each 0 joins the distinct islands around it: 1 + their sizes | |
| 3534 | Path Existence Queries in a Graph II | Hard | Sort; reach[p] = farthest sorted position within maxDiff; binary lifting on reach counts the fewest jumps | |
| 2421 | Number of Good Paths | Hard | Solved in 11 · Depth-First Search (DFS) on Graphs: Add nodes by increasing value with union-find; within a component, equal maxima pair up | |
| 1489 | Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree | Hard | Solved in 20 · Shortest Path & MST: Kruskal three ways: normal, without edge e (critical if worse), with e forced first (pseudo if equal) | |
| 3600 | Maximize Spanning Tree Stability with Upgrades | Hard | Solved in 20 · Shortest Path & MST: Binary search the stability x; check with union-find: must edges, then edges ≥ x, then upgradable edges (count ≤ k) |
Worked solutions for the practice list
Problems already solved in section 7 link back up there, and rows marked "Solved in …" link to the page where that problem is worked through. Every other problem from the table is solved here.
Hard 305. Number of Islands II Google Amazon
Problem: An m × n grid starts as water. After each addLand position, return the number of islands.
Solve it on LeetCode ↗ (Premium)
m = 3, n = 3, positions = [[0,0],[0,1],[1,2],[2,1]][1,1,2,3]m = 1, n = 1, positions = [[0,0]][1]Constraints: 1 ≤ m, n, positions.length ≤ 10⁴ · 1 ≤ m * n ≤ 10⁴ · positions[i].length == 2 · 0 ≤ ri < m · 0 ≤ ci < n
Hint 1
Islands only ever merge as land appears.
Hint 2
Use union-find: each new cell is a new island, and each merge with a different neighbouring island removes one.
From brute force to the pattern: Counting islands with BFS after every addition is O(k · mn); union-find is near O(k).
Spot it: Islands only merge as land is added, never split → union-find handles each addition in near-constant time: the new cell is a new island, and each union with a neighbouring island of a different root removes one.
Why not another pattern? Re-running a flood-fill count after every addition is O(k · m · n).
Approach:
parentdict for land cells only;islands = 0.- Add
(r, c)(skip duplicates):parent[cell] = cell,islands += 1. - For each land neighbour: if its root differs, union and
islands −= 1. - Record
islands.
class Solution:
def numIslands2(self, m: int, n: int, positions: List[List[int]]) -> List[int]:
parent = {} # land cell -> parent cell
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
islands = 0
result = []
for r, c in positions:
cell = (r, c)
if cell not in parent: # adding land twice changes nothing
parent[cell] = cell
islands += 1 # a brand-new island
for nb in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if nb in parent:
a, b = find(cell), find(nb)
if a != b:
parent[a] = b # two islands become one
islands -= 1
result.append(islands)
return result
What the code is doing, line by line:
- Only land cells are stored, so the grid is never allocated.
- Each successful union merges two different islands, reducing the count by one.
- A neighbour already in the same set (e.g. two neighbours of one island) doesn't change the count.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- The same position added twice.
- A cell that joins three islands at once.
- 1×1 grid.
Complexity: O(k · α(k)) time for k additions, O(k) space.
Hard 827. Making A Large Island Google Meta Amazon Microsoft
Problem: Change at most one 0 to 1. Return the size of the largest island possible.
grid = [[1,0],[0,1]]3grid = [[1,1],[1,0]]4Constraints: n == grid.length · n == grid[i].length · 1 ≤ n ≤ 500 · grid[i][j] is either 0 or 1.
Hint 1
Measure every island once, before trying flips.
Hint 2
A flipped 0 connects the different islands adjacent to it; don't count the same island twice.
From brute force to the pattern: Flip + BFS for every zero is O(n⁴); precomputed sizes make each zero O(1).
Spot it: Precompute every island's component id and size (union-find or flood fill). Flipping a 0 joins the distinct islands next to it, so its result is 1 + Σ sizes of the distinct neighbouring ids.
Why not another pattern? Flipping each 0 and re-measuring with BFS is O(n⁴).
Approach:
- Union-find over land cells, tracking component sizes.
- For each 0: collect the roots of its land neighbours in a set; candidate
1 + Σ size[root]. - Answer: the max candidate, or
n²if there's no 0.
class Solution:
def largestIsland(self, grid: List[List[int]]) -> int:
n = len(grid)
parent = list(range(n * n))
size = [1] * (n * n)
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for r in range(n):
for c in range(n):
if grid[r][c]:
for nr, nc in ((r + 1, c), (r, c + 1)):
if nr < n and nc < n and grid[nr][nc]:
a, b = find(r * n + c), find(nr * n + nc)
if a != b:
parent[a] = b
size[b] += size[a] # island sizes add up
best = max((size[find(i)] for i in range(n * n) if grid[i // n][i % n]), default=0)
for r in range(n):
for c in range(n):
if grid[r][c] == 0:
roots = {find(nr * n + nc) for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1))
if 0 <= nr < n and 0 <= nc < n and grid[nr][nc]} # distinct neighbouring islands
best = max(best, 1 + sum(size[x] for x in roots))
return best
What the code is doing, line by line:
- Cells are numbered
r · n + cfor the union-find arrays. - The set removes duplicates when the same island touches the 0 from two sides.
- If the grid has no 0, the answer is the existing largest island (all ones → n²).
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- All ones → n².
- All zeros → 1.
- One island touching a zero on two sides.
Complexity: O(n² · α(n²)) time, O(n²) space.
Medium 1101. The Earliest Moment When Everyone Become Friends Google
Problem: Friendship is transitive. Given [timestamp, x, y] logs, return the earliest time everyone is acquainted, or −1.
Solve it on LeetCode ↗ (Premium)
logs = [[20190101,0,1],[20190104,3,4],[20190107,2,3],[20190211,1,5],[20190224,2,4],[20190301,0,3],[20190312,1,2],[20190322,4,5]], n = 620190301logs = [[0,2,0],[1,0,1],[3,0,3],[4,1,2],[7,3,1]], n = 43Constraints: 2 ≤ n ≤ 100 · 1 ≤ logs.length ≤ 10⁴ · logs[i].length == 3 · 0 ≤ timestampi ≤ 10⁹ · 0 ≤ xi, yi ≤ n - 1 · xi != yi · All the values timestampi are unique · All the pairs (xi, yi) occur at most one time in the input
Hint 1
Process events in time order.
Hint 2
Track how many friend groups exist with union-find.
From brute force to the pattern: Checking connectivity with BFS after every log is O(L · (n + L)); union-find is near-linear.
Spot it: Groups only merge over time → process logs in time order with union-find, counting groups. The first time the count reaches 1 is the answer.
Why not another pattern? Rebuilding the graph and checking connectivity after every log is O(logs · n).
Approach:
- Sort logs by timestamp;
groups = n. - For each log: if
xandyhave different roots, union andgroups −= 1. - When
groups == 1, return the timestamp; otherwise −1.
class Solution:
def earliestAcq(self, logs: List[List[int]], n: int) -> int:
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
groups = n
for t, x, y in sorted(logs): # in time order
a, b = find(x), find(y)
if a != b:
parent[a] = b
groups -= 1 # two friend groups merge
if groups == 1:
return t # everyone is connected now
return -1
What the code is doing, line by line:
- Each merge of two different groups reduces the number of groups by one.
- Logs between people already in the same group change nothing.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Never fully connected → −1.
- Logs not sorted.
n = 1(never happens per constraints, but 0 groups to merge).
Complexity: O(L log L + L · α(n)) time, O(n) space.
Medium 1722. Minimize Hamming Distance After Swap Operations Google
Problem: Swaps between given index pairs can be done any number of times. Minimise the Hamming distance between source and target.
source = [1,2,3,4], target = [2,1,4,5], allowedSwaps = [[0,1],[2,3]]1source = [1,2,3,4], target = [1,3,2,4], allowedSwaps = []2Constraints: n == source.length == target.length · 1 ≤ n ≤ 10⁵ · 1 ≤ source[i], target[i] ≤ 10⁵ · 0 ≤ allowedSwaps.length ≤ 10⁵ · allowedSwaps[i].length == 2 · 0 ≤ ai, bi ≤ n - 1 · ai != bi
Hint 1
If you can swap (a, b) and (b, c), can you rearrange a, b, c in any order?
Hint 2
Group indices into components and compare value counts inside each.
From brute force to the pattern: Exploring swap sequences is exponential; components + counting is linear.
Spot it: Swaps compose: indices in the same union-find component can be permuted arbitrarily. So per component, the matches are the multiset overlap between source values and target values.
Why not another pattern? Simulating swaps or searching permutations is exponential.
Approach:
- Union the index pairs.
- Per component root: counter of source values.
- For each index, if its target value is available in its component's counter, use it; otherwise count a mismatch.
from collections import Counter, defaultdict
class Solution:
def minimumHammingDistance(self, source: List[int], target: List[int], allowedSwaps: List[List[int]]) -> int:
n = len(source)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in allowedSwaps:
parent[find(a)] = find(b) # these positions can exchange values
available = defaultdict(Counter) # component -> source values it holds
for i, x in enumerate(source):
available[find(i)][x] += 1
distance = 0
for i, t in enumerate(target):
pool = available[find(i)]
if pool[t]:
pool[t] -= 1 # some source value in this component can be moved here
else:
distance += 1
return distance
What the code is doing, line by line:
- Adjacent transpositions within a connected set generate every permutation of that set.
- Matching target values against the component's multiset counts the best overlap.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- No swaps (plain Hamming distance).
- Duplicate values.
- Everything connected.
Complexity: O(n + s · α(n)) time, O(n) space.
Medium 2492. Minimum Score of a Path Between Two Cities Google
Problem: A path's score is its smallest road. Paths may repeat roads and cities. Return the minimum score of a path from 1 to n.
n = 4, roads = [[1,2,9],[2,3,6],[2,4,5],[1,4,7]]5n = 4, roads = [[1,2,2],[1,3,4],[3,4,7]]2Constraints: 2 ≤ n ≤ 10⁵ · 1 ≤ roads.length ≤ 10⁵ · roads[i].length == 3 · 1 ≤ ai, bi ≤ n · ai != bi · 1 ≤ distancei ≤ 10⁴ · There are no repeated edges. · There is at least one path between 1 and n.
Hint 1
Paths can reuse roads. Which roads can a path from 1 to n include?
Hint 2
Find city 1's connected component.
From brute force to the pattern: Searching paths with a max-min objective is overkill; the component's smallest road is the answer.
Spot it: Because paths can wander and come back, any road in the same connected component as city 1 (and n) can be included. Find the component with union-find and take its smallest road.
Why not another pattern? Dijkstra-style path optimisation is unnecessary: the repetition rule removes the path structure.
Approach:
- Union all roads.
- Return the minimum distance among roads whose endpoints share city 1's root.
class Solution:
def minScore(self, n: int, roads: List[List[int]]) -> int:
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b, _ in roads:
parent[find(a)] = find(b)
root = find(1)
return min(d for a, b, d in roads if find(a) == root) # any road reachable from city 1
What the code is doing, line by line:
- From city 1 you can detour to any road in its component and come back, then continue to n.
- Both endpoints of a road are in the same component, so checking one is enough.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Disconnected parts with smaller roads (ignored).
- A single road.
- The smallest road on a dead-end branch.
Complexity: O(E · α(n)) time, O(n) space.
Medium 3532. Path Existence Queries in a Graph I Google Amazon Microsoft
Problem: Nodes i, j are linked if |nums[i] − nums[j]| ≤ maxDiff (nums sorted). Answer reachability queries.
n = 2, nums = [1,3], maxDiff = 1, queries = [[0,0],[0,1]][true,false]n = 4, nums = [2,5,6,8], maxDiff = 2, queries = [[0,1],[0,2],[1,3],[2,3]][false,false,true,true]Constraints: 1 ≤ n == nums.length ≤ 10⁵ · 0 ≤ nums[i] ≤ 10⁵ · nums is sorted in non-decreasing order. · 0 ≤ maxDiff ≤ 10⁵ · 1 ≤ queries.length ≤ 10⁵ · queries[i] == [ui, vi] · 0 ≤ ui, vi < n
Hint 1
The array is sorted. When can two nodes on opposite sides of a big gap be connected?
Hint 2
Components are contiguous segments.
From brute force to the pattern: All pairwise edges + BFS is O(n²); scanning gaps is O(n).
Spot it: With sorted values, if i reaches some j > i + 1, then each step in between also has a small gap, so components are contiguous runs broken where nums[i+1] − nums[i] > maxDiff. Label runs (a 1D union-find).
Why not another pattern? Building all O(n²) edges is too slow.
Approach:
group[0] = 0;group[i] = group[i − 1] + (gap > maxDiff).- Query:
group[u] == group[v].
class Solution:
def pathExistenceQueries(self, n: int, nums: List[int], maxDiff: int, queries: List[List[int]]) -> List[bool]:
group = [0] * n
for i in range(1, n):
group[i] = group[i - 1] + (nums[i] - nums[i - 1] > maxDiff) # a big gap starts a new component
return [group[u] == group[v] for u, v in queries]
What the code is doing, line by line:
- If adjacent sorted values are close, they're directly linked, so a run of close neighbours is connected.
- A gap larger than maxDiff can't be jumped by anyone, since every pair across it differs even more.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
u == v.- Equal values.
- Every gap too large.
Complexity: O(n + q) time, O(n) space.
Hard 3534. Path Existence Queries in a Graph II Google
Problem: Same graph but nums is unsorted, and queries ask for the shortest path length (−1 if unreachable).
n = 5, nums = [1,8,3,4,2], maxDiff = 3, queries = [[0,3],[2,4]][1,1]n = 5, nums = [5,3,1,9,10], maxDiff = 2, queries = [[0,1],[0,2],[2,3],[4,3]][1,2,-1,1]Constraints: 1 ≤ n == nums.length ≤ 10⁵ · 0 ≤ nums[i] ≤ 10⁵ · 0 ≤ maxDiff ≤ 10⁵ · 1 ≤ queries.length ≤ 10⁵ · queries[i] == [ui, vi] · 0 ≤ ui, vi < n
Hint 1
Sort the values. From a node, which reachable node is the best next step?
Hint 2
Greedy farthest jumps give shortest paths; count jumps quickly with binary lifting.
From brute force to the pattern: BFS per query is O(q · n²) on the implicit graph; greedy jumps with binary lifting are O(log n) per query.
Spot it: In sorted order, the best move from a node is to the farthest position still within maxDiff (greedy jumps, like Jump Game II). Precompute that with two pointers, then binary lifting answers each query in O(log n).
Why not another pattern? BFS per query is O(n²) per query.
Approach:
- Sort indices by value;
pos[node]= its sorted position. reach[p]= largestqwithvals[q] − vals[p] ≤ maxDiff(two pointers);up[0] = reach,up[j] = up[j−1] ∘ up[j−1].- Query
a < b(positions): jump with the largest powers whileup[j][a] < b, counting2^j; then one more jump ifreach[a] ≥ b, else −1.
class Solution:
def pathExistenceQueries(self, n: int, nums: List[int], maxDiff: int, queries: List[List[int]]) -> List[int]:
order = sorted(range(n), key=lambda i: nums[i])
pos = [0] * n
for p, i in enumerate(order):
pos[i] = p
vals = [nums[i] for i in order]
reach = [0] * n # farthest sorted position reachable in one step
q = 0
for p in range(n):
q = max(q, p)
while q + 1 < n and vals[q + 1] - vals[p] <= maxDiff:
q += 1
reach[p] = q
up = [reach] # up[j][p]: position after 2^j greedy jumps
for _ in range(n.bit_length()):
prev = up[-1]
up.append([prev[prev[p]] for p in range(n)])
answer = []
for u, v in queries:
a, b = sorted((pos[u], pos[v]))
if a == b:
answer.append(0)
continue
jumps = 0
for j in range(len(up) - 1, -1, -1):
if up[j][a] < b:
a = up[j][a] # still short of b: take these 2^j jumps
jumps += 1 << j
answer.append(jumps + 1 if reach[a] >= b else -1) # one more jump, or stuck
return answer
What the code is doing, line by line:
- Jumping to the farthest reachable position is never worse: anything reachable from a nearer position is reachable from the farther one too.
- Binary lifting takes jumps in powers of two while staying short of the target, then checks the final jump.
- If the greedy walk gets stuck (
reach[a] == a), the target is in another component: −1.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Same node → 0.
- Different components → −1.
- Equal values.
Complexity: O((n + q) log n) time, O(n log n) space.
Medium 684. Redundant Connection Start here Amazon Microsoft
Problem: A tree had one extra edge added. Return the edge to remove (the last such edge in the input if several work).
edges = [[1,2],[1,3],[2,3]][2,3]edges = [[1,2],[2,3],[3,4],[1,4],[1,5]][1,4]Constraints: n == edges.length · 3 ≤ n ≤ 1000 · edges[i].length == 2 · 1 ≤ ai < bi ≤ edges.length · ai != bi · There are no repeated edges. · The given graph is connected.
Hint 1
Add edges one by one. When does an edge become redundant?
Hint 2
Use union-find to check if its endpoints are already connected.
From brute force to the pattern: Removing each edge and testing is O(n²); union-find is near-linear.
Spot it: Adding edges one by one, the extra edge is the one that joins two nodes already connected → union-find detects it immediately.
Why not another pattern? Removing each edge and checking connectivity is O(n²).
Approach:
- For each edge in order: if
find(a) == find(b), return it. - Otherwise union them.
class Solution:
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
parent = list(range(len(edges) + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in edges:
ra, rb = find(a), find(b)
if ra == rb:
return [a, b] # already connected: this edge closes the cycle
parent[ra] = rb
return []
What the code is doing, line by line:
- The graph has exactly one cycle; the first edge that closes it, in input order, is the last cycle edge to appear — the one the problem asks for.
- Every other edge joins two different components.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- The cycle includes the first edges.
- Small cycle of length 3.
- The extra edge is last.
Complexity: O(n · α(n)) time, O(n) space.
Medium 990. Satisfiability of Equality Equations Start here Amazon
Problem: Equations are a==b or a!=b over letters. Can all hold at once?
equations = ["a==b","b!=a"]falseequations = ["b==a","a==b"]trueConstraints: 1 ≤ equations.length ≤ 500 · equations[i].length == 4 · equations[i][0] is a lowercase letter. · equations[i][1] is either '=' or '!'. · equations[i][2] is '='. · equations[i][3] is a lowercase letter.
Hint 1
Which letters must end up equal?
Hint 2
Group them with union-find, then check the inequalities.
From brute force to the pattern: Assigning values is exponential; union-find is linear.
Spot it: Equalities are transitive → union-find groups letters that must be equal. Then each != must connect different groups.
Why not another pattern? Trying value assignments is exponential in the number of letters.
Approach:
- Union the two letters of every
==. - For every
!=, if both letters have the same root, return false.
class Solution:
def equationsPossible(self, equations: List[str]) -> bool:
parent = list(range(26))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for e in equations:
if e[1] == "=":
parent[find(ord(e[0]) - 97)] = find(ord(e[3]) - 97) # must be equal
for e in equations:
if e[1] == "!" and find(ord(e[0]) - 97) == find(ord(e[3]) - 97):
return False # forced equal, but required different
return True
What the code is doing, line by line:
- All equalities must be applied before checking inequalities, because equality chains can come in any order.
a != ais caught because a letter is always in its own group.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- a!=a → false.
- Long equality chains.
- Only inequalities → true.
Complexity: O(m · α(26)) time, O(1) space.
Medium 1319. Number of Operations to Make Network Connected Start here Amazon
Problem: Move cables (each connects two computers) so all n computers are connected. Return the fewest moves, or −1.
n = 4, connections = [[0,1],[0,2],[1,2]]1n = 6, connections = [[0,1],[0,2],[0,3],[1,2],[1,3]]2Constraints: 1 ≤ n ≤ 10⁵ · 1 ≤ connections.length ≤ min(n * (n - 1) / 2, 10⁵) · connections[i].length == 2 · 0 ≤ ai, bi < n · ai != bi · There are no repeated connections. · No two computers are connected by more than one cable.
Hint 1
How many cables does a connected network need at minimum?
Hint 2
Count the components with union-find.
From brute force to the pattern: Simulating moves is complicated; counting components is near-linear.
Spot it: Each move can join two components, using a redundant cable. With at least n − 1 cables there are always enough spares, so the answer is components − 1, counted with union-find.
Why not another pattern? Simulating cable moves is unnecessary once the counting argument is clear.
Approach:
- If
len(connections) < n − 1, return −1. - Union all cables, counting successful unions.
- Return
components − 1.
class Solution:
def makeConnected(self, n: int, connections: List[List[int]]) -> int:
if len(connections) < n - 1:
return -1 # not enough cables to connect n computers
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
components = n
for a, b in connections:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
components -= 1
return components - 1 # one move per extra component
What the code is doing, line by line:
- A connected graph on
nnodes needsn − 1edges, so fewer cables can never work. - Otherwise the redundant cables (those inside a component) are enough to link the components.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- Too few cables → −1.
- Already connected → 0.
- Isolated computers.
Complexity: O(m · α(n)) time, O(n) space.
Medium 3607. Power Grid Maintenance Cisco
Problem: Stations go offline over time (grids stay connected). For a check at x: x if online, else the smallest online id in its grid, else −1.
c = 5, connections = [[1,2],[2,3],[3,4],[4,5]], queries = [[1,3],[2,1],[1,1],[2,2],[1,2]][3,2,3]c = 3, connections = [], queries = [[1,1],[2,1],[1,1]][1,-1]Hint 1
Does going offline change which stations are in the same grid?
Hint 2
For each grid, you need the smallest online id under deletions: a heap with lazy deletion.
From brute force to the pattern: Scanning a grid per query is O(c); lazy heaps are O(log c) amortised.
Spot it: Grids are fixed connected components → union-find once. Each component keeps a min-heap of its ids; offline stations are removed lazily when they reach the top.
Why not another pattern? Scanning a grid for its smallest online station per query is O(c) each.
Approach:
- Union all cables; heap of ids per root.
- Type 2: mark offline.
- Type 1: if online, answer
x; else pop offline ids from its grid's heap and answer the top or −1.
import heapq
class Solution:
def processQueries(self, c: int, connections: List[List[int]], queries: List[List[int]]) -> List[int]:
parent = list(range(c + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in connections:
parent[find(a)] = find(b)
grid = {} # root -> min-heap of station ids in that grid
for s in range(1, c + 1):
grid.setdefault(find(s), []).append(s) # added in increasing order: already a heap
offline = set()
answer = []
for kind, x in queries:
if kind == 2:
offline.add(x)
continue
if x not in offline:
answer.append(x) # it handles its own check
continue
heap = grid[find(x)]
while heap and heap[0] in offline:
heapq.heappop(heap) # lazily drop stations that went offline
answer.append(heap[0] if heap else -1)
return answer
What the code is doing, line by line:
- Taking a station offline doesn't change connectivity, so the components are computed once.
- Stations never come back online, so a popped offline id never needs to return.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- All stations offline → −1.
- Isolated stations.
- Checking an online station.
Complexity: O((c + n) α(c) + q log c) time, O(c) space.
Medium 1202. Smallest String With Swaps Microsoft
Problem: Swap characters at any given pairs any number of times. Return the lexicographically smallest string.
s = "dcab", pairs = [[0,3],[1,2]]"bacd"s = "dcab", pairs = [[0,3],[1,2],[0,2]]"abcd"Constraints: 1 ≤ s.length ≤ 10^5 · 0 ≤ pairs.length ≤ 10^5 · 0 ≤ pairs[i][0], pairs[i][1] < s.length · s only contains lower case English letters.
Hint 1
Which positions can exchange letters (even indirectly)?
Hint 2
Within such a group, which arrangement is smallest?
From brute force to the pattern: Searching swap sequences is exponential; components + sorting is near-linear.
Spot it: Swaps compose: indices in the same union-find component can be permuted freely, so give each component its characters in sorted order.
Why not another pattern? Simulating swaps can't reach every permutation efficiently.
Approach:
- Union all pairs.
- Group indices by root.
- Sort each group's characters and write them into its sorted indices.
from collections import defaultdict
class Solution:
def smallestStringWithSwaps(self, s: str, pairs: List[List[int]]) -> str:
parent = list(range(len(s)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in pairs:
parent[find(a)] = find(b)
groups = defaultdict(list)
for i in range(len(s)):
groups[find(i)].append(i) # indices in increasing order
out = list(s)
for idx in groups.values():
for i, ch in zip(idx, sorted(s[i] for i in idx)):
out[i] = ch # smallest letters to the earliest positions
return "".join(out)
What the code is doing, line by line:
- Repeated swaps along connected pairs can produce any permutation of a component.
- Sorted letters into sorted positions is the smallest arrangement of each component.
Dry run: use the Step through the solution player under the code. It runs this exact code on Example 1 and shows every line with the variables at that moment.
Edge cases to test:
- No pairs (unchanged).
- Everything connected (sorted string).
- Repeated pairs.
Complexity: O(n log n + p α(n)) time, O(n) space.