Data Structures & Algorithms (DSA) Module 11 – Graphs: modelling, traversal and connectivity
Union-find (disjoint set union)
How union-find keeps items in groups that only merge, answers "same group?" with find and union, and stays nearly constant per step with two simple rules.
What you will learn
- Implement union by size or rank with path compression
- Explain the near-constant amortised bound
- Apply union-find to connectivity and grouping problems
Before you start
What you’ll learn · Before you start
What you will learn
- Implement union by size or rank with path compression
- Explain the near-constant amortised bound
- Apply union-find to connectivity and grouping problems
Before you start
On this page
Union-find, also called disjoint set union (DSU), tells you in almost no time whether two items belong together while
the items are being joined into groups, step by step. It offers two operations. find(x) names the group of x by
returning one chosen member of it, the representative, and union(a, b) joins the group of a to the group of b.
A group can grow, but it can never split. Inside, each group is a tree of parent pointers whose root is the
representative, and two rules keep the trees short: the smaller tree goes under the larger root, and every find
points the items it passes straight at the root. With both rules, any m operations on n items take O(m α(n)) time,
where α, an inverse of Ackermann’s function, is at most 4 for any number of items a computer could ever store. That
makes it the tool for growing networks, merging duplicates and Kruskal’s spanning trees.
Groups as trees of parent pointers
The whole structure is two arrays. parent[x] is the item one step above x in its tree, and an item that is its
own parent is a root. Every group has exactly one root, so two items share a group exactly when they reach the same
root. size[r] counts the items under the root r. For a million items that is two arrays of a million numbers,
about 8 MB as 4-byte integers.
The lab computers as a forest of parent pointers
Text description of the diagram
Arrows point from each item to its parent; a root has no arrow of its own.
The first picture shows the forest after the first six cables. There are two trees of four items each. In the first, items 1 and 2 point to the root 0, and item 3 points to 2, so 3 is two steps from its root. In the second, items 5 and 6 point to the root 4, and item 7 points to 6.
The second picture shows the same items after the question same(3, 7) and the cable union(3, 7). The question walked from 3 to 0 and from 7 to 4, and path compression pointed 3 straight at 0 and 7 straight at 4. The union then found the two roots, 0 and 4, saw that both trees had 4 items, and linked root 4 under root 0. Now items 1, 2, 3 and 4 point to 0, and items 5, 6 and 7 point to 4, so no item is more than two steps from the root.
find(x) follows parent pointers from x until it reaches a root. union(a, b) finds both roots; if they are the
same item, the two were already in one group, and otherwise one root becomes the parent of the other, which merges
the trees in one step. Nothing else ever changes a pointer, so a group can grow but never split.
find and union, step by step
Eight lab computers, numbered 0 to 7, are joined by cables one at a time. After each cable the program prints the parent array and the number of groups, and it answers two questions on the way:
# Union-find with union by size and path compression, traced on eight lab computers joined by cables one at a time.
# parent[x] is x's parent in its tree; a root is its own parent and stands for its whole group. size[r] is the number
# of items in the tree of root r (it is only kept up to date for roots).
class UnionFind:
def __init__(self, n):
self.parent = list(range(n)) # every item starts as a group of its own
self.size = [1] * n
self.groups = n
def find(self, x):
root = x
while self.parent[root] != root: # walk up to the root
root = self.parent[root]
while self.parent[x] != root: # path compression: point every item on the way at the root
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already in one group: nothing changes
if self.size[ra] < self.size[rb]: # union by size: the smaller tree goes under the larger root
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
def same(self, a, b):
return self.find(a) == self.find(b)
uf = UnionFind(8)
print(f"{'start':<16} parent {uf.parent} groups {uf.groups}")
steps = [("union", 0, 1), ("union", 2, 3), ("union", 1, 3), ("union", 4, 5), ("union", 6, 7), ("union", 5, 7),
("same", 3, 7), ("union", 1, 2), ("union", 3, 7), ("same", 0, 6)]
for op, a, b in steps:
if op == "union":
joined = uf.union(a, b)
note = "" if joined else " (already one group)"
print(f"{f'union({a}, {b})':<16} parent {uf.parent} groups {uf.groups}{note}")
else:
answer = "yes" if uf.same(a, b) else "no"
print(f"{f'same({a}, {b})?':<16} parent {uf.parent} {answer}")
print("sizes of the roots:", {r: uf.size[r] for r in range(8) if uf.parent[r] == r}) Output
start parent [0, 1, 2, 3, 4, 5, 6, 7] groups 8
union(0, 1) parent [0, 0, 2, 3, 4, 5, 6, 7] groups 7
union(2, 3) parent [0, 0, 2, 2, 4, 5, 6, 7] groups 6
union(1, 3) parent [0, 0, 0, 2, 4, 5, 6, 7] groups 5
union(4, 5) parent [0, 0, 0, 2, 4, 4, 6, 7] groups 4
union(6, 7) parent [0, 0, 0, 2, 4, 4, 6, 6] groups 3
union(5, 7) parent [0, 0, 0, 2, 4, 4, 4, 6] groups 2
same(3, 7)? parent [0, 0, 0, 0, 4, 4, 4, 4] no
union(1, 2) parent [0, 0, 0, 0, 4, 4, 4, 4] groups 2 (already one group)
union(3, 7) parent [0, 0, 0, 0, 0, 4, 4, 4] groups 1
same(0, 6)? parent [0, 0, 0, 0, 0, 4, 0, 4] yes
sizes of the roots: {0: 8}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 union_find.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each union of two separate groups lowers the count by one, from 8 to 1 over seven unions. The third cable,
union(1, 3), finds the roots 0 and 2, two trees of two items, and hangs 2 under 0, so item 3 is now two steps from
its root. The question same(3, 7) walks 3 → 2 → 0 and 7 → 6 → 4, answers no, and leaves parent[3] = 0 and
parent[7] = 4 behind: those paths are one step long from now on. union(1, 2) changes nothing, because both items
already reach root 0. The last union links root 4 under root 0, and the final question shortens the path of item 6.
Two rules that keep the trees short
Without any rule, unions can stack the trees into one long chain, and every find then walks the whole chain. The first rule decides which root goes on top. Union by size hangs the root of the smaller tree under the root of the larger one. An item then moves one level deeper only when its tree joins one at least as large, so the size of its tree at least doubles each time, and no tree is ever taller than log₂ n. Union by rank compares ranks instead of sizes, a rank being an upper bound on a tree’s height, and it caps the height just as well.
The second rule works inside find. Path compression points every item on the walk straight at the root, so the
next find from any of them takes one step. Path halving points every other item at its grandparent during the
same single walk, which needs no second loop.
Galler and Fischer described the structure in 1964. Tarjan proved in 1975 that union by rank with path compression runs any sequence of m operations in O(m α(m, n)) time, and Tarjan and van Leeuwen extended the bound to path halving and to union by size in 1984. A single operation can still be slower than average; the bound is for whole sequences.
| Operation | Time | Extra space |
|---|---|---|
| Make n groups of one item | O(n) | O(n): two arrays |
| find(x) | O(α(n)) amortised, O(log n) worst case | O(1) |
| union(a, b) | O(α(n)) amortised, O(log n) worst case | O(1) |
| Split a group | Not supported | - |
Exercise and self-check
Process unions and same-group questions on up to 200,000 items and return the number of groups after each union.
Exercise · Medium · Python, JavaScript
Groups after each union, and same-group questions
n items are numbered from 0 to n - 1, and each starts in a group of its own. Write group_counts(n, operations) (JavaScript: groupCounts) that processes the operations in order and returns one answer per operation:
("union", a, b)(JavaScript:["union", a, b]) puts the groups ofaandbtogether; its answer is the number of groups right after it, which stays the same whenaandbwere already in one group;("same", a, b)asks whetheraandbare in one group; its answer isTrueorFalse(JavaScript:trueorfalse). Every item is in the same group as itself.
The sample tests replay the eight lab computers of the lesson, check repeated and redundant unions, compare your answers with a slow recount on random small cases, and finish with a chain of 200,000 items followed by 200,000 questions about it, which needs short trees and a find that does not recurse.
Python · Starter code · groups.py
def group_counts(n, operations):
"""One answer per operation: the number of groups after a union, True or False for a same-group question."""
raise NotImplementedError("write group_counts") The sample tests · test_groups.py
import random
from groups import group_counts
LAB = [("union", 0, 1), ("union", 2, 3), ("union", 1, 3), ("union", 4, 5), ("union", 6, 7), ("union", 5, 7),
("same", 3, 7), ("union", 1, 2), ("union", 3, 7), ("same", 0, 6)]
def slow_counts(n, operations):
"""Recolours every item after each union: right, and far too slow for large inputs."""
label = list(range(n))
out = []
for op, a, b in operations:
if op == "union":
old, new = label[b], label[a]
if old != new:
label = [new if x == old else x for x in label]
out.append(len(set(label)))
else:
out.append(label[a] == label[b])
return out
def test_lab_computers():
"""the eight lab computers of the lesson"""
assert group_counts(8, LAB) == [7, 6, 5, 4, 3, 2, False, 2, 1, True]
def test_redundant_and_self_unions():
"""a union of one group with itself, or of an item with itself, keeps the count"""
ops = [("union", 0, 0), ("union", 0, 1), ("union", 1, 0), ("same", 2, 2), ("same", 0, 2)]
assert group_counts(3, ops) == [3, 2, 2, True, False]
def test_single_item():
"""one item is one group, and in the same group as itself"""
assert group_counts(1, [("same", 0, 0), ("union", 0, 0)]) == [True, 1]
def test_random_against_recount():
"""300 random small cases, compared with a slow recount"""
rng = random.Random(5)
for _ in range(300):
n = rng.randint(1, 9)
ops = [(rng.choice(["union", "same"]), rng.randrange(n), rng.randrange(n)) for _ in range(rng.randint(0, 15))]
assert group_counts(n, ops) == slow_counts(n, ops), (n, ops)
def test_long_chain():
"""200,000 unions that form a chain, then 200,000 questions about its far ends"""
n = 200_000
ops = [("union", i, i + 1) for i in range(n - 1)] + [("same", 0, i) for i in range(n - 1, -1, -1)]
answers = group_counts(n, ops)
assert answers[: n - 1] == list(range(n - 1, 0, -1))
assert all(answers[n - 1:]) JavaScript · Starter code · groups.mjs
/** One answer per operation: the number of groups after a union, true or false for a same-group question. */
export function groupCounts(n, operations) {
throw new Error('write groupCounts');
} The sample tests · groups.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { groupCounts } from './groups.mjs';
const LAB = [['union', 0, 1], ['union', 2, 3], ['union', 1, 3], ['union', 4, 5], ['union', 6, 7], ['union', 5, 7],
['same', 3, 7], ['union', 1, 2], ['union', 3, 7], ['same', 0, 6]];
/** Recolours every item after each union: right, and far too slow for large inputs. */
function slowCounts(n, operations) {
let label = Array.from({ length: n }, (_, i) => i);
const out = [];
for (const [op, a, b] of operations) {
if (op === 'union') {
const oldLabel = label[b];
const newLabel = label[a];
if (oldLabel !== newLabel) label = label.map((x) => (x === oldLabel ? newLabel : x));
out.push(new Set(label).size);
} else out.push(label[a] === label[b]);
}
return out;
}
test('the eight lab computers of the lesson', () => {
assert.deepEqual(groupCounts(8, LAB), [7, 6, 5, 4, 3, 2, false, 2, 1, true]);
});
test('a union of one group with itself, or of an item with itself, keeps the count', () => {
const ops = [['union', 0, 0], ['union', 0, 1], ['union', 1, 0], ['same', 2, 2], ['same', 0, 2]];
assert.deepEqual(groupCounts(3, ops), [3, 2, 2, true, false]);
});
test('one item is one group, and in the same group as itself', () => {
assert.deepEqual(groupCounts(1, [['same', 0, 0], ['union', 0, 0]]), [true, 1]);
});
test('300 random small cases, compared with a slow recount', () => {
let seed = 5;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
const pick = (k) => Math.floor(random() * k);
for (let t = 0; t < 300; t++) {
const n = 1 + pick(9);
const ops = Array.from({ length: pick(16) }, () => [random() < 0.5 ? 'union' : 'same', pick(n), pick(n)]);
assert.deepEqual(groupCounts(n, ops), slowCounts(n, ops), JSON.stringify([n, ops]));
}
});
test('200,000 unions that form a chain, then 200,000 questions about its far ends', () => {
const n = 200000;
const ops = [];
for (let i = 0; i < n - 1; i++) ops.push(['union', i, i + 1]);
for (let i = n - 1; i >= 0; i--) ops.push(['same', 0, i]);
const answers = groupCounts(n, ops);
let ok = answers.length === 2 * n - 1;
for (let i = 0; i < n - 1 && ok; i++) if (answers[i] !== n - 1 - i) ok = false;
for (let i = n - 1; i < answers.length && ok; i++) if (answers[i] !== true) ok = false;
assert.ok(ok, 'the counts fall from 199,999 to 1 and every question is answered true');
}); A hint
Keep parent, size and a running groups count, as in the lesson's code. A union first finds both roots: when they are equal, nothing changes and the answer is the current count; otherwise put the smaller tree under the larger root and subtract 1. Write find with a loop, not recursion, so a tall tree cannot exhaust the call stack.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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
5 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.
Why it is nearly constant: the height bound proved and the steps counted Pro: opens with a Pro, Premium or Ultimate pass
Each rule caps the height of the trees in its own way, and together they keep a find below one pointer step on average. This part proves the logarithmic bound for union by size and counts the steps of four versions of the structure on up to a million items.
In this part:
- Union by size keeps every tree within log₂ n levels: the proof
- What path compression adds to the bound
- Steps per find on four versions and three workloads, measured
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Union-find in five languages, with memory measured and four bugs reproduced Pro: opens with a Pro, Premium or Ultimate pass
The structure is the same two arrays in every language; what differs is the type of the arrays and what each entry costs. Below, a single program handles a million items identically in all five interview languages, followed by four bugs that slip through small tests.
In this part:
- The same program in five languages, with run records
- Four bugs that pass small tests, reproduced
- Edge cases worth a test
4 runnable examples (C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Loops, spanning trees, shrinking networks and a practice set Pro: opens with a Pro, Premium or Ultimate pass
A union that finds both items already joined is information too: the link it was asked to add closes a loop. This part uses that for loop checks and minimum spanning trees, groups accounts that share an identifier, answers questions about a network that loses links by running time backwards, and ends with a practice set.
In this part:
- A failed union closes a loop, and Kruskal keeps the cheapest links
- Groups that share an identifier
- Questions about a shrinking network, answered backwards
- A practice set, with worked answers
3 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
find(x) returned 4 a minute ago and returns 9 now, although nothing touched x itself. Is that a bug?
No. find returns the representative of the group that holds x, the root of its tree, and a later union can hang that root under another one. When the group of x joined a larger group whose root is 9, root 4 became a child of 9, so every item of the old group, x included, now reaches 9. What union-find promises holds at every moment: two items are in one group exactly when find gives the same answer for both right now. So never keep a find result to compare later; call find again when you need it. Path compression rewrites pointers too, but it never changes which item is the root, so it cannot change the answer on its own.
-
Mid to senior
A network monitor receives 20 million "link up" events among 4 million devices, and links never go down. After each event it must answer up to five "can A reach B?" questions in about a microsecond each. Which structure do you use, how much memory does it need, and what is the worst case of a single question?
-
Mid to senior
A deduplication job built on union-find has run for months. On one customer's file of 300,000 records it now crashes with a stack overflow, yet every test passes. What is the likely cause, how do you reproduce it in a test, and what do you change?
-
Mid to staff
A matcher emits 30 million "same product" pairs over 8 million listings, and every listing needs a group id. A colleague stores the pairs as a graph and runs breadth-first search. Compare that with union-find in time, in memory, and when new pairs keep arriving every minute.
Key takeaways
- Union-find keeps groups that only merge:
findreturns a group’s root andunionlinks two roots. - Two items share a group exactly when they have the same root; compare roots, never parents.
- Union by size (or by rank) caps the height at log₂ n; path compression makes later finds take one step.
- With both rules, m operations cost O(m α(n)), where α is at most 4 for any realistic n.
References
- An improved equivalence algorithm (B. A. Galler and M. J. Fischer, Communications of the ACM 7(5), 1964) (Association for Computing Machinery)
- Efficiency of a Good But Not Linear Set Union Algorithm (R. E. Tarjan, Journal of the ACM 22(2), 1975) (Association for Computing Machinery)
- Worst-case Analysis of Set Union Algorithms (R. E. Tarjan and J. van Leeuwen, Journal of the ACM 31(2), 1984) (Association for Computing Machinery)
- The cell probe complexity of dynamic data structures (M. Fredman and M. Saks, STOC 1989) (Association for Computing Machinery)
- On the shortest spanning subtree of a graph and the traveling salesman problem (J. B. Kruskal, 1956) (American Mathematical Society)
- Dictionary of Algorithms and Data Structures: inverse Ackermann function (National Institute of Standards and Technology (NIST))
- Dictionary of Algorithms and Data Structures: disjoint set (National Institute of Standards and Technology (NIST))
- sys.getrecursionlimit and sys.setrecursionlimit (Python Software Foundation)
- array: efficient arrays of numeric values (Python Software Foundation)
- tracemalloc: trace memory allocations (Python Software Foundation)
- The java command: -Xss, the thread stack size (Oracle)
- Package runtime/debug: SetMaxStack (The Go Authors)
- ECMAScript language specification: TypedArray objects (Ecma International (TC39))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress