Union-Find (Disjoint Set)
Introduction
Union-Find (also known as Disjoint Set Union or DSU) is a data structure used exclusively to keep track of a set of elements partitioned into a number of disjoint (non-overlapping) subsets.
It answers two questions incredibly quickly (nearly \(O(1)\) time): 1. Find: Which set does this element belong to? 2. Union: Merge two sets together.
How to Recognize It
Use Union-Find when: - The problem involves finding Connected Components in an undirected graph. - You need to determine if adding an edge will create a Cycle. - You are implementing Kruskal's Algorithm for Minimum Spanning Trees. - The problem describes establishing connections between items (e.g., "Friend Circles", "Redundant Connection").
The Core Concept
Imagine every node starts as its own independent set (a "parent" of itself).
When we Union two nodes \(A\) and \(B\), we find their respective "root" parents. We then make the root of \(A\) point to the root of \(B\). Now they belong to the same set.
To keep the trees extremely flat and fast, we use two optimizations: 1. Path Compression: When finding the root of \(A\), we make every node along the path point directly to the root. 2. Union by Rank (or Size): When merging two sets, we attach the smaller tree to the root of the larger tree.
The Pythonic Implementation
You can implement Union-Find using a simple array where parent[i] stores the parent of node i.
class UnionFind:
def __init__(self, size):
# Initially, each node is its own parent
self.parent = list(range(size))
# Rank tracks the depth of the tree to optimize merges
self.rank = [1] * size
self.components = size
def find(self, i):
# Path Compression
if self.parent[i] != i:
# Recursively find the root and compress the path
self.parent[i] = self.find(self.parent[i])
return self.parent[i]
def union(self, i, j):
root_i = self.find(i)
root_j = self.find(j)
if root_i == root_j:
return False # They are already in the same set! (Cycle detected)
# 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.components -= 1
return True
Example: Redundant Connection
Given an undirected graph, return an edge that can be removed to make the graph a tree (i.e., remove the edge that forms a cycle).
def find_redundant_connection(edges):
# Graph has N nodes labeled 1 to N
uf = UnionFind(len(edges) + 1)
for u, v in edges:
# If union returns False, u and v are already connected!
# Therefore, this edge (u, v) is the one creating the cycle.
if not uf.union(u, v):
return [u, v]
return []
Time and Space Complexity
Let \(N\) be the number of nodes.
- Time Complexity: \(O(\alpha(N))\) for both Find and Union operations, where \(\alpha\) is the Inverse Ackermann function. For all practical purposes in the universe, \(\alpha(N) \le 4\). Therefore, operations take amortized \(O(1)\) time.
- Space Complexity: \(O(N)\) to store the parent and rank arrays.
Summary
- Use Union-Find for Connected Components and Cycle Detection in undirected graphs.
- Memorize the
findfunction with Path Compression. - Memorize the
unionfunction with Union by Rank.