Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ১২/২৪অ্যাডভান্সড৫০ মিনিট

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-এ ৪-এরও কম।

এই লেসন শেষে আপনি পারবেন

  • universe-কে disjoint set-এ ভাগ করার সমস্যা find/union ADT হিসেবে formulate করতে পারবেন
  • parent-array forest-এ naive union-এর degenerate chain, union by rank-এর height-doubling guard, path compression-এর flattening — pointer-স্তরে trace করতে পারবেন
  • inverse Ackermann α(n) intuitively ব্যাখ্যা করে বলতে পারবেন কেন 'effectively constant', আর কেন আক্ষরিক O(1) না
  • adversarial chain pattern-সহ test চালিয়ে নিজের DSU-র invariant যাচাই করতে পারবেন
  • Kruskal, dynamic connectivity, image labeling, accounts-merge — কোথায় DSU-ই সঠিক হাতিয়ার তা চিনতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত পাঁচ লেসনে 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⁡2n  ≪  log⁡∗n  ≪  α(n)\log_2 n \;\ll\; \log^* n \;\ll\; \alpha(n)

  • 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-র বিষয়।
এক union-history-র তিন ভাগ্য — identity এক, 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-র প্রবেশদ্বার।
Component-count decay — random unions-এ group গলা (experiment-সংখ্যা)।

নিজে চালিয়ে দেখুন

EXPERIMENT

Naive বনাম rank+compression — ~৫০০× ফারাক ঘড়িতে

Python 3.8+· ১৫ মিনিট
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 বদলায় না।

নিজে বানান

BUILD IT

DSU class — rank + path compression + adversarial test-suite

Python · ●●○○○
  1. constructor: parent array (self-loop=root), rank, count
  2. find: two-pass — root খোঁজা, তারপর path compression
  3. union: rank compare, smaller-under-larger, tie-এ rank++
  4. connected helper + component counter
  5. 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 OK

Adversarial 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 ফারাক এই প্রশ্নে।

বুঝেছেন কি না দেখুন

1

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-এর ইঞ্জিন।

2

Union 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-রূপে।

3

Compression তো প্রতি 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-জাদুর হিসাব।

4

10⁸-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-ই সব।

5

Accounts-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-অংশের অনুপ্রেরণা