DataData/11 · Union-Find
CHAPTER 11 · Union-Find

The Union-Find structure

It answers exactly two questions: are these two elements in the same set, and merge these two sets. It keeps the elements in a forest, where each set is one tree and the root of that tree is the set's representative. One integer array holds the whole thing, and with both optimizations each operation costs effectively constant time.

§01

Why it exists: connectivity that keeps changing

A is connected to B, B is connected to C. Are A and C in the same group?

Suppose you run a social network. Events keep arriving: A and B became friends, C and D became friends, B and C became friends. Between those events, someone asks: are A and D in the same group now? Being in the same group is transitive. If A—B, B—C and C—D all exist, then A and D belong to the same group even though they never met. This problem has a name: dynamic connectivity. Connections are added over time, and queries can arrive at any moment.

Answering that question needs only two operations, and a Union-Find structure provides exactly those two, no more.

OPERATION 1
find(x) — which set is x in?

Each set elects one member as its representative, called the root. To learn whether A and D are in the same set, you do not follow the chain of friendships. You ask for the representative of A and the representative of D. Same representative means same set. This turns "are they connected" into "are these two numbers equal", which is the whole idea.

OPERATION 2
union(a, b) — merge two sets

a and b became friends. Find the representative of each side, then make one representative point at the other. The two sets become one immediately. Every other member of the smaller set is now in the merged set without being visited, because they all reach the same root anyway.

Why not use graph traversal? You could store the edges and run a DFS or BFS from A each time, checking whether D is reachable (chapter 12 covers those). That works, but every query traverses the graph again at O(V+E), and the work is thrown away because new edges keep arriving. Union-Find is built for exactly this shape of problem. It is also called a disjoint set union structure, or DSU.

ApproachAdd one connectionOne connectivity queryGood for
Traverse again with DFS / BFSO(1) to storeO(V+E)A fixed graph, or when you need the path itself
Union-FindO(α(n))O(α(n))Connections keep being added, and you only ask about connectivity

The trade: it forgets the path, and it cannot split

Union-Find is fast because it keeps the conclusion and drops the details. After a union, the information about how A and D are connected is gone. All that remains is that they are in the same set. So it cannot tell you a route from A to D, and it cannot answer how far apart they are.

There is a second limit, and it is the one most often left out. Union-Find merges, but it cannot split a set back apart. There is no efficient way to undo a union, because the elements that were merged are no longer distinguishable from the ones that were already there. If a problem removes edges, the standard fix is to read all the operations first and process them in reverse order, so that every removal becomes an addition. That only works when you can see the whole sequence in advance, which is called an offline solution. Other cases need a rollback variant that stores an undo log and gives up path compression.

Sixty years old and still in use

The structure was published by Galler and Fischer in 1964 and is still used everywhere. Kruskal's minimum spanning tree algorithm sorts the edges by weight and adds them one by one. Before adding an edge it asks Union-Find whether the two endpoints are already connected. If they are, adding the edge would create a cycle, so the edge is skipped. Compilers use the same structure during type inference, to merge type variables that must be equal. Image processing uses it to label connected regions of pixels. The core is three short methods, yet the matching lower bound on its running time was only proved in 1989, by Fredman and Saks.

§02

In memory: one array is the whole forest

parent[i] is the element directly above i; parent[i] = i means i is a root

Everything a Union-Find owns is one array of integers, called parent. There are only two rules.

  • parent[i] = j means the element directly above i is j. j is not necessarily the root. It may be one link in a longer chain.
  • parent[i] = i means i points at itself, so i is a root and represents its whole set.

Why mark a root by making it point at itself? A root has no parent, so the slot has to hold something. Storing i itself costs no extra memory, needs no second array of flags, and makes the stopping test a single comparison: parent[x] == x. Every element, root or not, is handled by the same loop. That is why find is written this way: start at x and keep moving up until the element points at itself.

find_naive.py
1# Naive find: keep climbing until parent[x] == x
2def find(self, x: int) -> int:
3 while self.parent[x] != x: # something is still above x
4 x = self.parent[x] # move up one level
5 return x # points at itself = root = representative

Now look at the array differently. Draw an arrow from every i to parent[i], and the array becomes a forest: several trees, one tree per set, each root representing its set. This is the second time in this book that an array plays the part of a tree. A heap encodes the parent-child links implicitly through the index arithmetic 2i+1 and 2i+2. Union-Find is more direct: it stores the parent of each node explicitly. In both cases the tree is only a logical shape. Physically there is one contiguous row of integers.

Try it below. Click two nodes and watch union call find on each side to reach the two roots, then attach one root under the other. Watch the parent array underneath at the same time and check that everything happening in the diagram is one number changing in the array. Then press Worst-case union to see what goes wrong.

Union-Find lab — click two nodes to union them, toggle the optimizations, compare the shapes
👑0👑1👑2👑3👑4👑5👑6👑7👑8👑9
00
11
22
33
44
55
66
77
88
99
Ten elements, ten separate sets. parent[i] = i, so every element is its own root. Click two nodes to union them.
components 10 · 👑 = root; a green cell in the parent array points at itself

What went wrong: the tree degenerated into a chain

union(0,1) hangs 0 under 1. union(1,2) then hangs the root of that tree under 2, and so on. Every step attaches an existing root under a single fresh node, so the tree grows taller and never grows wider. Ten elements end up in one chain, and find(0) has to take 9 steps to reach the root, which is O(n). The speed Union-Find is known for is gone. The structure is not the problem. The way union chooses which root goes under which is the problem, and that is what the next section fixes.

§03

Two optimizations: keep the trees short

★ Asked in interviews

Path compression works during find, union by rank works during union. They prevent different things.

The problem in the previous section is that the tree is too tall. The cost of find is the number of levels between a node and its root. Both optimizations aim at the same target, keeping the trees short, but they act at different moments and prevent different failures. Both matter, and it is worth being able to say what each one does.

OPTIMIZATION 1

Path compression: flatten the path while walking it

find(x) walks from x up to the root anyway. Every node it passes on the way already has a known root by the time the walk finishes. So there is no reason to leave them pointing at intermediate nodes. After reaching the root, repoint every node on the path directly at the root.

3210
before: find(0) takes 3 steps, 0→1→2→3
3012
after: 0, 1 and 2 point at the root, so the next find is 1 step

The extra cost is small, because the walk was going to happen anyway and compression only adds a few assignments on the way back. The benefit is permanent: once a node has been compressed, later find calls on it take one step, until a later union puts a new root above it. Path compression is what prevents the same long path from being walked twice. On its own, without union by rank, it already brings the amortized cost down to O(log n).

OPTIMIZATION 2

Union by rank: attach the shorter tree under the taller one

Which root goes under which is not an arbitrary choice. Look at the height of the merged tree.

  • Shorter tree under the taller one: the new height is max(taller, shorter + 1). While the shorter tree is strictly shorter, that is just the height of the taller tree, so the height does not grow.
  • Taller tree under the shorter one: the new height is taller + 1, so a level is added for no reason.
  • Two trees of equal height: whichever way you attach them, the result is one level taller. This is the only case where the height can grow.

So give every root a rank, a number that bounds the height of its tree. During union, the root with the smaller rank is attached under the root with the larger rank. If the two ranks are equal, attach either way and increase the new root's rank by 1. This alone keeps the height within O(log n), because a tree of rank r contains at least 2ʳ elements. Rank only rises when two trees of equal rank are merged, which at least doubles the size. The argument is the same one used for doubling a dynamic array. Some implementations use size instead of rank and attach the smaller set under the larger one. The bound is the same, and you get the size of each set as a by-product, which several problems need.

Path compression cannot prevent this problem, because it only runs during find and only fixes a path that has already been built. Union by rank stops the tall tree from being built at all. That is why they are not interchangeable.

Now check it in the lab. Turn on both switches and press Worst-case union again. The chain of 10 becomes a tree of depth 1.

Union-Find lab — click two nodes to union them, toggle the optimizations, compare the shapes
👑0👑1👑2👑3👑4👑5👑6👑7👑8👑9
00
11
22
33
44
55
66
77
88
99
Ten elements, ten separate sets. parent[i] = i, so every element is its own root. Click two nodes to union them.
components 10 · 👑 = root; a green cell in the parent array points at itself

α(n): why the cost is called effectively constant, not O(1)

With both optimizations, m operations on n elements cost O(m · α(n)) in total, where α is the inverse Ackermann function. The Ackermann function grows faster than any primitive recursive function, so its inverse grows extremely slowly. For every n up to 2^65536, which no machine can store, α(n) is at most 4. In practice each operation costs a small constant number of steps.

Be careful with the wording. The bound is amortized, not worst case: a single find can still walk a long path, and the cost is only small when averaged over the whole sequence of operations. And O(α(n)) is not the same as O(1). α does grow, just unimaginably slowly. Saying "effectively constant" is correct. Saying "it is O(1)" is not. The proof of the upper bound is due to Tarjan (1975) and is far beyond this course.

Common question: does path compression break rank?

It makes rank inexact, and that is fine. Compression flattens a tree without lowering the stored rank, so after compression rank is an upper bound on the height, not the height itself. That is exactly why the field is called rank and not height. The bound is never violated, so correctness and the complexity analysis both still hold. In practice you can also write path compression alone and skip rank entirely, which gives amortized O(log n) and is fast enough for most problems. But when an interviewer asks what each optimization does, the answer has to be precise: compression shortens a path that already exists, union by rank stops a tall tree from forming, and only the two together give O(α(n)).

§04

Build one: a template worth memorizing

parent, rank and count; five methods; under 40 lines

Union-Find is one of the few structures worth writing from memory. No standard library provides it, so in a problem it always appears as a handwritten template plus a little modeling. The version below has both optimizations, plus two methods that problems ask for constantly: connected, which tests connectivity, and a count of the current number of components.

union_find.py
1class UnionFind:
2 def __init__(self, n: int):
3 self.parent = list(range(n)) # parent[i] = i: every element is a root
4 self.rank = [0] * n # upper bound on the height
5 self.count = n # number of components
6
7 def find(self, x: int) -> int:
8 root = x
9 while self.parent[root] != root: # pass 1: locate the root
10 root = self.parent[root]
11 while self.parent[x] != root: # pass 2: repoint the whole path
12 self.parent[x], x = root, self.parent[x]
13 return root
14
15 def union(self, a: int, b: int) -> bool:
16 ra, rb = self.find(a), self.find(b) # always find both roots first
17 if ra == rb:
18 return False # same set already: change nothing
19 if self.rank[ra] < self.rank[rb]:
20 ra, rb = rb, ra # make ra the taller root
21 self.parent[rb] = ra # shorter tree goes under taller
22 if self.rank[ra] == self.rank[rb]:
23 self.rank[ra] += 1 # equal ranks: the height grows
24 self.count -= 1 # two sets became one
25 return True
26
27 def connected(self, a: int, b: int) -> bool:
28 return self.find(a) == self.find(b) # same root = same set
Easy to get wrong: the default recursion limit in Python is 1000, so a recursive find raises RecursionError on a long chain. This version does full path compression with two iterative passes instead. Keep find iterative in Python.

Three details in this template are used by problem after problem.

  • union returns a boolean. false means the two elements were already in the same set, so nothing was merged. LC 684 finds the redundant edge with this return value, and so does the cycle test inside Kruskal's algorithm.
  • count starts at n and only drops on a real merge. Each element begins as its own set, so there are n sets. A successful union replaces two sets with one, so the number falls by exactly 1. A union whose two sides already share a root merges nothing, so decrementing there would report fewer components than actually exist. This is why the check if (ra == rb) return false; comes before count--.
  • Always find both roots before merging. The first line of union is never anything else. What union merges is two sets, not two individual elements.
§05

Three languages: no built-in, so the template is the library

Java, Python and JavaScript all lack an official Union-Find. The only difference is how you allocate the array.

None of the three standard libraries includes a Union-Find. C++ does not have one either. You will find it in Boost or in competitive programming template libraries. The reason is that the structure is small, and the useful details differ per problem, so writing thirty seconds of code beats a general API. What is worth comparing is therefore not the API but the choice of container.

ConcernJavaPythonJavaScript
Container for parent / rankint[] parent = new int[n]list(range(n))Array or Int32Array
Point each slot at itselfA for loop: parent[i] = ilist(range(n)) does it in one stepArray.from({length: n}, (_, i) => i)
How to write findIterative (recursion is safe but pointless)Must be iterative (recursion limit 1000)Iterative (deep recursion risks a stack overflow too)
Performance notePrimitive int[], no boxing, fastest of the threeA list holds pointers to int objects, so the constant factor is largerInt32Array stores fixed-size integers and uses about half the memory

One practical question comes up constantly: what if the keys are not the integers 0 to n−1? In LC 721 the keys are email addresses. In LC 128 they are arbitrary integers that may be negative or as large as 10⁹. The answer is the hash table from chapter 6: map each key to a fresh consecutive id, then use a normal array. The hash table translates, and Union-Find merges.

uf_with_string_keys.py
1# String keys: number them with a dict, then use the array version
2ids: dict[str, int] = {}
3for key in keys:
4 # setdefault: a new key gets the next id, a known key keeps the old one
5 ids.setdefault(key, len(ids))
6
7uf = UnionFind(len(ids))
8# from here on, work with ids only:
9uf.union(ids["a@x.com"], ids["b@x.com"])

A hash-map Union-Find also works, but do not reach for it first

You can use a Map<String, String> directly as the parent structure, mapping a key to the key above it, which skips the numbering step. The cost is that every step of find becomes a hash lookup instead of an array index, several times slower per step. A useful rule: if the elements can be numbered, number them. An array is the best home for a Union-Find, for the reason given in the array chapter: contiguous memory plus direct indexing.

§06

Three patterns: counting, cycle detection, equivalence classes

★ Interview core

Three modeling questions for any Union-Find problem: what is a node, what is an edge, and are you counting or testing?

About 90% of the code in a Union-Find problem is the same template. What is actually being tested is modeling: translating the problem statement into "these are the nodes, this counts as an edge". Once that translation is done, only three patterns remain.

PATTERN 1
Count the components

How many provinces, islands or groups are there? Start count at n, subtract 1 on every successful union, and read the answer when the scan ends. LC 547, 200 and 2316.

PATTERN 2
Detect a cycle

Add the edges one at a time. An edge whose two endpoints are already connected closes a cycle. This is the same test Kruskal's algorithm uses. LC 684 and 685.

PATTERN 3
Merge equivalence classes

Relations like equal, similar or same account are transitive once you group by them. Union everything, then check or group by root. LC 990, 721 and 839.

WALKTHROUGH A

LC 547 · Number of Provinces

MEDIUM

The problem: there are n cities, and isConnected[i][j] = 1 means i and j are directly connected. Being connected is transitive, and you must return the number of provinces, that is the number of components. The brute force approach: run a DFS from every unvisited city to mark its whole province, and count how many times you started one. That works and costs O(n²); chapter 12 covers it. The Union-Find view: every 1 in the matrix is an edge. Union cell by cell, let count fall from n, and the value left is the number of provinces. No visited array is needed.

LC 547 · union cell by cell, count falls from 4 to 2 (the cells show the parent array)
00
11
22
33
Four cities, parent = [0,1,2,3]. Every city is its own root, so count = 4. In the matrix, M[i][j] = 1 means i and j are directly connected. The scan goes cell by cell over the upper triangle only (j > i), because the matrix is symmetric.
1 / 5
lc547_provinces.py
1class Solution:
2 def findCircleNum(self, isConnected: list[list[int]]) -> int:
3 n = len(isConnected)
4 uf = UnionFind(n) # the template class from §04
5 for i in range(n):
6 for j in range(i + 1, n): # symmetric: upper triangle only
7 if isConnected[i][j] == 1:
8 uf.union(i, j) # an edge: merge, count updates itself
9 return uf.count # components left = provinces

Complexity and follow-ups

Time O(n²·α(n)), because the matrix has n² cells and reading it once is already the lower bound. Space O(n). First follow-up: DFS is also O(n²), so what does Union-Find buy here? On this input, nothing. But if the relations arrive one at a time and the count has to be reported in between, Union-Find updates incrementally while DFS starts over. Second follow-up: why only the upper triangle? The matrix is symmetric, so i-j and j-i are the same edge. Scanning both is only wasted work, not an error, because a second union of the same pair returns false and changes nothing.

WALKTHROUGH B

LC 684 · Redundant Connection

MEDIUM

The problem: a tree with n nodes should have exactly n−1 edges. One extra edge was added, so the graph now contains exactly one cycle. The edges are given in order, and you must return the extra edge. If more than one edge would work, return the one that appears last in the input.

The idea: a tree is a graph that is connected and has no cycle. Process the edges one at a time and union each one. Normally the two endpoints are in different sets, so the merge succeeds and the number of components drops by one. But if an edge's two endpoints already have the same root, a path between them already exists, and adding this edge closes a cycle. That edge is the redundant one.

Why does this give the last one automatically? The graph has exactly one cycle. Every edge that is not on the cycle joins two previously separate parts, so it never triggers the test. Among the cycle's edges, all but the last one in input order are added before the cycle is complete. So the edge that triggers the test is the only one that can, and it is the last edge of the cycle in input order. Below, the parent array is merged edge by edge, and the two endpoints of the conflicting edge are marked.

LC 684 · union edge by edge; an edge between two already connected nodes closes a cycle (the cells show the parent array)
·0
11
22
33
44
55
Edges: [1,2] [2,3] [3,4] [1,4] [1,5]. A tree plus one extra edge contains exactly one cycle. Process the edges in the given order. The edge whose two endpoints are already connected is the extra one.
1 / 6
lc684_redundant_connection.py
1class Solution:
2 def findRedundantConnection(self, edges: list[list[int]]) -> list[int]:
3 uf = UnionFind(len(edges) + 1) # n edges: nodes 1..n, one spare slot
4 for u, v in edges:
5 if uf.connected(u, v): # same root already
6 return [u, v] # adding this edge closes a cycle
7 uf.union(u, v) # not connected: merge normally
8 return [] # unreachable: a solution is granted

Complexity and follow-ups

Time O(n·α(n)): n edges, one union per edge, and each union does two finds. Space O(n) for the parent array. Follow-up: LC 685, Redundant Connection II, makes the graph directed, and it is much harder. The extra edge can cause two different faults: some node ends up with in-degree 2, meaning two parents, or the graph contains a directed cycle, and both can happen at once. You have to separate the cases, pick the candidate edges, and only then use Union-Find to verify. You cannot simply union straight through as in this problem.

WALKTHROUGH C

LC 200 · Number of Islands

MEDIUM

The problem: a grid holds only '1' for land and '0' for water. Land cells that touch horizontally or vertically form one island. Return the number of islands. The more common solution is DFS or BFS flooding, covered in the graph chapter. Here the same problem is solved from the Union-Find point of view: treat each land cell as a node, put an edge between adjacent land cells, and the number of components at the end is the number of islands.

Two things to get right. First, a 2D coordinate (r, c) has to be flattened into a single index id = r×cols + c before it can go into the parent array. This is the row-major layout from the array chapter. Second, each cell only has to look right and down. Its left and upper neighbors were already joined when those cells were processed, so checking them again is wasted work, the same reason 547 scans only the upper triangle.

count starts at the number of land cells, assuming each one is its own island, and drops by one on every successful union. Below, the 2×4 grid is flattened, scanned and merged, and count falls from 5.

LC 200 · the grid flattened to one dimension; scan, union adjacent land, count falls (1 = land, 0 = water)
10
11
02
13
14
05
06
17
A 2×4 grid flattened into one dimension: idx = r×4 + c, so the first 4 cells are row 0 and the last 4 are row 1. There are 5 land cells (value 1), so count = 5 at the start: assume every land cell is its own island.
1 / 5
lc200_number_of_islands.py
1class Solution:
2 def numIslands(self, grid: list[list[str]]) -> int:
3 rows, cols = len(grid), len(grid[0])
4 uf = UnionFind(rows * cols)
5 islands = sum(row.count('1') for row in grid) # each land cell is an island
6 for r in range(rows):
7 for c in range(cols):
8 if grid[r][c] == '0':
9 continue # skip water
10 idx = r * cols + c # flatten (r,c)
11 if r + 1 < rows and grid[r + 1][c] == '1': # land below
12 if uf.union(idx, (r + 1) * cols + c):
13 islands -= 1
14 if c + 1 < cols and grid[r][c + 1] == '1': # land to the right
15 if uf.union(idx, r * cols + c + 1):
16 islands -= 1
17 return islands # islands after merging

Union-Find or DFS for islands: each has its case

To be fair, DFS flooding is shorter and easier to read for this problem. Start from any land cell, recursively turn the whole island into water, and count how many times you started. The graph chapter solves this problem again that way. Union-Find has no advantage here: it needs an extra template class and a manual 2D-to-1D conversion.

Where does Union-Find win? When land is added while the program runs. See LC 305, Number of Islands II: new land cells appear one at a time, and the current island count has to be reported immediately after each one. DFS would rescan the whole grid every time, while Union-Find only unions the new cell with its four neighbors and answers at once. Counting a fixed grid once favours DFS. Merging continuously over time favours Union-Find.

§07

Problem set: 8 Union-Find problems

Connectivity

Easy to hard. Ask yourself first: what is a node, what is an edge, and do you need a count or a connectivity test?

§08

Quiz

✎ Quiz

Answer all 7 correctly to mark this chapter as finished

QUESTION 01 / 7

What exactly does find(x) return in a Union-Find structure?

QUESTION 02 / 7

Why must union(a, b) find both roots first and then point one root at the other? Why not just write parent[a] = b?

QUESTION 03 / 7

What does path compression change?

QUESTION 04 / 7

What does union by rank prevent?

QUESTION 05 / 7

With both optimizations, m operations on n elements cost O(m·α(n)), where α is the inverse Ackermann function. What is the right intuition for α(n)?

QUESTION 06 / 7

Both answer connectivity questions. When is Union-Find clearly better than DFS or BFS?

QUESTION 07 / 7

You count components with Union-Find. There are n = 10 elements at the start, so count = 10. You run 7 unions, and in 2 of them the two sides already had the same root. What is count now?

What to take away from this chapter
  • Union-Find does exactly two things: find, which returns the root of an element's set, and union, which merges two sets. Two elements are connected when their roots are equal. It gives up the path information to keep dynamic connectivity cheap.
  • One parent array is the whole forest. parent[i] = i marks a root, which costs no extra memory and makes the find loop a single comparison. After the heap, this is the second time an array plays the part of a tree.
  • The two optimizations do different jobs. Path compression flattens a path that already exists, during find. Union by rank stops a tall tree from forming, during union. Either one alone gives amortized O(log n). Together they give O(m·α(n)) for m operations, which is effectively constant, not O(1).
  • union always finds both roots first, because it merges sets, not individual elements. For counting components, start at n and subtract 1 only when union actually merged something. A union of two elements that already share a root must not decrement the count.
  • Choosing the structure: connections keep arriving and you only ask about connectivity, use Union-Find. You need the path itself, use BFS or DFS. Keys are not the integers 0 to n−1, map them with a hash table first. Elements have to be removed from a set, Union-Find cannot split, so process the operations in reverse if you can see them all in advance.