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.
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.
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.
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.
| Approach | Add one connection | One connectivity query | Good for |
|---|---|---|---|
| Traverse again with DFS / BFS | O(1) to store | O(V+E) | A fixed graph, or when you need the path itself |
| Union-Find | O(α(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.
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] = jmeans the element directly above i is j. j is not necessarily the root. It may be one link in a longer chain.parent[i] = imeans 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.
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.
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.
Two optimizations: keep the trees short
★ Asked in interviewsPath 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.
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.
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).
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.
α(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)).
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.
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 beforecount--. - 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.
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.
| Concern | Java | Python | JavaScript |
|---|---|---|---|
| Container for parent / rank | int[] parent = new int[n] | list(range(n)) | Array or Int32Array |
| Point each slot at itself | A for loop: parent[i] = i | list(range(n)) does it in one step | Array.from({length: n}, (_, i) => i) |
| How to write find | Iterative (recursion is safe but pointless) | Must be iterative (recursion limit 1000) | Iterative (deep recursion risks a stack overflow too) |
| Performance note | Primitive int[], no boxing, fastest of the three | A list holds pointers to int objects, so the constant factor is larger | Int32Array 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.
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.
Three patterns: counting, cycle detection, equivalence classes
★ Interview coreThree 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.
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.
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.
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.
LC 547 · Number of Provinces
MEDIUMThe 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.
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.
LC 684 · Redundant Connection
MEDIUMThe 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.
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.
LC 200 · Number of Islands
MEDIUMThe 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.
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.
Problem set: 8 Union-Find problems
ConnectivityEasy to hard. Ask yourself first: what is a node, what is an edge, and do you need a count or a connectivity test?
Quiz
✎ QuizAnswer all 7 correctly to mark this chapter as finished
What exactly does find(x) return in a Union-Find structure?
Why must union(a, b) find both roots first and then point one root at the other? Why not just write parent[a] = b?
What does path compression change?
What does union by rank prevent?
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)?
Both answer connectivity questions. When is Union-Find clearly better than DFS or BFS?
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?
- 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.