পাঠ ২৩ · ৫৭-এর মধ্যে · মডিউল ৫
Home / Courses / Design and Analysis of Algorithms / MST — ক্রুসকাল ও প্রিম

মিনিমাম স্প্যানিং ট্রি — ক্রুসকাল ও প্রিম, প্রমাণসহ

Minimum spanning trees — Kruskal's & Prim's, with proofs
১৪ মিনিট পড়া কঠিন · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

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

  • MST-এর আনুষ্ঠানিক সংজ্ঞা এবং কাট প্রপার্টির বিবৃতি
  • কাট প্রপার্টি কীভাবে ক্রুসকাল ও প্রিম — উভয় ভিন্ন-দেখতে অ্যালগরিদমের সঠিকতা একসাথে ব্যাখ্যা করে
  • সরল ইউনিয়ন-ফাইন্ড দিয়ে ক্রুসকাল এবং heapq দিয়ে প্রিমের প্রকৃত ইমপ্লিমেন্টেশন
  • দুই অ্যালগরিদমের ফলাফল পরস্পরের বিপরীতে, এবং ছোট গ্রাফে প্রকৃত ব্রুট-ফোর্সের বিপরীতে যাচাই

১ · সমস্যাটি এবং কাট প্রপার্টি

একটি কানেক্টেড, আনডাইরেক্টেড, ওয়েটেড গ্রাফ $G = (V, E)$ দেওয়া আছে। একটি স্প্যানিং ট্রি হলো $E$-এর একটি সাবসেট যা $G$-এর সব $V$টি ভার্টেক্স সংযুক্ত রাখে, ঠিক $|V|-1$টি এজ ব্যবহার করে, এবং কোনো চক্র (cycle) তৈরি করে না। মিনিমাম স্প্যানিং ট্রি হলো সব সম্ভাব্য স্প্যানিং ট্রির মধ্যে সর্বনিম্ন মোট এজ-ওজনের একটি। এই সমস্যার মেকানিক্স DSA কোর্সে কভার করা হয়েছে — এখানে আমাদের কেন্দ্রীয় প্রশ্ন হলো কেন গ্রিডি চয়েস (সবচেয়ে সস্তা উপলব্ধ এজ) নিরাপদ, তার প্রমাণ।

এই প্রমাণের মূল হাতিয়ার হলো কাট প্রপার্টিCut Propertyভার্টেক্স সেট $V$-কে যেকোনোভাবে দুই অ-খালি ভাগ $(S, V-S)$-এ ভাগ করলে (এটাই একটি "কাট"), এই দুই ভাগের মাঝে যাওয়া এজগুলোর মধ্যে সবচেয়ে কম ওজনের এজটি অন্তত একটি MST-তে থাকবেই — শর্ত থাকে যে এই সর্বনিম্ন-ওজনের এজটি ইউনিক হয়, অথবা টাই থাকলে অন্তত একটি MST-তে থাকে।

সংক্ষেপে প্রমাণ (এক্সচেঞ্জ আর্গুমেন্টের একটি রূপ): ধরি $(u, v)$ কাট $(S, V-S)$ পার হওয়া সবচেয়ে কম-ওজনের এজ, কিন্তু কোনো একটি MST $T$-তে এটি নেই। $T$-তে $u$ থেকে $v$-এ যাওয়ার একটি (একমাত্র) পথ আছে — সেই পথে অবশ্যই কাট পার হওয়া অন্তত একটি এজ $(x, y)$ আছে। $(x, y)$-কে সরিয়ে $(u, v)$ বসালে ($T$-তে exchange), নতুন সেটও একটি স্প্যানিং ট্রি (এখনও সংযুক্ত, একই সংখ্যক এজ), এবং যেহেতু $(u,v)$-ই কাটের সর্বনিম্ন-ওজনের এজ, তাই $w(u,v) \le w(x,y)$ — মোট ওজন কমেনি বা একই থেকেছে। সুতরাং $(u,v)$ যোগ করা নিরাপদ — এটি এক্সচেঞ্জ আর্গুমেন্টের ঠিক একই কাঠামো (L19), শুধু "চয়েস" এখানে একটি এজ এবং "ক্ষতি না হওয়া" প্রমাণিত হচ্ছে ট্রি-এক্সচেঞ্জ দিয়ে।

কাট প্রপার্টি সর্বনিম্ন ক্রসিং এজ কোনো MST-তে আছে ক্রুসকাল এজ ওজন অনুযায়ী সাজিয়ে, চক্র এড়িয়ে যোগ প্রিম একটি ট্রি ধীরে বাড়িয়ে, সস্তা ক্রসিং এজ যোগ
দুটো অ্যালগরিদম ভিন্ন কৌশলে কাজ করে (এজ-কেন্দ্রিক বনাম ট্রি-কেন্দ্রিক), কিন্তু উভয়ের প্রতিটি চয়েস একই কাট প্রপার্টি দিয়ে ন্যায্যতাপ্রাপ্ত — এজন্যই দুটোই সঠিক এবং সবসময় একই মোট ওজন দেয়।
ক্রুসকালে কাট প্রপার্টি
প্রতিটি ধাপে সবচেয়ে সস্তা এজ (যদি চক্র তৈরি না করে) নেওয়া হয় — এই এজটি সবসময় তার দুই এন্ডপয়েন্টের বর্তমান কম্পোনেন্ট দুটোর মধ্যকার কাটের সর্বনিম্ন এজ, তাই কাট প্রপার্টি অনুযায়ী নিরাপদ।
প্রিমে কাট প্রপার্টি
প্রতিটি ধাপে "ট্রিতে থাকা ভার্টেক্স" বনাম "বাইরের ভার্টেক্স" — এই কাটের সর্বনিম্ন ক্রসিং এজটি যোগ করা হয়, যা সরাসরি কাট প্রপার্টির প্রয়োগ।

২ · ইমপ্লিমেন্টেশন — ক্রুসকাল (সরল ইউনিয়ন-ফাইন্ড) ও প্রিম (heapq)

নিচে ক্রুসকাল একটি সরল ইউনিয়ন-ফাইন্ড (কোনো পাথ-কম্প্রেশন বা ইউনিয়ন-বাই-র‍্যাংক অপ্টিমাইজেশন ছাড়া — সেই পূর্ণাঙ্গ অপ্টিমাইজেশন M10/L46-এ আমর্টাইজড অ্যানালাইসিসের প্রসঙ্গে বিস্তারিত আসবে) দিয়ে ইমপ্লিমেন্ট করা হয়েছে, এবং প্রিম একটি সত্যিকারের min-heap (heapq) দিয়ে। দুটো অ্যালগরিদম বহু র‍্যান্ডম কানেক্টেড গ্রাফে একই মোট MST ওজন দেয় কি না তা যাচাই করা হয়েছে।

Python
import random
import heapq
from itertools import combinations

# ---------- সরল ইউনিয়ন-ফাইন্ড (পাথ-কম্প্রেশন/র‍্যাংক ছাড়া -- পূর্ণাঙ্গ সংস্করণ M10/L46-এ) ----------
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        while self.parent[x] != x:
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False    # ইতিমধ্যে একই কম্পোনেন্টে -- যোগ করলে চক্র হতো
        self.parent[ra] = rb
        return True

def kruskal(n, edges):
    # edges: (u, v, w) ট্রিপলের লিস্ট
    uf = UnionFind(n)
    total = 0
    for u, v, w in sorted(edges, key=lambda e: e[2]):
        if uf.union(u, v):
            total += w
    return total

def build_adj(n, edges):
    adj = {i: [] for i in range(n)}
    for u, v, w in edges:
        adj[u].append((v, w))
        adj[v].append((u, w))
    return adj

def prim(n, adj):
    visited = [False] * n
    total = 0
    heap = [(0, 0)]   # (ওজন, ভার্টেক্স) -- ভার্টেক্স 0 থেকে শুরু
    while heap:
        w, u = heapq.heappop(heap)
        if visited[u]:
            continue
        visited[u] = True
        total += w
        for v, wt in adj[u]:
            if not visited[v]:
                heapq.heappush(heap, (wt, v))
    return total

def is_connected(n, edge_list):
    if n == 0:
        return True
    adj = {i: [] for i in range(n)}
    for u, v, w in edge_list:
        adj[u].append(v)
        adj[v].append(u)
    seen = {0}
    stack = [0]
    while stack:
        node = stack.pop()
        for nb in adj[node]:
            if nb not in seen:
                seen.add(nb)
                stack.append(nb)
    return len(seen) == n

random.seed(3)
mismatches = 0
trials = 20
for trial in range(trials):
    n = random.randint(4, 12)
    nodes = list(range(n))
    random.shuffle(nodes)
    # প্রথমে একটি র‍্যান্ডম স্প্যানিং পথ দিয়ে গ্র্যাফ কানেক্টেড আছে তা নিশ্চিত করা
    edges = [(nodes[i], nodes[i + 1], random.randint(1, 20)) for i in range(n - 1)]
    # তারপর কিছু অতিরিক্ত র‍্যান্ডম এজ যোগ করা
    extra_pairs = list(combinations(range(n), 2))
    random.shuffle(extra_pairs)
    extra_count = random.randint(0, len(extra_pairs) // 2)
    for u, v in extra_pairs[:extra_count]:
        edges.append((u, v, random.randint(1, 20)))

    assert is_connected(n, edges)
    k_total = kruskal(n, edges)
    p_total = prim(n, build_adj(n, edges))
    if k_total != p_total:
        mismatches += 1
        print("MISMATCH:", n, edges, k_total, p_total)

print(f"মোট র‍্যান্ডম গ্রাফ: {trials}, ক্রুসকাল বনাম প্রিমের মধ্যে অমিল: {mismatches}")

    

৩ · টাইনি গ্রাফে সত্যিকারের ব্রুট-ফোর্স ক্রস-চেক

উপরের যাচাই শুধু প্রমাণ করে ক্রুসকাল ও প্রিম পরস্পরের সাথে একমত — কিন্তু যদি দুটোতেই একই ভুল যুক্তি থাকত (উদাহরণস্বরূপ, কাট প্রপার্টি ভুল বোঝা), তারা তখনও একমত হতে পারত অথচ ভুল হতে পারত। তাই আমরা আরও ছোট গ্রাফে ($n \le 6$) একটি সম্পূর্ণ স্বতন্ত্র ব্রুট-ফোর্স চালাচ্ছি: $|V|-1$টি এজের প্রতিটি সম্ভাব্য কম্বিনেশন নিয়ে (এজ সেটের উপর combinations), প্রতিটির জন্য যাচাই করা হচ্ছে এটি (ক) সংযুক্ত (connectivity চেক) কি না — মনে রাখবেন $|V|-1$টি এজ থাকা এবং সংযুক্ত হওয়া একসাথে চক্রবিহীন স্প্যানিং ট্রি হওয়ার জন্য যথেষ্ট শর্ত (একটি গ্রাফ থিওরি ফলাফল: $n$ ভার্টেক্স, $n-1$ এজ, এবং সংযুক্ত হলে তা অবশ্যই একটি ট্রি — অতিরিক্ত কোনো চক্র-চেক দরকার নেই)।

Python
def brute_force_mst(n, edges):
    # সত্যিকারের এক্সহস্টিভ সার্চ: |V|-1 এজের প্রতিটি কম্বিনেশন, সংযুক্ততা যাচাই করে সর্বনিম্ন ওজন খোঁজা
    best = None
    for subset in combinations(edges, n - 1):
        if is_connected(n, subset):     # n ভার্টেক্স, n-1 এজ, সংযুক্ত => নিশ্চিতভাবে একটি স্প্যানিং ট্রি
            weight = sum(w for _, _, w in subset)
            if best is None or weight < best:
                best = weight
    return best

random.seed(3)
tiny_mismatches = 0
tiny_trials = 15
for trial in range(tiny_trials):
    n = random.randint(3, 6)
    nodes = list(range(n))
    random.shuffle(nodes)
    edges = [(nodes[i], nodes[i + 1], random.randint(1, 10)) for i in range(n - 1)]
    extra_pairs = list(combinations(range(n), 2))
    random.shuffle(extra_pairs)
    k_extra = random.randint(0, min(3, len(extra_pairs)))
    for u, v in extra_pairs[:k_extra]:
        edges.append((u, v, random.randint(1, 10)))

    assert is_connected(n, edges)
    k_total = kruskal(n, edges)
    p_total = prim(n, build_adj(n, edges))
    b_total = brute_force_mst(n, edges)   # সম্পূর্ণ স্বতন্ত্র এক্সহস্টিভ সার্চ

    if not (k_total == p_total == b_total):
        tiny_mismatches += 1
        print("TINY MISMATCH:", n, edges, "kruskal=", k_total, "prim=", p_total, "brute=", b_total)

print(f"টাইনি গ্রাফ ({tiny_trials}টি, n<=6): ক্রুসকাল/প্রিম বনাম ব্রুট-ফোর্সের মধ্যে অমিল: {tiny_mismatches}")

# একটি নির্দিষ্ট উদাহরণ বিস্তারিত দেখানো
example_edges = [(0, 1, 4), (0, 2, 1), (1, 2, 2), (1, 3, 5), (2, 3, 8), (2, 4, 10), (3, 4, 2)]
example_n = 5
print("\nউদাহরণ গ্রাফ (u, v, weight):", example_edges)
print("  ক্রুসকাল মোট ওজন:      ", kruskal(example_n, example_edges))
print("  প্রিম মোট ওজন:         ", prim(example_n, build_adj(example_n, example_edges)))
print("  ব্রুট-ফোর্স মোট ওজন:   ", brute_force_mst(example_n, example_edges))

    
লক্ষ করুন brute_force_mst কাট প্রপার্টি বা "সবচেয়ে সস্তা এজ" ধারণার কোনো ব্যবহারই করছে না — এটি নিছক সংজ্ঞা প্রয়োগ করছে (একটি স্প্যানিং ট্রি মানেই $n-1$টি এজ এবং সংযুক্ততা)। ছোট গ্রাফে ($n \le 6$) এজের সংখ্যা সীমিত থাকায় $\binom{|E|}{n-1}$ কম্বিনেশন দ্রুত পরীক্ষা করা সম্ভব। প্রতিবার তিনটি সংখ্যাই (ক্রুসকাল, প্রিম, ব্রুট-ফোর্স) মিলে যাওয়া মানে কাট প্রপার্টির প্রমাণ শুধু কাগজে-কলমেই নয়, বাস্তবেও প্রতিটি পরীক্ষিত গ্রাফে যাচাই হচ্ছে।
মূল কথা · Key takeaway

MST দেখায় এক্সচেঞ্জ আর্গুমেন্ট একটি নির্দিষ্ট অ্যালগরিদমের সাথে বাঁধা নয় — কাট প্রপার্টির মতো একটি সাধারণ, গঠন-ভিত্তিক যুক্তি সম্পূর্ণ ভিন্ন দুটো অ্যালগরিদমিক কৌশলকেই (এজ-ভিত্তিক ক্রুসকাল, ট্রি-ভিত্তিক প্রিম) একসাথে ন্যায্যতা দিতে পারে। এটাই M5-এর শেষ পাঠ — M6 থেকে আমরা এমন সমস্যায় যাব যেখানে এই ধরনের একক-চয়েস প্রমাণ সম্ভব নয়, এবং তখন ডাইনামিক প্রোগ্রামিং দরকার হয়।

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

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

প্র ০১ ব্রুট-ফোর্স ফাংশনে "$n-1$ এজ এবং সংযুক্ত হলেই স্প্যানিং ট্রি" — এই শর্টকাটটি নেওয়া হয়েছে, আলাদাভাবে চক্র (cycle) চেক না করে। এটি কি নিরাপদ?

হ্যাঁ, এটি একটি প্রতিষ্ঠিত গ্রাফ থিওরি ফলাফল: $n$ ভার্টেক্সের একটি গ্রাফে যদি ঠিক $n-1$টি এজ থাকে এবং গ্রাফটি সংযুক্ত হয়, তাহলে তাতে কোনো চক্র থাকতেই পারে না। (স্বজ্ঞাগতভাবে: একটি চক্র থাকলে অন্তত একটি এজ "অপ্রয়োজনীয়" হয়ে যায় — সেটি সরালেও সংযুক্ততা বজায় থাকত, যার মানে $n-1$টি এজেই যথেষ্ট সংযুক্ত রাখার জন্য ন্যূনতম প্রয়োজনীয় সংখ্যা, তার বেশি বা কম নয় বলে চক্র সম্ভব নয়।) তাই আলাদা চক্র-চেক না করেও এই দুটো শর্ত (এজ সংখ্যা + সংযুক্ততা) একত্রে একটি স্প্যানিং ট্রি নিশ্চিত করার জন্য পর্যাপ্ত।

প্র ০২ যদি একটি গ্রাফে একাধিক ভিন্ন MST থাকে (একই সর্বনিম্ন ওজনের একাধিক স্প্যানিং ট্রি), তাহলে ক্রুসকাল ও প্রিম কি একই নির্দিষ্ট ট্রি খুঁজে পাবে?

প্রয়োজন নেই — তারা একই মোট ওজন খুঁজে পাবে (এটাই কোড সেলে যাচাই করা হয়েছে), কিন্তু নির্দিষ্ট কোন এজগুলো বেছে নেওয়া হলো তা ভিন্ন হতে পারে, বিশেষ করে যখন একাধিক এজের ওজন সমান হয় (টাই)। কাট প্রপার্টি নিশ্চিত করে সর্বনিম্ন ওজন অর্জনযোগ্য, কিন্তু যখন একাধিক এজ একই কাটে সমান-ন্যূনতম ওজনের হয়, তখন যেকোনো একটি বেছে নেওয়াই বৈধ — উভয় অ্যালগরিদম ভিন্ন এজ বেছে নিলেও চূড়ান্ত মোট ওজন সমান থাকবে।

প্র ০৩ কোড সেলে ইউনিয়ন-ফাইন্ডে পাথ-কম্প্রেশন ব্যবহার করা হয়নি। এটি কি ক্রুসকালের ফলাফলের সঠিকতাকে প্রভাবিত করে?

না, একদমই না। পাথ-কম্প্রেশন ও ইউনিয়ন-বাই-র‍্যাংক শুধুই find অপারেশনের গতি (টাইম কমপ্লেক্সিটি) উন্নত করে — এই অপ্টিমাইজেশন ছাড়া find ধীরে চলতে পারে (ওয়ার্স্ট কেসে $O(n)$ প্রতি কল), কিন্তু তবুও সঠিক উত্তরই দেয় (কোন কম্পোনেন্টে কোন ভার্টেক্স আছে তা সঠিকভাবে ট্র্যাক করে)। তাই ফলাফলের সঠিকতা অক্ষুণ্ণ থাকে, শুধু বড় গ্রাফে গতি কম হতে পারে — সেই পারফরম্যান্স গল্পটাই M10/L46-এ পূর্ণাঙ্গভাবে (অ্যামর্টাইজড অ্যানালাইসিসসহ) আসবে।

অনুশীলন

  1. চিন্তা করুন: উপরের example_edges গ্রাফে হাতে-কলমে ক্রুসকাল প্রয়োগ করুন (এজগুলো ওজন অনুযায়ী সাজিয়ে, চক্র এড়িয়ে) — মোট MST ওজন কত হবে বলে মনে হয়?

    ওজন অনুযায়ী সাজালে: (0,2,1), (1,2,2), (3,4,2), (0,1,4), (1,3,5), (2,3,8), (2,4,10)। প্রথমে (0,2,1) নেওয়া হয়। এরপর (1,2,2) নেওয়া হয় (ভিন্ন কম্পোনেন্ট)। এরপর (3,4,2) নেওয়া হয় (ভিন্ন কম্পোনেন্ট)। এরপর (0,1,4) — 0 ও 1 এখন একই কম্পোনেন্টে ({0,1,2}), তাই বাদ (চক্র হতো)। এরপর (1,3,5) — {0,1,2} ও {3,4} যুক্ত করে, নেওয়া হয়। এখন ৪টি এজ হয়ে গেছে ($n-1=4$), থামা। মোট ওজন: $1+2+2+5=10$।

  2. পরীক্ষা করুন: কোড সেল রান করে kruskal(example_n, example_edges), prim(...), এবং brute_force_mst(...) — তিনটিই আপনার হাতে-কলমের হিসাব অনুযায়ী 10 দিচ্ছে কি না যাচাই করুন।

    রান করলে তিনটি ফাংশনই 10 রিটার্ন করবে — হাতে-কলমের হিসাবের সাথে হুবহু মিলে যায়। এটি নিশ্চিত করে যে ইউনিয়ন-ফাইন্ড-ভিত্তিক ক্রুসকাল, heapq-ভিত্তিক প্রিম, এবং সম্পূর্ণ স্বতন্ত্র ব্রুট-ফোর্স — তিনটি সম্পূর্ণ ভিন্ন পদ্ধতি — একই সঠিক উত্তরে পৌঁছাচ্ছে।

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

আগের পাঠ
ফ্র্যাকশনাল ন্যাপস্যাক