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

Union-Find (Disjoint Set Union)

About thirty lines of C# that answer “are these two things connected?” in near constant time. They keep answering it correctly as new connections arrive.

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. You need to know which group something is in. The strongest signal is that the groups merge over time and never split.

Union-Find or DFS?

Union-Find cannot un-merge. Sets only ever combine. If the problem deletes edges, this is the wrong tool. The usual trick is to process the input backwards in time. Then deletions become additions.

Core idea

Each set is a tree. Each element stores only a pointer to its parent, in a plain int[]. 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. That merges two whole trees in one array 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.

Reading the figure. Arrows point from a node to its parent. Before the union there are two trees, rooted at 1 and 5. Union(3, 5) finds root 1 for node 3 and root 5 for node 5. The smaller tree’s root, 5, gets one new parent pointer, drawn in green. Node 6 never moves.

The two optimisations

Naive Union-Find can turn into a linked list. Then each operation costs O(n). Two small heuristics fix it, and you need both to get the famous bound.

α is the inverse Ackermann function. It grows so slowly that α(n) ≤ 4 for any n that fits in memory. So the operations are constant time for every practical purpose. Tarjan proved this bound is tight. 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. Rank is an upper bound on the tree height. Prefer size. The number means something on its own, so “how big is this group” comes for free. It is also harder to get subtly wrong.

The template

The class to memorise
/// <summary>
/// Union-Find over 0 .. n-1, with path halving and union by size.
/// </summary>
public sealed class DisjointSet
{
    private readonly int[] _parent;   // _parent[x] = x means x is a root
    private readonly int[] _size;     // only valid at roots: elements in that set

    /// <summary>The number of disjoint sets remaining.</summary>
    /// <remarks>Settable so a caller can start from 0, as Islands II does.</remarks>
    public int Count { get; set; }

    /// <summary>Creates n singleton sets, one per element.</summary>
    /// <param name="n">Number of elements, labelled 0 .. n-1.</param>
    /// <example>new DisjointSet(4).Count == 4</example>
    public DisjointSet(int n)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(n);
        _parent = new int[n];
        _size = new int[n];

        // Invariant: elements 0 .. i-1 are already their own one-element roots.
        for (int i = 0; i < n; i++)
        {
            _parent[i] = i;               // everyone starts as their own root
            _size[i] = 1;                 // 1: each set holds just its root
        }

        Count = n;                        // n: one set per element at the start
    }

    /// <summary>Root of x's set, shortening the path on the way up.</summary>
    /// <param name="x">Any element.</param>
    /// <returns>The root that names x's set.</returns>
    /// <example>new DisjointSet(3).Find(2) == 2</example>
    public int Find(int x)
    {
        // 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 (_parent[x] != x)
        {
            // Path halving: point x at its grandparent, then step up.
            // Same bound as full compression, in one loop with no recursion.
            _parent[x] = _parent[_parent[x]];
            x = _parent[x];
        }

        return x;
    }

    /// <summary>Merges the sets holding a and b.</summary>
    /// <param name="a">One element.</param>
    /// <param name="b">The other element.</param>
    /// <returns>
    /// True if they were separate and are now merged. False if they were
    /// already together, which means this edge closes a cycle.
    /// </returns>
    /// <example>new DisjointSet(2).Union(0, 1) == true</example>
    public bool Union(int a, int b)
    {
        int rootA = Find(a), rootB = Find(b);

        if (rootA == rootB)
            return false;                 // same root: already one set

        // Attach the smaller tree under the larger one.
        // This keeps trees shallow, so Find stays fast.
        if (_size[rootA] < _size[rootB])
            (rootA, rootB) = (rootB, rootA);

        _parent[rootB] = rootA;
        _size[rootA] += _size[rootB];     // rootA now holds both sets
        Count--;                          // -1: two sets became one

        return true;
    }

    /// <summary>True if a and b are in the same set.</summary>
    /// <param name="a">One element.</param>
    /// <param name="b">The other element.</param>
    /// <returns>Whether a and b share a root.</returns>
    /// <example>new DisjointSet(2).Connected(0, 1) == false</example>
    public bool Connected(int a, int b) => Find(a) == Find(b);   // same root, same set

    /// <summary>How many elements share x's set.</summary>
    /// <param name="x">Any element.</param>
    /// <returns>The size of x's set.</returns>
    /// <example>new DisjointSet(5).SizeOf(3) == 1</example>
    public int SizeOf(int x) => _size[Find(x)];   // sizes are only kept at roots
}

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 one return value solves Redundant Connection outright and drives Kruskal’s algorithm.
Path halving versus full compression. The recursive version flattens the path completely. It looks like this:
// Full compression: every node on the path ends up pointing at the root.
int Find(int x) => _parent[x] == x ? x : (_parent[x] = Find(_parent[x]));
It costs one stack frame per level. A .NET thread has about 1 MB of stack. A chain of a million nodes, before any compression, can throw StackOverflowException. You cannot catch that exception, so the process dies. Path halving points each node at its grandparent while walking. It meets the same amortised bound and never recurses. Prefer it in C#.
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.

Why arrays and not a Dictionary. When the elements are already 0 .. n-1, two int[] arrays are smaller and faster than a Dictionary<int, int>. Use a dictionary only for sparse or non-integer keys. Even then, the usual move is to map each key to a small integer first, then use this class unchanged.

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

public static class ConnectedComponents
{
    /// <summary>Number of connected components in an undirected graph.</summary>
    /// <param name="n">Node count. Nodes are labelled 0 .. n-1.</param>
    /// <param name="edges">Undirected edges as [a, b] pairs.</param>
    /// <returns>The number of connected components.</returns>
    /// <example>CountComponents(5, [[0, 1], [1, 2], [3, 4]]) returns 2</example>
    public static int CountComponents(int n, int[][] edges)
    {
        var dsu = new DisjointSet(n);     // n nodes, so n separate sets to start

        // Invariant: dsu.Count = components among the edges seen so far.
        // [0] and [1] are the two endpoints of the pair.
        foreach (var edge in edges)
            dsu.Union(edge[0], edge[1]);  // only a real merge lowers dsu.Count

        return dsu.Count;
    }
}

Walkthrough

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

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). Quote the α factor, then say “which is at most 4, so effectively linear”. That is the complete answer.

The related one-liner

public static class GraphValidTree
{
    /// <summary>
    /// 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.
    /// </summary>
    /// <param name="n">Node count. Nodes are labelled 0 .. n-1.</param>
    /// <param name="edges">Undirected edges as [a, b] pairs.</param>
    /// <returns>True if the edges form one tree over all n nodes.</returns>
    /// <example>IsTree(3, [[0, 1], [1, 2]]) returns true</example>
    public static bool IsTree(int n, int[][] edges)
    {
        // n - 1: a tree on n nodes has exactly one fewer edge than nodes.
        if (edges.Length != n - 1)
            return false;

        var dsu = new 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. All stops at the first false.
        return edges.All(edge => dsu.Union(edge[0], edge[1]));
    }
}

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 on its own. That two-fact argument is worth having ready. Note that LINQ’s All short-circuits, so it stops at the first cycle edge. A lambda with a side effect inside LINQ is fine here, but say you know it is unusual.

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, which made exactly one cycle. Given the edge list, return the edge you can remove to restore a tree. If several answers exist, return the one that appears last in the input.

Approach

Solution

public static class RedundantConnection
{
    /// <summary>The edge that closes the single cycle in a tree plus one edge.</summary>
    /// <param name="edges">Undirected edges over nodes 1..n, in input order.</param>
    /// <returns>The edge to remove: the last one in the input that closes a cycle.</returns>
    /// <exception cref="ArgumentException">No edge closes a cycle.</exception>
    /// <example>FindRedundant([[1, 2], [1, 3], [2, 3]]) returns [2, 3]</example>
    public static int[] FindRedundant(int[][] edges)
    {
        // Size by the largest label, so bad input cannot index past the end.
        // 0: labels start at 1, so 0 is a safe floor for the running max.
        int maxNode = 0;
        foreach (var edge in edges)
            maxNode = Math.Max(maxNode, Math.Max(edge[0], edge[1]));

        // + 1: nodes are 1-indexed, so slots 0..maxNode. Slot 0 is never used.
        var dsu = new DisjointSet(maxNode + 1);

        // Invariant: the edges before this one form a forest with no cycle.
        foreach (var edge in edges)
        {
            // Union returns false when both ends already share a root.
            // That means this edge creates a cycle.
            if (!dsu.Union(edge[0], edge[1]))
                return [edge[0], edge[1]];
        }

        throw new ArgumentException("no redundant edge: the graph is already a forest",
            nameof(edges));
    }
}

Walkthrough

[[1,2], [1,3], [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 the largest label plus one

A tree on n nodes has n - 1 edges. This graph has one more, so edges.Length == n. Many solutions just write new DisjointSet(edges.Length + 1), and that is correct under the promise. Nodes run from 1 to n, so the arrays need n + 1 slots. Slot 0 is allocated and never touched.

The version above scans for the largest label instead. It costs one extra pass. In return, input that breaks the promise gets a clear ArgumentException. With edges.Length + 1, a plain tree input would crash with IndexOutOfRangeException on the highest node. That 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. There a node can also end up with two parents. Union-Find alone is not enough. You first find any node with in-degree 2 and try removing each of its two incoming edges. If the interviewer says “directed”, name the extra case 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. 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. Say accounts 1 and 2 share an email, and 2 and 3 share a different one. Then 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. The name lookup stays trivial too.

Approach

Solution

public static class AccountsMerge
{
    /// <summary>Merges accounts that share any email address.</summary>
    /// <param name="accounts">Each entry is [name, email1, email2, ...].</param>
    /// <returns>One entry per person: [name, ...emails sorted ascending].</returns>
    /// <example>
    /// Merge([["A", "x@z"], ["B", "y@z"], ["A", "x@z", "w@z"]])
    /// returns [["A", "w@z", "x@z"], ["B", "y@z"]] in some order
    /// </example>
    public static List<List<string>> Merge(string[][] accounts)
    {
        var dsu = new DisjointSet(accounts.Length);   // one set per account to start
        var ownerOf = new Dictionary<string, int>();  // email -> first account index with it

        // Invariant: every email in accounts 0 .. index-1 has an owner, and
        // any two of those accounts that share an email are in one set.
        for (int index = 0; index < accounts.Length; index++)
        {
            // [1..]: skip slot 0, which is the name. The rest are emails.
            foreach (var email in accounts[index][1..])
            {
                // TryAdd is false when the email already has an owner.
                if (!ownerOf.TryAdd(email, index))
                    dsu.Union(index, ownerOf[email]);   // shared email: same person
            }
        }

        // Bucket every email under the root of the account that owns it.
        var groups = new Dictionary<int, List<string>>();
        foreach (var (email, owner) in ownerOf)
        {
            int root = dsu.Find(owner);
            if (!groups.TryGetValue(root, out var bucket))
                groups[root] = bucket = [];
            bucket.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.
        // Ordinal: plain char-code order, the same on every machine and culture.
        return [.. groups.Select(g =>
            (List<string>)[accounts[g.Key][0], .. g.Value.Order(StringComparer.Ordinal)])];
    }
}

Walkthrough

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

Accounts 0 and 2 now share a root, so their emails land in one bucket. Account 1 stands alone.

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. Name the sort as the bottleneck, not the merging. That is the sharp answer here.

Two C# traps in the output step. First, Dictionary enumeration order is not guaranteed. So the groups come out in no fixed order, and tests must sort them before comparing. Second, the default string sort is culture-aware. Pass StringComparer.Ordinal to get the plain character order the problem expects.

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. Only a shared email merges accounts. The name just comes along 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 near constant time, giving O(k · α) overall. This is the clearest example of when Union-Find beats DFS.

Approach

Solution

public static class NumberOfIslandsII
{
    // The four neighbours of a cell: down, up, right, left.
    // Each pair is (row change, column change). 1 moves forward, -1 back, 0 stays.
    private static readonly (int Dr, int Dc)[] Directions = [(1, 0), (-1, 0), (0, 1), (0, -1)];

    /// <summary>Island count after each land addition to an empty grid.</summary>
    /// <param name="m">Number of rows.</param>
    /// <param name="n">Number of columns.</param>
    /// <param name="positions">Cells [row, col] turned into land, in order.</param>
    /// <returns>The island count after each addition.</returns>
    /// <example>
    /// NumIslands2(3, 3, [[0, 0], [0, 1], [1, 2], [2, 1]]) returns [1, 1, 2, 3]
    /// </example>
    public static List<int> NumIslands2(int m, int n, int[][] positions)
    {
        // m * n: one slot per grid cell. checked: throw rather than wrap on overflow.
        var dsu = new DisjointSet(checked(m * n)) { Count = 0 };   // 0: all water at first

        var isLand = new bool[m, n];      // m rows of n cells, all false (water)
        var answer = new List<int>(positions.Length);   // one count per addition

        // Invariant: dsu.Count = number of islands among the land cells so far.
        foreach (var pos in positions)
        {
            int r = pos[0], c = pos[1];   // [0] is the row, [1] the column

            if (isLand[r, c])
            {
                answer.Add(dsu.Count);    // repeat position: nothing changes
                continue;
            }

            isLand[r, c] = true;
            dsu.Count++;                  // a brand new island, for the moment

            foreach (var (dr, dc) in Directions)
            {
                int nr = r + dr, nc = c + dc;   // the neighbour cell
                // 0 <= ... < m and 0 <= ... < n keep the neighbour inside the grid.
                if (nr >= 0 && nr < m && nc >= 0 && nc < n && isLand[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.Add(dsu.Count);
        }

        return answer;
    }
}

Two C# details are worth a sentence. new bool[m, n] is a true rectangular array, one block of memory, indexed as isLand[r, c]. And checked(m * n) turns a silent 32-bit overflow into an OverflowException if the grid is huge.

Walkthrough

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

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

Do not work out in advance how many distinct neighbouring islands there are. Add one, and let each successful merge take one back. Say the new cell touches three separate islands. Three unions succeed and the count goes +1 -3, a net -2. That is right: three islands plus a new cell became one. You get this for free because Union returns 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 the patterns come the advanced C# chapters. They start with types and memory, which explains why DisjointSet must be a class. The guides cover the mechanics around the patterns. See the C# toolkit, how to read a constraint as a hint, and the script to follow in the room. Also see how to test your own code and the BCL essentials. The Python version of this page is here.


← 15 — Topological Sort 17 — Greedy →