Files
Acid e54607e9ed
Go / build (push) Successful in 26s
new file: explanations/disjointSets.md
new file:   tests/setsTests/unionFind_test.go
2026-07-17 03:37:39 -04:00

9.8 KiB
Raw Permalink Blame History

Disjoint-Set (Union-Find) — Visual Explanations

A disjoint-set structure tracks a collection of elements split into non-overlapping groups. It answers one question extremely fast:

"Are these two elements in the same group?"

while allowing groups to be merged at any time.

Only two operations exist:

Operation Meaning
Find(x) Which group does x belong to? (returns the group's root)
Union(a, b) Merge the groups containing a and b

Two elements are in the same group iff Find(a) == Find(b).


1. How groups are stored: parent pointers

There is no list of groups anywhere. Each element stores exactly one thing: its parent. A group is a tree, and the tree's root (the one element that is its own parent) acts as the group's name.

parent array:   index:   0  1  2  3  4  5
                value:  [0, 0, 1, 3, 3, 5]

This array encodes three trees, i.e. three groups:

      0            3          5
     /            |
    1             4
    |
    2

  {0,1,2}       {3,4}        {5}
  • parent[0] == 0 → 0 is a root (its own parent)
  • parent[2] == 1, parent[1] == 0 → 2's chain leads to root 0
  • The shape of the tree carries no meaning. Only "which root do you reach" matters. This fact is what makes path compression legal.

Fresh initialization

New(6) makes every element its own root — six groups of one:

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

  0    1    2    3    4    5      (six separate trees)

2. Find — walking to the root

Find(4) on the arrangement above:

      3
      |
      4   ← start here, parent[4] = 3

Walk: 4 → 3. parent[3] == 3, so 3 is the root. Find(4) returns 3.

Find never compares values or searches anything — it only follows parent links upward until it finds a node that points at itself.


3. Union — root adoption

Union(a, b) does not link a and b directly. It:

  1. finds the root of each,
  2. points one root at the other.

Example: Union(2, 4) on our three trees.

Step 1: Find(2) → root 0        Find(4) → root 3

Step 2: point root 3 at root 0

BEFORE                      AFTER
      0        3                  0
     /         |                / | \
    1          4               1  .  3
    |                          |     |
    2                          2     4

parent: [0,0,1,3,3,5]       parent: [0,0,1,0,3,5]
                                            ↑
                                only ONE value changed: parent[3]

One write merged the entire groups: every element under 3 now reaches root 0, because their chains pass through 3.

Why roots, never elements directly

If Union(2, 4) had set parent[4] = 2 (element to element):

      0
      |
      1
      |
      2
      |
      4        ← chain keeps growing with every careless union

Chains are the enemy: Find cost equals chain length. Linking root-to-root keeps trees shallow; the next two techniques keep them very shallow.


4. Union by rank — shorter tree goes under taller

When merging two roots, we get to choose who adopts whom. The rule: attach the shorter tree under the taller root.

Tall tree T (height 3)     Short tree S (height 1)

        T                        S
       /|\                       |
      . . .                      s
      |
      .

Option A — short under tall (what we do):

        T            height stays 3
       /|\ \         (S sits at depth 1, reaches depth 2 ≤ 3)
      . . . S
      |     |
      .     s

Option B — tall under short (what we avoid):

        S            height becomes 4!
        | \          (the whole tall tree got pushed one level deeper)
        s  T
          /|\
         . . .
           |
           .

Each root stores a rank — an upper bound on its tree height — used only for this comparison. The one growth case: when both trees have equal rank, the merged tree is necessarily one taller, so the winning root's rank increments. Ranks never decrease, and ranks of non-roots become stale garbage that is simply never read again.

With union by rank alone, tree height stays ≤ log n.


5. Path compression — Find repairs the tree as a side effect

The key idea: Find is not a read-only query. While answering "what is x's root?", it rewires every node it walked past to point directly at that root. The walk happens once; afterwards those nodes answer in a single hop.

Worked example

A degenerate chain (parent: [1, 2, 3, 4, 4]):

        4    ← root
        ↑
        3
        ↑
        2
        ↑
        1
        ↑
        0    ← Find(0) starts here: 4 hops to the root

Call Find(0). It walks 0 → 1 → 2 → 3 → 4, learns the root is 4, and on the way back overwrites each node's parent with 4:

BEFORE  parent: [1, 2, 3, 4, 4]        AFTER  parent: [4, 4, 4, 4, 4]

        4                                        4
        ↑                                     ↗ ↑ ↑ ↖
        3                                    0  1  2  3
        ↑
        2               ONE call to Find(0) flattened
        ↑               the entire chain into a star.
        1
        ↑
        0

Nothing about membership changed — all five elements are still one group with root 4. But the next Find(0) is 1 hop instead of 4. The expensive walk prepaid for every future query on this path.

Because the tree shape is not information (section 1). Any shape that preserves "everyone reaches root 4" is equivalent, so Find is free to pick the flattest one.

The two-pass mechanics

You can't write the answer before you know it. So:

Pass 1 (read-only):  walk up from x, discover the root.

        4  ← found it
        ↑
        3
        ↑          nothing written yet;
        2          the chain is still intact
        ↑
        1
        ↑
        0  ← started here

Pass 2 (writes):     re-walk the same chain from x,
                     paving each parent link with the root.

   visit 0:  parent[0] = 4
   visit 1:  parent[1] = 4
   visit 2:  parent[2] = 4
   visit 3:  parent[3] = 4   (was already 4)
func (uf *UnionFind) Find(element int) int {
    // Pass 1: find the root (read-only)
    root := element
    for uf.parent[root] != root {
        root = uf.parent[root]
    }

    // Pass 2: point everyone on the path at the root
    current := element
    for uf.parent[current] != root {
        next := uf.parent[current]  // where the old chain goes
        uf.parent[current] = root   // pave over it
        current = next
    }

    return root
}

The recursive version hides the same two phases: calls going down are pass 1; the assignments while unwinding are pass 2.

Path halving — one-pass variant

Instead of two passes, skip each node to its grandparent while walking. The path halves in length on every traversal:

BEFORE                 AFTER ONE Find(0)

  4                        4
  ↑                       ↑ ↑
  3                      3  2
  ↑                     ↑   ↑
  2                    (…)  0     ← 0 skipped over 1
  ↑                         ↑
  1                    every node now points
  ↑                    at its old grandparent
  0
func (uf *UnionFind) Find(element int) int {
    for uf.parent[element] != element {
        uf.parent[element] = uf.parent[uf.parent[element]] // skip to grandparent
        element = uf.parent[element]
    }
    return element
}

Less thorough per call, but repeated calls flatten just as fast, and it's the shortest non-recursive form — the common choice in practice.


6. Cost: why it's nearly free

Variant Cost per operation
Naive (no rank, no compression) O(n) worst case — chains
Union by rank only O(log n)
Rank + path compression amortized O(α(n))

α is the inverse Ackermann function: α(n) ≤ 4 for any n that fits in the physical universe. Effectively constant.

"Amortized" matters: one Find on a fresh tall tree can still cost O(log n) — but that call flattens the path, making future calls 1-hop. Averaged over any operation sequence, each costs α(n).

Find(0) #1 on a 1,000,000-chain:   ~1,000,000 hops   (pays)
Find(0) #2:                         1 hop            (collects)
Find(500000):                       1 hop            (collects)

Compare with re-running BFS/DFS per connectivity query: O(n) every time, with no memory of previous work. For q queries that's O(q·n) vs Union-Find's O(q·α(n)) — the entire reason this structure exists.


7. Mental model: lazy signposts

You're at house 0 looking for the town hall. Each house only knows "ask next door." You walk 0 → 1 → 2 → 3 → 4; house 4 says "I'm the town hall." On your way back you update every signpost you passed:

before:  [→1] [→2] [→3] [→4] [HALL]
after:   [→4] [→4] [→4] [→4] [HALL]

Nobody ever does the long walk again. Find = asking directions and fixing the signs; Union = one town hall deferring to another.


8. Cheat sheet

  • A group = a tree; the root = the group's identity.
  • parent[x] == x ⟺ x is a root.
  • Union links root to root, never element to element.
  • Tree shape carries no meaning → compression is always safe.
  • rank is internal balancing bookkeeping, only valid on roots — not a ranking of your data.
  • Merges only — no splitting. If you need "un-union", this is the wrong structure.
  • Union returning false = "already connected" = cycle detected (this is the heart of Kruskal's algorithm).