পাঠ ৪৬ · ৫৭-এর মধ্যে · মডিউল ১০
Home / Courses / Design and Analysis of Algorithms / ইউনিয়ন-ফাইন্ড

ডাইনামিক অ্যারে ও পাথ-কম্প্রেশনসহ ইউনিয়ন-ফাইন্ড

Dynamic arrays & union-find with path compression
১২ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • পাথ কম্প্রেশন ও ইউনিয়ন বাই র‍্যাংক ঠিক কীভাবে কাজ করে, এবং কেন এরা "ভবিষ্যতের find অপারেশনকে সস্তা করে দেয়" এই অ্যামর্টাইজড যুক্তির সাথে মিলে যায়
  • উভয় অপ্টিমাইজেশনসহ ইউনিয়ন-ফাইন্ড ইমপ্লিমেন্ট করা, এবং একটি নেইভ (কোনো অপ্টিমাইজেশন ছাড়া) ভার্সনের সাথে পাশাপাশি তুলনা
  • একই র‍্যান্ডম অপারেশন সিকোয়েন্সে উভয় ভার্সন চালিয়ে গড় pointer-hop প্রকৃতপক্ষে গুনে নাটকীয় পার্থক্যটি যাচাই করা
  • M10 মডিউলের সমাপ্তি — কীভাবে তিনটি ভিন্ন প্রমাণ-কৌশল (L43-L45) এবং এই লেসনের ভিন্ন ডেটা স্ট্রাকচার একই "অ্যামর্টাইজড অ্যানালাইসিস" ছাতার নিচে পড়ে তা সংক্ষিপ্ত করা

১ · ইউনিয়ন-ফাইন্ড সংক্ষেপে

ইউনিয়ন-ফাইন্ড (disjoint-set union) একগুচ্ছ ডিসজয়েন্ট সেট বজায় রাখে, প্রতিটি সেটকে একটি গাছ (tree) হিসেবে প্রতিনিধিত্ব করে যেখানে প্রতিটি নোড তার প্যারেন্টকে নির্দেশ করে (রুট নোড নিজেকেই নির্দেশ করে)। দুটো মূল অপারেশন: find(x) — $x$ কোন সেটে আছে তার "প্রতিনিধি" (রুট) খুঁজে বের করা, এবং union(a, b) — দুটো সেট একত্রিত করা। DSA কোর্সে ইউনিয়ন-ফাইন্ডের বেসিক ইমপ্লিমেন্টেশন এবং L23-এ (এই কোর্সেই) ক্রুসকালের MST-তে এর একটি সরল সংস্করণ ইতিমধ্যে ব্যবহৃত হয়েছে — এই পাঠে আমরা তার পূর্ণ অপ্টিমাইজড সংস্করণ তৈরি করব এবং এর amortized cost-এর সুবিধাটা প্রকৃতপক্ষে মেপে দেখব।

কোনো অপ্টিমাইজেশন ছাড়া, union সবসময় একটি নির্দিষ্ট নিয়মে (যেমন সবসময় প্রথম রুটকে দ্বিতীয় রুটের নিচে বসানো) গাছ দুটো জোড়া দিলে, একটি দীর্ঘ চেইন (linked-list-এর মতো) তৈরি হতে পারে — তখন find-এর cost $O(n)$ পর্যন্ত পৌঁছাতে পারে।

২ · দুটো অপ্টিমাইজেশন

পাথ কম্প্রেশন (Path Compression)
find(x) চলার সময় রুট খুঁজে পাওয়ার পর, $x$ থেকে রুট পর্যন্ত পথের প্রতিটি নোডকে সরাসরি রুটের দিকে নির্দেশ করানো হয় — পরের বার সেই নোডগুলোর যেকোনোটির find মাত্র ১ hop-এ শেষ হয়।
ইউনিয়ন বাই র‍্যাংক (Union by Rank)
প্রতিটি রুটের একটি আনুমানিক গাছ-উচ্চতা ("র‍্যাংক") ট্র্যাক রাখা হয়; union-এ সবসময় কম র‍্যাংকের গাছকে বেশি র‍্যাংকের গাছের রুটের নিচে বসানো হয় — এতে গাছ কখনও অপ্রয়োজনীয়ভাবে লম্বা হয় না।
কম্প্রেশনের আগে (chain) ১ ২ ৩ ৪ ৫ find(১): ১→২→৩→৪→৫, মোট ৪ hop কম্প্রেশনের পরে ৫ ১ ২ ৩ ৪ এখন প্রতিটি সরাসরি ৫-কে নির্দেশ করে -- পরের find মাত্র ১ hop
find(১) কল করার সময়ই পাথ কম্প্রেশন পুরো পথের সব নোডকে সরাসরি রুটের সাথে যুক্ত করে দেয় — এই একবারের বাড়তি কাজ ভবিষ্যতের বহু find-কে $O(1)$ করে তোলে।

এই দুটো অপ্টিমাইজেশনের প্রভাব একত্রে করলে, ক্লাসিক ফলাফল (Tarjan, ১৯৭৫) হলো: $n$-টি এলিমেন্টের উপর $m$-টি union/find অপারেশনের সিকোয়েন্সের মোট cost $O(m \cdot \alpha(n))$, যেখানে $\alpha$ হলো ইনভার্স অ্যাকারম্যান ফাংশন — এমন ধীরে বাড়ে যে $n$ যত বড়ই হোক (এমনকি পরমাণুর সংখ্যার সমানও), $\alpha(n) \le 4$। ব্যবহারিক উদ্দেশ্যে একে $O(1)$ amortized ধরে নেওয়া হয়। এই পূর্ণ প্রমাণ (Ackermann ফাংশনের বিপরীত ফাংশনের ডেরিভেশন) এই কোর্সের পরিধির বাইরে — এখানে আমরা এই দাবিটির প্রকৃত অভিজ্ঞতাগত প্রমাণ দেখব: হাজার হাজার অপারেশনে গড় hop সংখ্যা সত্যিই প্রায় ধ্রুবক থাকে কিনা।

Python
import random

def naive_uf_run(n, ops):
    """কোনো অপ্টিমাইজেশন ছাড়া ইউনিয়ন-ফাইন্ড: প্লেইন প্যারেন্ট পয়েন্টার,
    কোনো পাথ কম্প্রেশন নেই, কোনো ইউনিয়ন-বাই-র‍্যাংক নেই।"""
    parent = list(range(n))
    total_hops = 0
    total_finds = 0

    def find(x):
        nonlocal total_hops, total_finds
        hops = 0
        while parent[x] != x:
            x = parent[x]
            hops += 1
        total_hops += hops
        total_finds += 1
        return x

    for kind, a, b in ops:
        if kind == 'find':
            find(a)
        else:
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb   # নেইভ: সবসময় a-এর রুটকে b-এর রুটের নিচে বসানো
    return total_hops, total_finds


def optimized_uf_run(n, ops):
    """পাথ কম্প্রেশন + ইউনিয়ন বাই র‍্যাংক দুটোসহ ইউনিয়ন-ফাইন্ড।"""
    parent = list(range(n))
    rank = [0] * n
    total_hops = 0
    total_finds = 0

    def find(x):
        nonlocal total_hops, total_finds
        hops = 0
        root = x
        while parent[root] != root:
            root = parent[root]
            hops += 1
        total_hops += hops
        total_finds += 1
        # পাথ কম্প্রেশন: x থেকে root পর্যন্ত সব নোডকে সরাসরি root-এর দিকে নির্দেশ করানো
        while parent[x] != root:
            nxt = parent[x]
            parent[x] = root
            x = nxt
        return root

    for kind, a, b in ops:
        if kind == 'find':
            find(a)
        else:
            ra, rb = find(a), find(b)
            if ra != rb:
                if rank[ra] < rank[rb]:
                    ra, rb = rb, ra
                parent[rb] = ra   # ছোট র‍্যাংকের গাছকে বড় র‍্যাংকের রুটের নিচে বসানো
                if rank[ra] == rank[rb]:
                    rank[ra] += 1
    return total_hops, total_finds


random.seed(42)
num_elems = 300
num_ops = 3000
ops = []
for _ in range(num_ops):
    a = random.randrange(num_elems)
    b = random.randrange(num_elems)
    kind = 'union' if random.random() < 0.5 else 'find'
    ops.append((kind, a, b))

naive_hops, naive_finds = naive_uf_run(num_elems, ops)
opt_hops, opt_finds = optimized_uf_run(num_elems, ops)

naive_avg = naive_hops / naive_finds
opt_avg = opt_hops / opt_finds

print(f"এলিমেন্ট সংখ্যা: {num_elems}, মোট অপারেশন: {num_ops}, মোট find কল: {naive_finds}")
print(f"\nনেইভ (কোনো অপ্টিমাইজেশন নেই):")
print(f"  মোট hop: {naive_hops}, গড় hop/find: {naive_avg:.4f}")
print(f"\nঅপ্টিমাইজড (পাথ কম্প্রেশন + ইউনিয়ন বাই র‍্যাংক):")
print(f"  মোট hop: {opt_hops}, গড় hop/find: {opt_avg:.4f}")
print(f"\nস্পিডআপ (নেইভ/অপ্টিমাইজড): {naive_avg / opt_avg:.2f}x")

    
একই ৩,০০০-অপারেশন সিকোয়েন্সে (৩০০টি এলিমেন্টের উপর, seed $=42$ দিয়ে পুনরুৎপাদনযোগ্য) নেইভ সংস্করণের গড় hop/find দাঁড়ায় $\approx 29.30$ — কিছু find চেইনের গভীরে গিয়ে বহু hop নিচ্ছে। অপ্টিমাইজড সংস্করণে (পাথ কম্প্রেশন + ইউনিয়ন বাই র‍্যাংক) গড় দাঁড়ায় $\approx 0.92$ — প্রায় সবসময় $O(1)$-এর কাছাকাছি, $O(\alpha(n))$-এর ভবিষ্যদ্বাণীর সাথে সামঞ্জস্যপূর্ণ। স্পিডআপ $\approx 32$ গুণ — একই এলিমেন্ট সংখ্যা ও একই অপারেশন সিকোয়েন্সে, শুধু দুটো অপ্টিমাইজেশন যোগ করার ফলে।
M10 মডিউল সমাপ্তি · অ্যামর্টাইজড অ্যানালাইসিসের সারাংশ

L43-L45-এ আমরা একই ক্যাপাসিটি-ডাবলিং ডাইনামিক অ্যারে তিনটি ভিন্ন প্রমাণ-কৌশলে (অ্যাগ্রিগেট, অ্যাকাউন্টিং, পটেনশিয়াল) বিশ্লেষণ করেছি — সবগুলোই একই সিদ্ধান্তে পৌঁছেছে: amortized cost $O(1)$। এই পাঠে সম্পূর্ণ ভিন্ন একটি ডেটা স্ট্রাকচারে (ইউনিয়ন-ফাইন্ড) একই মূল ধারণা প্রয়োগ করা হলো — "মাঝেমধ্যে ব্যয়বহুল কাজ (রিয়েলোকেশন, বা কম্প্রেস না করা চেইন) করেও, দীর্ঘমেয়াদে গড় cost কম থাকে" — এবং প্রতিবারই এই দাবিটি প্রকৃতপক্ষে মেপে যাচাই করা হয়েছে, শুধু প্রমাণ করে ছেড়ে দেওয়া হয়নি। M11-এ আমরা এবার একটি ভিন্ন ধরনের সীমাবদ্ধতার দিকে যাব — যেখানে কোনো অ্যালগরিদমই পলিনোমিয়াল-টাইমে চলে না বলে বিশ্বাস করা হয় (NP-হার্ড সমস্যা)।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ যদি শুধু পাথ কম্প্রেশন থাকে (ইউনিয়ন বাই র‍্যাংক ছাড়া), তাহলে কি এখনও উল্লেখযোগ্য উন্নতি হবে?

হ্যাঁ, যদিও দুটো একসাথে থাকার মতো নাটকীয় নয়। শুধু পাথ কম্প্রেশন থাকলে amortized cost per operation হয় $O(\log n)$ (কারণ প্রতিটি find এখনও কম্প্রেস করে, কিন্তু ইউনিয়নের সময় গাছ অপ্রয়োজনীয়ভাবে লম্বা হতে পারে যতক্ষণ না পরবর্তী find সেটা সংশোধন করে)। শুধু ইউনিয়ন বাই র‍্যাংক থাকলে (কম্প্রেশন ছাড়া) একইভাবে $O(\log n)$। দুটো একসাথে থাকলেই $O(\alpha(n))$-এ পৌঁছায় — দুটো অপ্টিমাইজেশন একে অপরকে শক্তিশালী করে, একা একা যথেষ্ট নয়।

প্র ০২ নেইভ সংস্করণে parent[ra] = rb (সবসময় প্রথম আর্গুমেন্টের রুটকে দ্বিতীয়টির নিচে বসানো) — এটাই কি সবচেয়ে খারাপ সম্ভাব্য নেইভ নীতি?

এটি নির্বিচারে (arbitrary), কিন্তু সবচেয়ে খারাপ করার জন্য ইচ্ছাকৃতভাবে ডিজাইন করা নয় — বাস্তবে যদি কেউ ইচ্ছাকৃতভাবে সবচেয়ে খারাপ union অর্ডার বেছে নেয় (যেমন সবসময় একটি ছোট চেইনকে আরেকটি চেইনের রুটের নিচে জোড়া দিয়ে একটি ক্রমবর্ধমান লম্বা চেইন তৈরি করা), $n$-টি এলিমেন্টে একটি $n$-লম্বা চেইন তৈরি করা সম্ভব, যেখানে শেষ এলিমেন্টের find-এর cost হবে ঠিক $O(n)$ — উপরের কোডে র‍্যান্ডম অপারেশনেও আমরা ইতিমধ্যে গড়ে $\approx 29$ hop দেখেছি (৩০০টি এলিমেন্টের মধ্যে), যা দেখায় নেইভ নীতি সত্যিই বাস্তবেও খারাপ পারফর্ম করে, কোনো বিশেষ adversarial ডিজাইন ছাড়াই।

প্র ০৩ এই লেসনের পরিমাপ কি L43-L45-এর "amortized cost measurement" থেকে ভিন্ন কোনো নীতিতে কাজ করে?

না, মূল নীতি একই: একটি লম্বা অপারেশন সিকোয়েন্স চালিয়ে মোট/গড় cost সরাসরি গণনা করা, প্রতিটি অপারেশনকে আলাদাভাবে worst-case ধরে না নিয়ে। L43-L45-এ আমরা "cost" হিসেবে কপি-অপারেশন গুনেছিলাম; এখানে "cost" হিসেবে find-এর সময় parent-pointer অনুসরণ করার hop গুনেছি। উভয় ক্ষেত্রেই দেখা গেছে ব্যক্তিগত অপারেশন কখনও কখনও ব্যয়বহুল হলেও, দীর্ঘমেয়াদী গড় স্থিতিশীল ও কম থাকে — এটাই অ্যামর্টাইজড অ্যানালাইসিসের সার্বজনীন প্যাটার্ন, ডেটা স্ট্রাকচার যা-ই হোক না কেন।

অনুশীলন

  1. চিন্তা করুন: যদি num_elems ৩০০ থেকে বাড়িয়ে ৩,০০০ করা হয় (কিন্তু num_ops একই রাখা হয়), নেইভ সংস্করণের গড় hop/find কীভাবে বদলাবে বলে আপনার ধারণা?

    বেশি এলিমেন্টে একই সংখ্যক অপারেশন ছড়িয়ে পড়লে প্রতিটি এলিমেন্টের গড়ে কম union হবে, তাই চেইনগুলো তুলনামূলকভাবে কম লম্বা হওয়ার সম্ভাবনা থাকে — নেইভ সংস্করণের গড় hop/find হয়তো কিছুটা কমতে পারে (নির্দিষ্ট র‍্যান্ডম সিকোয়েন্সের উপর নির্ভর করে)। কিন্তু অপ্টিমাইজড সংস্করণের গড় hop/find প্রায় অপরিবর্তিতই থাকবে ($n$ যত বড়ই হোক $\alpha(n)$ কার্যত ধ্রুবক) — এটাই দুই সংস্করণের মধ্যে মূল পার্থক্য: নেইভ সংস্করণ $n$-এর প্রতি সংবেদনশীল, অপ্টিমাইজড প্রায় নয়।

  2. পরীক্ষা করুন: উপরের কোডে num_elems = 3000 করে (এবং num_ops = 3000 অপরিবর্তিত রেখে) Run চাপুন — নেইভ ও অপ্টিমাইজড উভয়ের গড় hop/find কীভাবে বদলাল লক্ষ করুন এবং আপনার আগের অনুমানের সাথে তুলনা করুন।

    আউটপুটে দেখা যাবে অপ্টিমাইজড সংস্করণের গড় hop/find এখনও ছোট (প্রায় $1$-এর কাছাকাছি) থাকে, যেখানে নেইভ সংস্করণের গড় পরিবর্তিত হবে (নির্দিষ্ট মান র‍্যান্ডম সিকোয়েন্সের উপর নির্ভরশীল, কিন্তু এখনও অপ্টিমাইজড সংস্করণের চেয়ে উল্লেখযোগ্যভাবে বেশি থাকবে)। এই তুলনাটাই দেখায় কেন Python-এর নিজস্ব সেট ইউনিয়ন-জাতীয় অপারেশন বা গ্রাফ লাইব্রেরিগুলো ব্যবহারিকভাবে সবসময় উভয় অপ্টিমাইজেশনসহ ইউনিয়ন-ফাইন্ড ব্যবহার করে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
পটেনশিয়াল মেথড