Hello! Welcome to your next lesson in our journey through core data structures.
In our last session, we mastered several powerful patterns using hash tables, focusing on how to group or find items based on a predefined key—like grouping anagrams by their character count. Today, we're going to explore a different, highly specialized data structure for managing groups: the Disjoint Set Union (DSU), also known as Union-Find.
While a hash table is great for static grouping, a DSU excels at dynamically tracking connectivity and merging sets of elements. It's the perfect tool for problems involving networks, equivalence classes, and determining if two items are connected, even indirectly. Your goal for this lesson is to implement a fully optimized DSU from the ground up, understanding the critical optimizations—union by rank and path compression—that make it incredibly fast.
1. The Core Idea: Managing Disjoint Sets
At its heart, a DSU tracks a collection of elements partitioned into a number of non-overlapping (disjoint) sets. It provides three fundamental operations:
make_set(v): Creates a new set containing only the elementv.find(v): Determines the representative (or "leader") of the set that elementvbelongs to. Iffind(a)andfind(b)return the same representative,aandbare in the same set.union(a, b): Merges the two sets containing elementsaandbinto a single set.
To get a strong intuition for what a DSU is and how it works, let's start with a clear visual explanation.
This video from Hello Byte, 'Union Find Visually Explained', provides an excellent introduction to the data structure. It uses a helpful real-world analogy of a company hierarchy to explain the core operations.
Please watch from the beginning to 06:35. Focus on: The definitions of the three core operations: make_set, find, and union. The 'finding your boss' analogy, which maps perfectly to the concepts of elements, parents, and roots. How a forest of trees is represented internally using a simple parent array.
Metaphor: Social Circles
Imagine you're tracking social circles at a large conference.
make_set: Each person starts as their own social circle of one.find: To see if two people, Alice and Bob, are in the same social circle, you find the "most well-known person" (the representative) in each of their groups. If they trace their connections back to the same person, they are in the same circle.union: When two people from different circles become friends (e.g., Alice introduces her circle to Charlie's), their two distinct social circles merge into one larger one.
2. A Naive Implementation and Its Pitfall
The simplest way to implement a DSU is by using an array, parent, where parent[i] stores the parent of element i. The representative of a set is an element r such that parent[r] == r.
Technical Explanation
- Initialization: We start with
nelements, each in its own set. We can represent this with an array whereparent[i] = ifor alli. find(i): To find the representative ofi, we traverse up the chain of parents:i,parent[i],parent[parent[i]], and so on, until we reach a node that is its own parent.union(i, j): We find the representatives ofiandj, let's call themroot_iandroot_j. If they are different, we merge the sets by setting one root as the parent of the other, e.g.,parent[root_j] = root_i.
The Pitfall: This naive approach can be very slow. If we repeatedly perform unions in an unlucky order, we can create a tree that is just a long, spindly chain.
Caption: A worst-case scenario for a naive DSU. Performing union(0,1), then union(1,2), then union(2,3) creates a chain. A find(3) operation would have to traverse 3 steps to reach the root, 0. In a set of size n, this can take time.
In this worst-case scenario, the find operation has a time complexity of , which is no better than searching through a linked list.
Python Implementation (Naive)
class DSU_Naive:
def __init__(self, n):
# Each element is its own parent initially
self.parent = list(range(n))
def find(self, i):
# Traverse up until we find the root
while i != self.parent[i]:
i = self.parent[i]
return i
def union(self, i, j):
root_i = self.find(i)
root_j = self.find(j)
if root_i != root_j:
self.parent[root_j] = root_i
To make this data structure practical, we need to keep our trees flat. This leads to our first key optimization.
3. Optimization 1: Union by Rank
The problem with the naive union is that it merges sets arbitrarily. Union by Rank (or its close cousin, Union by Size) provides a simple heuristic: when merging two trees, always attach the shorter tree to the root of the taller tree. This minimizes the resulting tree's height.
Technical Explanation
We maintain an extra array, rank, which stores an upper bound on the height of the tree rooted at i.
- Initialization: All ranks start at 0.
union(i, j):- Find the roots
root_iandroot_j. - If
rank[root_i] < rank[root_j], makeroot_jthe parent ofroot_i. The ranks don't change. - If
rank[root_i] > rank[root_j], makeroot_ithe parent ofroot_j. The ranks don't change. - If
rank[root_i] == rank[root_j], it doesn't matter which way we merge. Pick one (e.g., makeroot_ithe parent ofroot_j) and increment the rank of the new root (rank[root_i]++).
- Find the roots
This strategy ensures that the height of any tree is at most . Consequently, the find operation's time complexity improves to .

Metaphor: Smart Corporate Mergers
In our company hierarchy metaphor, a naive union is like making a large company's CEO report to a small startup's CEO. This creates a needlessly tall and inefficient structure. Union by Rank is the sensible strategy: when two companies merge, the CEO of the smaller company (in terms of management layers) reports to the CEO of the larger one. This keeps the overall chain of command as short as possible.
4. Optimization 2: Path Compression
Union by Rank gives us a good logarithmic performance guarantee, but we can do even better. Path Compression is a powerful optimization applied to the find operation that dramatically flattens the tree structure over time.
Technical Explanation
The idea is simple: whenever we call find(i), we trace a path from i to its root. After we find the root, we can go back along that path and make every node we visited point directly to the root.

A particularly elegant way to implement this is with recursion:
# In the find method...
def find(self, i):
if self.parent[i] == i:
return i
# Recursively find the root and...
# ...set the parent of i directly to the root.
self.parent[i] = self.find(self.parent[i])
return self.parent[i]
Each time you search for an element's root, you are effectively "caching" the result for all elements along that path, making future searches on them almost instantaneous.
5. The Fully Optimized DSU: A Near-Constant Time Wonder
When we combine Union by Rank with Path Compression, the DSU data structure becomes astonishingly efficient.
Technical Explanation and Complexity
The amortized time complexity of the find and union operations becomes , where is the inverse Ackermann function.
This function grows so slowly that for any practical input size n you could ever encounter (even values larger than the number of atoms in the universe), is never greater than 5. For all intents and purposes, the optimized DSU offers near-constant time operations. This is a profound result in algorithm analysis.
To understand the theory and implementation details more deeply, the cp-algorithms article is an excellent reference.
The article 'Disjoint Set Union' from cp-algorithms.com provides a concise and rigorous explanation of the DSU data structure and its optimizations.
Please read the sections 'Path compression optimization', 'Union by size / rank', and 'Time complexity'. This will solidify your understanding of how the two optimizations work together to achieve the remarkable O(\alpha(n)) complexity. The code examples are in C++, but the logic is identical to our Python implementation.
Final Python Implementation
Here is the complete, optimized DSU class. This is the implementation you should aim to master for interviews.
class DSU:
"""
An implementation of Disjoint Set Union (DSU) with Path Compression and Union by Rank.
"""
def __init__(self, n: int):
# parent[i] = parent of item i
self.parent = list(range(n))
# rank[i] = rank of subtree rooted at i
self.rank = [0] * n
# Optional: track number of disjoint sets
self.count = n
def find(self, i: int) -> int:
"""
Finds the representative of the set containing item i with path compression.
Time Complexity: Amortized O(α(n))
"""
if self.parent[i] == i:
return i
# Path compression: set parent to the root
self.parent[i] = self.find(self.parent[i])
return self.parent[i]
def union(self, i: int, j: int) -> bool:
"""
Merges the sets containing items i and j using union by rank.
Returns True if a merge occurred, False otherwise.
Time Complexity: Amortized O(α(n))
"""
root_i = self.find(i)
root_j = self.find(j)
if root_i != root_j:
# Union by rank
if self.rank[root_i] > self.rank[root_j]:
self.parent[root_j] = root_i
elif self.rank[root_i] < self.rank[root_j]:
self.parent[root_i] = root_j
else:
self.parent[root_j] = root_i
self.rank[root_i] += 1
self.count -= 1 # Decrement component count
return True
return False
Test your understanding!
Consider a DSU with 8 elements, initially in 8 separate sets. The following union operations are performed in order: union(0, 1), union(2, 3), union(4, 5), union(6, 7), union(0, 2), union(4, 6), union(0, 4).
After all operations, how many disjoint sets remain? What is the root of the set containing element 7?
Show answer
Let's trace the operations:
union(0, 1): Sets {0} and {1} merge.count = 7.union(2, 3): Sets {2} and {3} merge.count = 6.union(4, 5): Sets {4} and {5} merge.count = 5.union(6, 7): Sets {6} and {7} merge.count = 4.union(0, 2): The set {0, 1} and {2, 3} merge.count = 3.union(4, 6): The set {4, 5} and {6, 7} merge.count = 2.union(0, 4): The two large sets merge.count = 1.
After all operations, there is 1 disjoint set remaining. All elements are now connected.
To find the root of the set containing element 7, we trace the find(7) operation through the final merged structure. After union(0, 4), all elements will eventually point to a single root (likely 0, depending on the ranks). Therefore, the root of the set containing 7 is the same as the root of every other element.
Real-World Application: Counting Connected Components
A classic interview problem and real-world application of DSU is counting the number of connected components in a graph. This is essential in network analysis, image processing (segmenting pixels), and is a key subroutine in algorithms like Kruskal's for finding Minimum Spanning Trees.
The algorithm is straightforward:
- Initialize a DSU with
nnodes, wherecount = n. - Iterate through each edge
(u, v)in the graph. - For each edge, call
dsu.union(u, v). - The
unionfunction will automatically handle decrementing thecountonly when two previously disconnected components are merged. - After processing all edges,
dsu.countgives the number of connected components.
This video from NeetCode demonstrates this exact problem, showing how naturally DSU solves it.
Number of Connected Components in an Undirected Graph - Union Find - Leetcode 323 - Python
This video, 'Number of Connected Components in an Undirected Graph', solves a common LeetCode problem that is tailor-made for the DSU data structure.
Please watch from 04:07 to the end. The video first briefly mentions DFS, but quickly pivots to the Union-Find solution. Pay close attention to: How the number of components is initialized to n and decremented with each successful union. The implementation of find with path compression. The implementation of union by rank (or size, as it's called there). How the main loop ties it all together.
Conclusion
Today we've built the Disjoint Set Union data structure from the ground up, moving from a simple but flawed concept to a highly optimized tool used to solve complex connectivity problems with remarkable efficiency.
Key Takeaways:
- Purpose: DSU is designed to efficiently manage and merge disjoint sets, primarily to answer connectivity questions.
- Naive Implementation: A simple parent array leads to performance in the worst case, as trees can degenerate into long chains.
- Union by Rank: A crucial
unionheuristic that keeps trees balanced by attaching shorter trees to taller ones, guaranteeing performance. - Path Compression: A powerful
findoptimization that flattens tree structures on the fly by re-parenting nodes directly to the root. - Combined Power: When used together, these two optimizations yield a near-constant amortized time complexity of , making DSU one of the most efficient data structures for its specific task.
Preview of the Next Lesson:
We've now seen how to group items with hash tables and track connectivity with DSU. Next, we will tackle a different kind of problem: answering questions about ranges of data. We will implement the Segment Tree, a powerful hierarchical data structure that can calculate the sum, minimum, maximum, or other properties of an array segment (e.g., from index i to j) in highly efficient logarithmic time.