Part V · Aggregates, Stacks and Graphs Pattern 16 4 problems

Union-Find (Disjoint Set Union)

Twenty lines that answer “are these two things connected?” in effectively constant time, and keep answering it correctly as new connections keep arriving.

DFS also finds connected components, in O(V + E). Union-Find wins when the graph is dynamic: edges arrive one at a time and you must answer after each one. DFS would have to start over every time. Union-Find just absorbs the edge.

Contents

  1. When to use
  2. Core idea
  3. The two optimisations
  4. The template
  5. Common mistakes
  6. Number of Connected Components
  7. Redundant Connection
  8. Accounts Merge
  9. Number of Islands II
  10. Recap

When to use

The trigger. Items get grouped by a relation that is reflexive, symmetric and transitive, and you need to know which group something is in. The strongest signal is that the groups merge over time and never split.
SignalWhat it looks like
Connectivity“how many connected components”, “are A and B connected”, “is the graph a tree”.
Streaming edges“after each new edge, report…”. This is the case DFS cannot do cheaply.
Cycle detection, undirectedAn edge whose endpoints are already together closes a cycle. One line.
Merging identities“merge accounts sharing an email”, “group equivalent variables”, “same-person records”.
KruskalMinimum spanning tree: sort the edges, add one if it joins two different components.

Union-Find or DFS?

Union-FindDFS or BFS
Edges known up frontWorksWorks, and is simpler
Edges arrive over timeWorks, O(α) per edgeNeeds a full re-run per edge
Gives the actual pathNoYes
Handles deletionsNo, merges are permanentYes
Directed graphsNoYes
Union-Find cannot un-merge. Sets only ever combine. If the problem deletes edges, this is the wrong tool, and the usual trick is to process the whole thing backwards in time so deletions become additions.

Core idea

Each set is a tree, and each element stores only a pointer to its parent. The root points at itself and acts as the set’s name. find(x) walks up to the root. Two elements are in the same set exactly when find returns the same root. union(a, b) hangs one root under the other, which merges two whole trees in one pointer write.
before union(3, 5) 1 2 3 4 size 4 5 6 size 2 after 1 2 3 5 4 6 size 6, one pointer written
Figure 16.1 — The smaller root is attached under the larger one. Nothing below either root has to move.

The two optimisations

Naive Union-Find degenerates into a linked list and gives O(n) per operation. Two small heuristics fix it, and you need both to get the famous bound.

HeuristicWhat it doesAlone gives
Union by size (or rank)Always attach the smaller tree under the larger one, so the result never gets deeper than it must.O(log n)
Path compressionDuring find, re-point the nodes you walk past directly at the root. The walk was happening anyway, so it is free.O(log n) amortised
Both together—O(α(n))
α is the inverse Ackermann function. It grows so slowly that α(n) ≤ 4 for any n that could be written down, so the operations are constant time for every practical purpose. Tarjan proved this bound is tight, so it cannot be improved. The full proof is in CLRS chapter 19.

Union by size versus union by rank

Both work and both give the same bound. Size counts the elements in the set, and rank is an upper bound on the tree height. Prefer size: the number is meaningful on its own, so problems asking “how big is this group” get the answer for free, and it is harder to get subtly wrong.

The template

The class to memorise
class DisjointSet:
    """Union-Find over 0 .. n-1, with path compression and union by size.

    Attributes:
        count: The number of disjoint sets remaining.
    """

    def __init__(self, n: int) -> None:
        self.parent = list(range(n))    # everyone starts as their own root
        self.size = [1] * n             # 1: each set holds just its root
        self.count = n                  # n: one set per element at the start

    def find(self, x: int) -> int:
        """Root of x's set, compressing the path on the way up."""
        # Invariant: x is always on the path from the start node to the root.
        # A root is the one node that is its own parent.
        while self.parent[x] != x:
            # Path halving: point x at its grandparent, then step up two.
            # Same effect as full compression, in one iterative pass.
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]

        return x

    def union(self, a: int, b: int) -> bool:
        """Merge the sets holding a and b.

        Returns:
            True if they were separate and are now merged. False if they
            were already together, which means this edge closes a cycle.
        """
        root_a, root_b = self.find(a), self.find(b)

        if root_a == root_b:
            return False                # same root: already one set

        # Attach the smaller tree under the larger one.
        # This keeps trees shallow, so find stays fast.
        if self.size[root_a] < self.size[root_b]:
            root_a, root_b = root_b, root_a

        self.parent[root_b] = root_a
        self.size[root_a] += self.size[root_b]   # root_a now holds both sets
        self.count -= 1                 # -1: two sets became one

        return True

    def connected(self, a: int, b: int) -> bool:
        """True if a and b are in the same set."""
        return self.find(a) == self.find(b)   # same root means same set

Write this once and reuse it. Every problem below is this class plus a few lines of mapping.

The bool return from union is the most useful line in the class. False means the two endpoints were already connected, so the edge is redundant and closes a cycle. That single return value solves Redundant Connection outright and drives Kruskal’s algorithm.
Path halving versus full compression. The recursive version, parent[x] = find(parent[x]), flattens the path completely but costs stack depth. Path halving, shown above, points each node at its grandparent while walking. It halves the path length per pass, achieves the same amortised bound, and never recurses. Prefer it in Python.
before find(4) 0 1 2 3 4 root find(4) walks up the chain. 4 is re-pointed at its grandparent 2. Then x jumps to 2. 2 is re-pointed at its grandparent 0. Then x jumps to 0, the root. path from 4: 4 steps before, 2 after after 0 1 2 3 4
Figure 16.2 — Path halving re-points every other node on the walk, so the next find is shorter.

Reading the figure. Arrows point from a node to its parent. The green node 0 is the root. Amber nodes are the ones find(4) stands on as it jumps two levels at a time. On the right, the two green arrows are the pointers it rewrote. Nodes 1 and 3 keep their old parents, yet every node is now closer to the root.

Common mistakes

The problems

1. Number of Connected Components Medium

Problem

Given n nodes labelled 0 to n-1 and a list of undirected edges, return the number of connected components.

Approach

Solution

def count_components(n: int, edges: list[list[int]]) -> int:
    """Number of connected components in an undirected graph.

    Args:
        n: Node count. Nodes are labelled 0 .. n-1.
        edges: Undirected edges as [a, b] pairs.

    Returns:
        The number of connected components.

    Example:
        >>> count_components(5, [[0, 1], [1, 2], [3, 4]])
        2
    """
    dsu = DisjointSet(n)        # n nodes, so n separate sets to start

    # Invariant: dsu.count = components among the edges seen so far.
    for a, b in edges:
        dsu.union(a, b)      # only a real merge decrements dsu.count

    return dsu.count

Walkthrough

n = 5, edges [[0,1], [1,2], [3,4]]:

edgemerged?count
start—5
[0, 1]yes4
[1, 2]yes3
[3, 4]yes2

Components are {0, 1, 2} and {3, 4}.

start 0 1 2 3 4 count = 5 union(0, 1) 0 1 2 3 4 count = 4 union(1, 2) 0 1 2 3 4 count = 3 union(3, 4) 0 1 2 3 4 count = 2
Figure 16.3 — Each successful union removes one root, so the count drops by one.

Reading the figure. Arrows point from a node to its parent. Green nodes are roots, and each root names one set. Blue nodes point to a parent. The thick green arrow is the link made in that step. Count the green nodes in any frame and you get count.

TimeO(E · α(n))SpaceO(n)

Effectively O(E). Quoting the α factor and then saying “which is at most 4, so effectively linear” is the complete answer.

The related one-liner

def valid_tree(n: int, edges: list[list[int]]) -> bool:
    """A graph is a tree when it is connected and has exactly n-1 edges.

    Equivalently: n-1 edges and no edge is ever redundant.
    """
    # n - 1: a tree on n nodes has exactly one fewer edge than nodes.
    if len(edges) != n - 1:
        return False

    dsu = DisjointSet(n)        # n nodes, each its own set
    # union is False on a cycle edge. With n - 1 edges and no cycle,
    # the graph must be connected.
    return all(dsu.union(a, b) for a, b in edges)

If any union returns False, that edge closed a cycle, so it is not a tree. With exactly n - 1 edges and no cycle, connectivity follows automatically. That is a neat two-fact argument worth having ready.

Edge cases to raise

Say this out loud: “Start at n components and subtract one for every edge that actually merges two different sets. DFS works too, but Union-Find is the one that survives edges arriving over time.”

2. Redundant Connection Medium

Problem

A tree on n nodes had one extra edge added, creating exactly one cycle. Given the edge list, return the edge that can be removed to restore a tree. If several answers exist, return the one that appears last in the input.

Approach

Solution

def find_redundant_connection(edges: list[list[int]]) -> list[int]:
    """The edge that closes the single cycle in a tree plus one extra edge.

    Args:
        edges: Undirected edges over nodes 1..n, in the order they were added.

    Returns:
        The edge to remove, the last one in the input that closes a cycle.

    Raises:
        ValueError: If no edge closes a cycle.

    Example:
        >>> find_redundant_connection([[1, 2], [1, 3], [2, 3]])
        [2, 3]
    """
    # Nodes are 1-indexed, so allocate one extra slot and ignore index 0.
    # n nodes and n edges here, so len(edges) + 1 gives slots 0..n.
    dsu = DisjointSet(len(edges) + 1)

    # Invariant: the edges before this one form a forest with no cycle.
    for a, b in edges:
        # union returns False when a and b already share a root, which
        # means this edge creates a cycle.
        if not dsu.union(a, b):
            return [a, b]

    raise ValueError("no redundant edge: the graph is already a tree")

Walkthrough

[[1,2], [1,3], [2,3]]:

edgefind(a)find(b)outcome
[1, 2]12different, merge
[1, 3]13different, merge
[2, 3]11same, so return [2, 3]
edge [1, 2] 1 2 3 roots 1 and 2 differ: merge edge [1, 3] 1 2 3 roots 1 and 3 differ: merge edge [2, 3] 1 2 3 find(2) = find(3) = 1: return [2, 3]
Figure 16.4 — The first edge whose ends already share a root is the one that closes the cycle.

Reading the figure. Each box adds one input edge. A green edge joined two different sets. Gray edges were added in earlier steps. The dashed red edge connects 2 and 3, which already share root 1. union returns False there, and that edge is the answer.

TimeO(n · α(n))SpaceO(n)

Why the sizing is len(edges) + 1

A tree on n nodes has n - 1 edges, and this graph has one more, so len(edges) == n. Nodes run from 1 to n, so the arrays need n + 1 slots. Slot 0 is allocated and never touched. Getting this wrong gives an IndexError on the highest-numbered node, which is easy to miss on a small test.

The directed version is a different problem. Redundant Connection II asks the same thing on a directed graph, where a node can also end up with two parents. Union-Find alone is not enough there: you first have to find any node with in-degree 2 and try removing each of its two incoming edges. If the interviewer says “directed”, say the extra case out loud before writing anything.

Edge cases to raise

Say this out loud: “An edge whose endpoints already share a root must close a cycle, so union returning false is the answer. Scanning forwards also satisfies the last-in-input rule, because there is only one extra edge.”

3. Accounts Merge Medium

Problem

Each account is a name followed by a list of emails. Two accounts belong to the same person if they share at least one email. Merge them, and return each merged account as the name followed by its emails in sorted order. The same name may belong to different people.

The modelling decision

The relation “shares an email” is transitive: if account 1 and 2 share an email, and 2 and 3 share a different one, all three are one person. Transitive grouping is exactly Union-Find. The real design choice is what the elements are. Union the account indices, not the emails. Indices are already small integers, so no extra id mapping is needed, and the name lookup stays trivial.

Approach

Solution

from collections import defaultdict


def accounts_merge(accounts: list[list[str]]) -> list[list[str]]:
    """Merge accounts that share any email address.

    Args:
        accounts: Each entry is [name, email1, email2, ...].

    Returns:
        One entry per person: [name, ...emails sorted ascending].

    Example:
        >>> accounts_merge([["A", "x@z"], ["B", "y@z"], ["A", "x@z", "w@z"]])
        [['A', 'w@z', 'x@z'], ['B', 'y@z']]
    """
    dsu = DisjointSet(len(accounts))  # one set per account to start
    owner_of: dict[str, int] = {}     # email -> index of the first account seen with it

    for index, account in enumerate(accounts):
        # account[1:]: skip index 0, which is the name. The rest are emails.
        for email in account[1:]:
            if email in owner_of:
                # A shared email means the same person: join the two accounts.
                dsu.union(index, owner_of[email])
            else:
                owner_of[email] = index

    # Bucket every email under the root of the account that owns it.
    groups: defaultdict[int, set[str]] = defaultdict(set)
    for email, index in owner_of.items():
        groups[dsu.find(index)].add(email)

    # Every account in a group carries the same name, so the root's will do.
    # [0]: the name is the first entry of an account.
    return [
        [accounts[root][0], *sorted(emails)]
        for root, emails in groups.items()
    ]

Walkthrough

Accounts [["A","x@z"], ["B","y@z"], ["A","x@z","w@z"]]:

accountemailaction
0x@znew, owner 0
1y@znew, owner 1
2x@zseen, union(2, 0)
2w@znew, owner 2

Accounts 0 and 2 now share a root, so their emails bucket together. Account 1 stands alone even though nothing else was needed to separate it.

account 0: A account 1: B account 2: A x@z y@z w@z x@z seen before: union(2, 0) result A: w@z, x@z accounts 0 and 2 share a root B: y@z account 1 stays alone
Figure 16.5 — A shared email unions two accounts, and each root becomes one merged person.

Reading the figure. Boxes on the left are accounts. Boxes in the middle are emails. Gray lines are emails seen for the first time, so they just record an owner. The amber line is x@z showing up a second time, which triggers union(2, 0). Same color on the left means same root, and the right side shows the merged output.

TimeO(E log E)SpaceO(E)

E is the total number of emails. The Union-Find work is effectively linear, so the sort dominates. Naming the sort as the bottleneck, rather than the merging, is the sharp answer here.

Why two accounts with the same name are not merged

The name is not the key. Two different people can both be called “John”, and the problem says so explicitly. Only a shared email merges accounts, and the name is carried along purely for output. Keying on the name is the intended trap.

Edge cases to raise

Say this out loud: “Sharing an email is transitive, so it is Union-Find. I union account indices rather than emails, because the indices are already integers and they carry the name. The sort at the end dominates the cost.”

4. Number of Islands II Hard

Problem

You start with an m × n grid of water. Land is added one cell at a time. After each addition, report the current number of islands. Return the list of counts.

Why this is the Union-Find problem

Number of Islands is a DFS flood fill because the grid is static. Here the grid changes after every query. Re-running the flood fill per addition costs O(k · m · n). Union-Find absorbs each addition in effectively constant time, giving O(k · α) overall. This is the single clearest example of when Union-Find beats DFS.

Approach

Solution

# The four neighbours of a cell: down, up, right, left.
# Each pair is (row change, column change). 1 moves forward, -1 back, 0 stays.
DIRECTIONS: tuple[tuple[int, int], ...] = ((1, 0), (-1, 0), (0, 1), (0, -1))


def num_islands2(m: int, n: int, positions: list[list[int]]) -> list[int]:
    """Island count after each land addition to an initially empty grid.

    Args:
        m: Number of rows.
        n: Number of columns.
        positions: Cells [row, col] turned into land, in order.

    Returns:
        The island count after each addition.

    Example:
        >>> num_islands2(3, 3, [[0, 0], [0, 1], [1, 2], [2, 1]])
        [1, 1, 2, 3]
    """
    dsu = DisjointSet(m * n)          # m * n: one slot per grid cell
    dsu.count = 0                     # the grid starts as all water

    is_land = [[False] * n for _ in range(m)]   # m rows of n cells, all water
    answer: list[int] = []

    # Invariant: dsu.count = number of islands among the land cells so far.
    for r, c in positions:
        if is_land[r][c]:
            answer.append(dsu.count)  # repeat position: nothing changes
            continue

        is_land[r][c] = True
        dsu.count += 1                # a brand new island, for the moment

        for dr, dc in DIRECTIONS:
            nr, nc = r + dr, c + dc   # the neighbour cell
            # 0 <= ... < m and 0 <= ... < n keep the neighbour inside the grid.
            if 0 <= nr < m and 0 <= nc < n and is_land[nr][nc]:
                # Each successful merge takes the count back down by one.
                # r * n + c flattens (row, col) to one id: n cells per row.
                dsu.union(r * n + c, nr * n + nc)

        answer.append(dsu.count)

    return answer

Walkthrough

m = 3, n = 3, positions [[0,0], [0,1], [1,2], [2,1]]:

positioncount after +1mergesreported
[0, 0]1no land neighbours1
[0, 1]2joins (0,0), so −11
[1, 2]2none adjacent2
[2, 1]3none adjacent3

Notice [1,2] is diagonal to [0,1], and diagonals do not connect.

add (0,0) islands = 1 +1, no land next to it add (0,1) islands = 1 +1, joins (0,0), then −1 add (1,2) islands = 2 +1, diagonal does not count add (2,1) islands = 3 +1, no land next to it
Figure 16.6 — Add one island per new cell, then take one back for each successful union.

Reading the figure. Each grid is the state after one addition. The cell with the amber border is the one just added. Cells of the same color belong to the same island. In frame 2 the new cell touches (0,0). One union succeeds, so the count goes up 1 and back down 1. In frame 3 the new cell only touches (0,1) at a corner, so it starts a new island.

TimeO(k · α(mn))SpaceO(m · n)

k is the number of additions. Each one does at most four unions, so the work per addition is constant.

The optimistic counting trick

Rather than working out in advance how many distinct neighbouring islands there are, add one and let each successful merge take one back. If the new cell touches three separate islands, three unions succeed and the count goes +1 -3, a net -2, which is right: three islands plus a new cell became one. Getting this for free is why union should return whether it merged.

Edge cases to raise

Say this out loud: “The grid is dynamic, so flood fill would restart on every query. I flatten to 1D indices, add one to the count for the new cell, and let each successful union take one back.”

Recap

The six things to carry forward

Where this goes next

That closes the five classical parts. Part VI adds seven more shapes, starting with Pattern 17, Greedy. After that, the guides cover the mechanics around the patterns: the Python toolkit you write them with, how to read a constraint as a hint, the script to follow in the room, how to test your own code before the interviewer does, the advanced language features a senior loop probes, and the ML and data-processing layer on top.


← 15 — Topological Sort 17 — Greedy →