Union-Find

Splits elements into sets, merges sets and tells whether two elements share a set in almost constant time.

Worth knowing first

#How does it work?

Union-Find (disjoint set union) splits elements into non-overlapping sets and answers two questions very quickly: merge two sets, and are two elements in the same set?

Each set is stored as a tree; every element only knows its parent, and the root is the set's representative. The whole structure fits in one parent array.

Two improvements keep the trees almost flat: union by rank hangs the shorter tree under the taller one, and path compression points every node visited by find straight at the root.

#Try it yourself

$ union_find --interactive

Each tree is a set and its root is the set's representative. union merges two sets, find returns an element's root.

Elements: 8/8VisitingNewRemovingFound / read

#Basic operations

find(x)
Follows parents to the root (representative) of x and shortens the path with path compression.
union(a, b)
Finds the roots of a and b; if they differ, links the lower-rank root under the other.
connected(a, b)
Two elements are in the same set when find(a) == find(b).

#Example code

The same algorithm in 8 languages. Every program is compiled, run and checked to print the same output.

class UnionFind:
    def __init__(self, n: int) -> None:
        self._parent = list(range(n))  # every element is its own root
        self._rank = [0] * n  # an upper bound on each tree's height
        self.sets = n

    def find(self, x: int) -> int:
        if self._parent[x] != x:
            self._parent[x] = self.find(self._parent[x])  # path compression
        return self._parent[x]

    def union(self, a: int, b: int) -> None:
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return  # already in the same set
        # Union by rank: hang the shorter tree under the taller one.
        if self._rank[ra] < self._rank[rb]:
            ra, rb = rb, ra
        self._parent[rb] = ra
        if self._rank[ra] == self._rank[rb]:
            self._rank[ra] += 1
        self.sets -= 1

    def connected(self, a: int, b: int) -> bool:
        return self.find(a) == self.find(b)


uf = UnionFind(8)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 3)
uf.union(5, 6)
show = lambda b: "true" if b else "false"
print(show(uf.connected(0, 2)))  # true
print(show(uf.connected(0, 5)))  # false
print(uf.sets)                   # 4: {0,1,2,3} {4} {5,6} {7}

#Complexity

OperationTime
findO(α(n))
unionO(α(n))
connectedO(α(n))
MemoryO(n)

α(n) is the inverse Ackermann function; it stays below 5 even for as many elements as there are atoms in the universe. With both improvements the operations are constant time in practice. Without them a tree can degrade into a chain and find becomes O(n).

#When to use it?

Kruskal's minimum spanning tree, counting connected components, cycle detection in undirected graphs, network connectivity questions and region labelling in image processing use union-find.

#Practice

Log in to answer questions and save your progress.

Easy

1/3

In union-find, which element represents a set?

  • 1The root of its tree
  • 2The smallest element
  • 3The last element added
  • 4A random element

Medium

1/1

With 8 elements (0-7), after union(0,1), union(2,3), union(1,3), union(5,6), how many sets are there?

  • 14
  • 25
  • 33
  • 46

Hard

1/1

With union by rank and path compression together, what is the amortized cost per operation?

  • 1O(α(n))
  • 2O(log n)
  • 3O(n)
  • 4Always exactly O(1)

#Up next

Lessons that build on this one: