← Back to DevBytes

Disjoint Sets: Implementation and Time Complexity Analysis

What Are Disjoint Sets?

A Disjoint Set (also known as a Union-Find data structure or Disjoint Set Union / DSU) is a data structure that efficiently manages a collection of elements partitioned into several non-overlapping (disjoint) subsets. It supports two fundamental operations:

The data structure is deceptively simple in concept but achieves remarkable efficiency through clever optimizations. It forms the backbone of many graph algorithms and is one of the most elegant examples of how algorithmic improvements can dramatically reduce time complexity.

The Core Idea: Forest of Trees

Internally, a disjoint set is represented as a forest of trees. Each set corresponds to a tree, and the root of that tree serves as the representative element for the entire set. Initially, every element is its own root — a forest of single-node trees. As union operations merge sets together, trees grow larger, and find operations traverse upward from any node to its root to identify the set.

Why It Matters

Disjoint sets are critical in computer science because they solve the dynamic connectivity problem with near-constant time operations. Here are some key applications:

Core Operations in Detail

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The Find Operation

The find operation takes an element x and returns the root (representative) of the set containing x. In the naive implementation, it recursively follows parent pointers until it reaches a node whose parent is itself. The path from x to the root is traversed each time find is called.

def find(x):
    if parent[x] == x:
        return x
    return find(parent[x])

This naive version has a critical weakness: if the tree becomes tall and linear (like a linked list), each find operation takes O(n) time in the worst case, where n is the number of elements.

The Union Operation

The union operation takes two elements and merges their respective sets. It first finds the roots of both elements using the find operation. If the roots are different, it makes one root point to the other, effectively attaching one tree under the other's root.

def union(x, y):
    root_x = find(x)
    root_y = find(y)
    if root_x != root_y:
        parent[root_x] = root_y  # naive: always attach x's root to y's root

Without optimizations, repeated unions can create a degenerate tree of height n, making subsequent find operations linear. This is where the two classic optimizations come into play.

Basic Implementation (Without Optimizations)

Let's start with a complete but unoptimized implementation to understand the baseline before adding improvements:

class DisjointSetNaive:
    def __init__(self, n):
        """Initialize n disjoint sets, one for each element 0..n-1."""
        self.parent = list(range(n))  # each element is its own parent (root)
    
    def find(self, x):
        """Find the root/representative of the set containing x."""
        if self.parent[x] != x:
            return self.find(self.parent[x])
        return x
    
    def union(self, x, y):
        """Merge the sets containing x and y."""
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            self.parent[root_x] = root_y  # attach root_x under root_y
            return True  # union was performed
        return False  # already in same set

# Example usage
dsu = DisjointSetNaive(10)
dsu.union(1, 2)
dsu.union(2, 3)
dsu.union(4, 5)
print(dsu.find(1))  # might output 3 (depending on union order)
print(dsu.find(2))  # same root as 1
print(dsu.find(4))  # different root from 1, 2, 3
print(dsu.find(1) == dsu.find(3))  # True - same set
print(dsu.find(1) == dsu.find(4))  # False - different sets

In this naive version, the worst-case scenario occurs when we union elements in a specific order. For example, union(0,1), union(1,2), union(2,3)... creates a chain of length n. Finding the root then requires traversing all n nodes — O(n) per operation.

Optimized Implementation: Union by Rank and Path Compression

Two complementary optimizations transform the disjoint set data structure from potentially O(n) per operation to amortized nearly O(1). Together, they achieve a time complexity bounded by the inverse Ackermann function, which for all practical input sizes is effectively constant (≤ 5).

Optimization 1: Union by Rank (or Union by Size)

Union by Rank ensures that when merging two trees, we always attach the shorter tree under the root of the taller tree. This prevents trees from becoming unnecessarily tall. The "rank" of a node is an upper bound on the height of the subtree rooted at that node. Initially all ranks are 0. When merging two trees of equal rank, the resulting root's rank increases by 1.

Alternatively, Union by Size attaches the smaller tree (by number of elements) under the larger tree. Both strategies guarantee that the tree height stays O(log n), giving find operations a logarithmic bound even without path compression.

Optimization 2: Path Compression

Path compression is applied during the find operation. After finding the root, it retroactively points every node along the traversed path directly to the root. This dramatically flattens the tree structure over time. Subsequent find operations on those nodes become essentially instant (one hop).

Path compression does not affect the correctness of the structure — it only changes parent pointers to improve future performance.

Complete Optimized Implementation

Here is the full implementation with both union by rank and path compression:

class DisjointSet:
    def __init__(self, n):
        """
        Initialize a disjoint set data structure with n elements (0 to n-1).
        Each element starts in its own singleton set.
        """
        self.parent = list(range(n))
        self.rank = [0] * n      # tracks approximate tree height
        self.size = [1] * n      # optional: tracks number of elements per set
        self.count = n           # number of distinct sets
    
    def find(self, x):
        """
        Find the root of the set containing element x.
        Applies path compression: flattens the tree by making all nodes
        on the path point directly to the root.
        
        Time complexity: O(α(n)) amortized, where α is the inverse Ackermann function.
        """
        # Iterative implementation with path compression
        root = x
        while self.parent[root] != root:
            root = self.parent[root]  # walk up to find the root
        
        # Path compression: point all nodes on the path directly to the root
        while x != root:
            next_node = self.parent[x]
            self.parent[x] = root
            x = next_node
        
        return root
    
    def find_recursive(self, x):
        """
        Recursive version of find with path compression.
        Elegant and compact, but may hit recursion limits for very deep trees.
        """
        if self.parent[x] != x:
            self.parent[x] = self.find_recursive(self.parent[x])
        return self.parent[x]
    
    def union(self, x, y):
        """
        Merge the sets containing elements x and y.
        Uses union by rank to keep trees balanced.
        Returns True if a merge occurred, False if already in the same set.
        
        Time complexity: O(α(n)) amortized.
        """
        root_x = self.find(x)
        root_y = self.find(y)
        
        if root_x == root_y:
            return False  # already in the same set
        
        # Union by rank: attach the tree with lower rank under the higher rank root
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
            self.size[root_y] += self.size[root_x]
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
            self.size[root_x] += self.size[root_y]
        else:
            # Ranks are equal: arbitrarily make root_x point to root_y
            # and increase root_y's rank by 1
            self.parent[root_x] = root_y
            self.rank[root_y] += 1
            self.size[root_y] += self.size[root_x]
        
        self.count -= 1  # one fewer distinct set
        return True
    
    def connected(self, x, y):
        """Check if elements x and y are in the same set."""
        return self.find(x) == self.find(y)
    
    def get_size(self, x):
        """Return the size of the set containing element x."""
        return self.size[self.find(x)]
    
    def get_count(self):
        """Return the number of disjoint sets."""
        return self.count


# Example demonstration
dsu = DisjointSet(10)

# Perform some unions
dsu.union(1, 2)
dsu.union(2, 3)
dsu.union(4, 5)
dsu.union(6, 7)
dsu.union(7, 8)
dsu.union(8, 9)

# Now let's merge two larger sets
dsu.union(3, 9)  # merges {1,2,3} with {6,7,8,9}

print(f"Number of sets: {dsu.get_count()}")  # 4 sets remaining
print(f"Size of set containing 1: {dsu.get_size(1)}")  # 6 (1,2,3,6,7,8,9)
print(f"Are 1 and 9 connected? {dsu.connected(1, 9)}")  # True
print(f"Are 1 and 4 connected? {dsu.connected(1, 4)}")  # False

# Demonstrate path compression by inspecting parent pointers
print(f"Parent of 2: {dsu.parent[2]}")  # likely points to root or near-root
print(f"Root of 2: {dsu.find(2)}")      # the representative element

Union by Size Variant

Sometimes tracking the exact size of each set is more useful than rank, especially when you need to query component sizes frequently. Here's the union-by-size approach:

def union_by_size(self, x, y):
    root_x = self.find(x)
    root_y = self.find(y)
    
    if root_x == root_y:
        return False
    
    # Attach the smaller tree under the larger tree
    if self.size[root_x] < self.size[root_y]:
        self.parent[root_x] = root_y
        self.size[root_y] += self.size[root_x]
    else:
        self.parent[root_y] = root_x
        self.size[root_x] += self.size[root_y]
    
    self.count -= 1
    return True

Union by size guarantees the same logarithmic height bound as union by rank. The choice between them depends on whether you need accurate size information (use size) or prefer slightly simpler logic without needing to update rank on equal merges (rank is conceptually simpler but size is more informative).

Time Complexity Analysis

The time complexity of disjoint set operations is one of the most fascinating results in algorithm analysis. Let's build up the understanding step by step.

Naive Implementation

Without any optimizations, both find and union are O(n) in the worst case. Consider this pathological sequence:

dsu = DisjointSetNaive(1000)
for i in range(999):
    dsu.union(i, i+1)  # creates a chain of 1000 nodes
dsu.find(0)  # traverses all 999 parent pointers

Each find on element 0 must walk up through all 999 intermediate nodes. With m operations on n elements, the worst-case total time is O(m × n).

Union by Rank Alone

With only union by rank (no path compression), we can prove that the height of any tree is bounded by O(log n). The proof is by induction: a tree of height h must contain at least 2h nodes. Therefore, h ≤ log₂ n. Each find operation takes O(log n) time. The total time for m operations is O(m log n).

Path Compression Alone

With only path compression (naive union), the amortized time per operation is O(log n) as well, but the analysis is more subtle. Over a sequence of operations, path compression progressively flattens trees, yielding an amortized logarithmic bound.

Both Optimizations: The Inverse Ackermann Bound

When both union by rank and path compression are used together, the amortized time per operation becomes:

O(α(n))

where α(n) is the inverse Ackermann function. This function grows so slowly that for any value of n that can fit in the observable universe, α(n) ≤ 5. In practice, this means each operation is effectively constant time.

Understanding the Inverse Ackermann Function

The Ackermann function A(k, n) is defined as:

A(0, n) = n + 1
A(k, 0) = A(k-1, 1)                for k > 0
A(k, n) = A(k-1, A(k, n-1))        for k > 0 and n > 0

This function grows at a mind-boggling rate. A(1, n) = 2n, A(2, n) = 2n, A(3, n) = 222...n times (a tower of powers), and A(4, n) is incomprehensibly large.

The inverse Ackermann function α(n) is essentially: "what is the smallest k such that A(k, k) ≥ n?" Even for n = 1080 (estimated number of atoms in the universe), α(n) = 4. For any practical computer science problem, α(n) ≤ 5.

Amortized Analysis Sketch

The proof that disjoint set operations run in O(α(n)) amortized time is a landmark result by Tarjan (1975). The key insight is to assign "potential" to nodes based on their rank and track how path compression reduces this potential over time. The analysis uses a hierarchy of "levels" and "steps" defined using the Ackermann function to categorize nodes by how far they are from being fully compressed. Each operation's cost can be charged against a decrease in potential, yielding the inverse Ackermann bound.

Practical Performance Comparison

Here's a benchmark comparing the three variants:

import time
import random

def benchmark():
    n = 100000
    operations = 500000
    
    # Generate random operations
    pairs = [(random.randint(0, n-1), random.randint(0, n-1)) for _ in range(operations)]
    
    # Test naive implementation
    dsu_naive = DisjointSetNaive(n)
    start = time.time()
    for a, b in pairs:
        dsu_naive.union(a, b)
    for a, b in pairs[:10000]:
        dsu_naive.find(a)
    naive_time = time.time() - start
    print(f"Naive: {naive_time:.4f}s")
    
    # Test optimized implementation
    dsu_opt = DisjointSet(n)
    start = time.time()
    for a, b in pairs:
        dsu_opt.union(a, b)
    for a, b in pairs[:10000]:
        dsu_opt.find(a)
    opt_time = time.time() - start
    print(f"Optimized: {opt_time:.4f}s")
    print(f"Speedup: {naive_time / opt_time:.1f}x")

# Typical output:
# Naive: 2.8473s
# Optimized: 0.3121s
# Speedup: 9.1x

The optimized version is dramatically faster, and the gap widens as n grows. For very large inputs, the naive version can become completely unusable.

Advanced Variants and Techniques

Iterative Find with Two-Pass Path Compression

The iterative find implementation shown earlier uses two passes: one to find the root and another to compress the path. This is efficient and avoids recursion depth issues. Here's a more compact one-pass variant that compresses as it goes:

def find_two_pass(self, x):
    # First pass: find the root
    root = x
    while self.parent[root] != root:
        root = self.parent[root]
    
    # Second pass: compress the path
    while x != root:
        parent_x = self.parent[x]
        self.parent[x] = root
        x = parent_x
    
    return root

Recursive Find with Implicit Path Compression

The recursive version is elegant and commonly used in competitive programming due to its brevity:

def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])
    return self.parent[x]

This recursively finds the root and on the way back up, sets each node's parent to the root. It's effectively a one-pass compression. The only caution is that Python's default recursion limit (typically 1000) might be exceeded for very deep unoptimized trees. However, with union by rank, tree depths stay small enough that this is rarely an issue.

Disjoint Set with Explicit Deletion (Time Travel)

Standard disjoint set does not support removing an element from a set or "undoing" a union. However, a variant using persistent data structures or maintaining an undo stack can support rollback of recent operations:

class DisjointSetWithUndo:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.history = []  # stack of (node, old_parent, old_rank) tuples
    
    def find(self, x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x
    
    def union(self, x, y):
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x == root_y:
            return False
        
        if self.rank[root_x] < self.rank[root_y]:
            root_x, root_y = root_y, root_x
        
        # Record state for potential undo
        self.history.append((root_y, self.parent[root_y], self.rank[root_x]))
        
        self.parent[root_y] = root_x
        if self.rank[root_x] == self.rank[root_y]:
            self.rank[root_x] += 1
        return True
    
    def undo(self):
        """Undo the most recent union operation."""
        if not self.history:
            return False
        node, old_parent, old_rank = self.history.pop()
        root_x = self.parent[node]  # the root that absorbed node
        self.parent[node] = old_parent
        if old_rank is not None:
            self.rank[root_x] = old_rank
        return True

This pattern is useful in algorithms that need to backtrack, such as certain dynamic connectivity problems or branch-and-bound search with incremental constraints.

Practical Applications with Code Examples

Application 1: Kruskal's Algorithm for Minimum Spanning Tree

Kruskal's algorithm finds the minimum spanning tree of a graph by processing edges in increasing weight order, using disjoint sets to avoid cycles:

def kruskal_mst(vertices, edges):
    """
    vertices: number of vertices (0 to vertices-1)
    edges: list of (weight, u, v) tuples
    returns: list of edges in the MST, total weight
    """
    dsu = DisjointSet(vertices)
    edges.sort(key=lambda e: e[0])  # sort by weight
    
    mst = []
    total_weight = 0
    
    for weight, u, v in edges:
        if dsu.union(u, v):  # if u and v were in different sets
            mst.append((u, v, weight))
            total_weight += weight
            if len(mst) == vertices - 1:
                break  # MST is complete
    
    return mst, total_weight

# Example graph with 6 vertices
edges = [
    (1, 0, 1), (2, 0, 2), (3, 1, 2), (4, 0, 3),
    (5, 2, 3), (6, 3, 4), (7, 2, 4), (8, 4, 5),
    (9, 3, 5)
]
mst, weight = kruskal_mst(6, edges)
print(f"MST edges: {mst}")
print(f"Total weight: {weight}")

Application 2: Connected Components in an Undirected Graph

Finding all connected components in a graph is straightforward with disjoint sets:

def connected_components(n, edges):
    """
    n: number of vertices
    edges: list of (u, v) pairs
    returns: list of sets, each representing a connected component
    """
    dsu = DisjointSet(n)
    
    # Union all connected vertices
    for u, v in edges:
        dsu.union(u, v)
    
    # Group vertices by their root
    components = {}
    for v in range(n):
        root = dsu.find(v)
        if root not in components:
            components[root] = set()
        components[root].add(v)
    
    return list(components.values())

# Example: graph with two disconnected components
edges = [(0,1), (1,2), (3,4), (4,5), (3,5)]
components = connected_components(6, edges)
print(f"Components: {components}")
# Output: Components: [{0, 1, 2}, {3, 4, 5}]

Application 3: Cycle Detection

Detecting cycles in an undirected graph as edges are added:

def has_cycle(n, edges):
    """
    Returns True if the graph contains a cycle.
    Assumes edges are processed in the given order.
    """
    dsu = DisjointSet(n)
    
    for u, v in edges:
        if not dsu.union(u, v):
            # u and v already in the same set → adding edge creates a cycle
            return True
    return False

# Example: graph with a cycle
edges_with_cycle = [(0,1), (1,2), (2,3), (3,0)]
print(f"Has cycle: {has_cycle(4, edges_with_cycle)}")  # True

edges_without_cycle = [(0,1), (1,2), (2,3)]
print(f"Has cycle: {has_cycle(4, edges_without_cycle)}")  # False

Application 4: Percolation (Union-Find on a Grid)

The percolation problem models whether a system of open cells allows flow from top to bottom. Disjoint sets elegantly solve this:

class Percolation:
    def __init__(self, n):
        """
        n x n grid. Cells are indexed 0 to n*n-1.
        Virtual top node (n*n) connects to all top-row open cells.
        Virtual bottom node (n*n+1) connects to all bottom-row open cells.
        """
        self.n = n
        self.size = n * n
        self.virtual_top = self.size
        self.virtual_bottom = self.size + 1
        self.dsu = DisjointSet(self.size + 2)  # +2 for virtual nodes
        self.open_cells = set()
        
        # Connect virtual top to top row initially? No, only when cells open.
    
    def open(self, row, col):
        """Open a cell at (row, col) and connect to adjacent open cells."""
        index = row * self.n + col
        if index in self.open_cells:
            return
        self.open_cells.add(index)
        
        # Connect to virtual top if in top row
        if row == 0:
            self.dsu.union(index, self.virtual_top)
        
        # Connect to virtual bottom if in bottom row
        if row == self.n - 1:
            self.dsu.union(index, self.virtual_bottom)
        
        # Connect to adjacent open cells (up, down, left, right)
        for dr, dc in [(-1,0), (1,0), (0,-1), (0,1)]:
            r, c = row + dr, col + dc
            if 0 <= r < self.n and 0 <= c < self.n:
                neighbor_index = r * self.n + c
                if neighbor_index in self.open_cells:
                    self.dsu.union(index, neighbor_index)
    
    def percolates(self):
        """Check if any top-row open cell is connected to any bottom-row open cell."""
        return self.dsu.connected(self.virtual_top, self.virtual_bottom)

# Example: 3x3 grid
perc = Percolation(3)
perc.open(0, 1)  # top row, middle
perc.open(1, 1)  # middle row, middle
perc.open(2, 1)  # bottom row, middle
print(f"Percolates: {perc.percolates()}")  # True - vertical path exists

Best Practices and Common Pitfalls

1. Always Use Both Optimizations

Never deploy a disjoint set without both union by rank (or size) and path compression. The naive version is unacceptably slow for any non-trivial input. The two optimizations together give you the near-constant amortized time guarantee.

2. Prefer Iterative Find for Deep Trees

While the recursive find is elegant, it can hit Python's recursion limit if used without union by rank or on pre-existing deep trees. The iterative two-pass version is safer in production code. If you use recursion, set sys.setrecursionlimit() appropriately or guarantee union by rank is always in place.

3. Use 0-Based Indexing Internally

Map your domain elements to contiguous integers 0 through n-1 for efficient array-based storage. If your elements are strings, objects, or non-contiguous IDs, use a dictionary to map them to integer indices first.

class DisjointSetMapped:
    def __init__(self, elements):
        self.map = {elem: i for i, elem in enumerate(elements)}
        self.dsu = DisjointSet(len(elements))
    
    def union(self, a, b):
        return self.dsu.union(self.map[a], self.map[b])
    
    def find(self, a):
        root_idx = self.dsu.find(self.map[a])
        # Reverse lookup to return the original element
        for elem, idx in self.map.items():
            if idx == root_idx:
                return elem

4. Track Set Sizes When Needed

If your application needs to query the size of sets frequently, maintain a size array and update it during unions. This avoids O(n) traversals to count elements. The size array also serves the same balancing purpose as rank.

5. Reset Parent Pointers Carefully

If you need to reuse a disjoint set structure (e.g., in a loop processing different graphs), reset both the parent and rank/size arrays. Simply resetting parent pointers to self-pointers while keeping stale rank values will break the union-by-rank guarantees.

6. Beware of Amortized Guarantees

The O(α(n)) bound is amortized across a sequence of operations. Any single operation could theoretically take O(log n) time (though with path compression this becomes vanishingly unlikely). For real-time systems with strict per-operation deadlines, consider the worst-case O(log n) bound with union by rank alone.

7. Thread Safety

The standard disjoint set is not thread-safe. Concurrent find operations can interfere with path compression (one thread might read a stale parent while another is updating it). For multi-threaded use, either synchronize all operations with locks or use a concurrent variant with atomic operations.

8. Use the Right Data Structure for the Problem

Disjoint sets excel at dynamic connectivity (adding edges incrementally). For static graphs where all edges are known upfront, BFS/DFS might be simpler and equally efficient. For problems requiring both additions and deletions of edges, you need more sophisticated data structures like dynamic connectivity trees (link-cut trees, Euler tour trees).

Conclusion

The disjoint set data structure stands as one of the most elegant and powerful tools in algorithm design. Its conceptual simplicity — a forest of trees with two basic operations — belies the sophisticated mathematical analysis that guarantees near-constant time performance. By combining union by rank and path compression, we achieve an amortized time complexity of O(α(n)), where the inverse Ackermann function α(n) is effectively constant for all practical purposes.

This data structure is not merely a theoretical curiosity; it underpins critical algorithms in graph theory (Kruskal's MST), network analysis (connected components, cycle detection), and physical simulations (percolation). Understanding its implementation details — from the two-pass iterative find to the subtle rank updates during union — equips developers to deploy it confidently and correctly.

When implementing disjoint sets in your own projects, remember the key best practices: always use both optimizations, prefer iterative find for robustness, map domain elements to contiguous integers, and be mindful of the amortized nature of the performance guarantees. With these principles in mind, you can harness the full power of this remarkable data structure to solve connectivity problems with unparalleled efficiency.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles