From e54607e9ed0eb175dc3553edf0fff767865b5a1e Mon Sep 17 00:00:00 2001 From: Acid Date: Fri, 17 Jul 2026 03:37:39 -0400 Subject: [PATCH] new file: explanations/disjointSets.md new file: tests/setsTests/unionFind_test.go --- README.md | 2 +- explanations/disjointSets.md | 361 ++++++++++++++++++++++++++++++ tests/setsTests/unionFind_test.go | 169 ++++++++++++++ 3 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 explanations/disjointSets.md create mode 100644 tests/setsTests/unionFind_test.go diff --git a/README.md b/README.md index 1249717..870b821 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## Sets -- [ ] Union Find +- [x] Union Find - [ ] Bloom filter - [ ] HyperLogLog diff --git a/explanations/disjointSets.md b/explanations/disjointSets.md new file mode 100644 index 0000000..7b667f1 --- /dev/null +++ b/explanations/disjointSets.md @@ -0,0 +1,361 @@ +# 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. + +### Why this is legal + +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) +``` + +```go +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 +``` + +```go +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). diff --git a/tests/setsTests/unionFind_test.go b/tests/setsTests/unionFind_test.go new file mode 100644 index 0000000..d6724cf --- /dev/null +++ b/tests/setsTests/unionFind_test.go @@ -0,0 +1,169 @@ +package tests + +import ( + "testing" + + "datastructures/sets" +) + +// --- NewUnionFind --- + +func TestNewUnionFindStartsFullyDisjointed(t *testing.T) { + uf := sets.NewUnionFind(8) + if got := uf.Disjointed(); got != 8 { + t.Errorf("expected 8 disjoint sets initially, got %d", got) + } +} + +func TestNewUnionFindEachNodeIsOwnRoot(t *testing.T) { + uf := sets.NewUnionFind(5) + for i := 1; i <= 5; i++ { + if root := uf.Find(i); root != i { + t.Errorf("expected node %d to be its own root, got %d", i, root) + } + } +} + +func TestNewUnionFindNothingIsUnionedYet(t *testing.T) { + uf := sets.NewUnionFind(5) + for a := 1; a <= 5; a++ { + for b := 1; b <= 5; b++ { + if a == b { + continue + } + if uf.IsUnion(a, b) { + t.Errorf("expected %d and %d to be disjoint initially", a, b) + } + } + } +} + +// --- Union --- + +func TestUnionReturnsTrueOnMerge(t *testing.T) { + uf := sets.NewUnionFind(3) + if !uf.Union(1, 2) { + t.Error("expected Union(1,2) to return true on a fresh merge") + } +} + +func TestUnionReturnsFalseWhenAlreadyJoined(t *testing.T) { + uf := sets.NewUnionFind(3) + uf.Union(1, 2) + if uf.Union(1, 2) { + t.Error("expected Union(1,2) to return false when already in the same set") + } +} + +func TestUnionReturnsFalseTransitively(t *testing.T) { + uf := sets.NewUnionFind(3) + uf.Union(1, 2) + uf.Union(2, 3) + // 1 and 3 are now connected through 2 + if uf.Union(1, 3) { + t.Error("expected Union(1,3) to return false (connected via 2)") + } +} + +func TestUnionDecrementsDisjointedCount(t *testing.T) { + uf := sets.NewUnionFind(4) + uf.Union(1, 2) // 4 -> 3 + uf.Union(3, 4) // 3 -> 2 + if got := uf.Disjointed(); got != 2 { + t.Errorf("expected 2 disjoint sets, got %d", got) + } +} + +func TestUnionRedundantDoesNotDecrement(t *testing.T) { + uf := sets.NewUnionFind(4) + uf.Union(1, 2) + uf.Union(1, 2) // redundant, must not change the count + if got := uf.Disjointed(); got != 3 { + t.Errorf("expected 3 disjoint sets after a redundant union, got %d", got) + } +} + +func TestUnionIsSymmetric(t *testing.T) { + uf := sets.NewUnionFind(2) + uf.Union(2, 1) + if !uf.IsUnion(1, 2) { + t.Error("expected Union(2,1) to connect 1 and 2 regardless of argument order") + } +} + +// --- IsUnion / Find --- + +func TestIsUnionTrueAfterMerge(t *testing.T) { + uf := sets.NewUnionFind(3) + uf.Union(1, 2) + if !uf.IsUnion(1, 2) { + t.Error("expected 1 and 2 to be in the same set after Union") + } +} + +func TestIsUnionTransitive(t *testing.T) { + uf := sets.NewUnionFind(4) + uf.Union(1, 2) + uf.Union(2, 3) + if !uf.IsUnion(1, 3) { + t.Error("expected 1 and 3 to be connected through 2") + } + if uf.IsUnion(1, 4) { + t.Error("expected 4 to remain disjoint from the {1,2,3} set") + } +} + +func TestFindSharedRootAfterChain(t *testing.T) { + uf := sets.NewUnionFind(5) + // build a chain 1-2-3-4-5 + uf.Union(1, 2) + uf.Union(2, 3) + uf.Union(3, 4) + uf.Union(4, 5) + + root := uf.Find(1) + for i := 2; i <= 5; i++ { + if uf.Find(i) != root { + t.Errorf("expected node %d to share root %d, got %d", i, root, uf.Find(i)) + } + } +} + +func TestFindIsIdempotent(t *testing.T) { + uf := sets.NewUnionFind(4) + uf.Union(1, 2) + uf.Union(2, 3) + first := uf.Find(1) + // path compression runs on the first call; the answer must not drift + if second := uf.Find(1); first != second { + t.Errorf("expected Find to be stable, got %d then %d", first, second) + } +} + +// --- Scenario: the island/network example from main.go --- + +func TestNetworkIslands(t *testing.T) { + cables := [][2]int{ + {0, 1}, {1, 2}, // island A: 0-1-2 + {3, 4}, // island B: 3-4 + {5, 6}, {6, 7}, {5, 7}, // island C: 5-6-7 (redundant cable) + } + + network := sets.NewUnionFind(8) + for _, c := range cables { + network.Union(c[0], c[1]) + } + + if got := network.Disjointed(); got != 3 { + t.Errorf("expected 3 islands, got %d", got) + } + if !network.IsUnion(0, 2) { + t.Error("expected 0 and 2 to be on the same island") + } + if network.IsUnion(0, 4) { + t.Error("expected 0 (island A) and 4 (island B) to be on different islands") + } + if !network.IsUnion(5, 7) { + t.Error("expected 5 and 7 to be on the same island despite the redundant cable") + } +}