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 togetherO(α(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
        self.count = n

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

        return x

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

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

        if root_a == root_b:
            return False

        # Attach the smaller tree under the larger one.
        if self.size[root_a] < self.size[root_b]:
            root_a, root_b = root_b, root_a

        self.parent[root_b] = root_a
        self.size[root_a] += self.size[root_b]
        self.count -= 1

        return True

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

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

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.

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)

    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
start5
[0, 1]yes4
[1, 2]yes3
[3, 4]yes2

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

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.
    """
    if len(edges) != n - 1:
        return False

    dsu = DisjointSet(n)
    return all(dsu.union(a, b) for a, b in edges)

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

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.
    dsu = DisjointSet(len(edges) + 1)

    for a, b in edges:
        # union returns False when a and b already share a root, which
        # means this edge creates a cycle.
        if not dsu.union(a, b):
            return [a, b]

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

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]
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))
    owner_of: dict[str, int] = {}     # email -> index of the first account seen with it

    for index, account in enumerate(accounts):
        for email in account[1:]:
            if email in owner_of:
                dsu.union(index, owner_of[email])
            else:
                owner_of[email] = index

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

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

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.

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

DIRECTIONS: tuple[tuple[int, int], ...] = ((1, 0), (-1, 0), (0, 1), (0, -1))


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

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

    Returns:
        The island count after each addition.

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

    is_land = [[False] * n for _ in range(m)]
    answer: list[int] = []

    for r, c in positions:
        if is_land[r][c]:
            answer.append(dsu.count)  # repeat position: nothing changes
            continue

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

        for dr, dc in DIRECTIONS:
            nr, nc = r + dr, c + dc
            if 0 <= nr < m and 0 <= nc < n and is_land[nr][nc]:
                # Each successful merge takes the count back down by one.
                dsu.union(r * n + c, nr * n + nc)

        answer.append(dsu.count)

    return answer

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.

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 is all sixteen patterns. What is left is not more patterns, it is the mechanics around them. The four guides cover that: the Python toolkit you write them with, how to read a constraint as a hint, the script to follow in the room, and how to test your own code before the interviewer does.


15 — Topological Sort Guide 1 — The Python Toolkit