Union-Find — disjoint set-এর inverse Ackermann magic
Union-Find and Path Compression
Union-Find দুই op জানে — same group? merge করো — rank + path compression মিলে খরচ নামায় inverse Ackermann α(n)-এ, বাস্তবের যেকোনো n-এ ৪-এরও কম।
আগে এটা বুঝি
গত পাঁচ লেসনে key খুঁজেছি — value, prefix, priority দিয়ে। আজকের প্রশ্ন ভিন্ন জাতের:
- এই দুই user এক account-এর মালিক?
- এই দুই pixel এক বস্তু?
- এই দুই node-এর মধ্যে এখন রাস্তা আছে?
ভেতরে একই গাণিতিক অবস্থা: universe (user/pixel/node) অসংযুক্ত group- বিভাজনে, ঘটনা দুটো — “এক group?” (find), “এখন থেকে এক” (union)।
এই ছোট্ট ADT-র নাম Union-Find (Disjoint Set Union, DSU) — algorithms-
এর অন্যতম মিষ্টি গল্প। Naive version ভয়ংকর (linked-list-chain!), অথচ দুইটা
অতি-সরল চাল — union by rank + path compression — খরচ নামায় O(α(n))-
এ; α = inverse Ackermann, এমন ধীর function যে universe-এর সব atom-গণনার n-
তেও α ≤ ৪।
দুই প্রশ্ন: (১) ঠিক কোন দুই চাল এই bound দেয়, একটা বাদ দিলে কী ভাঙে? (২) “effectively constant” vs “proven constant” — ফারাক কোথায়?
মূল ধারণা
Partition — formal রূপ
Universe {0..n−1} partition-এ: প্রতিটা element ঠিক এক set-এ
(graph-theory-র equivalence-class মনে আছে?)। তিন operation:
| Operation | অর্থ | Contract |
|---|---|---|
make_set(x) | singleton | {x} |
find(x) | x-এর set-এর representative | এক set → এক উত্তর |
union(x, y) | দুই set এক | পুরনো দুই rep-এর একটা মরে |
ডিজাইনের সূক্ষ্মতা: set-তালিকা কখনো চাই না — শুধু identity-comparison; প্রতি set-এ দরকার একটাই নাম। এই set→id অবনমই speed-এর চাবি।
Parent-array forest — representation
প্রতিটা element এক parent-pointer; root-এর parent সে নিজে:
parent = [0, 0, 2, 2, 4] # groups: {0,1}, {2,3}, {4}
find(1): 1 → 0; parent[0]==0 → root=0 ✓Plain array, zero object; প্রতি set একটা tree, জগৎটা forest। এবার খেলাটা হলো tree লম্বা হতে না দেওয়া — find-খরচ = root- দূরত্ব।
Naive union — chain জন্মে
def union_naive(a, b):
ra, rb = find(a), find(b)
parent[rb] = ra # b-এর পুরো tree a-এর নিচে ঝুলাওunion(i, i−1), union(i+1, i)… করতে থাকলে straight chain — n−1 level!
find(0) = O(n); m op-এ O(n·m)। চেনা ভিলেন — BST-র sorted-input chain,
B-tree-avoided depth — নতুন পোশাকে।
Fix #১: Union by rank — ছোট বড়ের নিচে
Rank = height-এর upper-bound proxy। Rank-r tree-তে ≥ 2^r element —
induction: rank r+1 তৈরি হয় দুই rank-r জোড়ায়, element দ্বিগুণ। Element
সীমিত ⇒ height ≤ log₂ n — perfect-tree counting-উল্টোপথে।
Fix #২: Path compression — একবার হাঁটলেই সোজা
find(x) ফেরার সময় পথের সবাইকে root-এর direct child করে দাও:
আগে: x → p → q → r → ROOT find(x)...
পরে: x → ROOT (p,q,r-ও সবাই → ROOT)প্রথম find-এর খরচে রাস্তা flat — পরে ওই path ধরে কেউ যাবেই, খরচ ~০। খরচ একবার, লাভ বারবার — amortization-এর aggregate-method হাতে-কলমে।
দুটো মিলে — α(n)
Rank-only O(log n); compression-only amortized worst-case তবু Θ(log n)
পর্যন্ত; Tarjan 1975: দুটো একসাথে O((n+m)·α(n))। কেউ একা জাদু দেয়
না — জুটি ম্যাজিশিয়ান (misconception-এ ফিরবে)।
ভেতরে কী ঘটছে
Inverse Ackermann — কত ধীরে?
Ackermann recursion-এর extreme sport:
A(1, n) = n + 2
A(2, n) = 2n + 3
A(3, n) = 2^(n+3) − 3
A(4, 0) = 13; A(4, 1) = 65533; A(4, 2) ≈ 10^(19729)
A(4, 3) — মহাবিশ্বের সব atom-এও লেখা যায় নাα(n) = উল্টো প্রশ্ন: কোন m-এ A(m,m) ≥ n? Ladder:
log₂ n: n=10⁹ → ৩০log* n: n=10⁹ → ৪α(n): n=10⁹ → ≤৩; physical n-এ α ≤ ৪
Analysis কেন কঠিন? প্রতি op আলাদা bound না — খরচ জমে potential-হিসেবে, compression-এর prepayment পরের op-গুলোর বিল মেটায়; Tarjan-এর proof levels- of-amortization-এ কাজ করে।
Variant-গুলো
- Weighted quick-union: rank-এর বদলে subtree-size track — asymptotically equivalent, Sedgewick-এর প্রিয় উপস্থাপনা।
- Path halving/splitting: one-pass compression (
parent[x] = parent[parent[x]]হাঁটতে হাঁটতে) — two-pass-এর সস্তা কাজিন, practical-এ competitive। - Rollback DSU: union-history stack + undo — delete-support-হীন DSU-র offline-workaround।
- Permutation-parity variant (advanced): edge-weight XOR accumulate করলে DSU বলে দেয় swap-cycle even/odd — bipartite-check-এর আত্মীয়।
উদাহরণ
আটজনের জগৎ — হাতে
Elements 0..7; unions: (0,1),(2,3),(1,3),(4,5),(6,7),(5,7),(3,7)
union(0,1): tie(r0,r0) → parent[1]=0, rank[0]=1
union(2,3): tie → parent[3]=2, rank[2]=1
union(1,3): find(1)=0(r1), find(3)=2(r1) → tie → parent[2]=0, rank[0]=2
tree: 0 ─┬─ 1
└─ 2 ─ 3
union(4,5): parent[5]=4, rank[4]=1
union(6,7): parent[7]=6, rank[6]=1
union(5,7): tie → parent[6]=4, rank[4]=2
union(3,7): 0(r2) vs 4(r2) → tie → parent[4]=0, rank[0]=3
final: 0 ─┬─ 1
├─ 2 ─ 3
└─ 4 ─┬─ 5
└─ 6 ─ 7
count: 8 → 1; height 3 = ⌊log₂ 8⌋ ✓এবার compression: find(7) —
হাঁটা: 7 → 6 → 4 → 0 (root) ৩ ধাপ
ফেরতে: parent[7]=0, parent[6]=0 path compressed!
tree: 0 ─┬─ 1, 2─3, 4, 6, 7 ← 7 আর 6 root-এ direct!পরের find(7) এক ধাপ। কিছু “মরেনি” — membership অটুট; শুধু shortcut।
ছবিতে
naive (blind attach): union by rank: + path compression:
0 0 0
└─ 1 / \\ / | \\ \\
└─ 2 1 4 1 2 4 6 7 ...
└─ 3 / \\ / \\ (find(7)-এর পরে
└─ ... 2 ? 5 6 subtree flat)
└─ n−1 /
height: n−1 7 height: ≤ 2–3
find: O(n) find: O(log n) find: α(n)
partition তিনটাতেই এক — গঠনের বাকিটা efficiency-র বিষয়।unions: 0 25k 50k 75k 100k
components: 50k 25k 8k 2.7k ~1k
50k │●
│
25k │ ●
10k │ ●
5k │ ●
1k │ ●
└──────────────────────→ unions
শুরুতে linear decay (successful union = −১ component);
শেষে ঢালু — random pair already-connected হওয়ার সম্ভাবনা বাড়ে।
এই saturation-curve percolation theory-র প্রবেশদ্বার।নিজে চালিয়ে দেখুন
Naive বনাম rank+compression — ~৫০০× ফারাক ঘড়িতে
import random, time
class NaiveDSU:
def __init__(self, n):
self.p = list(range(n)); self.count = n
def find(self, x):
while self.p[x] != x:
x = self.p[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False
self.p[rb] = ra # blind attach — chain জন্মায়
self.count -= 1; return True
class FastDSU:
def __init__(self, n):
self.parent = list(range(n)); self.rank = [0]*n; self.count = n
def find(self, x):
root = x
while self.parent[root] != root: root = self.parent[root]
while self.parent[x] != root: # compression
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False
if self.rank[ra] \< self.rank[rb]: ra, rb = rb, ra
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]: self.rank[ra] += 1
self.count -= 1; return True
random.seed(9)
N, M, CH = 50_000, 100_000, 25_000
pairs = [(random.randrange(N), random.randrange(N)) for _ in range(M)]
for cls in (FastDSU, NaiveDSU):
d = cls(N)
t0 = time.perf_counter(); last = t0
chunks = []
for i, (a, b) in enumerate(pairs, 1):
d.union(a, b)
if i % CH == 0:
now = time.perf_counter()
chunks.append(now - last); last = now
total = time.perf_counter() - t0
print(f'{cls.__name__:8s}: total {total:6.2f}s '
f'({M/total:>9,.0f} ops/sec)')
print(f' chunk times:',
' '.join(f'{c:.2f}' for c in chunks),
'| components:', d.count)laptop-নমুনা output (naive-এ ধৈর্য — ~১ মিনিট; সেটাই দেখার বিষয়):
FastDSU : total 0.13s ( 769,231 ops/sec)
chunk times: 0.03 0.04 0.03 0.03 | components: 1008
NaiveDSU: total 62.55s ( 1,599 ops/sec)
chunk times: 0.02 6.71 24.04 31.77 | components: 1008তিন reading: (১) ~৪৯০× gap, naive chunk-time ৬.৭→২৪→৩২s — trees চেইনে পচছে, প্রতি union-এ find লম্বাচ্ছে; (২) FastDSU flat — rank+compression গতি ধরে; (৩) component-count দুজনের হুবহু 1008 — optimization ফল বদলায় না, খরচ বদলায়।
নিজে বাড়ান: (১) M বাড়ান — naive-র superlinear decay-rate দেখুন; (২) FastDSU থেকে rank সরে compression-only — Θ(log n)-এর বাস্তব মুখ; (৩) union-success-rate ট্র্যাক — শেষ চাঙ্কে redundant-union-এর অনুপাত।
একই ১ লাখ random union-এ FastDSU স্থির ~৮ লাখ ops/sec, naive চাঙ্ক-প্রতি ৬.৭s→২৪s→৩২s-এ মাটিতে (~১,৬০০ ops/sec) — tree-পচন live; component-count দুজনের হুবহু এক — optimization semantics বদলায় না।
নিজে বানান
DSU class — rank + path compression + adversarial test-suite
- constructor: parent array (self-loop=root), rank, count
- find: two-pass — root খোঁজা, তারপর path compression
- union: rank compare, smaller-under-larger, tie-এ rank++
- connected helper + component counter
- tests: adversarial sequential chain ১ লাখ element + random invariant suite + max-depth probe
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.count = n
def find(self, x):
root = x
while self.parent[root] != root: # পাস ১: root খোঁজা
root = self.parent[root]
while self.parent[x] != root: # পাস ২: পথ সমতল
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.rank[ra] \< self.rank[rb]:
ra, rb = rb, ra # ছোট বড়ের নিচে
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1 # tie-তেই height বাড়ে
self.count -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
def max_depth(d):
"""diagnostic: সবচেয়ে গভীর parent-chain"""
worst = 0
for x in range(len(d.parent)):
cur, depth = x, 0
while d.parent[cur] != cur:
cur = d.parent[cur]; depth += 1
worst = max(worst, depth)
return worst
if __name__ == '__main__':
N = 100_000
# --- adversarial chain: union(i, i-1) sequentially ---
d = DSU(N)
for i in range(1, N):
d.union(i, i - 1)
assert d.count == 1
assert d.connected(0, N - 1)
print('chain OK; max depth:', max_depth(d))
d.find(0); d.find(N - 1) # দুই find-ই flatten করে
print('after finds :', max_depth(d))
# --- random invariant suite ---
import random
random.seed(5)
e = DSU(1000)
for _ in range(2000):
a, b = random.randrange(1000), random.randrange(1000)
before = (e.connected(a, b), e.count)
changed = e.union(a, b)
after = (e.connected(a, b), e.count)
assert before[0] or changed # connected হলে union no-op
if not before[0]:
assert after[0] and after[1] == before[1] - 1
else:
assert after[1] == before[1]
print('random invariant suite OK')প্রত্যাশিত output:
chain OK; max depth: 1
after finds : 1
random invariant suite OKAdversarial chain-টা খেয়াল করুন: union(i, i−1) naive-কে দিলে নিশ্চিত
n−1-level chain; rank-guarded version-এ max depth ১ (প্রতিটা নতুন i
singleton — সবসময় smaller-under-larger, star হয়ে যায়)। Production bug-hunt-
এ প্রথম যে fingerprint খোঁজা হয়, এটাই।
নিজে বাড়ান: (১) permutation-parity variant — edge-XOR weight accumulate
find-এ, rewrite compression-এ; even/odd swap-cycle বলে দেবে; (২) rollback
DSU (history stack + undo); (৩) path-halving one-pass (parent[x] = parent[parent[x]]) লিখে two-pass benchmark।
বাস্তব সিস্টেমে
Union-Find যেখানে যেখানে
- Kruskal’s MST: edges sort → প্রান্তদ্বয়ের components আলাদা হলে edge নাও — cycle-guard ইঞ্জিনটাই DSU (minimum-spanning-trees module)।
- Type inference (Hindley–Milner): type-variable unification = equation- sides union — ML/Haskell/Rust trait-solver-এর ভেতরে আস্ত DSU।
- Register allocation: compiler coalescing phase — move-eliminable register-class = union-find class।
- Image processing: connected-component labeling — flood-fill-এর বদলে two-pass DSU, বিশাল image-এ বহুগুণ দ্রুত; segmentation pipeline-এর ধাপ ১।
- Accounts/entity merging: email-share = same person (LeetCode Accounts Merge classic) — spam/fraud-graph পর্যন্ত।
- Physics/networking: percolation site-open → neighbor-union (“উপর-নিচ সংযুক্ত?” সস্তা); link-up/down dynamic-connectivity query।
Common thread: প্রশ্নটা membership, ordering না — তাই hash/tree নয়, DSU।
যে ভুলগুলো সবাই করে
“α(n) constant — union-find প্রমাণিত O(1)।”
α mathematically unbounded — কখনো বাড়ে, তাই O(α(n)) কে O(1) লেখা ভুল। সত্যি কথা: α-৫-ছাড়ানো n-এর decimal-universe-এই ধরে না। Engineering- এ “effectively constant”; formal-analysis-এ “O(α(n))” — দুই context, দুই ভাষা, interchange করলেই ভুল। Complexity-পরীক্ষায় এই সূক্ষ্মতাই distinguisher।
“Path compression-ই আসল জাদু — rank ঐচ্ছিক।”
Compression-only: amortized bound ভালো কিন্তু α না — Θ(log n) পর্যন্ত (experiment-bonus-এ মাপার নির্দেশ)। Rank-only তো O(log n)-ই। Tarjan-bound conjunction-এ: compression সমতল করে কিন্তু new-attach অরক্ষিত রাখে, rank new-attach নিয়ন্ত্রণ করে কিন্তু পুরনো গভীরতা ধরে — দুজনের সহযোগিতাতেই দামি step-এর খরচ পরে amortize হয়।
“Union by height ভালো — height-ই minimize-এর লক্ষ্য তো।”
Height-track compression-এর সাথে সংগতহীন: compression height কমায়, stored value মিথ্যা — ঠিক রাখতে প্রতি find-এ extra pass। Rank = upper-bound proxy যা compression invalidate করে না (height কমাতে পারে, বাড়াতে পারে না)। Rank = “কখনো এত লম্বা ছিল”, height = “এখন কত” — bounding-argument-এ প্রথমটাই maintenance-free।
“DSU সব connectivity পারে — delete/split-ও সামলাবে।”
Contract একমুখী: union reversible না। Element-delete/group-split-এ information already destroyed। Workaround আলাদা যন্ত্র: rollback-DSU, small-to-large rebuild, segment-tree-on-time D&C। প্রথম প্রশ্ন: operations monotone (শুধু merge)? হ্যাঁ → DSU; না → ভিন্ন structure — expert-vs-novice ফারাক এই প্রশ্নে।
বুঝেছেন কি না দেখুন
1make 0..5; union(0,1); union(2,3); union(0,2); union(4,5); union(3,4)
— প্রতি union-এ parent/rank/count state।
স্মরণ
make 0..5; union(0,1); union(2,3); union(0,2); union(4,5); union(3,4)
— প্রতি union-এ parent/rank/count state।- Start: p=[0..5], r=[0]*6, count=6
- u(0,1): tie → p[1]=0, r[0]=1, count=5
- u(2,3): tie → p[3]=2, r[2]=1, count=4
- u(0,2): 0(r1) vs 2(r1) tie → p[2]=0, r[0]=2, count=3; tree 0─
{1, 2─3} - u(4,5): tie → p[5]=4, r[4]=1, count=2
- u(3,4): find(3)=0(r2) > find(4)=4(r1) → 4-এর tree 0-এর নিচে, rank unchanged (tie না), count=1
Final: p=[0,0,0,2,0,4], r=[2,0,1,0,1,0], count=1। Rank বাড়ে শুধু equal- rank merge-এ — এই asymmetry-ই height-log-bound-এর ইঞ্জিন।
2Union by rank কেন height ≤ ⌊log₂ n⌋ বাধ্য করে? Induction লিখুন।
যুক্তি
Claim: rank-r root-এর subtree ≥ 2^r elements। Base: rank-0 singleton ✓। Step: rank r+1 তৈরি শুধু tie-merge-এ — দুই rank-r subtree ≥ 2^r করে → ≥ 2^(r+1) ✓; unequal attach-এ rank বাড়ে না, বড়টা root থাকে। সুতরাং size ≥ 2^rank সর্বদা → rank ≤ log₂ n; আর height ≤ rank। ∎ Perfect-binary counting-এর 2^h সূত্র, constraint-রূপে।
3Compression তো প্রতি find-কে আরো কাজ দেয় — মোট খরচ কমায় কীভাবে?
Amortization-ভাষায়।
যুক্তি
Single-call lens-এ হ্যাঁ — constant factor বাড়ে। কিন্তু aggregate-method: প্রতিটা expensive find prepayment — পথের node-দের account-এ credit জমে, credit-ই পরের finds-এর বিল মেটায়। Formally: প্রতিটা element সর্বোচ্চ একবার “গভীর থেকে ওঠে” (compression-পরে root-direct-child — আর নড়বে না) — lifetime upward-moves O(n); এই global budget-ই compression-এর amortized-জাদুর হিসাব।
410⁸-pixel image-এ connected-component labeling, memory-constrained। Full-image
parent array ছাড়া কীভাবে DSU?
ডিজাইন
Two-pass scan: Pass ১ raster-order — foreground pixel-এ provisional label; left/up neighbor-label আলাদা হলে union — কিন্তু label-id space-এ (label- count ≈ foreground-run, image-চেয়ে অনেক ছোট — সাধারণত 10⁶-মাত্রা, 10⁸ না!)। Pass ২: প্রতি pixel-এর label-এর find() = canonical id — compression সব chain flat করে। Bonus: rank-byte/halving-এ memory আরো ছোট; output relabel dense 1..k। শিক্ষা: DSU pixel-এ না, entity-তে — universe-size-ই সব।
5Accounts-merge: input accounts (name + email-set), email share = same person
— merged groups বের করুন। DSU-formulation: node, edge, output?
প্রয়োগ
Node = unique email (name না — এক name দুই মানুষ হতে পারে)। Account-এর ভেতরের emails consecutive-pair union (k−1 union, clique)। শেষে প্রতি email- এর find(email) → root-dictionary — প্রতি root এক merged group; emails sorted + যেকোনো member-এর name attached। Complexity: E email, A account → O(E·α(E)) union/find + O(E log E) sort। BFS-alternative সম্ভব — DSU-তে traversal- bookkeeping নেই, incremental input-ও খায়। Membership-problem-এ elegance জেতে।
এরপর কী
এরপর কী
Union-Find দিয়ে data-structure sprint শেষ — array, list, stack/queue, hash table, tree-family (binary/balanced/disk/array-flattened/string-indexed), DSU। প্রতিটার পেছনে এক প্রশ্ন, প্রতি প্রশ্নের পেছনে hardware-real constraint।
এবার focus shift: structure → traversal। পরের লেসনে Graph — node-edge- এর general ভাষা (tree = cycle-হীন rooted special case)। BFS আর DFS — আজকের level-order আর depth-first-এর বড় ভাই — shortest hop-path, cycle detection, connectivity হাতে-কলমে; adjacency list vs matrix memory-hিসাবসহ।
আরও পড়ুন
- "Efficiency of a Good But Not Linear Set Union Algorithm" (JACM, 1975) — Robert Endre Tarjan · α(n) bound-এর মূল প্রমাণ — union-find analysis-র landmark কাগজ
- Introduction to Algorithms, 3rd ed., Chapter 21 — Data Structures for Disjoint Sets — Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein · Rank+compression accounting-proof ও linked-list representation-তুলনা
- Algorithms, 4th ed., Section 1.5 — Case Study: Union-Find — Robert Sedgewick, Kevin Wayne · Weighted quick-union-এর client-driven narrative — experiment-অংশের অনুপ্রেরণা