পাঠ ৩৭ · ৫৭-এর মধ্যে · মডিউল ৮
Home / Courses / Design and Analysis of Algorithms / সাবসেট সাম ও হ্যামিল্টোনিয়ান সাইকেল

সাবসেট সাম ও হ্যামিল্টোনিয়ান সাইকেল ব্যাকট্র্যাকিং

Subset sum & Hamiltonian cycle backtracking
১৩ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • সাবসেট সামকে একটি include/exclude বাইনারি ডিসিশন ট্রি হিসেবে মডেল করা এবং $2^N$ নেইভ স্পেস চেনা
  • দুই ধরনের প্রুনিং শর্ত — "already infeasible" ও "cannot possibly reach" — এবং কেন দুটোই দরকার
  • হ্যামিল্টোনিয়ান সাইকেল সমস্যার ব্যাকট্র্যাকিং সমাধান, এবং এটি সাইকেল না থাকা অবস্থায়ও সঠিকভাবে "নেই" প্রমাণ করার জন্য পুরো (প্রুনড) স্পেস এক্সপ্লোর করে তা বোঝা
  • প্রতিটি ক্ষেত্রেই একটি নোড কাউন্টার ব্যবহার করে প্রুনিং-এর প্রকৃত প্রভাব সংখ্যায় দেখা

১ · সাবসেট সাম — include/exclude ট্রি ও $2^N$ নেইভ স্পেস

সাবসেট সাম সমস্যা: একটি সংখ্যার সেট $\{a_1, \ldots, a_N\}$ এবং একটি টার্গেট $T$ দেওয়া থাকলে, এমন একটি সাবসেট খুঁজে বের করো যার যোগফল ঠিক $T$। এই সমস্যার মেকানিক্স ইতিমধ্যে DSA কোর্সে DP-দৃষ্টিকোণ থেকে দেখানো হয়েছে — এখানে আমরা এটিকে ব্যাকট্র্যাকিং-এর দৃষ্টিকোণ থেকে দেখব, যেখানে প্রতিটি এলিমেন্টের জন্য দুটি চয়েস আছে: নাও অথবা বাদ দাও।

কোনো প্রুনিং ছাড়া, এই সিদ্ধান্ত-ট্রি-র গভীরতা $N$ এবং প্রতিটি নোডে ২টি শাখা — তাই সম্পূর্ণ ট্রি-র লিফ সংখ্যা:

$$\text{নেইভ স্পেস} = 2^N$$

$N=12$-এ এটি $4096$ — এখনো ছোট, কিন্তু $N=40$-এ এটি প্রায় ১ ট্রিলিয়ন। প্রুনিং ছাড়া এই স্পেস দ্রুতই ব্যবহারিকভাবে অসম্ভব হয়ে যায়।

২ · দুই ধরনের প্রুনিং শর্ত

প্রুন ১ — ইতিমধ্যে অসম্ভব
যদি এখন পর্যন্ত বাছাই করা এলিমেন্টগুলোর যোগফল ইতিমধ্যে $T$ পার হয়ে গেছে, তাহলে আর কোনো (অ-ঋণাত্মক) এলিমেন্ট যোগ করলে যোগফল আরও বাড়বে — কখনোই আর $T$-তে ফিরে আসা সম্ভব নয়।
প্রুন ২ — কখনোই পৌঁছানো সম্ভব নয়
যদি এখনো বাকি থাকা সবগুলো এলিমেন্ট নিলেও যোগফল $T$-তে পৌঁছাতে না পারে (আংশিক যোগফল + বাকি সবকিছুর যোগফল $< T$), তাহলে এই শাখায় সমাধান খোঁজার কোনো মানে নেই।

প্রুন ২ কাজে লাগাতে প্রতিটি ইনডেক্স $i$-এর জন্য "সাফিক্স সাম" — $a_i + a_{i+1} + \cdots + a_{N-1}$ — আগে থেকেই হিসেব করে রাখা হয়, যাতে প্রতিটি নোডে এই চেক $O(1)$ সময়ে করা যায়।

Python
import itertools
import random

def subset_sum_backtrack(nums, target):
    n = len(nums)
    node_count = 0
    found = {"subset": None}

    # suffix_sum[i] = nums[i] + nums[i+1] + ... + nums[n-1]
    # -- অর্থাৎ ইনডেক্স i থেকে শুরু করে বাকি সবগুলো এলিমেন্ট নিলে সর্বোচ্চ কত যোগ করা সম্ভব
    suffix_sum = [0] * (n + 1)
    for i in range(n - 1, -1, -1):
        suffix_sum[i] = suffix_sum[i + 1] + nums[i]

    chosen = []

    def backtrack(i, current_sum):
        nonlocal node_count
        node_count += 1
        if found["subset"] is not None:
            return
        if current_sum == target:
            found["subset"] = chosen.copy()
            return
        if i == n:
            return
        if current_sum > target:
            return  # প্রুন ১: আংশিক যোগফলই টার্গেট পার হয়ে গেছে
        if current_sum + suffix_sum[i] < target:
            return  # প্রুন ২: বাকি সবগুলো নিলেও টার্গেটে পৌঁছানো অসম্ভব
        chosen.append(nums[i])
        backtrack(i + 1, current_sum + nums[i])   # nums[i] নেওয়া
        chosen.pop()
        if found["subset"] is not None:
            return
        backtrack(i + 1, current_sum)              # nums[i] বাদ দেওয়া

    backtrack(0, 0)
    return found["subset"], node_count


def brute_force_subset_exists(nums, target):
    n = len(nums)
    for r in range(n + 1):
        for combo in itertools.combinations(nums, r):
            if sum(combo) == target:
                return True
    return False


nums = [3, 34, 4, 12, 5, 2, 9, 7, 15, 21, 1, 19]
n = len(nums)
print(f"n={n}, নেইভ স্পেস 2^n = {2**n}\n")

targets = [9, 24, 37, 100, 61]
for t in targets:
    subset, nodes = subset_sum_backtrack(nums, t)
    exists_bf = brute_force_subset_exists(nums, t)
    ok = (subset is not None) == exists_bf
    if subset is not None:
        assert sum(subset) == t
    print(f"target={t:>4} | পাওয়া গেল={str(subset):40} | নোড={nodes:>4} | ব্রুট-ফোর্সের সাথে মিলল={ok}")

# ফিক্সড সিড দিয়ে একাধিক র‍্যান্ডম ইনস্ট্যান্সে ব্রুট-ফোর্সের বিপরীতে ক্রস-চেক
random.seed(7)
print("\nর‍্যান্ডম ইনস্ট্যান্সে ব্রুট-ফোর্সের সাথে যাচাই:")
all_match = True
for trial in range(8):
    size = random.randint(4, 12)
    arr = [random.randint(1, 30) for _ in range(size)]
    tgt = random.randint(1, sum(arr))
    subset, nodes = subset_sum_backtrack(arr, tgt)
    exists_bf = brute_force_subset_exists(arr, tgt)
    ok = (subset is not None) == exists_bf
    if subset is not None:
        ok = ok and sum(subset) == tgt
    all_match = all_match and ok
    print(f"  trial {trial}: n={size:>2} target={tgt:>4} নোড={nodes:>4} (2^n={2**size:>5}) মিলল={ok}")

print("\nসবগুলো ট্রায়ালে ব্যাকট্র্যাকিং ব্রুট-ফোর্সের সাথে একমত:", all_match)

    
$n=12$-এর সেটে নেইভ স্পেস $2^{12} = 4096$। কিন্তু target=37-এর জন্য মাত্র $3$টি নোড এক্সপ্লোর করেই সমাধান পাওয়া যায় (প্রথম দুটি বড় এলিমেন্ট $\{3, 34\}$ দ্রুতই যোগফল দেয়), আর সবচেয়ে "কঠিন" কেস target=100-তেও মাত্র $83$টি নোড লাগে — নেইভ স্পেসের মাত্র প্রায় $2\%$। প্রতিটি র‍্যান্ডম ট্রায়ালেও ব্যাকট্র্যাকিং-এর ফলাফল ব্রুট-ফোর্সের সাথে হুবহু মিলছে — অর্থাৎ প্রুনিং কোনো সঠিক উত্তর বাদ দিচ্ছে না, শুধু অপ্রয়োজনীয় কাজ এড়াচ্ছে।

৩ · হ্যামিল্টোনিয়ান সাইকেল — পাথ তৈরি ও ডেড-এন্ডে ব্যাকট্র্যাক

একটি হ্যামিল্টোনিয়ান সাইকেলHamiltonian Cycleএকটি গ্রাফের এমন একটি সাইকেল যা প্রতিটি ভার্টেক্স ঠিক একবার ভিজিট করে এবং শেষে শুরুর ভার্টেক্সে ফিরে আসে। খোঁজার ব্যাকট্র্যাকিং কৌশল সহজ: একটি শহর থেকে শুরু করে, প্রতিবার একটি নতুন (এখনো ভিজিট না করা) প্রতিবেশী শহরে যাওয়ার চেষ্টা করা হয়। সব শহর ভিজিট হয়ে গেলে দেখা হয় শেষ শহর থেকে শুরুর শহরে সরাসরি এজ আছে কি না — থাকলে সাইকেল সম্পূর্ণ। কোনো শহরে সব প্রতিবেশী হয় ভিজিট করা অথবা এজ নেই — এমন ডেড-এন্ডে পৌঁছালে ব্যাকট্র্যাক করে আগের শহরে ফিরে অন্য প্রতিবেশী চেষ্টা করা হয়।

এই সমস্যার গুরুত্বপূর্ণ দিক হলো — একটি গ্রাফে হ্যামিল্টোনিয়ান সাইকেল না থাকতেও পারে। সেক্ষেত্রে অ্যালগরিদমকে পুরো (প্রুনড) সার্চ স্পেস এক্সপ্লোর করেই নিশ্চিতভাবে "না" সিদ্ধান্তে পৌঁছাতে হয় — নিচের কোডে দুটি ছোট গ্রাফে এই দুই ধরনের ফলাফলই (আছে / নেই) প্রকৃতপক্ষে যাচাই করা হয়েছে।

Python
def hamiltonian_cycle(graph, n):
    node_count = 0
    path = [0]
    visited = [False] * n
    visited[0] = True
    result = {"cycle": None}

    def backtrack():
        nonlocal node_count
        node_count += 1
        if result["cycle"] is not None:
            return
        if len(path) == n:
            if graph[path[-1]][path[0]] == 1:   # শেষ শহর থেকে শুরুতে ফেরার এজ আছে কি
                result["cycle"] = path.copy() + [path[0]]
            return
        last = path[-1]
        for nxt in range(n):
            if not visited[nxt] and graph[last][nxt] == 1:
                visited[nxt] = True
                path.append(nxt)
                backtrack()
                path.pop()
                visited[nxt] = False
                if result["cycle"] is not None:
                    return

    backtrack()
    return result["cycle"], node_count


def edges_to_matrix(n, edges):
    g = [[0] * n for _ in range(n)]
    for u, v in edges:
        g[u][v] = 1
        g[v][u] = 1
    return g


def validate_cycle(graph, n, cycle):
    if cycle is None:
        return False
    if len(cycle) != n + 1 or cycle[0] != cycle[-1]:
        return False
    if sorted(cycle[:-1]) != list(range(n)):
        return False
    return all(graph[cycle[i]][cycle[i + 1]] == 1 for i in range(len(cycle) - 1))


n = 5
# গ্রাফ ১: একটি 5-সাইকেল 0-1-2-3-4-0, প্লাস একটি অতিরিক্ত কর্ড 0-2 -- হ্যামিল্টোনিয়ান সাইকেল অবশ্যই আছে
edges_with_cycle = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2)]
graph_with = edges_to_matrix(n, edges_with_cycle)

# গ্রাফ ২: ভার্টেক্স 4-এর ডিগ্রি মাত্র ১ (শুধু 0-এর সাথে যুক্ত)
# -- ডিগ্রি-১ ভার্টেক্স কখনো কোনো সাইকেলে থাকতে পারে না (সাইকেলে ঢোকা ও বেরোনোর জন্য কমপক্ষে ২টি এজ লাগে)
edges_without_cycle = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 4)]
graph_without = edges_to_matrix(n, edges_without_cycle)

cycle1, nodes1 = hamiltonian_cycle(graph_with, n)
cycle2, nodes2 = hamiltonian_cycle(graph_without, n)

print("গ্রাফ ১ (সাইকেল থাকার কথা):", cycle1, "| নোড এক্সপ্লোরড:", nodes1)
print("  বৈধ কিনা:", validate_cycle(graph_with, n, cycle1))

print("\nগ্রাফ ২ (সাইকেল না থাকার কথা):", cycle2, "| নোড এক্সপ্লোরড:", nodes2)
print("  সঠিকভাবে 'নেই' রিপোর্ট করল:", cycle2 is None)

    
গ্রাফ ১-এ অ্যালগরিদম মাত্র $5$টি নোড এক্সপ্লোর করেই সাইকেল [0, 1, 2, 3, 4, 0] খুঁজে পায় (প্রথম চেষ্টাতেই সঠিক পথ পাওয়া গেছে বলে ব্যাকট্র্যাক করারও দরকার হয়নি)। গ্রাফ ২-এ অ্যালগরিদম $8$টি নোড এক্সপ্লোর করে প্রতিটি সম্ভাব্য পাথ ডেড-এন্ডে পৌঁছানোর পর নিশ্চিতভাবে None রিটার্ন করে — এটাই দেখায় "সমাধান নেই" প্রমাণ করতেও ব্যাকট্র্যাকিং-এর পুরো (প্রুনড) স্পেস দেখতে হয়, কিন্তু তা এখনো ব্রুট-ফোর্সের চেয়ে অনেক ছোট (এই গ্রাফে সম্পূর্ণ পারমুটেশন স্পেস $(5-1)! = 24$, অথচ মাত্র $8$টি নোড লেগেছে)।
মূল কথা · Key takeaway

দুটি সমস্যাই দেখাচ্ছে একই প্যাটার্ন: প্রতিটি নোডে একটি সস্তা (O(1) বা O(N)) ফিজিবিলিটি চেক করে পুরো একটি সাবট্রি একসাথে বাদ দেওয়া যায়। সাবসেট সামে এই চেক ছিল "যোগফল সম্ভব কি না", হ্যামিল্টোনিয়ান সাইকেলে "এই শহরে যাওয়ার এজ ও ভিজিট-স্ট্যাটাস ঠিক আছে কি না"। L38-এ আমরা দেখব একটি ভিন্ন ধরনের প্রুনিং — যেখানে শুধু ফিজিবিলিটি নয়, বরং "এই শাখা কি বর্তমান সেরা সমাধানের চেয়ে ভালো হতে পারে" এই প্রশ্নের ভিত্তিতেও প্রুন করা হয়।

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

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

প্র ০১ সাবসেট সামের প্রুন ১ (আংশিক যোগফল ইতিমধ্যে টার্গেট পার) শুধুমাত্র সব সংখ্যা অ-ঋণাত্মক (non-negative) হলেই বৈধ কেন?

প্রুন ১-এর যুক্তি হলো "আরও এলিমেন্ট যোগ করলে যোগফল শুধু বাড়বে, কখনো কমবে না — তাই একবার টার্গেট পার হয়ে গেলে আর ফিরে আসার উপায় নেই।" এই যুক্তি তখনই সত্যি যখন প্রতিটি এলিমেন্ট $\ge 0$। যদি ঋণাত্মক সংখ্যাও সেটে থাকতে পারত, তাহলে আংশিক যোগফল টার্গেট পার হয়ে যাওয়ার পরও পরবর্তী কোনো ঋণাত্মক এলিমেন্ট যোগ করলে যোগফল আবার টার্গেটে ফিরে আসতে পারত — তখন এই প্রুনিং শর্তটি ভুলভাবে বৈধ সমাধান বাদ দিয়ে দিত।

প্র ০২ হ্যামিল্টোনিয়ান সাইকেল কোডে visited অ্যারে ছাড়া শুধু graph[last][nxt] == 1 চেক করলে কী সমস্যা হতো?

visited ছাড়া অ্যালগরিদম একই শহর বারবার ভিজিট করতে পারত (যদি দুটি শহরের মধ্যে এজ থাকে, সেই দুটির মধ্যে অসীমবার আসা-যাওয়া সম্ভব) — এটি কখনো থামত না এবং কখনো একটি বৈধ হ্যামিল্টোনিয়ান সাইকেলও তৈরি করত না, কারণ সাইকেলের সংজ্ঞাই বলছে প্রতিটি ভার্টেক্স ঠিক একবার ভিজিট হতে হবে। visited অ্যারেটিই এখানে মূল ফিজিবিলিটি-প্রুনিং শর্ত বাস্তবায়ন করছে।

প্র ০৩ গ্রাফ ২-তে ভার্টেক্স $4$-এর ডিগ্রি মাত্র ১। এটা দিয়ে কি আগে থেকেই (ব্যাকট্র্যাকিং ছাড়াই) বলা সম্ভব ছিল যে হ্যামিল্টোনিয়ান সাইকেল নেই?

হ্যাঁ — এটি একটি সহজ, দ্রুত "নেসেসারি কন্ডিশন" চেক: একটি সাইকেলে প্রতিটি ভার্টেক্সের ঠিক দুটি প্রতিবেশী লাগে (একটি দিয়ে ঢোকা, একটি দিয়ে বেরোনো) — তাই ডিগ্রি $< 2$ এমন যেকোনো ভার্টেক্স থাকলে সরাসরি "না" বলে দেওয়া যায়, কোনো সার্চ ছাড়াই। এই লেসনের কোড ইচ্ছাকৃতভাবে সাধারণ ব্যাকট্র্যাকিং দেখানোর জন্য এই শর্টকাটটি ব্যবহার করেনি — বাস্তব ব্যবহারে এই ধরনের "প্রি-চেক" যোগ করলে আরও নোড বাঁচানো সম্ভব।

অনুশীলন

  1. চিন্তা করুন: সাবসেট সাম কোডে যদি প্রুন ২ (সাফিক্স সাম চেক) সরিয়ে ফেলা হতো কিন্তু প্রুন ১ রাখা হতো, তাহলে target-এর মান খুব বড় (সব এলিমেন্টের যোগফলের চেয়েও বেশি) হলে নোড সংখ্যা কেমন হতো?

    প্রুন ১ কখনোই ট্রিগার হতো না (যোগফল কখনোই টার্গেট পার হতে পারবে না, যেহেতু টার্গেট সব এলিমেন্টের যোগফলের চেয়েও বড়) — অ্যালগরিদমকে পুরো $2^N$ ট্রি সম্পূর্ণভাবে এক্সপ্লোর করতে হতো একটি নেতিবাচক উত্তরে পৌঁছাতে। এটাই দেখায় প্রুন ২ ("কখনোই পৌঁছানো সম্ভব নয়") ঠিক এই ধরনের কেসের জন্য প্রয়োজনীয় — দুটো প্রুনিং শর্ত ভিন্ন ধরনের অসম্ভবতা ধরে।

  2. পরীক্ষা করুন: সাবসেট সাম কোডে targets লিস্টে এমন একটি মান যোগ করুন যা sum(nums)-এর চেয়ে বেশি (যেমন 200) এবং Run চেপে দেখুন নোড সংখ্যা কত আসে এবং তা $2^{12}=4096$-এর কতটা কাছাকাছি।

    nums-এর যোগফল $132$, তাই target=200 কখনোই সম্ভব নয়। কোডটি এক্ষেত্রে প্রুন ২-এর কারণে দ্রুতই (সম্পূর্ণ $4096$-এর অনেক কম নোডে) "নেই" (None) বলে দেবে, কারণ প্রতিটি সাফিক্স-সাম চেকই তাৎক্ষণিকভাবে দেখিয়ে দেবে বাকি এলিমেন্ট দিয়েও $200$-তে পৌঁছানো অসম্ভব।

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

আগের পাঠ
ব্যাকট্র্যাকিং প্যারাডাইম ও N-Queens