ট্র্যাভেলিং সেলসম্যান — ব্রাঞ্চ-অ্যান্ড-বাউন্ড
এই পাঠে যা শিখবেন
- TSP-তে একটি জেনুইনভাবে বৈধ (অ্যাডমিসিবল) লোয়ার বাউন্ড ফর্মুলেট করা এবং কেন এটি বৈধ তার সম্পূর্ণ যুক্তি
- ব্রাঞ্চ-অ্যান্ড-বাউন্ড দিয়ে TSP সমাধান, এবং ব্রুট-ফোর্স পারমুটেশন সার্চের সাথে একাধিক ইনস্ট্যান্সে কঠোর মিল-যাচাই
- একটি ভুল (non-admissible) বাউন্ড ঠিক কীভাবে নিঃশব্দে প্রকৃত অপটিমাম হারিয়ে ফেলতে পারে, এবং কেন আমাদের বাউন্ডটি সেই ঝুঁকি এড়ায়
- প্রুনিং-এর কার্যকারিতা সততার সাথে পরিমাপ করা — "নোড" ও "$(N-1)!/2$টি স্বতন্ত্র ট্যুর" দুটো ভিন্ন জিনিস, এবং কেন সরাসরি তুলনা করলে বিভ্রান্তিকর হতে পারে
১ · সমস্যা ও লোয়ার বাউন্ড ফর্মুলা
ট্র্যাভেলিং সেলসম্যান সমস্যার মেকানিক্স ও এর NP-hardness ইতিমধ্যে DSA কোর্সে এবং এই কোর্সের L52 (মেট্রিক TSP অ্যাপ্রক্সিমেশন)-এ প্রসঙ্গক্রমে উল্লেখ করা হয়েছে — এখানে আমরা ছোট ইনস্ট্যান্সে ($n \le 9$) এক্স্যাক্ট সমাধান খুঁজব ব্রাঞ্চ-অ্যান্ড-বাউন্ড দিয়ে, L38-এর সাধারণ কাঠামো প্রয়োগ করে।
একটি আংশিক ট্যুর $\pi$ (শহর $0$ থেকে শুরু করে কিছু শহর ইতিমধ্যে ভিজিট করা হয়েছে) থাকলে, বাকি ট্যুর সম্পূর্ণ করতে ন্যূনতম কত খরচ লাগবে তার একটি আশাবাদী অনুমান দরকার। আমরা ব্যবহার করছি:
$$B(\pi) = \text{cost}(\pi) + \sum_{c \,\in\, U} \min_{d \ne c} \text{dist}(c, d)$$যেখানে $U$ হলো এখনো-না-ভিজিট-করা শহরগুলোর সেট, এবং $\min_{d \ne c} \text{dist}(c, d)$ মানে শহর $c$-এর সবচেয়ে সস্তা আউটগোয়িং এজ (যেকোনো অন্য শহরের দিকে, ভিজিটেড হোক বা না হোক)।
২ · বাউন্ডটি কেন বৈধ (অ্যাডমিসিবল) — ধাপে ধাপে যুক্তি
একটি ব্রাঞ্চ-অ্যান্ড-বাউন্ড অ্যালগরিদমের সবচেয়ে গুরুত্বপূর্ণ ও সবচেয়ে সহজে ভুল হওয়ার মতো অংশ এটাই — বাউন্ড যদি কখনো প্রকৃত ন্যূনতম সম্পূর্ণ-করার-খরচের চেয়ে বেশি অনুমান করে ফেলে, তাহলে $B(\pi) \ge \text{best\_found}$ শর্তে প্রুন করা একটি বৈধ (এমনকি প্রকৃত অপটিমাল) শাখাকেও ভুলভাবে বাদ দিয়ে দিতে পারে। তাই আমরা সতর্কভাবে যাচাই করছি:
- একটি সম্পূর্ণ ট্যুরে, প্রতিটি এখনো-না-ভিজিট-করা শহর $c \in U$-এর ঠিক একটি আউটগোয়িং এজ থাকবে (ট্যুরের পরবর্তী শহরের দিকে, অথবা যদি $c$ সবার শেষে ভিজিট হয় তাহলে শুরুর শহরে ফেরার এজ)।
- এই আউটগোয়িং এজের খরচ কখনোই $c$-এর সবচেয়ে সস্তা সম্ভাব্য আউটগোয়িং এজের চেয়ে কম হতে পারে না — সংজ্ঞানুসারেই এটি সর্বনিম্ন। তাই প্রতিটি $c \in U$-এর জন্য প্রকৃত খরচ $\ge \min_{d \ne c} \text{dist}(c, d)$।
- এই ন্যূনতম মানগুলো $U$-এর সব শহরের উপর যোগ করলে, তা বাকি ট্যুরের প্রকৃত মোট খরচের একটি লোয়ার বাউন্ড দেয় — এমনকি যদি দুটি ভিন্ন শহরের "ন্যূনতম এজ" একই বাস্তব এজকে নির্দেশ করে (যা সম্ভব), তাতেও সমস্যা নেই কারণ আমরা শুধু ন্যূনতম দাবি করছি, প্রকৃত এজ চিহ্নিত করছি না — যোগফলটি এখনো একটি বৈধ লোয়ার বাউন্ড থাকে।
- লক্ষ্য করুন, এই যোগফলে বর্তমান শহর (যেখান থেকে পরবর্তী পদক্ষেপ নেওয়া হবে) থেকে পরের শহরে যাওয়ার এজটি অন্তর্ভুক্ত করা হয়নি — কারণ বর্তমান শহর $U$-এর সদস্য নয়। এতে বাউন্ডটি সামান্য ঢিলা (কম টাইট) হয়, কিন্তু এটি বাউন্ডকে আরও নিরাপদ করে তোলে — বাউন্ড যত কম গণনা করবে প্রকৃত খরচের চেয়ে, ভুলভাবে প্রুন করার ঝুঁকি তত কম।
সংক্ষেপে: $B(\pi)$ সবসময় বাকি ট্যুরের প্রকৃত ন্যূনতম খরচের সমান বা কম — কখনো বেশি নয়। তাই
$B(\pi) \ge \text{best\_found}$ হলে প্রুন করাটা সম্পূর্ণ নিরাপদ: এই শাখা থেকে সম্পূর্ণ যেকোনো ট্যুরের প্রকৃত
খরচ অবশ্যই $B(\pi)$-এর সমান বা বেশি হবে, যা ইতিমধ্যেই best_found-এর সমান বা বেশি — অর্থাৎ এই
শাখা থেকে কখনো best_found-এর চেয়ে ভালো কিছু আসতে পারবে না।
৩ · ইমপ্লিমেন্টেশন ও ব্রুট-ফোর্সের বিপরীতে কঠোর যাচাই
নিচের কোডে ব্রাঞ্চ-অ্যান্ড-বাউন্ড সলভার এবং একটি সম্পূর্ণ ব্রুট-ফোর্স পারমুটেশন সলভার — দুটোই ইমপ্লিমেন্ট করা হয়েছে, এবং $n = 6, 7, 8, 9$-এর প্রতিটিতে ৩টি করে ভিন্ন (ফিক্সড সিড, রিপ্রোডিউসিবল) এলোমেলো ইউক্লিডিয়ান ইনস্ট্যান্সে দুটোর ফলাফল তুলনা করা হয়েছে।
import itertools
import math
import random
def make_euclidean_instance(n, seed):
# ফিক্সড সিড দিয়ে n টি এলোমেলো 2D পয়েন্ট, সিমেট্রিক ইউক্লিডিয়ান দূরত্ব ম্যাট্রিক্স
rng = random.Random(seed)
pts = [(rng.uniform(0, 100), rng.uniform(0, 100)) for _ in range(n)]
dist = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j:
dist[i][j] = math.dist(pts[i], pts[j])
return dist
def tsp_brute_force(dist, n):
# সব (n-1)! পারমুটেশন চেষ্টা করে প্রকৃত অপটিমাম -- শুধু বেসলাইন, ছোট n-এর জন্য
best_cost = float('inf')
best_tour = None
for perm in itertools.permutations(range(1, n)):
tour = [0] + list(perm)
cost = sum(dist[tour[i]][tour[(i + 1) % n]] for i in range(n))
if cost < best_cost:
best_cost = cost
best_tour = tour
return best_tour, best_cost
def tsp_branch_and_bound(dist, n, use_bound=True):
best_cost = [float('inf')]
best_tour = [None]
nodes_explored = [0]
# প্রতিটি শহরের সবচেয়ে সস্তা আউটগোয়িং এজ -- বাউন্ড ফর্মুলার মূল উপাদান
min_out = [min(dist[c][j] for j in range(n) if j != c) for c in range(n)]
visited = [False] * n
visited[0] = True
path = [0]
def backtrack(current, current_cost):
nodes_explored[0] += 1
if len(path) == n:
total = current_cost + dist[current][0] # শুরুর শহরে ফিরে আসার এজ
if total < best_cost[0]:
best_cost[0] = total
best_tour[0] = path.copy()
return
if use_bound:
unvisited = [c for c in range(n) if not visited[c]]
bound = current_cost + sum(min_out[c] for c in unvisited)
if bound >= best_cost[0]:
return # B(pi) >= best_found -- এই শাখায় উন্নতির সম্ভাবনা নেই, নিরাপদে প্রুন
for nxt in range(n):
if not visited[nxt]:
visited[nxt] = True
path.append(nxt)
backtrack(nxt, current_cost + dist[current][nxt])
path.pop()
visited[nxt] = False
backtrack(0, 0)
return best_tour[0], best_cost[0], nodes_explored[0]
print("n=6,7,8,9 -- প্রতিটিতে ৩টি ভিন্ন রান্ডম ইনস্ট্যান্স -- মোট ১২টি তুলনা:\n")
all_match = True
for n in [6, 7, 8, 9]:
for seed in [1, 2, 3]:
dist = make_euclidean_instance(n, seed)
_, bf_cost = tsp_brute_force(dist, n)
_, bb_cost, nodes = tsp_branch_and_bound(dist, n)
match = abs(bf_cost - bb_cost) < 1e-9
all_match = all_match and match
print(f" n={n} seed={seed}: brute-force={bf_cost:8.3f} branch&bound={bb_cost:8.3f} মিলল={match} নোড={nodes}")
print("\nসবগুলো ইনস্ট্যান্সে ব্রাঞ্চ-অ্যান্ড-বাউন্ড ব্রুট-ফোর্সের সাথে একমত:", all_match)
all_match = True)
— এটাই ধারা ২-এর প্রমাণের ব্যবহারিক নিশ্চয়তা। মনে রাখবেন এটি শুধু "কিছু উদাহরণে কাজ করেছে" নয় — ধারা
২-এর গাণিতিক প্রমাণটাই আসল নিশ্চয়তা দেয় যে এই মিল সবসময় হবে; এই যাচাইটি সেই প্রমাণকে ব্যবহারিকভাবে
সমর্থন করছে মাত্র।
৪ · প্রুনিং কতটা কাজ বাঁচায় — একটি সৎ পরিমাপ
এখন আসল প্রশ্ন: বাউন্ড-প্রুনিং কতটা কাজ বাঁচাল? এখানে একটি সূক্ষ্ম বিষয় স্পষ্ট করা দরকার। ব্রুট-ফোর্স
$(n-1)!$টি পারমুটেশন চেষ্টা করে (দিক উল্টানো ট্যুরকে আলাদা গণনা করে), তাই সিমেট্রিক TSP-তে স্বতন্ত্র
ট্যুরের সংখ্যা তার অর্ধেক, $(n-1)!/2$ — এটি একটি প্রচলিতভাবে উদ্ধৃত (commonly cited) সংখ্যা যা আমরা নিচে
সত্যিই গণনা করেছি। কিন্তু আমাদের nodes_explored কাউন্টার প্রতিটি রিকার্সিভ কল (অর্থাৎ সার্চ
ট্রি-র প্রতিটি লেভেলের প্রতিটি নোড, শুধু সম্পূর্ণ লিফ-ট্যুর নয়) গোনে — তাই ছোট $n$-এ এই নোড সংখ্যা
$(n-1)!/2$-এর চেয়ে বড়ও হতে পারে, যা বিভ্রান্তিকর মনে হতে পারে।
তাই একটি সঠিক, apples-to-apples তুলনার জন্য আমরা একই কোড দুইভাবে চালিয়েছি —
একবার বাউন্ড-প্রুনিং সহ (use_bound=True), একবার সম্পূর্ণ বন্ধ রেখে (use_bound=False,
যা কেবল ফিজিবিলিটি মেনে সব সম্ভাব্য ট্যুর এক্সপ্লোর করে) — এবং দুটোর নোড সংখ্যার অনুপাতই আসল, নির্ভরযোগ্য
প্রুনিং-এফেক্টিভনেস মেট্রিক।
import math
import random
def make_euclidean_instance(n, seed):
rng = random.Random(seed)
pts = [(rng.uniform(0, 100), rng.uniform(0, 100)) for _ in range(n)]
dist = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j:
dist[i][j] = math.dist(pts[i], pts[j])
return dist
def tsp_branch_and_bound(dist, n, use_bound=True):
# আগের কোড সেলের মতোই, শুধু use_bound=False দিলে বাউন্ড-চেকটি সম্পূর্ণ বন্ধ থাকে
# (তখন এটি শুধু ফিজিবিলিটি মেনে সব সম্ভাব্য ট্যুর এক্সপ্লোর করে -- প্লেইন ব্যাকট্র্যাকিং)
best_cost = [float('inf')]
nodes_explored = [0]
min_out = [min(dist[c][j] for j in range(n) if j != c) for c in range(n)]
visited = [False] * n
visited[0] = True
path = [0]
def backtrack(current, current_cost):
nodes_explored[0] += 1
if len(path) == n:
total = current_cost + dist[current][0]
if total < best_cost[0]:
best_cost[0] = total
return
if use_bound:
unvisited = [c for c in range(n) if not visited[c]]
bound = current_cost + sum(min_out[c] for c in unvisited)
if bound >= best_cost[0]:
return
for nxt in range(n):
if not visited[nxt]:
visited[nxt] = True
path.append(nxt)
backtrack(nxt, current_cost + dist[current][nxt])
path.pop()
visited[nxt] = False
backtrack(0, 0)
return best_cost[0], nodes_explored[0]
print(f"{'n':>3} | {'বাউন্ড-সহ নোড':>14} | {'বাউন্ড-বিহীন নোড':>17} | {'প্রুনিং রেশিও':>14} | {'(n-1)!/2':>10}")
for n in [6, 7, 8, 9]:
dist = make_euclidean_instance(n, 1)
cost_bound, nodes_bound = tsp_branch_and_bound(dist, n, use_bound=True)
cost_full, nodes_full = tsp_branch_and_bound(dist, n, use_bound=False)
sym_space = math.factorial(n - 1) // 2
ratio = nodes_bound / nodes_full
# বাউন্ড থাকা/না-থাকা উভয় ক্ষেত্রেই একই অপটিমাম আসা উচিত -- বাউন্ড শুধু গতি বাড়ায়, উত্তর বদলায় না
assert abs(cost_bound - cost_full) < 1e-9
print(f"{n:>3} | {nodes_bound:>14} | {nodes_full:>17} | {ratio:>13.2%} | {sym_space:>10}")
L36-L39 একসাথে ব্যাকট্র্যাকিং ও ব্রাঞ্চ-অ্যান্ড-বাউন্ড-এর সম্পূর্ণ চিত্র দেখিয়েছে: L36-L37-এ শুধু ফিজিবিলিটির ভিত্তিতে প্রুনিং (N-Queens, সাবসেট সাম, হ্যামিল্টোনিয়ান সাইকেল), L38-এ একটি বাউন্ডের সাথে তুলনা করে অতিরিক্ত প্রুনিং যোগ করা (0/1 ন্যাপস্যাক), এবং এই লেসনে সেই বাউন্ডিং কৌশল একটি ক্লাসিক NP-hard অপটিমাইজেশন সমস্যায় (TSP) প্রয়োগ করে দেখানো হলো — প্রতিবারই একটি নোড কাউন্টার দিয়ে প্রুনিং-এর প্রকৃত প্রভাব সংখ্যায় প্রমাণ করা হয়েছে, শুধু "অনেক দ্রুত" বলে ছেড়ে দেওয়া হয়নি। M11-এ আমরা ফিরে আসব এই প্রশ্নে: কেন TSP-র মতো সমস্যায় এক্স্যাক্ট অ্যালগরিদম বড় $n$-এ ব্যবহারিকভাবে অসম্ভব হয়ে যায় (NP-hardness), এবং M12-এ দেখব কীভাবে অ্যাপ্রক্সিমেশন (L52) এই সীমাবদ্ধতা মোকাবিলা করে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ বাউন্ড ফর্মুলায় বর্তমান শহর (যেখান থেকে পরের পদক্ষেপ নেওয়া হবে)-এর নিজের আউটগোয়িং এজ যোগ করা হয়নি — এটা কি বাউন্ডকে ভুল (invalid) করে তোলে, নাকি শুধু কম টাইট (looser)?
শুধু কম টাইট — ভুল নয়। বাউন্ডের একটি অংশ (বর্তমান শহরের আউটগোয়িং এজ) সম্পূর্ণ বাদ দেওয়া মানে বাউন্ডটি প্রকৃত খরচের চেয়ে আরও কম অনুমান করছে, যা এখনো একটি বৈধ লোয়ার বাউন্ডের শর্ত (বাউন্ড $\le$ প্রকৃত খরচ) পূরণ করে। একটি অ্যাডমিসিবল বাউন্ড কম-টাইট (looser) হলে অ্যালগরিদম কিছুটা কম প্রুন করে (কিছুটা ধীর), কিন্তু সঠিকতা কখনো নষ্ট হয় না। বিপরীতে, বাউন্ড যদি প্রকৃত খরচের চেয়ে বেশি অনুমান করত, তাহলেই কেবল সঠিকতা নষ্ট হতো।
প্র ০২ যদি বাউন্ড ফর্মুলায় প্রতিটি শহরের ন্যূনতম এজের বদলে "দ্বিতীয় সবচেয়ে সস্তা" এজ ব্যবহার করা হতো (একটি আরও "আক্রমণাত্মক" অনুমান), তাহলে কী সমস্যা হতে পারত?
এটি বাউন্ডকে নন-অ্যাডমিসিবল করে ফেলতে পারত। দ্বিতীয় সবচেয়ে সস্তা এজ ব্যবহার করলে অনুমানটি সবসময় ন্যূনতম এজের চেয়ে বড় বা সমান হবে — এবং কিছু ক্ষেত্রে এটি প্রকৃত প্রয়োজনীয় খরচের চেয়েও বেশি হয়ে যেতে পারে (যদি সেই শহরের প্রকৃত ব্যবহৃত এজটিই তার সবচেয়ে সস্তা এজ হয়)। তখন $B(\pi) \ge \text{best\_found}$ শর্তে প্রুন করলে এমন একটি শাখা বাদ পড়তে পারত যেখানে প্রকৃত অপটিমাল ট্যুর লুকিয়ে ছিল — একটি নিঃশব্দ, বিপজ্জনক সঠিকতার ভুল যা কোনো এরর মেসেজ ছাড়াই ভুল উত্তর দিত।
প্র ০৩ $n=6$-এ বাউন্ড-প্রুনিং সহ নোড সংখ্যা ($311$) $(n-1)!/2 = 60$-এর চেয়ে অনেক বেশি। এর মানে কি প্রুনিং আসলে কাজ করছে না?
না — এই দুটো সংখ্যা তুলনাযোগ্যই নয়। $(n-1)!/2 = 60$ মানে স্বতন্ত্র সম্পূর্ণ ট্যুরের সংখ্যা
(শুধু ট্রি-র সবচেয়ে নিচের লেভেলের লিফ, দিক-উল্টানো ডুপ্লিকেট বাদ দিয়ে), অথচ nodes_explored
গোনে সার্চ ট্রি-র প্রতিটি লেভেলের প্রতিটি নোড (আংশিক ট্যুরসহ) এবং দিক-উল্টানো ডুপ্লিকেটও আলাদা
গোনে। প্রুনিং সত্যিই কাজ করছে কি না বোঝার সঠিক উপায় হলো ধারা ৪-এ দেখানো তুলনা — একই ট্রি বাউন্ড সহ ও
ছাড়া চালিয়ে ($311$ বনাম $326$) — যেখানে প্রুনিং সামান্য হলেও প্রকৃত প্রভাব দেখাচ্ছে, এবং $n$ বাড়ার
সাথে সাথে এই প্রভাব দ্রুত বৃদ্ধি পাচ্ছে।
অনুশীলন
-
চিন্তা করুন: $n=9$-এ প্রুনিং রেশিও ছিল $16.2\%$ (অবশিষ্ট কাজ)। $n=10$-এ এই রেশিও কি আরও
কমবে, নাকি বাড়বে? কেন?
আরও কমবে — কারণ $n$ বাড়ার সাথে সাথে সার্চ ট্রি আরও গভীর ও চওড়া হয়, এবং প্রতিটি অতিরিক্ত লেভেলে বাউন্ড-চেক আরও বেশি শাখা তাড়াতাড়ি বাদ দেওয়ার সুযোগ পায় (কারণ ভুল দিকের আংশিক ট্যুরগুলো আরও দ্রুত
best_found-এর তুলনায় খারাপ প্রমাণিত হয়)। প্রবণতাটি ($95.4\%, 74.0\%, 45.3\%, 16.2\%$) স্পষ্টভাবে দ্রুত হ্রাসমান। -
পরীক্ষা করুন: ধারা ৪-এর কোড সেলে
for n in [6, 7, 8, 9]:লাইনে10যোগ করে (for n in [6, 7, 8, 9, 10]:) Run চেপে প্রকৃত রেশিও দেখুন।আউটপুটে দেখা যাবে $n=10$-এ বাউন্ড-সহ নোড সংখ্যা $60{,}306$, বাউন্ড-বিহীন $986{,}410$ — রেশিও মাত্র $6.1\%$, অর্থাৎ প্রায় $94\%$ কাজ বাঁচানো হয়েছে। এটি $n=9$-এর $16.2\%$-এর চেয়েও ছোট, নিশ্চিত করে প্রবণতাটি সঠিক ছিল।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পরবর্তী পাঠ — নেইভ স্ট্রিং ম্যাচিং ও রবিন-কার্প L40 M9 শুরু — স্ট্রিং অ্যালগরিদমে একই "নেইভ বেসলাইনের বিপরীতে যাচাই" দর্শন প্রয়োগ।
- M12 — মেট্রিক TSP অ্যাপ্রক্সিমেশন L52 যখন $n$ অনেক বড় হয়ে যায় এবং এক্স্যাক্ট ব্রাঞ্চ-অ্যান্ড-বাউন্ডও ব্যবহারিকভাবে অসম্ভব হয়ে পড়ে, তখন একটি প্রমাণিত অনুপাতসহ আসন্ন সমাধান কীভাবে পাওয়া যায়।
- আগের পাঠ — ব্রাঞ্চ-অ্যান্ড-বাউন্ড প্যারাডাইম L38 এই লেসনে প্রয়োগ করা সাধারণ কাঠামো ও বাউন্ডিং-এর মূল ধারণা সেখানে বিস্তারিত।