DSAUnion-Find (DSU)

Union-Find (DSU)

Union-Find, also called Disjoint Set Union (DSU), is a data structure that tracks a collection of elements partitioned into non-overlapping sets. It supports two operations in nearly constant time: find (which set does this element belong to?) and union (merge two sets together).

It is the go-to structure for any problem involving dynamic connectivity — checking whether two nodes are connected, counting connected components, or detecting cycles.

Core Idea

Each set is represented as a tree where the root is the set's representative. We store a parent array — each element's parent in its tree. The root's parent is itself.

Two elements are in the same set if and only if they have the same root.

TS
// Naive Union-Find (no optimizations) — O(n) per operation in worst case
class UnionFindNaive {
  parent: number[];

  constructor(n: number) {
    this.parent = Array.from({ length: n }, (_, i) => i);  // each is its own parent
  }

  find(x: number): number {
    if (this.parent[x] !== x) return this.find(this.parent[x]);
    return x;  // root
  }

  union(x: number, y: number): void {
    const rx = this.find(x), ry = this.find(y);
    if (rx !== ry) this.parent[rx] = ry;
  }

  connected(x: number, y: number): boolean {
    return this.find(x) === this.find(y);
  }
}
Optimization 1 — Path Compression

After calling find(x), set the parent of every node on the path directly to the root. Future find calls on those nodes skip the whole chain and reach the root in one hop.

TS
find(x: number): number {
  if (this.parent[x] !== x) {
    this.parent[x] = this.find(this.parent[x]);  // path compression
  }
  return this.parent[x];
}
Optimization 2 — Union by Rank

When merging two trees, attach the smaller tree under the root of the larger tree. We track tree height (rank) and always make the higher-rank root the new root. This keeps trees flat.

Full Optimized Implementation

TS
class UnionFind {
  private parent: number[];
  private rank: number[];
  private components: number;  // number of disjoint sets

  constructor(n: number) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank   = new Array(n).fill(0);
    this.components = n;
  }

  // Path compression — amortized O(α(n)) per call
  find(x: number): number {
    if (this.parent[x] !== x) {
      this.parent[x] = this.find(this.parent[x]);
    }
    return this.parent[x];
  }

  // Union by rank — keeps trees balanced
  union(x: number, y: number): boolean {
    const rx = this.find(x), ry = this.find(y);
    if (rx === ry) return false;  // already in same set

    if (this.rank[rx] < this.rank[ry]) {
      this.parent[rx] = ry;
    } else if (this.rank[rx] > this.rank[ry]) {
      this.parent[ry] = rx;
    } else {
      this.parent[ry] = rx;
      this.rank[rx]++;  // tie: arbitrarily make rx root, increase rank
    }
    this.components--;
    return true;
  }

  connected(x: number, y: number): boolean {
    return this.find(x) === this.find(y);
  }

  getComponents(): number {
    return this.components;
  }
}

const uf = new UnionFind(5);  // 5 elements: 0, 1, 2, 3, 4
uf.union(0, 1);
uf.union(1, 2);
uf.union(3, 4);
console.log(uf.connected(0, 2)); // true
console.log(uf.connected(0, 3)); // false
console.log(uf.getComponents()); // 2
Note
With both path compression and union by rank, the amortized time per operation is O(α(n)) where α is the inverse Ackermann function. α(n) ≤ 4 for any practical value of n — essentially O(1).
Number of Connected Components

TS
// LeetCode 323 — Number of Connected Components in an Undirected Graph
function countComponents(n: number, edges: number[][]): number {
  const uf = new UnionFind(n);
  for (const [u, v] of edges) {
    uf.union(u, v);
  }
  return uf.getComponents();
}

console.log(countComponents(5, [[0,1],[1,2],[3,4]]));  // 2
console.log(countComponents(5, [[0,1],[1,2],[2,3],[3,4]])); // 1
Redundant Connection (Cycle Detection)

TS
// LeetCode 684 — Redundant Connection
// Find the edge that, when removed, results in a tree (no cycles)
function findRedundantConnection(edges: number[][]): number[] {
  const n = edges.length;
  const uf = new UnionFind(n + 1);  // 1-indexed nodes
  for (const edge of edges) {
    const [u, v] = edge;
    if (!uf.union(u, v)) {
      return edge;  // union returns false → they were already connected → cycle
    }
  }
  return [];
}

console.log(findRedundantConnection([[1,2],[1,3],[2,3]]));  // [2,3]
console.log(findRedundantConnection([[1,2],[2,3],[3,4],[1,4],[1,5]])); // [1,4]
Accounts Merge

TS
// LeetCode 721 — Accounts Merge
// Group accounts by email — if two accounts share an email they belong to the same person
function accountsMerge(accounts: string[][]): string[][] {
  const emailToId  = new Map<string, number>();
  const emailToName = new Map<string, string>();
  let id = 0;

  // Assign a unique id to each email
  for (const acc of accounts) {
    const name = acc[0];
    for (let i = 1; i < acc.length; i++) {
      if (!emailToId.has(acc[i])) {
        emailToId.set(acc[i], id++);
        emailToName.set(acc[i], name);
      }
    }
  }

  const uf = new UnionFind(id);

  // Union emails within the same account
  for (const acc of accounts) {
    for (let i = 2; i < acc.length; i++) {
      uf.union(emailToId.get(acc[1])!, emailToId.get(acc[i])!);
    }
  }

  // Group emails by their root representative
  const groups = new Map<number, string[]>();
  for (const [email, eid] of emailToId) {
    const root = uf.find(eid);
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root)!.push(email);
  }

  // Build result: name + sorted emails
  return Array.from(groups.values()).map(emails => {
    emails.sort();
    return [emailToName.get(emails[0])!, ...emails];
  });
}
Kruskal's Minimum Spanning Tree

Kruskal's algorithm finds the MST of a weighted graph by greedily adding the cheapest edge that does not form a cycle. Union-Find provides the cycle check in near-O(1).

TS
// Kruskal's MST — O(E log E) due to edge sorting
function kruskalMST(n: number, edges: [number, number, number][]): number {
  // edges: [weight, u, v]
  edges.sort((a, b) => a[0] - b[0]);  // sort by weight
  const uf = new UnionFind(n);
  let mstWeight = 0;
  let edgesAdded = 0;

  for (const [w, u, v] of edges) {
    if (uf.union(u, v)) {      // add edge only if it doesn't form a cycle
      mstWeight += w;
      edgesAdded++;
      if (edgesAdded === n - 1) break;  // MST has exactly n-1 edges
    }
  }
  return mstWeight;
}

// Graph: 4 nodes, edges with weights
const edges: [number, number, number][] = [
  [1, 0, 1], [4, 0, 2], [3, 1, 2], [2, 1, 3], [5, 2, 3]
];
console.log(kruskalMST(4, edges)); // 6  (edges: 0-1 w=1, 1-3 w=2, 1-2 w=3)
Satisfiability of Equality Equations

TS
// LeetCode 990 — Satisfiability of Equality Equations
function equationsPossible(equations: string[]): boolean {
  const uf = new UnionFind(26);  // one node per letter a-z
  const code = (c: string) => c.charCodeAt(0) - 97;

  // First pass: process all == equations
  for (const eq of equations) {
    if (eq[1] === '=') uf.union(code(eq[0]), code(eq[3]));
  }

  // Second pass: check != equations for contradictions
  for (const eq of equations) {
    if (eq[1] === '!' && uf.connected(code(eq[0]), code(eq[3]))) {
      return false;  // x==y and x!=y is a contradiction
    }
  }
  return true;
}

console.log(equationsPossible(["a==b","b!=a"])); // false
console.log(equationsPossible(["a==b","b==c","a==c"])); // true
When to Use Union-Find

Signal in problem

Why DSU fits

Connected components

Directly counts via components counter

Dynamic connectivity

Edges added one at a time, check connectivity after each

Cycle detection (undirected)

Union returns false when two nodes already connected

Merge accounts / group similar items

Union elements sharing a property; find their group

Minimum spanning tree (Kruskal)

Fast cycle check for each candidate edge

Satisfiability / constraint propagation

Equality groups form connected components

Tip
Always use both path compression and union by rank together — without union by rank, trees can degrade to chains and path compression alone only helps on repeat queries. Together, they guarantee the near-O(1) inverse Ackermann complexity.