Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 29

Union-Find with Optimizations

Recommended resources

Hello! Welcome to your next lesson in our journey through core algorithms.

In a previous lesson on Kruskal's algorithm, we used a data structure as a "black box" to efficiently track which vertices belonged to the same component, allowing us to detect and prevent cycles. Today, we're going to open that box and build this powerful tool ourselves.

Our goal for this lesson is to implement the Union-Find data structure with path compression and union by rank optimizations. This data structure, also known as Disjoint Set Union (DSU), is fundamental for solving problems related to partitioning, component labeling, and connectivity, making it a key part of an engineer's algorithmic toolkit.

We'll start with the basic concept, see why a naive approach is too slow, and then implement two crucial optimizations that make this data structure incredibly fast.

1. What is a Disjoint Set Union (DSU)?

A DSU is a data structure that manages a collection of disjoint sets. Think of the vertices in a graph at the beginning of Kruskal's algorithm: each vertex is its own separate component, or its own set. The DSU specializes in two main operations:

  1. find(element): Determines which set an element belongs to. It returns a single, consistent "representative" or "leader" for that set.
  2. union(element1, element2): Merges the two sets containing these elements into a single set.

These operations directly map to what Kruskal's needs: before adding an edge (u, v), we check if find(u) == find(v). If they are the same, the vertices are already in the same component, and adding the edge would create a cycle. If not, we perform a union(u, v) to merge their components.

To build a strong intuition for this, let's start with a video that explains the core idea and its direct application to detecting cycles in a graph.

1.12 Disjoint Sets Data Structure - Weighted Union and Collapsing Find

This video from Abdul Bari provides an excellent conceptual foundation. It explains what disjoint sets are and demonstrates how the find and union operations are used to detect cycles, just like we did in Kruskal's algorithm.

Watch the first 11 minutes of the video (from 00:00 to 11:10). Focus on: The definitions of find and union. The step-by-step example showing how edges are processed and sets are merged. How a cycle is detected when find returns the same representative for both vertices of an edge.


2. Implementing the DSU: Trees and Parent Pointers

A common and effective way to represent these sets is as a collection of trees. Each tree represents one set, and the root of the tree serves as the set's representative.

Initially, every element is in its own set, so we start with a "forest" where each element is a single-node tree. To implement this, we use a parent array. If parent[i] == i, then i is the root of its tree (and the representative of its set).

  • make_set(v): To create a new set with a single element v, we simply set parent[v] = v.
  • find_set(v): To find the representative of v, we traverse up the tree from v by following the parent pointers until we reach a node that is its own parent (the root).
  • union_sets(a, b): To merge the sets containing a and b, we first find their representatives. If they are different, we make one root the child of the other by updating its parent pointer.

This approach seems simple, but it has a major flaw. If we aren't careful about how we perform the union operation, we can end up with very tall, skinny trees that look like linked lists. In this worst-case scenario, a find operation would have to traverse the entire chain, taking time, which is far too slow.

The following resource provides a clear explanation and code for this naive approach.

Disjoint Set Union

The article 'Disjoint Set Union' from cp-algorithms.com clearly illustrates the tree-based approach and the pitfalls of a naive implementation.

Read the sections 'Build an efficient data structure' and 'Naive implementation'. Pay attention to how the parent array is used to represent the trees and why this simple approach can degenerate into a long chain.


3. The Optimizations: Making DSU Fast

To overcome the worst-case, we introduce two powerful optimizations that, when combined, bring the average time per operation down to nearly constant.

Optimization 1: Union by Rank (or Size)

This optimization addresses the problem of creating tall, unbalanced trees during the union operation. The strategy is simple: when merging two trees, always attach the root of the shorter tree to the root of the taller tree. This heuristic prevents the tree heights from growing unnecessarily.

We keep track of the "rank" of each tree, which is an upper bound on its height.

  • Initially, all elements are in sets of rank 0.
  • When unioning two trees of different ranks, the root of the smaller-rank tree becomes a child of the root of the larger-rank tree. The rank of the larger tree does not change.
  • When unioning two trees of the same rank, we can pick either root as the new parent. The key is that the new root's rank must be incremented by one.

A similar heuristic is Union by Size, where we attach the tree with fewer nodes to the root of the tree with more nodes. Both achieve the same goal of keeping the trees shallow.

Union-Find with Union by Rank Optimization
This image illustrates the Union by Rank heuristic. When merging sets with equal rank (like 0 and 1), the rank of the new root increases. When merging sets of different ranks (the set {0, 1} with rank 1 and the set {2} with rank 0), the smaller-rank tree is attached to the larger one, and the rank of the root remains unchanged.

Optimization 2: Path Compression

This optimization speeds up the find operation. The idea is that after we traverse a path from a node v up to its root r, we can make the path shorter for future find calls. We do this by re-attaching every node on that path directly to the root.

The next time we call find on v or any of its ancestors, the lookup will be much faster.

Path Compression in Union-Find Data Structure
This image shows the effect of path compression. After calling `find('E')`, all nodes on the path from 'E' to the root 'A' (i.e., E, D, and B) are made direct children of 'A'. This dramatically flattens the tree.

With these two optimizations combined, the DSU data structure becomes incredibly efficient.


4. Implementation and Analysis

Let's see how to combine these ideas into a working implementation. The following video provides a detailed walk-through, explaining the logic for both optimizations and then putting it all together in C++ code.

G-46. Disjoint Set | Union by Rank | Union by Size | Path Compression

The video 'Disjoint Set | Union by Rank | Union by Size | Path Compression' provides an excellent, detailed walkthrough of implementing an optimized DSU.

Please watch from 05:13 to 32:09. This covers: Union by Rank (05:13 - 12:50): A step-by-step example of merging sets using the rank heuristic. Path Compression (12:50 - 21:14): A clear explanation of how paths are flattened during the find operation. C++ Code Implementation (21:14 - 32:09): A walkthrough of a class-based implementation that combines both optimizations. (Optional) Union by Size (35:02 - 38:38): If you're curious, this part shows the alternative union by size heuristic, which is often more intuitive.

The core logic for an optimized DSU can be summarized in this pseudocode:

class DisjointSetUnion {
    parent = [];
    rank = []; // or size = [] for union by size

    constructor(n) {
        // Initialize n sets, each with one element
        for (let i = 0; i < n; i++) {
            this.parent[i] = i;
            this.rank[i] = 0; // or this.size[i] = 1
        }
    }

    // Find with Path Compression
    find(i) {
        if (this.parent[i] === i) {
            return i;
        }
        // Recursively find the root and set it as the parent of i
        this.parent[i] = this.find(this.parent[i]);
        return this.parent[i];
    }

    // Union by Rank
    union(i, j) {
        const root_i = this.find(i);
        const root_j = this.find(j);

        if (root_i !== root_j) {
            if (this.rank[root_i] > this.rank[root_j]) {
                this.parent[root_j] = root_i;
            } else if (this.rank[root_i] < this.rank[root_j]) {
                this.parent[root_i] = root_j;
            } else {
                // Ranks are equal, attach one to the other and increment rank
                this.parent[root_j] = root_i;
                this.rank[root_i]++;
            }
        }
    }
}

For complete, working examples in various languages, including Python and JavaScript which may be more familiar to you from your web development background, the following resource is excellent.

Union By Rank and Path Compression in Union-Find ...

This GeeksforGeeks article provides full code for an optimized DSU in multiple languages.

Skim the explanations of Path Compression and Union by Rank to reinforce your understanding. Then, review the implementation in your preferred language (C++, Java, Python, and JavaScript are all provided). This will help you see how the concepts translate directly into code.

Test your understanding!

Imagine a DSU with 6 elements (0 to 5). Starting with each element in its own set, trace the state of the parent and rank arrays after the following operations are performed in order:

  1. union(0, 1)
  2. union(2, 3)
  3. union(0, 2)

What are the final parent and rank arrays? (Assume the union function attaches the second element's set to the first's in case of a tie in rank).

Show answer

Initial State:

  • parent: [0, 1, 2, 3, 4, 5]
  • rank: [0, 0, 0, 0, 0, 0]

1. union(0, 1):

  • find(0) is 0, find(1) is 1. Ranks are equal (both 0).
  • parent[1] becomes 0. rank[0] increments to 1.
  • parent: [0, 0, 2, 3, 4, 5]
  • rank: [1, 0, 0, 0, 0, 0]

2. union(2, 3):

  • find(2) is 2, find(3) is 3. Ranks are equal (both 0).
  • parent[3] becomes 2. rank[2] increments to 1.
  • parent: [0, 0, 2, 2, 4, 5]
  • rank: [1, 0, 1, 0, 0, 0]

3. union(0, 2):

  • find(0) is 0, find(2) is 2. Ranks are equal (both 1).
  • parent[2] becomes 0. rank[0] increments to 2.
  • parent: [0, 0, 0, 2, 4, 5]
  • rank: [2, 0, 1, 0, 0, 0]

Final State:

  • parent: [0, 0, 0, 2, 4, 5]
  • rank: [2, 0, 1, 0, 0, 0]
    Note: The final parent value for element 3 is still 2. A subsequent find(3) would compress its path to point directly to 0.

Complexity Analysis

When both Union by Rank/Size and Path Compression are used, the amortized time complexity of the find and union operations is remarkably low: , where is the Inverse Ackermann Function.

This function grows so slowly that for any practical input size n (e.g., ), is less than 5. For all intents and purposes in software engineering, you can consider this nearly constant time. The space complexity is to store the parent and rank/size arrays.


Conclusion

You have now mastered the implementation of the Disjoint Set Union data structure. This is a significant step, as you've moved from using an abstract tool to understanding its inner workings and the optimizations that make it so efficient.

Key Takeaways:

  • A DSU is specialized for tracking membership in a collection of disjoint sets, with core operations find and union.
  • It is naturally represented as a forest of trees, where each tree root is the set's representative.
  • A naive implementation can lead to performance, which is too slow.
  • Union by Rank/Size keeps the trees shallow by attaching shorter/smaller trees to taller/larger ones.
  • Path Compression flattens the trees during find operations by making all nodes on a path point directly to the root.
  • Together, these optimizations yield a nearly constant amortized time complexity of per operation.

Preview of the Next Lesson:
We have now covered four major graph algorithms: Dijkstra's, Bellman-Ford, Kruskal's, and Prim's. In our next lesson, we will move from implementation to analysis. We will compare and contrast these algorithms, discussing their trade-offs, constraints, and ideal use cases. Your deep understanding of the Union-Find data structure will be crucial for properly evaluating the performance profile of Kruskal's algorithm.