InterviewKata
Pattern 23 of 24

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?

A party where people join friend groups. Each group picks a "representative". To check whether two guests are in the same group, ask each who their representative is. When two groups meet, one representative simply starts reporting to the other. Nobody re-labels every guest; the chain of "who do you report to" does the work.

Union-find keeps a parent pointer per element. Following parents leads to the set's root, its representative.

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)

  1. Decide what the nodes are: indices, grid cells (r · cols + c), emails, rows and columns.
  2. Initialise every node as its own root: parent[i] = i; components = n.
  3. For each connection, find both roots. If they differ, link them and decrement components.
  4. Answer queries by comparing roots, reading components, or grouping nodes by root.
  5. 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.
The one question to ask"Do groups only ever merge, and do I mostly need to know who is in the same group?"

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 betweenThe graph is fixed and you query once: one DFS/BFS is simpler
You need component counts or sizes as things mergeYou need actual paths or distances: BFS / Dijkstra
Kruskal's MST, equivalence classes, swap groupsEdges get deleted: reverse time, or use another structure
Directed reachability is not neededDirection 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:

  1. parent[x] == x marks a root. Every set is a small tree pointing up to its root.
  2. Path halving (parent[x] = parent[parent[x]]) makes every visited node skip a level, so later finds are shorter.
  3. union returns False when the two are already connected: that's how cycle detection and "redundant connection" work.
  4. Attaching the smaller tree under the larger keeps trees shallow; size also answers "how big is this group?".
  5. components starts at n and drops by one per successful union.

Dry run on 6 nodes with unions (0,1), (2,3), (1,3), (4,5), (0,2):

unionroots foundactioncomponents
(0, 1)0, 1parent[1] = 05
(2, 3)2, 3parent[3] = 24
(1, 3)0, 2sizes equal: parent[2] = 03
(4, 5)4, 5parent[5] = 42
(0, 2)0, 0same root → nothing (would close a cycle)2

6. How to remember it forever

Memory hook: "Ask the boss"Two people are in the same group if they have the same boss. Merging groups = one boss starts reporting to the other.
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 storyIn the code
Who do you report to?parent[x]
You are the bossparent[x] == x
Ask the bossfind(x)
Shortcut the chain while askingparent[x] = parent[parent[x]]
One boss reports to the otherparent[rb] = ra
Number of groupscomponents, minus 1 per real merge

Watch it on numbers

Six people (0–5), unions (0,1), (2,3), (1,3), (4,5), (0,2).

  1. (0,1): different bosses, so 1 now reports to 0. (2,3): 3 reports to 2. Groups: 4.
  2. (1,3): ask the bosses. 1's boss is 0, 3's boss is 2. Different, so 2 reports to 0. Groups: 3.
  3. (4,5): 5 reports to 4. Groups: 2.
  4. (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

New to this pattern? Start with theseSolve 684. Redundant Connection, then 990. Satisfiability of Equality Equations, then 1319. Number of Operations to Make Network Connected. They are marked Start here below. Read the explanation, press Play on the step-through, then close the page and solve it yourself on LeetCode.
Medium 947. Most Stones Removed with Same Row or Column Google Amazon ArrayHash Map

Problem: A stone can be removed if another stone shares its row or column. Return the most stones that can be removed.

Example 1
Input: stones = [[0,0],[0,1],[1,0],[1,2],[2,1],[2,2]]
Output: 5
Explanation: One way to remove 5 stones is as follows: 1. Remove stone [2,2] because it shares the same row as [2,1]. 2. Remove stone [2,1] because it shares the same column as [0,1]. 3. Remove stone [1,2] because it shares the same row as [1,0]. 4. Remove stone [1,0] because it shares the same column as [0,0]. 5. Remove stone [0,1] because it shares the same row as [0,0]. Stone [0,0] cannot be removed since it does not share a row/column with another stone still on the plane.
Example 2
Input: stones = [[0,0],[0,2],[1,1],[2,0],[2,2]]
Output: 3
Explanation: One way to make 3 moves is as follows: 1. Remove stone [2,2] because it shares the same row as [2,0]. 2. Remove stone [2,0] because it shares the same column as [0,0]. 3. Remove stone [0,2] because it shares the same row as [0,0]. Stones [0,0] and [1,1] cannot be removed since they do not share a row/column with another stone still on the plane.

Constraints: 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:

  1. For each stone, union row with ~col (a distinct key for columns).
  2. Count distinct roots among the used keys.
  3. 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:

  1. ~c is −c − 1, which can never collide with a row index ≥ 0.
  2. 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 StringHash Map

Problem: Accounts sharing any email belong to the same person. Merge them: name followed by sorted emails (any order of accounts).

Example 1
Input: accounts = [["John","johnsmith@mail.com","john_newyork@mail.com"],["John","johnsmith@mail.com","john00@mail.com"],["Mary","mary@mail.com"],["John","johnnybravo@mail.com"]]
Output: [["John","john00@mail.com","john_newyork@mail.com","johnsmith@mail.com"],["Mary","mary@mail.com"],["John","johnnybravo@mail.com"]]
Explanation: The first and second John's are the same person as they have the common email "johnsmith@mail.com". The third John and Mary are different people as none of their email addresses are used by other accounts. We could return these lists in any order, for example the answer [['Mary', 'mary@mail.com'], ['John', 'johnnybravo@mail.com'], ['John', 'john00@mail.com', 'john_newyork@mail.com', 'johnsmith@mail.com']] would still be accepted.
Example 2
Input: 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"]]
Output: [["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:

  1. For each account, union each email with the account's first email; remember each email's name.
  2. Group emails by find(email).
  3. 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:

  1. Linking every email to the account's first email connects the whole account.
  2. A shared email links two accounts' components automatically.
  3. 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 MatrixArray

Problem: Each day one cell floods. Return the last day you can still walk from the top row to the bottom row on land.

Example 1
Input: row = 2, col = 2, cells = [[1,1],[2,1],[1,2],[2,2]]
Output: 2
Explanation: The above image depicts how the matrix changes each day starting from day 0. The last day where it is possible to cross from top to bottom is on day 2.
Example 2
Input: row = 2, col = 2, cells = [[1,1],[1,2],[2,1],[2,2]]
Output: 1
Explanation: The above image depicts how the matrix changes each day starting from day 0. The last day where it is possible to cross from top to bottom is on day 1.

Constraints: 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:

  1. Nodes 0..r·c − 1, plus TOP = r·c, BOTTOM = r·c + 1.
  2. For day from the last down to 1: make cells[day − 1] land; union with land neighbours; top-row cells with TOP, bottom-row with BOTTOM.
  3. If find(TOP) == find(BOTTOM), return day − 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:

  1. Going backwards, land only appears, so connections only form: exactly what union-find supports.
  2. The virtual TOP and BOTTOM nodes turn "any top cell to any bottom cell" into one connectivity question.
  3. When restoring the cell flooded on day creates the connection, the land on day day − 1 has 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

Linking the nodes instead of their rootsparent[a] = b without find can split a set's tree and lose members. Always link find(a) to find(b).
Counting a union that didn't mergeDecrement the component count only when the roots differ.
Recursive find on deep treesWithout compression, a recursive find can hit Python's recursion limit. Use the iterative loop.
Mixing id spacesRows and columns, or cells and virtual nodes, need distinct ids (~col, rows * cols, …).
Trying to delete edgesUnion-find can't split sets; reverse the timeline (Last Day Where You Can Still Cross) or use another approach.

9. Self-check quiz

Edges arrive one at a time; after each, report the number of connected components. Best tool?

Each edge is one union in near-constant time; the component count updates in O(1).

union(a, b) finds the same root for a and b. What does that mean?

Equal roots mean the two were in one set already, so adding the edge creates a cycle.

Cells flood one per day; when can you no longer cross? How does union-find help?

Backwards in time, land only appears, so sets only merge.

What makes union-find operations nearly constant time?

Together they keep the trees extremely shallow: amortised O(α(n)).

10. Practice list (in order)

#ProblemLevelData structureTwist
947Most Stones Removed with Same Row or ColumnMediumArrayHash MapUnion each stone's row with its column; answer = stones − connected components
721Accounts MergeMediumStringHash MapUnion every email of an account with its first email; group emails by root
1101The Earliest Moment When Everyone Become FriendsMediumArraySort logs by time; union friends and return the time when the number of groups hits 1
1722Minimize Hamming Distance After Swap OperationsMediumArrayHash MapAllowed swaps connect indices; inside a component any arrangement is possible: match values by counts
2492Minimum Score of a Path Between Two CitiesMediumGraphRoads may be reused, so the answer is the smallest road anywhere in city 1's component
3532Path Existence Queries in a Graph IMediumArraynums is sorted: neighbours i, i+1 are connected iff their gap ≤ maxDiff; components are runs
547Number of ProvincesMediumGraphMatrixSolved in 11 · Depth-First Search (DFS) on Graphs: Components from an adjacency matrix
684Redundant ConnectionMediumGraphUnion edges in order; the first edge whose endpoints are already connected closes the cycle
990Satisfiability of Equality EquationsMediumStringArrayUnion all '==' pairs first, then check no '!=' pair ends up in the same set
1319Number of Operations to Make Network ConnectedMediumGraphNeed at least n − 1 cables; then the answer is the number of components − 1 (union-find)
3607Power Grid MaintenanceMediumGraphHeapHash SetUnion-find for grids (connectivity never changes); per grid a min-heap of station ids, skipping offline ones lazily
1202Smallest String With SwapsMediumStringArraySwap pairs connect indices; inside each component, sort the characters and place them back in sorted index order
1970Last Day Where You Can Still CrossHardMatrixArrayReverse time: add land cells from the last day backwards; the first moment TOP and BOTTOM connect
305Number of Islands IIHardMatrixArrayAdd land one cell at a time: +1 island, then −1 for every union with a different neighbouring island
827Making A Large IslandHardMatrixHash SetLabel islands with their sizes first; each 0 joins the distinct islands around it: 1 + their sizes
3534Path Existence Queries in a Graph IIHardArraySort; reach[p] = farthest sorted position within maxDiff; binary lifting on reach counts the fewest jumps
2421Number of Good PathsHardGraphArraySolved in 11 · Depth-First Search (DFS) on Graphs: Add nodes by increasing value with union-find; within a component, equal maxima pair up
1489Find Critical and Pseudo-Critical Edges in Minimum Spanning TreeHardGraphArraySolved in 20 · Shortest Path & MST: Kruskal three ways: normal, without edge e (critical if worse), with e forced first (pseudo if equal)
3600Maximize Spanning Tree Stability with UpgradesHardGraphArraySolved 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 MatrixArray

Problem: An m × n grid starts as water. After each addLand position, return the number of islands.

Example 1
Input: m = 3, n = 3, positions = [[0,0],[0,1],[1,2],[2,1]]
Output: [1,1,2,3]
Explanation: (0,1) joins the island at (0,0); (1,2) and (2,1) each start a new island.
Example 2
Input: m = 1, n = 1, positions = [[0,0]]
Output: [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:

  1. parent dict for land cells only; islands = 0.
  2. Add (r, c) (skip duplicates): parent[cell] = cell, islands += 1.
  3. For each land neighbour: if its root differs, union and islands −= 1.
  4. 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:

  1. Only land cells are stored, so the grid is never allocated.
  2. Each successful union merges two different islands, reducing the count by one.
  3. 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 MatrixHash Set

Problem: Change at most one 0 to 1. Return the size of the largest island possible.

Example 1
Input: grid = [[1,0],[0,1]]
Output: 3
Explanation: Change one 0 to 1 and connect two 1s, then we get an island with area = 3.
Example 2
Input: grid = [[1,1],[1,0]]
Output: 4
Explanation: Change the 0 to 1 and make the island bigger, only one island with area = 4.

Constraints: 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:

  1. Union-find over land cells, tracking component sizes.
  2. For each 0: collect the roots of its land neighbours in a set; candidate 1 + Σ size[root].
  3. 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:

  1. Cells are numbered r · n + c for the union-find arrays.
  2. The set removes duplicates when the same island touches the 0 from two sides.
  3. 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 Array

Problem: Friendship is transitive. Given [timestamp, x, y] logs, return the earliest time everyone is acquainted, or −1.

Example 1
Input: 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 = 6
Output: 20190301
Explanation: At 20190301 the groups {0,1,5} and {2,3,4} join, so everyone is connected.
Example 2
Input: logs = [[0,2,0],[1,0,1],[3,0,3],[4,1,2],[7,3,1]], n = 4
Output: 3

Constraints: 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:

  1. Sort logs by timestamp; groups = n.
  2. For each log: if x and y have different roots, union and groups −= 1.
  3. 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:

  1. Each merge of two different groups reduces the number of groups by one.
  2. 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 ArrayHash Map

Problem: Swaps between given index pairs can be done any number of times. Minimise the Hamming distance between source and target.

Example 1
Input: source = [1,2,3,4], target = [2,1,4,5], allowedSwaps = [[0,1],[2,3]]
Output: 1
Explanation: source can be transformed the following way: - Swap indices 0 and 1: source = [2,1,3,4] - Swap indices 2 and 3: source = [2,1,4,3] The Hamming distance of source and target is 1 as they differ in 1 position: index 3.
Example 2
Input: source = [1,2,3,4], target = [1,3,2,4], allowedSwaps = []
Output: 2
Explanation: There are no allowed swaps. The Hamming distance of source and target is 2 as they differ in 2 positions: index 1 and index 2.

Constraints: 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:

  1. Union the index pairs.
  2. Per component root: counter of source values.
  3. 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:

  1. Adjacent transpositions within a connected set generate every permutation of that set.
  2. 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 Graph

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.

Example 1
Input: n = 4, roads = [[1,2,9],[2,3,6],[2,4,5],[1,4,7]]
Output: 5
Explanation: The path from city 1 to 4 with the minimum score is: 1 -> 2 -> 4. The score of this path is min(9,5) = 5. It can be shown that no other path has less score.
Example 2
Input: n = 4, roads = [[1,2,2],[1,3,4],[3,4,7]]
Output: 2
Explanation: The path from city 1 to 4 with the minimum score is: 1 -> 2 -> 1 -> 3 -> 4. The score of this path is min(2,2,4,7) = 2.

Constraints: 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:

  1. Union all roads.
  2. 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:

  1. From city 1 you can detour to any road in its component and come back, then continue to n.
  2. 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 Array

Problem: Nodes i, j are linked if |nums[i] − nums[j]| ≤ maxDiff (nums sorted). Answer reachability queries.

Example 1
Input: n = 2, nums = [1,3], maxDiff = 1, queries = [[0,0],[0,1]]
Output: [true,false]
Explanation: Query [0,0]: Node 0 has a trivial path to itself. Query [0,1]: There is no edge between Node 0 and Node 1 because |nums[0] - nums[1]| = |1 - 3| = 2, which is greater than maxDiff. Thus, the final answer after processing all the queries is [true, false].
Example 2
Input: n = 4, nums = [2,5,6,8], maxDiff = 2, queries = [[0,1],[0,2],[1,3],[2,3]]
Output: [false,false,true,true]
Explanation: The resulting graph is: Query [0,1]: There is no edge between Node 0 and Node 1 because |nums[0] - nums[1]| = |2 - 5| = 3, which is greater than maxDiff. Query [0,2]: There is no edge between Node 0 and Node 2 because |nums[0] - nums[2]| = |2 - 6| = 4, which is greater than maxDiff. Query [1,3]: There is a path between Node 1 and Node 3 through Node 2 since |nums[1] - nums[2]| = |5 - 6| = 1 and |nums[2] - nums[3]| = |6 - 8| = 2, both of which are within maxDiff. Query [2,3]: There is an edge between Node 2 and Node 3 because |nums[2] - nums[3]| = |6 - 8| = 2, which is equal to maxDiff. Thus, the final answer after processing all the queries is [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:

  1. group[0] = 0; group[i] = group[i − 1] + (gap > maxDiff).
  2. 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:

  1. If adjacent sorted values are close, they're directly linked, so a run of close neighbours is connected.
  2. 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 Array

Problem: Same graph but nums is unsorted, and queries ask for the shortest path length (−1 if unreachable).

Example 1
Input: n = 5, nums = [1,8,3,4,2], maxDiff = 3, queries = [[0,3],[2,4]]
Output: [1,1]
Explanation: The resulting graph is: Query Shortest Path Minimum Distance [0, 3] 0 → 3 1 [2, 4] 2 → 4 1 Thus, the output is [1, 1].
Example 2
Input: n = 5, nums = [5,3,1,9,10], maxDiff = 2, queries = [[0,1],[0,2],[2,3],[4,3]]
Output: [1,2,-1,1]
Explanation: The resulting graph is:

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:

  1. Sort indices by value; pos[node] = its sorted position.
  2. reach[p] = largest q with vals[q] − vals[p] ≤ maxDiff (two pointers); up[0] = reach, up[j] = up[j−1] ∘ up[j−1].
  3. Query a < b (positions): jump with the largest powers while up[j][a] < b, counting 2^j; then one more jump if reach[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:

  1. Jumping to the farthest reachable position is never worse: anything reachable from a nearer position is reachable from the farther one too.
  2. Binary lifting takes jumps in powers of two while staying short of the target, then checks the final jump.
  3. 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 Graph

Problem: A tree had one extra edge added. Return the edge to remove (the last such edge in the input if several work).

Example 1
Input: edges = [[1,2],[1,3],[2,3]]
Output: [2,3]
Example 2
Input: edges = [[1,2],[2,3],[3,4],[1,4],[1,5]]
Output: [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:

  1. For each edge in order: if find(a) == find(b), return it.
  2. 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:

  1. 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.
  2. 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 StringArray

Problem: Equations are a==b or a!=b over letters. Can all hold at once?

Example 1
Input: equations = ["a==b","b!=a"]
Output: false
Explanation: If we assign say, a = 1 and b = 1, then the first equation is satisfied, but not the second. There is no way to assign the variables to satisfy both equations.
Example 2
Input: equations = ["b==a","a==b"]
Output: true
Explanation: We could assign a = 1 and b = 1 to satisfy both equations.

Constraints: 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:

  1. Union the two letters of every ==.
  2. 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:

  1. All equalities must be applied before checking inequalities, because equality chains can come in any order.
  2. a != a is 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 Graph

Problem: Move cables (each connects two computers) so all n computers are connected. Return the fewest moves, or −1.

Example 1
Input: n = 4, connections = [[0,1],[0,2],[1,2]]
Output: 1
Explanation: Remove cable between computer 1 and 2 and place between computers 1 and 3.
Example 2
Input: n = 6, connections = [[0,1],[0,2],[0,3],[1,2],[1,3]]
Output: 2

Constraints: 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:

  1. If len(connections) < n − 1, return −1.
  2. Union all cables, counting successful unions.
  3. 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:

  1. A connected graph on n nodes needs n − 1 edges, so fewer cables can never work.
  2. 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 GraphHeapHash Set

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.

Example 1
Input: c = 5, connections = [[1,2],[2,3],[3,4],[4,5]], queries = [[1,3],[2,1],[1,1],[2,2],[1,2]]
Output: [3,2,3]
Explanation: Initially, all stations {1, 2, 3, 4, 5} are online and form a single power grid. Query [1,3]: Station 3 is online, so the maintenance check is resolved by station 3. Query [2,1]: Station 1 goes offline. The remaining online stations are {2, 3, 4, 5}. Query [1,1]: Station 1 is offline, so the check is resolved by the operational station with the smallest id among {2, 3, 4, 5}, which is station 2. Query [2,2]: Station 2 goes offline. The remaining online stations are {3, 4, 5}. Query [1,2]: Station 2 is offline, so the check is resolved by the operational station with the smallest id among {3, 4, 5}, which is station 3.
Example 2
Input: c = 3, connections = [], queries = [[1,1],[2,1],[1,1]]
Output: [1,-1]
Explanation: There are no connections, so each station is its own isolated grid. Query [1,1]: Station 1 is online in its isolated grid, so the maintenance check is resolved by station 1. Query [2,1]: Station 1 goes offline. Query [1,1]: Station 1 is offline and there are no other stations in its grid, so the result is -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:

  1. Union all cables; heap of ids per root.
  2. Type 2: mark offline.
  3. 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:

  1. Taking a station offline doesn't change connectivity, so the components are computed once.
  2. 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 StringArray

Problem: Swap characters at any given pairs any number of times. Return the lexicographically smallest string.

Example 1
Input: s = "dcab", pairs = [[0,3],[1,2]]
Output: "bacd"
Explanation: Swap s[0] and s[3], s = "bcad" Swap s[1] and s[2], s = "bacd"
Example 2
Input: s = "dcab", pairs = [[0,3],[1,2],[0,2]]
Output: "abcd"
Explanation: Swap s[0] and s[3], s = "bcad" Swap s[0] and s[2], s = "acbd" Swap s[1] and s[2], s = "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:

  1. Union all pairs.
  2. Group indices by root.
  3. 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:

  1. Repeated swaps along connected pairs can produce any permutation of a component.
  2. 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.