DSA course menu
Union-Find (Disjoint Set Union) in Python
Learn the union-find data structure: find and union operations, path compression and union by size, nearly O(1) operations, counting connected components and detecting cycles in undirected graphs.
On this page
Union-find (also called disjoint set union, DSU) keeps track of items split into groups, and answers two questions very fast:
- find(x): which group is
xin? - union(x, y): merge the groups of
xandy.
Two items are in the same group exactly when find returns the same answer for both. It is ideal when connections only ever get added: friends of friends, merging accounts, pixels joining into regions, and Kruskal's algorithm.
The idea: a forest of parent pointers#
Each group is a tree. Every item points to a parent; the root of the tree points to itself and represents the whole group. find follows parents up to the root; union hangs one root under the other.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n)) # everyone starts alone
def find(self, x):
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[rb] = ra
uf = UnionFind(6)
uf.union(0, 1)
uf.union(1, 2)
uf.union(3, 4)
print(uf.parent)
print(uf.find(2) == uf.find(0), uf.find(3) == uf.find(0), uf.find(5))[0, 0, 0, 3, 3, 5]
True False 5This works, but trees can grow tall, and then find walks a long chain.
Two optimisations#
Union by size: always hang the smaller tree under the root of the larger one, so trees stay shallow.
Path compression: after find reaches the root, point every node on the way directly at the root, so the next find is one step.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.groups = n
def find(self, x):
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # path compression
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 together
if self.size[ra] < self.size[rb]: # union by size
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
uf = UnionFind(8)
for a, b in [(0, 1), (2, 3), (1, 3), (4, 5), (6, 7), (5, 7)]:
uf.union(a, b)
print("groups:", uf.groups)
print("size of 0's group:", uf.size[uf.find(0)])
print(uf.find(3) == uf.find(0), uf.find(4) == uf.find(0))groups: 2
size of 0's group: 4
True FalseWith both optimisations, each operation takes O(α(n)) amortized time, where α is the inverse Ackermann function. It grows so slowly that it is below 5 for any input that could ever exist, so in practice every operation is constant time.
Detecting a cycle in an undirected graph#
Add edges one at a time. If an edge joins two vertices that are already in the same group, there was already a path between them, so the edge closes a cycle:
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # halve the path
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
self.parent[rb] = ra
return True
def first_cycle_edge(n, edges):
uf = UnionFind(n)
for a, b in edges:
if not uf.union(a, b):
return (a, b)
return None
print(first_cycle_edge(4, [(0, 1), (1, 2), (2, 3)]))
print(first_cycle_edge(4, [(0, 1), (1, 2), (2, 0), (2, 3)]))None
(2, 0)This version uses path halving (each node skips to its grandparent), a simpler form of path compression with the same guarantees.
Union-find or DFS?#
Both find connected components. Union-find wins when edges arrive over time and you must answer "are these connected?" between arrivals; DFS would have to search again each time. Union-find cannot handle edges being removed.
Common mistakes#
Practice#
Practice exercise
Accounts merge (simplified): each person entry is a list of email addresses. Two entries belong to the same person if they share any email. Write count_people(entries) that returns how many distinct people there are.
Show hint
Give each entry an index. Map each email to the first entry that had it; when an email is seen again, union the two entries. Count the groups at the end.
Show solution
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.groups = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[rb] = ra
self.groups -= 1
def count_people(entries):
uf = UnionFind(len(entries))
owner = {}
for i, emails in enumerate(entries):
for e in emails:
if e in owner:
uf.union(owner[e], i)
else:
owner[e] = i
return uf.groups
entries = [
["asha@a.com", "asha@work.com"],
["ravi@r.com"],
["asha@work.com", "asha.k@b.com"],
["meera@m.com"],
["asha.k@b.com"],
]
print(count_people(entries))3Entries 0, 2 and 4 are linked through shared emails, so there are 3 people.
Key takeaway#
Next: connecting everything as cheaply as possible, with Minimum Spanning Trees.
Finished this lesson?