Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Coding Interview Patterns  Module 22 – Union-find (disjoint sets)

Union-find: the 20-line template

Union-find with union by size and path halving: the 20-line template, why it runs in near-constant time, and a merging problem solved in four languages.

  • Beginner
  • 35 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 35 minutes

What you will learn

  • Implement find with path halving and union by size in about 20 lines
  • Explain why union-find operations take nearly constant amortised time
  • Track how many groups there are and how many items each one holds
  • Choose between union-find and a graph search from what a problem asks
  • Replace a recursive find that runs out of stack on a long path

Before you start

Union-find, also called a disjoint-set union or DSU, keeps items in groups that never overlap and answers one question quickly: are these two items in the same group? It has two operations. find(x) returns the representative of x’s group, one item that stands for the whole group, so two items are together exactly when find returns the same item for both. union(a, b) merges the group of a with the group of b. Groups only ever merge, never split, and that is why union-find suits problems in which connections arrive one at a time: friendships, cables, roads, records that share a phone number, edges of a graph. Two small rules, union by size and path halving, make a run of m operations on n items take O(m α(m, n)) time, where α is a function that stays at most 4 for any input that could ever fit in a computer.

This lesson writes the template in about 20 lines and measures what each rule saves. It shows a recursive version that runs out of stack on a long path, and it solves one problem in full: friend circles that merge as students are introduced, from the brute force to code in Python, JavaScript, Java and C++.

When union-find fits

The statement usually gives it away. Three signs point to union-find:

  • The input is a list of pairs that join things: two people who met, two computers on one cable, two records with the same email address, two land cells that touch.
  • The questions are about groups, not routes: whether two items are connected, how many groups there are, how large the largest one is.
  • The pairs arrive over time, and the questions come in between them, or the same question is asked again and again.

Connected here means joined by a chain of pairs, in either direction. If Asha met Bilal and Bilal met Chen, all three are in one group, even though Asha never met Chen. In the language of the lesson on modelling problems as graphs, the items are the nodes of an undirected graph, the pairs are its edges, and the groups are its connected components.

A breadth-first or depth-first search over the same graph finds the same groups. The two approaches differ in what they can answer and what each change costs:

Union-find or a graph search?
Criterion Union-findBFS or DFS over the graph
What it can answer Whether two items share a group; how many groups there are; how large each one isThe same, plus a path between two items, distances and the order of the visit
Pairs over time, questions in between Nearly constant time for each pair and each questionA new search after every change, O(n + m) each time
One-way links Not supported: every pair counts in both directionsSupported: follow edges only the way they point
A link removed Not supported: groups cannot splitSearch again on the changed graph
Memory Two arrays of n numbers; the pairs need not be storedEvery pair, in adjacency lists: O(n + m)
When to choose Groups that only merge, with many questions about themPaths, distances or directions, or one question about a graph that does not change

For a graph that is given in full and asked about once, both run in linear time, and a search is just as good. Two kinds of problem belong elsewhere: the fewest steps between two items is a shortest path, which breadth-first search finds, and one-way relations such as prerequisites belong to topological sort, the previous module of this track.

The forest behind the two operations

Union-find stores each group as a tree, but an unusual one. There are no child pointers: every item keeps a single number, parent[x], that points one step towards the root of its tree, and a root is the one item that is its own parent. The root is the group’s representative. find(x) follows parent pointers from x until it reaches a root. union(a, b) finds the two roots and, if they differ, makes one of them the parent of the other. That single assignment merges two whole trees: nothing below the old root moves, and every item there now reaches the new root through it.

Eight items as three trees of parent pointers, rooted at 0, 4 and 7; after union(3, 6) the tree of 4 hangs under 0.Before union(3, 6)After union(3, 6): dashed arrows changed0471256307123456

The forest that the parent array stands for, before and after union(3, 6)

Text description of the diagram

The diagram has two panels, one under another. In both, an arrow goes from an item to its parent, and a root, the item that is its own parent, has a double border.

  1. Before union(3, 6), the parent array is 0 0 0 2 4 4 4 7, which makes three trees. Root 0 has the children 1 and 2, and 3 hangs under 2: a group of 4 items. Root 4 has the children 5 and 6: a group of 3. Item 7 is a root on its own, a group of 1.
  2. After union(3, 6), the parent array is 0 0 0 0 0 4 4 7. Two arrows are dashed because the call changed them. The call find(3) walked 3, 2, 0 and pointed 3 straight at 0 on the way. The roots it found were 0, with 4 items, and 4, with 3, so the root of the smaller group, 4, was linked under 0, whose group now holds 7 items. Items 5 and 6 still point at 4, which points at 0. Item 7 is unchanged, so two groups are left.

The first panel is the array parent = [0, 0, 0, 2, 4, 4, 4, 7]. Items 0, 4 and 7 point at themselves, so there are three groups, of 4, 3 and 1 items, and find(3) walks 3, 2, 0. In the second panel, union(3, 6) found the roots 0 and 4 and put the smaller group’s root, 4, under the larger one. On the way, find(3) also pointed 3 straight at 0, which is path halving at work; the section on cost explains it. The two dashed arrows are the pointers that changed.

Every operation keeps one invariant: following parent pointers from any item ends at the root of its group. So two items are in the same group exactly when their walks end at the same root. Two more values travel with the forest. size[r] holds the number of items in the group of each root r; at an item that is no longer a root the value is out of date and never read. count holds the number of groups.

The template

Here it is in Python and JavaScript, with a short demo that builds the forest of the diagram. Either tab runs on this page:

The union-find template, with a demo

Python · template/dsu.py

class DSU:
    """Disjoint sets of the items 0 .. n-1: union by size, find with path halving."""

    def __init__(self, n):
        self.parent = list(range(n))  # parent[x] == x marks a root
        self.size = [1] * n           # size[r]: how many items the root r's group holds
        self.count = n                # how many groups there are

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # point x at its grandparent
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                  # one group already: nothing changes
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra               # ra is the root of the larger group
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.count -= 1
        return True

    def group_size(self, x):
        return self.size[self.find(x)]


if __name__ == "__main__":
    d = DSU(8)
    for a, b in [(0, 1), (2, 3), (1, 3), (4, 5), (6, 4)]:
        d.union(a, b)
    print("parent:", *d.parent)
    print("groups:", d.count, "| size of 6's group:", d.group_size(6))
    print("union(3, 6):", d.union(3, 6))
    print("parent:", *d.parent)
    print("groups:", d.count, "| size of 6's group:", d.group_size(6))
    print("union(1, 5):", d.union(1, 5), "(1 and 5 are in one group already)")

Output

parent: 0 0 0 2 4 4 4 7
groups: 3 | size of 6's group: 3
union(3, 6): True
parent: 0 0 0 0 0 4 4 7
groups: 2 | size of 6's group: 7
union(1, 5): False (1 and 5 are in one group already)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dsu.py

JavaScript · template/dsu.mjs

// Disjoint sets of the items 0 .. n-1: union by size, find with path halving.
class DSU {
  constructor(n) {
    this.parent = Int32Array.from({ length: n }, (_, i) => i); // parent[x] === x: a root
    this.size = new Int32Array(n).fill(1); // size[r]: the items in root r's group
    this.count = n; // how many groups there are
  }

  find(x) {
    const parent = this.parent;
    while (parent[x] !== x) {
      parent[x] = parent[parent[x]]; // point x at its grandparent
      x = parent[x];
    }
    return x;
  }

  union(a, b) {
    let ra = this.find(a);
    let rb = this.find(b);
    if (ra === rb) return false; // one group already: nothing changes
    if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra]; // ra: the larger group's root
    this.parent[rb] = ra;
    this.size[ra] += this.size[rb];
    this.count -= 1;
    return true;
  }

  groupSize(x) {
    return this.size[this.find(x)];
  }
}

const d = new DSU(8);
for (const [a, b] of [[0, 1], [2, 3], [1, 3], [4, 5], [6, 4]]) d.union(a, b);
console.log("parent:", Array.from(d.parent).join(" "));
console.log("groups:", d.count, "| size of 6's group:", d.groupSize(6));
console.log("union(3, 6):", d.union(3, 6));
console.log("parent:", Array.from(d.parent).join(" "));
console.log("groups:", d.count, "| size of 6's group:", d.groupSize(6));
console.log("union(1, 5):", d.union(1, 5), "(1 and 5 are in one group already)");

Output

parent: 0 0 0 2 4 4 4 7
groups: 3 | size of 6's group: 3
union(3, 6): true
parent: 0 0 0 0 0 4 4 7
groups: 2 | size of 6's group: 7
union(1, 5): false (1 and 5 are in one group already)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dsu.mjs

Line by line:

  1. The constructor makes every item a root of its own group: parent[x] = x, size[x] = 1, and count = n.
  2. find loops while x is not a root. Each pass points x at its grandparent and then moves x there. The loop never recurses, so a long path cannot exhaust the call stack.
  3. union finds both roots. If they are the same, a and b are already in one group: nothing changes, and it returns False. Otherwise the root of the larger group becomes the parent of the other root, the sizes are added, count drops by one, and it returns True.
  4. group_size (groupSize in JavaScript) reads the size stored at the root of x’s group.

The return value of union is often the answer itself. False means “these two were connected already”, which in a graph whose edges go both ways means the new edge closes a cycle. count falls by exactly one for every True, so after k successful unions of n items there are n − k groups.

When both groups have the same size, the template keeps the root of a’s group. Any choice is correct; the rule matters only when you trace the arrays by hand. Work out what this program prints before you read its output:

Ties keep the first root Python · predict/ties.py
class DSU:
    def __init__(self, n):
        self.parent, self.size, self.count = list(range(n)), [1] * n, n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.count -= 1
        return True


d = DSU(5)
print(d.union(0, 1), d.union(1, 0), d.union(2, 3), d.union(3, 1), d.count, d.find(0))

Output

True False True True 2 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ties.py

The second call joins 1 and 0 again and returns False, and three merges leave 5 − 3 = 2 groups. In the last call both roots, 2 and 0, lead groups of two items, so 0 goes under 2 and find(0) returns 2.

In JavaScript the arrays are Int32Arrays, typed arrays of 32-bit signed integers whose contents start at 0. They hold every index and size below 2³¹. Python’s lists hold the same numbers as objects, which costs more memory and is still fine at these sizes.

Why it is fast

What each rule saves, measured

Leave both rules out and union-find still gives correct answers: link the first root under the second, and let find walk without changing anything. The program below runs four versions on three workloads and counts the parent pointers every find follows, divided by the number of operations:

Pointer steps for four versions on three workloads Python · cost/steps.py
"""Counts the parent pointers that find follows,
for four versions of union-find on three workloads."""
import random


def pointer_steps(n, operations, by_size, halving):
    """Runs the operations and returns how many parent pointers all the finds followed."""
    parent = list(range(n))
    size = [1] * n
    steps = 0

    def find(x):
        nonlocal steps
        while parent[x] != x:
            steps += 1
            if halving:
                parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    for op in operations:
        if op[0] == "find":
            find(op[1])
            continue
        ra, rb = find(op[1]), find(op[2])
        if ra == rb:
            continue
        if not by_size:
            parent[ra] = rb  # plain linking: the first root goes under the second
        else:
            if size[ra] < size[rb]:
                ra, rb = rb, ra
            parent[rb] = ra
            size[ra] += size[rb]
    return steps


def a_line(n):
    """0 meets 1, 1 meets 2, and so on; then item 0 is looked up n times."""
    return [("union", i, i + 1) for i in range(n - 1)] + [("find", 0)] * n


def equal_halves(n):
    """Groups merge in pairs of equal size (1+1, 2+2, 4+4, ...);
    then item n - 1 is looked up n times."""
    operations, width = [], 1
    while width < n:
        operations += [("union", i, i + width) for i in range(0, n - width, 2 * width)]
        width *= 2
    return operations + [("find", n - 1)] * n


def mixed_at_random(n):
    """2n operations, each a union or a find of random items (the same ones every run)."""
    rng = random.Random(2026)
    operations = []
    for _ in range(2 * n):
        if rng.random() < 0.5:
            operations.append(("union", rng.randrange(n), rng.randrange(n)))
        else:
            operations.append(("find", rng.randrange(n)))
    return operations


VERSIONS = [  # (name, union by size, path halving)
    ("plain", False, False),
    ("by size", True, False),
    ("halving", False, True),
    ("both", True, True),
]
WORKLOADS = [
    ("a line", a_line),
    ("equal halves", equal_halves),
    ("mixed at random", mixed_at_random),
]

print("Pointer steps per operation (an operation is one union or one find)")
print(f"{'workload':<16}{'n':>6}" + "".join(f"{name:>10}" for name, _, _ in VERSIONS))
for label, make in WORKLOADS:
    for n in (1024, 4096):
        operations = make(n)
        total = len(operations)
        cells = [pointer_steps(n, operations, s, h) / total for _, s, h in VERSIONS]
        print(f"{label:<16}{n:>6,}" + "".join(f"{c:>10,.2f}" for c in cells))

Output

Pointer steps per operation (an operation is one union or one find)
workload             n     plain   by size   halving      both
a line           1,024    511.75      0.50      1.00      0.50
a line           4,096  2,047.75      0.50      1.00      0.50
equal halves     1,024      0.99      5.00      0.50      0.50
equal halves     4,096      1.00      6.00      0.50      0.50
mixed at random  1,024     25.66      1.25      1.39      0.78
mixed at random  4,096     74.40      1.34      1.46      0.77

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 steps.py

  • A line (0 meets 1, 1 meets 2, and so on, then item 0 is looked up n times). Plain linking turns the unions into a path through all n items, and every lookup walks the whole path: 2,047.75 pointers per operation at n = 4,096, four times as many as at 1,024. Union by size never lets the path form, and halving repairs it after a few walks.
  • Equal halves (groups merge in pairs of equal size, 1 + 1, 2 + 2, 4 + 4, and then the last item is looked up n times). Here union by size on its own builds trees log₂ n levels deep, and every lookup walks all of them: 5.00 pointers per operation at 1,024 and 6.00 at 4,096, one more each time n is multiplied by four. Halving flattens the path after a handful of lookups.
  • Mixed at random. Plain linking grows with n, from 25.66 to 74.40 pointers per operation; either rule alone keeps the cost between 1.25 and 1.46, and both together bring it under 0.8.

With both rules, no row of the table is above 0.78, and in every row the two rules together are the cheapest version or tied with it.

Union by size keeps every tree shallow

An item moves one level further from its root only when its root is linked under another root. Union by size links the smaller group under the larger one, or under an equal one, so each time that happens the item’s group becomes at least twice as large. A group can never hold more than n items, so this can happen at most log₂ n times. With union by size alone, no find ever follows more than ⌊log₂ n⌋ pointers: 17 for 200,000 items, 19 for 1,000,000. Union by rank, a common alternative, keeps an upper bound on each tree’s height instead of its size and gives the same guarantee; size is the more useful of the two because problems so often ask how large a group is.

Path halving shortens the paths that find walks

The fullest repair, path compression, points every item on the path straight at the root once the root is found. It needs either recursion or a second loop over the path. Path halving does a lighter repair in the single loop that find already makes: each item it visits is pointed at its grandparent, and the loop then jumps to that grandparent. Every second item on the path skips a level, and the path from where the walk started becomes about half as long:

A path of seven items from 6 up to the root 0; after one find(6) with path halving, 6, 4 and 2 point at their old grandparents.Before find(6)After find(6)01234560123456

One call of find(6) with path halving shortens the path from 6 to the root from 6 pointers to 3

Text description of the diagram

The diagram has two panels side by side. Arrows go from an item to its parent, and the root, 0, has a double border.

  1. Before find(6), the items form one path: 6 points at 5, 5 at 4, 4 at 3, 3 at 2, 2 at 1 and 1 at the root 0. Item 6 is 6 pointers from the root.
  2. After find(6), the call has pointed each item it visited at its grandparent: 6 now points at 4, 4 at 2 and 2 at 0, and these three arrows are dashed. The items it skipped, 5, 3 and 1, still point at their old parents, 4, 2 and 0. Item 6 is now 3 pointers from the root, half as far as before, and no item is further from the root than it was.

The items that the walk skipped (5, 3 and 1 here) keep their parents, and no item ends up further from the root than it was.

Together: nearly constant time per operation

Galler and Fischer described the forest of parent pointers in 1964. Tarjan proved in 1975 that union by size together with path compression runs any sequence of m ≥ n finds mixed with up to n − 1 unions in time proportional to m α(m, n), and that some sequences really need that much. Tarjan and van Leeuwen showed in 1984 that the bound also holds with union by rank, and with path halving in place of compression: halving is one of two one-pass methods, proposed by van Leeuwen and van der Weide, that they proved as good as the best. Fredman and Saks proved in 1989 that no union-find structure, however it is built, can need fewer than about α(m, n) memory accesses per operation on average. Those bounds are amortised: they hold for the total over a whole sequence. A single find can still walk up to log₂ n pointers, but every walk shortens the paths it crosses, which makes the later finds cheaper.

α is the inverse Ackermann function. NIST’s Dictionary of Algorithms and Data Structures defines it from Ackermann’s function A, whose value grows extremely fast: α(m, n) is the smallest i ≥ 1 for which A(i, ⌊m/n⌋) is larger than log₂ n. The values come straight from that definition:

How slowly α grows, from its definition Python · cost/alpha.py
"""How slowly the inverse Ackermann function grows, worked out from its definition in
NIST's Dictionary of Algorithms and Data Structures:
    A(0, j) = j + 1,  A(i, 0) = A(i - 1, 1),  A(i, j) = A(i - 1, A(i, j - 1));
    alpha(m, n) = the smallest i >= 1 with A(i, m // n) > log2(n)."""
import math


def ackermann(i, j):
    """A(i, j) from the definition, with a stack of pending first arguments."""
    pending = [i]
    while pending:
        i = pending.pop()
        if i == 0:
            j += 1
        elif j == 0:
            pending.append(i - 1)
            j = 1
        else:
            pending += [i - 1, i]  # A(i - 1, A(i, j - 1)): work out A(i, j - 1) first
            j -= 1
    return j


# The definition, run for small arguments, matches these closed forms,
# which then give the values that are too big to run.
CLOSED = {1: lambda j: j + 2, 2: lambda j: 2 * j + 3, 3: lambda j: 2 ** (j + 3) - 3}
for i, form in CLOSED.items():
    assert all(ackermann(i, j) == form(j) for j in range(6)), i
a = {i: form(1) for i, form in CLOSED.items()}
a[4] = CLOSED[3](CLOSED[3](1))  # A(4, 1) = A(3, A(4, 0)) and A(4, 0) = A(3, 1)
for i in range(1, 5):
    print(f"A({i}, 1) = {a[i]:,}")


def alpha(n):
    """alpha(m, n) with m = n, so m // n = 1; A(i, 1) > log2(n) means 2 ** A(i, 1) > n."""
    return next(i for i in range(1, 5) if 2 ** a[i] > n)


SIZES = [("7", 7), ("8", 8), ("31", 31), ("32", 32), ("8,191", 8_191), ("8,192", 8_192),
         ("10**6", 10**6), ("10**18", 10**18), ("10**80", 10**80)]
for label, n in SIZES:
    print(f"alpha(n, n) for n = {label}: {alpha(n)}")
digits = math.floor(65533 * math.log10(2)) + 1
print("With m >= n, alpha(m, n) is at most 4 for every n below 2 ** 65,533,")
print(f"a number of {digits:,} digits")

Output

A(1, 1) = 3
A(2, 1) = 5
A(3, 1) = 13
A(4, 1) = 65,533
alpha(n, n) for n = 7: 1
alpha(n, n) for n = 8: 2
alpha(n, n) for n = 31: 2
alpha(n, n) for n = 32: 3
alpha(n, n) for n = 8,191: 3
alpha(n, n) for n = 8,192: 4
alpha(n, n) for n = 10**6: 4
alpha(n, n) for n = 10**18: 4
alpha(n, n) for n = 10**80: 4
With m >= n, alpha(m, n) is at most 4 for every n below 2 ** 65,533,
a number of 19,728 digits

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 alpha.py

With as many operations as items, α reaches 4 at n = 8,192 and stays 4 for every n below 2 to the power 65,533, so for any input a computer could hold it is a small constant. In practice that means a few pointer steps per operation, and the worked problem below measures about one.

A problem solved in full: friend circles as students meet

The problem

A hostel has n new students, numbered 0 to n − 1. During the first week the organisers record each introduction as a pair (a, b) the moment it happens. Two students are in the same circle when a chain of introductions links them, and every student starts in a circle of their own. After every introduction, report two numbers: how many circles there are, and how many students the largest circle has. Limits: 1 ≤ n ≤ 200,000 and 1 ≤ m ≤ 200,000 introductions. A pair may repeat an earlier one, or name the same student twice; neither changes any circle.

Three examples, which every version of the code below runs:

  1. n = 6 with the introductions (0, 1), (2, 3), (1, 2), (4, 5), (3, 0), (5, 5) gives (5, 2), (4, 2), (3, 4), (2, 4), (2, 4), (2, 4). The third introduction merges two circles of two into one of four; the fifth joins two students who are already in one circle, and the sixth names one student twice.
  2. n = 1 with the introduction (0, 0) gives (1, 1).
  3. n = 5 with (3, 4), (4, 3), (0, 4), (1, 2), (2, 3) gives (4, 2), (4, 2), (3, 3), (2, 3), (1, 5): the repeated pair changes nothing, and the last introduction brings everyone together.

The brute force: recount every circle after each introduction

Keep every introduction in adjacency lists, and after each new one, run a breadth-first search from every student not yet reached, counting the circles and their sizes. It is plainly correct, which makes it the reference for testing a faster version. Its cost is the problem: after introduction i the search visits all n students and all 2i list entries, so the total is m·n + m(m + 1), about 8 × 10¹⁰ at the limits. At the rough budgets of the lesson on constraints, ten million simple operations a second in CPython and a hundred million in C++, that is more than two hours of Python and more than ten minutes of C++.

What to notice

Introductions only ever merge circles; nothing ever splits one. And the questions ask for counts and sizes, never for the chain of introductions that links two students. Merging groups and remembering their sizes is exactly what union-find does, so the circles can be kept up to date in place of being recounted.

Union-find, step by step

  1. Make a union-find of the n students: count starts at n, and largest starts at 1.
  2. For each introduction (a, b), call union(a, b).
  3. If it returns True, two circles merged, and the new one is the only circle that can be larger than before: largest = max(largest, group_size(a)).
  4. Record (count, largest).

Why it is correct

The loop keeps one invariant: after i introductions, the groups of the union-find are exactly the circles of those i introductions, count is how many there are, and largest is the size of the biggest.

  1. Initialisation. Before any introduction every student is a circle of one, and the union-find starts with n groups of one, count = n and largest = 1.
  2. Maintenance. Introduction (a, b) links every student of a’s circle to every student of b’s circle and changes no other circle. If the two circles are different, union merges exactly those two groups and lowers count by one. If they are the same circle, union returns False and nothing changes, as nothing should. Circles never shrink, so the largest circle is either the old largest or the one just made, which is what step 3 keeps.
  3. Termination. Every recorded pair is right at the moment it is recorded, so all m answers are right.

A dry run

The first example, one row per introduction. roots shows what the two finds returned, and the arrays are shown after the introduction:

The first example, traced Python · trace/circles_trace.py
"""The first example traced: one row per introduction, with the roots that find returned
and the arrays after the introduction."""
n = 6
introductions = [(0, 1), (2, 3), (1, 2), (4, 5), (3, 0), (5, 5)]
parent = list(range(n))
size = [1] * n
count, largest = n, 1


def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x


WIDTHS = [7, 7, 8, 13, 21, 9, 0]  # one width per column; the last column is not padded


def row(*cells):
    print("".join(f"{cell:<{width}}" for cell, width in zip(cells, WIDTHS)).rstrip())


row("meets", "roots", "merged", "parent", "sizes at roots", "circles", "largest")
for a, b in introductions:
    ra, rb = find(a), find(b)
    roots = f"{ra},{rb}"
    merged = ra != rb
    if merged:
        if size[ra] < size[rb]:
            ra, rb = rb, ra
        parent[rb] = ra
        size[ra] += size[rb]
        count -= 1
        largest = max(largest, size[ra])
    at_roots = " ".join(f"{r}:{size[r]}" for r in range(n) if parent[r] == r)
    row(f"{a}-{b}", roots, "yes" if merged else "no", " ".join(map(str, parent)), at_roots,
        count, largest)

Output

meets  roots  merged  parent       sizes at roots       circles  largest
0-1    0,1    yes     0 0 2 3 4 5  0:2 2:1 3:1 4:1 5:1  5        2
2-3    2,3    yes     0 0 2 2 4 5  0:2 2:2 4:1 5:1      4        2
1-2    0,2    yes     0 0 0 2 4 5  0:4 4:1 5:1          3        4
4-5    4,5    yes     0 0 0 2 4 4  0:4 4:2              2        4
3-0    0,0    no      0 0 0 0 4 4  0:4 4:2              2        4
5-5    4,4    no      0 0 0 0 4 4  0:4 4:2              2        4

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 circles_trace.py

Introduction 1-2 is the first to meet two roots, 0 and 2, that each lead two students, so the tie keeps 0, and 2 goes under it. In row 3-0, find(3) walks 3, 2, 0 and points 3 at its grandparent 0 on the way, which is why the parent array changes although no circle does. The last row names student 5 twice: both finds return 4, and nothing merges.

The code in four languages

Four versions of one solution. Press Run on the Python or the JavaScript tab to try it on this page:

Friend circles in four languages

Python · circles/circles.py

class DSU:
    """The lesson's template: union by size, find with path halving."""

    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n
        self.count = n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.count -= 1
        return True


def circles_after_each(n, introductions):
    """After each introduction: (how many circles there are,
    how many students the largest one has)."""
    dsu = DSU(n)
    largest = 1
    answers = []
    for a, b in introductions:
        if dsu.union(a, b):
            largest = max(largest, dsu.size[dsu.find(a)])
        answers.append((dsu.count, largest))
    return answers


EXAMPLES = [
    (6, [(0, 1), (2, 3), (1, 2), (4, 5), (3, 0), (5, 5)]),
    (1, [(0, 0)]),
    (5, [(3, 4), (4, 3), (0, 4), (1, 2), (2, 3)]),
]
for n, introductions in EXAMPLES:
    answers = circles_after_each(n, introductions)
    print(f"n = {n}:", " ".join(f"({c}, {big})" for c, big in answers))

Output

n = 6: (5, 2) (4, 2) (3, 4) (2, 4) (2, 4) (2, 4)
n = 1: (1, 1)
n = 5: (4, 2) (4, 2) (3, 3) (2, 3) (1, 5)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 circles.py

JavaScript · circles/circles.mjs

// The lesson's template: union by size, find with path halving.
class DSU {
  constructor(n) {
    this.parent = Int32Array.from({ length: n }, (_, i) => i);
    this.size = new Int32Array(n).fill(1);
    this.count = n;
  }

  find(x) {
    const parent = this.parent;
    while (parent[x] !== x) {
      parent[x] = parent[parent[x]];
      x = parent[x];
    }
    return x;
  }

  union(a, b) {
    let ra = this.find(a);
    let rb = this.find(b);
    if (ra === rb) return false;
    if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
    this.parent[rb] = ra;
    this.size[ra] += this.size[rb];
    this.count -= 1;
    return true;
  }
}

// After each introduction: [how many circles there are,
// how many students the largest one has].
function circlesAfterEach(n, introductions) {
  const dsu = new DSU(n);
  let largest = 1;
  const answers = [];
  for (const [a, b] of introductions) {
    if (dsu.union(a, b)) largest = Math.max(largest, dsu.size[dsu.find(a)]);
    answers.push([dsu.count, largest]);
  }
  return answers;
}

const EXAMPLES = [
  [6, [[0, 1], [2, 3], [1, 2], [4, 5], [3, 0], [5, 5]]],
  [1, [[0, 0]]],
  [5, [[3, 4], [4, 3], [0, 4], [1, 2], [2, 3]]],
];
for (const [n, introductions] of EXAMPLES) {
  const answers = circlesAfterEach(n, introductions);
  console.log(`n = ${n}:`, answers.map(([c, big]) => `(${c}, ${big})`).join(" "));
}

Output

n = 6: (5, 2) (4, 2) (3, 4) (2, 4) (2, 4) (2, 4)
n = 1: (1, 1)
n = 5: (4, 2) (4, 2) (3, 3) (2, 3) (1, 5)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node circles.mjs

Java · compiled/Circles.java

import java.util.ArrayList;
import java.util.List;

public class Circles {
    /** The lesson's template: union by size, find with path halving. */
    static final class DSU {
        final int[] parent;
        final int[] size;
        int count;

        DSU(int n) {
            parent = new int[n];
            size = new int[n];
            for (int i = 0; i < n; i++) {
                parent[i] = i;
                size[i] = 1;
            }
            count = n;
        }

        int find(int x) {
            while (parent[x] != x) {
                parent[x] = parent[parent[x]];
                x = parent[x];
            }
            return x;
        }

        boolean union(int a, int b) {
            int ra = find(a), rb = find(b);
            if (ra == rb) return false;
            if (size[ra] < size[rb]) {
                int t = ra;
                ra = rb;
                rb = t;
            }
            parent[rb] = ra;
            size[ra] += size[rb];
            count--;
            return true;
        }
    }

    /** After each introduction: {how many circles, how many students in the largest}. */
    static List<int[]> circlesAfterEach(int n, int[][] introductions) {
        DSU dsu = new DSU(n);
        int largest = 1;
        List<int[]> answers = new ArrayList<>();
        for (int[] pair : introductions) {
            if (dsu.union(pair[0], pair[1])) {
                largest = Math.max(largest, dsu.size[dsu.find(pair[0])]);
            }
            answers.add(new int[] {dsu.count, largest});
        }
        return answers;
    }

    public static void main(String[] args) {
        int[] sizes = {6, 1, 5};
        int[][][] examples = {
            {{0, 1}, {2, 3}, {1, 2}, {4, 5}, {3, 0}, {5, 5}},
            {{0, 0}},
            {{3, 4}, {4, 3}, {0, 4}, {1, 2}, {2, 3}},
        };
        for (int e = 0; e < sizes.length; e++) {
            StringBuilder line = new StringBuilder("n = " + sizes[e] + ":");
            for (int[] answer : circlesAfterEach(sizes[e], examples[e])) {
                line.append(" (").append(answer[0]).append(", ").append(answer[1]);
                line.append(")");
            }
            System.out.println(line);
        }
    }
}

C++ · compiled/circles.cpp

#include <algorithm>
#include <numeric>
#include <print>
#include <utility>
#include <vector>

// The lesson's template: union by size, find with path halving.
// "union" is a keyword in C++, so the merge is called unite().
struct DSU {
    std::vector<int> parent, size;
    int count;

    explicit DSU(int n) : parent(n), size(n, 1), count(n) {
        std::iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }

    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (size[ra] < size[rb]) std::swap(ra, rb);
        parent[rb] = ra;
        size[ra] += size[rb];
        --count;
        return true;
    }
};

using Pairs = std::vector<std::pair<int, int>>;

// After each introduction: {how many circles there are,
// how many students the largest one has}.
Pairs circles_after_each(int n, const Pairs& introductions) {
    DSU dsu(n);
    int largest = 1;
    Pairs answers;
    for (auto [a, b] : introductions) {
        if (dsu.unite(a, b)) largest = std::max(largest, dsu.size[dsu.find(a)]);
        answers.emplace_back(dsu.count, largest);
    }
    return answers;
}

int main() {
    const std::vector<std::pair<int, Pairs>> examples = {
        {6, {{0, 1}, {2, 3}, {1, 2}, {4, 5}, {3, 0}, {5, 5}}},
        {1, {{0, 0}}},
        {5, {{3, 4}, {4, 3}, {0, 4}, {1, 2}, {2, 3}}},
    };
    for (const auto& [n, introductions] : examples) {
        std::print("n = {}:", n);
        for (auto [circles, largest] : circles_after_each(n, introductions)) {
            std::print(" ({}, {})", circles, largest);
        }
        std::println();
    }
}

Java and C++ need a compiler, so their output comes from a terminal session:

Java and C++, compiled and run Python · compiled/circles_session.py
from session import run, versions

# Circles.java runs as a single source file;
# circles.cpp is built with the sanitizers on, then run.
versions("java -version", "clang++ --version")
run("""
java Circles.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined circles.cpp -o circles
./circles
""")

Output

# openjdk version "27"
# Apple clang version 21.0.0 (clang-2100.3.34.2)
$ java Circles.java
n = 6: (5, 2) (4, 2) (3, 4) (2, 4) (2, 4) (2, 4)
n = 1: (1, 1)
n = 5: (4, 2) (4, 2) (3, 3) (2, 3) (1, 5)
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined circles.cpp -o circles
$ ./circles
n = 6: (5, 2) (4, 2) (3, 4) (2, 4) (2, 4) (2, 4)
n = 1: (1, 1)
n = 5: (4, 2) (4, 2) (3, 3) (2, 3) (1, 5)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 circles_session.py

All four print the same three lines. A few details differ between the languages:

  • C++ reserves the word union (it declares a kind of class that holds one of several members at a time), so the method is called unite. Java, JavaScript and Python have no such keyword.
  • Java runs a program kept in a single source file with java Circles.java, compiling it in memory first, which the java launcher has done since Java 11 (JEP 330).
  • The C++ build turns on AddressSanitizer and UndefinedBehaviorSanitizer, two Clang checkers that stop a program at a bad memory access or at undefined behaviour; here they found nothing to report.
  • The sizes reach at most 200,000, so 32-bit integers are enough everywhere. A problem that adds sizes up, such as the exercise below, needs 64 bits in Java and C++.

Checked against the brute force

2,000 random cases, then the work of both counted Python · check/circles_check.py
"""Union-find checked against a BFS recount after every introduction,
then the work of both counted."""
import random
from collections import deque


def circles_union_find(n, introductions):
    """The lesson's solution; also returns how many parent pointers its finds followed."""
    parent, size = list(range(n)), [1] * n
    count, largest, steps = n, 1, 0
    answers = []

    def find(x):
        nonlocal steps
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
            steps += 1
        return x

    for a, b in introductions:
        ra, rb = find(a), find(b)
        if ra != rb:
            if size[ra] < size[rb]:
                ra, rb = rb, ra
            parent[rb] = ra
            size[ra] += size[rb]
            count -= 1
            largest = max(largest, size[ra])
        answers.append((count, largest))
    return answers, steps


def circles_brute_force(n, introductions):
    """Recounts every circle with BFS after each introduction.

    It also returns its work: the students and list entries that its searches visited."""
    friends = [[] for _ in range(n)]
    answers, work = [], 0
    for a, b in introductions:
        friends[a].append(b)
        friends[b].append(a)
        seen = [False] * n
        sizes = []
        for start in range(n):
            if seen[start]:
                continue
            seen[start] = True
            queue, members = deque([start]), 0
            while queue:
                person = queue.popleft()
                members += 1
                work += 1 + len(friends[person])
                for other in friends[person]:
                    if not seen[other]:
                        seen[other] = True
                        queue.append(other)
            sizes.append(members)
        answers.append((len(sizes), max(sizes)))
    return answers, work


def random_pairs(n, m):
    return [(rng.randrange(n), rng.randrange(n)) for _ in range(m)]


rng = random.Random(7)
cases = 2000
for _ in range(cases):
    n = rng.randint(1, 9)
    introductions = random_pairs(n, rng.randint(1, 14))
    fast = circles_union_find(n, introductions)[0]
    assert fast == circles_brute_force(n, introductions)[0], (n, introductions)
print(f"{cases:,} random cases with 1 to 9 students: both give the same answers")

n = m = 1000
introductions = random_pairs(n, m)
answers, work = circles_brute_force(n, introductions)
assert answers == circles_union_find(n, introductions)[0]
print(f"n = m = {n:,}: the BFS recounts visited {work:,} students and list entries")
print(f"n = m = {n:,}: m*n + m*(m + 1) = {m * n + m * (m + 1):,}")

n = m = 200_000
introductions = random_pairs(n, m)
answers, steps = circles_union_find(n, introductions)
visits = m * n + m * (m + 1)
print(f"n = m = {n:,}: the BFS recounts would visit m*n + m*(m + 1) = {visits:,}")
print(f"n = m = {n:,}: union-find's finds followed {steps:,} parent pointers in all")
circles, largest = answers[-1]
print(f"after the last introduction: {circles:,} circles, the largest of {largest:,}")

Output

2,000 random cases with 1 to 9 students: both give the same answers
n = m = 1,000: the BFS recounts visited 2,001,000 students and list entries
n = m = 1,000: m*n + m*(m + 1) = 2,001,000
n = m = 200,000: the BFS recounts would visit m*n + m*(m + 1) = 80,000,200,000
n = m = 200,000: union-find's finds followed 206,204 parent pointers in all
after the last introduction: 32,452 circles, the largest of 159,297

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 circles_check.py

The random cases are tiny and full of repeated pairs and students introduced to themselves, so every special case comes up hundreds of times. With 1,000 students and 1,000 introductions, the program counts what the brute force visits and gets exactly m·n + m(m + 1); at the limits, 200,000 of each, that formula gives 80,000,200,000 visits. Union-find’s finds followed 206,204 parent pointers for the same size, about one per introduction.

The cost

Friend circles after each of m introductions among n students
Operation Time Extra space
Recount with BFS after each introduction O(m · (n + m)) O(n + m)
Union-find O(n + m α(m, n)) O(n)

The union-find version needs two arrays of n numbers and keeps none of the introductions; the brute force must keep every one of them in its adjacency lists.

A recursive find that runs out of stack

Full path compression is often written recursively: if x is not a root, set its parent to find of its parent, then return that parent. It is correct and fits in two lines, but it needs one call frame for every item on the path. Union by size keeps paths at most log₂ n long, so the recursion stays shallow. The danger comes when the paths are long, for example when the links go in without union by size, or when the parent array is given as the input. Here is the line that plain linking builds from union(0, 1), union(1, 2) and so on, 100,000 items long:

A recursive find on a line of 100,000 items, then path halving

Python · recursion/deep_chain.py

"""A recursive find on a line of 100,000 items, then the loop with path halving on it."""
import sys

N = 100_000
# The parent array that plain linking builds from union(0, 1), union(1, 2), ...:
# 0 -> 1 -> 2 -> ... -> 99999.
parent = list(range(1, N)) + [N - 1]


def find_recursive(x):
    """Full path compression, the short recursive way."""
    if parent[x] != x:
        parent[x] = find_recursive(parent[x])
    return parent[x]


def find_halving(x):
    """The template's loop: no recursion; every other item on the path skips a level."""
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x


def depth(x):
    """How many pointers lead from x to its root."""
    d = 0
    while parent[x] != x:
        x = parent[x]
        d += 1
    return d


print("recursion limit:", sys.getrecursionlimit())
try:
    find_recursive(0)
except RecursionError as error:
    print("recursive find(0) stopped with RecursionError:", error)
print(f"depth of item 0: {depth(0):,} (the line is unchanged)")

calls = 0
while depth(0) > 1:
    root = find_halving(0)
    calls += 1
    if calls <= 4 or depth(0) == 1:
        print(f"after call {calls} of find(0): root {root:,}, depth of item 0: {depth(0):,}")
print("calls until item 0 points straight at the root:", calls)

Output

recursion limit: 1000
recursive find(0) stopped with RecursionError: maximum recursion depth exceeded
depth of item 0: 99,999 (the line is unchanged)
after call 1 of find(0): root 99,999, depth of item 0: 50,000
after call 2 of find(0): root 99,999, depth of item 0: 25,000
after call 3 of find(0): root 99,999, depth of item 0: 12,500
after call 4 of find(0): root 99,999, depth of item 0: 6,250
after call 17 of find(0): root 99,999, depth of item 0: 1
calls until item 0 points straight at the root: 17

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 deep_chain.py

JavaScript · recursion/deep_chain.mjs

// A recursive find on a line of 100,000 items, then the loop with path halving on it.
const N = 100_000;
// The parent array that plain linking builds from union(0, 1), union(1, 2), ...:
// 0 -> 1 -> 2 -> ... -> 99999.
const parent = Int32Array.from({ length: N }, (_, i) => Math.min(i + 1, N - 1));

function findRecursive(x) {
  if (parent[x] !== x) parent[x] = findRecursive(parent[x]); // full path compression
  return parent[x];
}

function findHalving(x) {
  while (parent[x] !== x) {
    parent[x] = parent[parent[x]];
    x = parent[x];
  }
  return x;
}

function depth(x) {
  let d = 0;
  while (parent[x] !== x) {
    x = parent[x];
    d += 1;
  }
  return d;
}

try {
  findRecursive(0);
} catch (error) {
  console.log(`recursive find(0) stopped with ${error.name}: ${error.message}`);
}
console.log(`depth of item 0: ${depth(0).toLocaleString("en")} (the line is unchanged)`);

let calls = 0;
while (depth(0) > 1) {
  findHalving(0);
  calls += 1;
}
console.log("calls of the halving find until item 0 points straight at the root:", calls);

Output

recursive find(0) stopped with RangeError: Maximum call stack size exceeded
depth of item 0: 99,999 (the line is unchanged)
calls of the halving find until item 0 points straight at the root: 17

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node deep_chain.mjs

CPython stops the first find(0) at its recursion limit, 1,000 frames by default, with RecursionError. The limit can be raised with sys.setrecursionlimit(), but the Python documentation warns that a limit set too high can crash the interpreter. Node.js gives up too, with RangeError: Maximum call stack size exceeded, the message MDN lists for Chrome and Safari, and Java throws StackOverflowError when an application recurses too deeply. In both runs the error came before any pointer had changed, because the assignments happen only as the calls return, so the line was still whole afterwards.

The halving loop needs no call stack at all. Its first walk halves the depth of item 0, from 99,999 to 50,000, and each later walk halves it again: 17 calls bring item 0 next to the root. Prefer the loop in every language, even with union by size, so that the template is safe whatever builds the trees.

Variants of the template

  • Labels that are not 0 to n − 1. Give each name, email address or coordinate a number the first time it is seen, with a dictionary, and run the template on the numbers. A grid cell (r, c) can use the number r · cols + c.
  • Items that arrive later. Append a new item to both arrays (parent gets its own index, size gets 1) and add one to count.
  • Full path compression with two loops. Find the root first, then walk the path again and point every item on it at the root. It has the same bound as halving and takes two passes.
  • What the rest of this module builds on it. The first union that returns False finds the cable that closed a loop; records are merged through the identifiers they share; a weight stored on each parent pointer answers ratio and parity questions; and queries sorted by a limit are answered offline as edges are added in order.
Python Online Compiler Paste the template and try your own unions: print the parent array after each one and compare it with your trace.

Exercise

Exercise · Easy · Python, JavaScript

Pairs of towns that can reach each other

A region has n towns, numbered 0 to n - 1, and no roads yet. Roads are built one at a time; each road joins two towns and can be used in both directions. Write reachable_pairs(n, roads) (reachablePairs(n, roads) in JavaScript). It returns one number per road: after that road is built, how many pairs of different towns can reach each other by some sequence of roads. A pair is unordered, so towns 0 and 1 count once.

reachable_pairs(5, [(0, 1), (1, 2), (3, 4), (2, 0), (2, 3)])   # [1, 3, 4, 4, 10]

After the third road, towns 0, 1 and 2 form one group (3 pairs) and towns 3 and 4 another (1 pair). The fourth road joins two towns that were connected already, so nothing changes. The fifth joins both groups into one group of five towns, and 5 × 4 / 2 = 10.

  • A road may join a town to itself, or repeat an earlier road. Neither adds a pair.
  • The sample tests go up to 200,000 towns and roads in Python and 100,000 in JavaScript, and some answers are larger than 2³¹.
  • Each road should cost nearly constant time: recounting every group after each road is far too slow.

Write the union-find template from this lesson without copying it, then decide what the sizes it keeps tell you at the moment two groups merge.

Python · Starter code · pairs.py

def reachable_pairs(n, roads):
    """After each road, how many pairs of different towns can reach each other. One number per road."""
    # Replace this line with your code.
    return []
The sample tests · test_pairs.py
import random
from collections import deque

from pairs import reachable_pairs


def trusted(n, roads):
    """The answer the tests trust: after each road, BFS finds every group, and a group of s towns has s(s-1)/2 pairs."""
    neighbours = [[] for _ in range(n)]
    answers = []
    for a, b in roads:
        neighbours[a].append(b)
        neighbours[b].append(a)
        seen = [False] * n
        pairs = 0
        for start in range(n):
            if seen[start]:
                continue
            seen[start] = True
            queue, towns = deque([start]), 0
            while queue:
                town = queue.popleft()
                towns += 1
                for other in neighbours[town]:
                    if not seen[other]:
                        seen[other] = True
                        queue.append(other)
            pairs += towns * (towns - 1) // 2
        answers.append(pairs)
    return answers


def test_example():
    """the example of the prompt: 1, 3, 4, 4, 10"""
    assert reachable_pairs(5, [(0, 1), (1, 2), (3, 4), (2, 0), (2, 3)]) == [1, 3, 4, 4, 10]


def test_no_roads():
    """no roads, no numbers"""
    assert reachable_pairs(4, []) == []


def test_road_to_itself_and_repeated_roads():
    """a road from a town to itself joins nothing, and a repeated road adds no pair"""
    assert reachable_pairs(3, [(1, 1), (0, 2), (2, 0), (0, 2)]) == [0, 1, 1, 1]
    assert reachable_pairs(1, [(0, 0)]) == [0]


def test_two_groups_merge():
    """groups of 3 and 4 towns that meet add 3 * 4 = 12 pairs at once"""
    roads = [(0, 1), (1, 2), (3, 4), (4, 5), (5, 6), (2, 6)]
    assert reachable_pairs(7, roads) == [1, 3, 4, 6, 9, 21]


def test_a_long_line_then_its_two_ends():
    """200,000 towns joined in a line, then its two ends joined again: answers beyond 32 bits, and a long path"""
    n = 200_000
    answers = reachable_pairs(n, [(i, i + 1) for i in range(n - 1)] + [(0, n - 1)])
    assert answers[:3] == [1, 3, 6]
    assert answers[-2] == answers[-1] == n * (n - 1) // 2 == 19_999_900_000


def test_hub_roads_stay_fast():
    """200,000 roads from town 0 to every other town, in an order that makes plain linking quadratic"""
    n = 200_000
    answers = reachable_pairs(n, [(0, i) for i in range(1, n)] + [(i, 0) for i in range(1, n)])
    assert answers[n - 2] == n * (n - 1) // 2
    assert answers[-1] == answers[n - 2]


def test_random_against_bfs():
    """400 random cases of up to 10 towns, checked against a BFS recount after every road"""
    rng = random.Random(22)
    for _ in range(400):
        n = rng.randint(1, 10)
        roads = [(rng.randrange(n), rng.randrange(n)) for _ in range(rng.randint(0, 15))]
        assert reachable_pairs(n, roads) == trusted(n, roads), (n, roads)

JavaScript · Starter code · pairs.mjs

/**
 * After each road, how many pairs of different towns can reach each other. Returns one number per road.
 * Towns are numbered 0 to n - 1; each road is a pair [a, b] and can be used both ways.
 */
export function reachablePairs(n, roads) {
  // Replace this line with your code.
  return [];
}
The sample tests · pairs.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { reachablePairs } from './pairs.mjs';

let seed = 22;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
const randInt = (lo, hi) => lo + Math.floor(random() * (hi - lo + 1));

// The answer the tests trust: after each road, BFS finds every group, and a group of s towns has s(s-1)/2 pairs.
function trusted(n, roads) {
  const neighbours = Array.from({ length: n }, () => []);
  const answers = [];
  for (const [a, b] of roads) {
    neighbours[a].push(b);
    neighbours[b].push(a);
    const seen = new Array(n).fill(false);
    let pairs = 0;
    for (let start = 0; start < n; start++) {
      if (seen[start]) continue;
      seen[start] = true;
      const queue = [start];
      for (let head = 0; head < queue.length; head++) {
        for (const other of neighbours[queue[head]]) {
          if (!seen[other]) {
            seen[other] = true;
            queue.push(other);
          }
        }
      }
      pairs += (queue.length * (queue.length - 1)) / 2;
    }
    answers.push(pairs);
  }
  return answers;
}

test('the example of the prompt: 1, 3, 4, 4, 10', () => {
  assert.deepEqual(reachablePairs(5, [[0, 1], [1, 2], [3, 4], [2, 0], [2, 3]]), [1, 3, 4, 4, 10]);
});

test('no roads, no numbers', () => {
  assert.deepEqual(reachablePairs(4, []), []);
});

test('a road from a town to itself joins nothing, and a repeated road adds no pair', () => {
  assert.deepEqual(reachablePairs(3, [[1, 1], [0, 2], [2, 0], [0, 2]]), [0, 1, 1, 1]);
  assert.deepEqual(reachablePairs(1, [[0, 0]]), [0]);
});

test('groups of 3 and 4 towns that meet add 3 * 4 = 12 pairs at once', () => {
  assert.deepEqual(reachablePairs(7, [[0, 1], [1, 2], [3, 4], [4, 5], [5, 6], [2, 6]]), [1, 3, 4, 6, 9, 21]);
});

test('100,000 towns joined in a line, then its two ends joined again', () => {
  const n = 100000;
  const roads = Array.from({ length: n - 1 }, (_, i) => [i, i + 1]);
  roads.push([0, n - 1]);
  const answers = reachablePairs(n, roads);
  assert.deepEqual(answers.slice(0, 3), [1, 3, 6]);
  assert.equal(answers[n - 2], 4999950000);
  assert.equal(answers[n - 1], 4999950000);
});

test('100,000 roads from town 0 to every other town, then the same roads the other way', () => {
  const n = 100000;
  const roads = Array.from({ length: n - 1 }, (_, i) => [0, i + 1]);
  for (let i = 1; i < n; i++) roads.push([i, 0]);
  const answers = reachablePairs(n, roads);
  assert.equal(answers[n - 2], 4999950000);
  assert.equal(answers[answers.length - 1], 4999950000);
});

test('400 random cases of up to 10 towns, checked against a BFS recount after every road', () => {
  for (let trial = 0; trial < 400; trial++) {
    const n = randInt(1, 10);
    const roads = Array.from({ length: randInt(0, 15) }, () => [randInt(0, n - 1), randInt(0, n - 1)]);
    assert.deepEqual(reachablePairs(n, roads), trusted(n, roads), JSON.stringify([n, roads]));
  }
});
A hint

Keep a running total. A road that joins two different groups of s and t towns lets every town of the first group reach every town of the second, so the total grows by exactly s * t; pairs inside each group were counted before. Read both sizes at the two roots before you add them together, and add nothing when find returns the same root for both towns.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 With the lesson's template, you call union(0, 1), union(2, 3) and then union(1, 3) on four items. Which item is the root of the one group left?

    Choose one answer.

    Show the answer to question 1

    Answer: 0

    union(0, 1) leaves 0 as the root, because the template swaps the roots only when the first one's group is strictly smaller. union(2, 3) leaves 2 as a root in the same way. union(1, 3) finds the roots 0 and 2, whose groups both hold 2 items, so there is no swap and 2 goes under 0.

  2. Question 2 of 7 What does ties.py print? Work it out on paper before you look at the recorded output.

    What does this program print? Choose one answer.

    class DSU:
        def __init__(self, n):
            self.parent, self.size, self.count = list(range(n)), [1] * n, n
    
        def find(self, x):
            while self.parent[x] != x:
                self.parent[x] = self.parent[self.parent[x]]
                x = self.parent[x]
            return x
    
        def union(self, a, b):
            ra, rb = self.find(a), self.find(b)
            if ra == rb:
                return False
            if self.size[ra] < self.size[rb]:
                ra, rb = rb, ra
            self.parent[rb] = ra
            self.size[ra] += self.size[rb]
            self.count -= 1
            return True
    
    
    d = DSU(5)
    print(d.union(0, 1), d.union(1, 0), d.union(2, 3), d.union(3, 1), d.count, d.find(0))
    Show the answer to question 2

    Answer: it prints

    True False True True 2 2

    The second call joins 1 and 0 again, so it returns False, and the three merges leave 5 − 3 = 2 groups. In the last merge both roots, 2 and 0, have groups of 2 items. The template keeps the first root on a tie, and the first root is find(3), which is 2, so 0 goes under 2 and find(0) returns 2.

  3. Question 3 of 7 With union by size and no path halving at all, what is the largest number of parent pointers a single find can follow in a union-find of 1,000,000 items?

    Type a number.

    Show the answer to question 3

    Answer: 19 pointers

    An item moves one level further from its root only when its group is linked under a group at least as large, so its group at least doubles each time, and an item d pointers from its root belongs to a group of at least 2ᵈ items. 2²⁰ = 1,048,576 is more than 1,000,000, so d is at most 19.

  4. Question 4 of 7 Which of these tasks are a good fit for union-find? Choose every one.

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • After each new cable, say whether two given computers can reach each other
    • Find the first cable that closes a loop in a network where every cable works both ways
    • Count the groups of customer records that share an email address

    Union-find answers "are these two in one group?" while groups only ever merge, and the relation works both ways. The fewest roads is a shortest path, which BFS finds. An ending friendship splits a group, which union-find cannot undo. One-way streets make reachability directed, and union-find cannot tell A to B from B to A.

  5. Question 5 of 7 What does the line self.parent[x] = self.parent[self.parent[x]] inside find do?

    Choose one answer.

    Show the answer to question 5

    Answer: It points x at its grandparent, so each item the loop visits skips one level

    The loop then moves x to that grandparent, so it visits every second item of the path and points each of them two levels up. The path gets about half as long, without recursion and without a second pass. Pointing every item straight at the root is full path compression, which needs a second loop or recursion.

  6. Question 6 of 7 A union-find over 10 items receives 12 calls of union, and 7 of them return True. How many groups are there now?

    Type a number.

    Show the answer to question 6

    Answer: 3 groups

    Every item starts in a group of its own, so there are 10 groups. Each call that returns True merges two groups into one, and each call that returns False changes nothing, so 10 − 7 = 3 groups are left.

  7. Question 7 of 7 A union-find links the first root under the second and finds the root recursively, with path compression. Which input makes it stop with RecursionError in Python with the default limit?

    Choose one answer.

    Show the answer to question 7

    Answer: Unions (0, 1), (1, 2), … , (99998, 99999), then find(0)

    Plain linking turns that line of unions into a path of 100,000 items, and the first find(0) needs one call for each of them, far beyond the limit of 1,000. Unions of an item with itself change nothing. The number of items does not matter, only the length of the path. With union by size every item stays within log₂ n pointers of its root, 16 for 100,000 items.

Interview questions

Fresher · What do find and union return in a union-find, and what does it mean when union returns False?

find(x) returns the root of x’s group: the item reached by following parent pointers from x until one points at itself. The root stands for the whole group, so two items are in the same group exactly when find returns the same root for both. union(a, b) finds both roots. If they are the same, a and b were already in one group, nothing changes, and it returns False; otherwise it links the root of the smaller group under the root of the larger, adds the sizes, lowers the group count by one and returns True. False carries information of its own: in a graph whose edges go both ways, a union that returns False means that the new edge joins two items that were already connected, so it closes a cycle.

Fresher to 2–5 years · Why does union by size keep every tree of a union-find at most log₂ n levels deep?

An item gets one level further from its root only when the root of its group is linked under another root. Union by size always links the smaller group under the larger one, or under one of equal size, so every time an item’s depth grows by one, the group it belongs to at least doubles. A group can never hold more than n items, and doubling from 1 item reaches n after log₂ n doublings, so no item is ever more than ⌊log₂ n⌋ pointers from its root: 17 for 200,000 items. That bound holds even with no path compression at all, and it is also why a recursive find is safe once union by size is in place.

Key takeaways

  • Union-find keeps groups that only merge. find(x) returns the root of x’s group, and union(a, b) links one root under the other and returns whether anything merged.
  • The template is two arrays and a count: parent (a root is its own parent), size (read only at roots) and count (one less after every merge).
  • Union by size keeps every tree within ⌊log₂ n⌋ levels; path halving points each visited item at its grandparent. Together they make m operations take O(m α(m, n)) time, and α(n, n) is at most 4 for any realistic n.
  • Measured: plain linking walked 2,047.75 pointers per operation on a line of 4,096 items, union by size alone 6.00 on equal halves, and both rules together at most 0.78 in every workload.
  • Choose union-find for “same group?”, “how many groups?” and “how large?” while connections only arrive. Choose a search for paths, distances, one-way edges or connections that are removed.
  • Write find as a loop. A recursive find with plain linking stopped with RecursionError in Python and RangeError in Node.js on a line of 100,000 items.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.