new file: tests/setsTests/unionFind_test.go
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
|
||||
## Sets
|
||||
|
||||
- [ ] Union Find
|
||||
- [x] Union Find
|
||||
- [ ] Bloom filter
|
||||
- [ ] HyperLogLog
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user