সরাসরি পর্বে যান
পর্ব ৫৬ · ৭২-এর মধ্যে · মডিউল ৭

Backtracking — N-queens, subset sum আর Hamiltonian cycle

Backtracking — State-Space Trees, Choose/Explore/Un-choose, N-Queens, Subset Sum with Pruning, Graph Colouring & Hamiltonian Cycles

Taught in Bangla, this lesson presents backtracking as depth-first search over a state-space tree with a choose, explore and un-choose template. It generates subsets, permutations and combinations, traces N-queens with O(1) safety checks and a bitmask version, prunes subset sum with two bounds, and solves sudoku, m-colouring and Hamiltonian cycles. It also counts backtracking cost and compares it with brute force and DP.

এই পর্বে যা শিখবেন

  • Backtracking-এর ধারণা, state-space tree আর choose/explore/un-choose টেমপ্লেট — আর কেন এটি recursion (পর্ব ৭) ও DFS (পর্ব ৪১)-এর সরাসরি প্রয়োগ
  • সব subset, permutation আর combination তৈরি করা, আর তাদের সংখ্যা 2ⁿ, n!, C(n, k) কেন
  • N-queens হাতে trace করা, O(1) নিরাপত্তা-যাচাই, আর bitmask সংস্করণ
  • Pruning-সহ subset sum, sudoku, m-colouring আর Hamiltonian cycle — আর কোন pruning কখন কাজ করে
  • Backtracking-এর জটিলতা গোনা (node × প্রতি node-এর কাজ), আর কখন brute force, backtracking বা DP বাছবেন

১ · ধারণা — চেষ্টা করুন, যাচাই করুন, না হলে ফিরে আসুন

সকালে অফিসে বের হওয়ার মুখে চাবি খুঁজে পাচ্ছেন না। আপনি কী করেন? একটি ঘরে ঢোকেন — শোবার ঘর। টেবিলে দেখেন, নেই; ড্রয়ারে দেখেন, নেই। শোবার ঘরের সব জায়গা শেষ হলে বেরিয়ে আসেন — যেখান থেকে ঢুকেছিলেন, সেই বারান্দায় — আর পরের ঘরে ঢোকেন। কোনো ঘর দুবার খোঁজেন না, আর কোনো ঘরে “এখানে নিশ্চয়ই নেই” বুঝলে (ধরুন রান্নাঘর, যেখানে আজ যাননি) পুরো ঘরটিই বাদ দেন।

উপমা — গোলকধাঁধা আর চকের দাগ। পর্ব ৪১-এ দেখেছেন, গোলকধাঁধায় একটি গলি ধরে যতদূর যাওয়া যায় যান, কানা গলিতে আটকালে ঠিক আগের মোড়ে ফিরে পরের গলি ধরুন। এই “ফিরে আসা” — ইংরেজিতে backtrack — হলো পুরো পদ্ধতির নাম। পার্থক্য একটাই: গোলকধাঁধার মোড়গুলো আগে থেকে আঁকা থাকে, আর এখানে “মোড়” হলো আপনার নিজের সিদ্ধান্ত — এই ঘরে খুঁজব, না ওই ঘরে? এই রানিকে কোন কলামে বসাব? এই সংখ্যাটি নেব, না নেব না?

Backtracking হলো এমন সমস্যার সাধারণ পদ্ধতি, যেখানে উত্তর একটি সিদ্ধান্তের ধারা — x₁, x₂, …, xₙ — আর প্রতিটি সিদ্ধান্তের কয়েকটি বিকল্প আছে। তিনটি ধাপ বারবার চলে:

  1. চেষ্টা (choose): পরের সিদ্ধান্তের একটি বিকল্প বসান।
  2. যাচাই (check): এ পর্যন্ত বসানো সিদ্ধান্তগুলো কি এখনো সব শর্ত মানছে? না মানলে এই পথে আর এগোনোর মানে নেই।
  3. ফেরত (undo): এই বিকল্পের নিচের সব সম্ভাবনা দেখা শেষ হলে (বা যাচাইয়ে ব্যর্থ হলে) সিদ্ধান্তটি মুছে পরের বিকল্প ধরুন। সব বিকল্প শেষ হলে আরও এক ধাপ পেছনে যান।

সাধারণ brute force-এর সাথে পার্থক্য দ্বিতীয় ধাপে। Brute force প্রথমে পুরো একটি সম্ভাব্য উত্তর বানায়, তারপর যাচাই করে — ৮টি রানির জন্য, প্রতি সারিতে একটি করে ধরলেও, ৮⁸ ≈ ১ কোটি ৬৭ লাখ বোর্ড। Backtracking আংশিক উত্তরেই যাচাই করে: প্রথম দুটি রানি যদি একে অন্যকে আক্রমণ করে, বাকি ছয়টি কোথায় বসবে তা ভাবারই দরকার নেই — একসাথে ৮⁶ ≈ ২ লাখ ৬২ হাজার বোর্ড বাদ। এই আগাম বাদ দেওয়াকে বলে pruning (গাছের ডাল ছাঁটা), আর এই পর্বের প্রায় সব কৌশল আসলে ভালো pruning-এর কৌশল।

২ · State-space tree আর choose–explore–un-choose টেমপ্লেট

সব সম্ভাব্য সিদ্ধান্ত-ধারাকে একটি গাছ হিসেবে কল্পনা করুন — state-space tree. Root হলো খালি আংশিক সমাধান (কোনো সিদ্ধান্ত নেওয়া হয়নি)। গভীরতা k-এর একটি node মানে প্রথম k টি সিদ্ধান্ত নেওয়া হয়েছে; তার প্রতিটি সন্তান (k + 1)-তম সিদ্ধান্তের একটি বিকল্প। Leaf হলো হয় পূর্ণ সমাধান, নয়তো এমন অবস্থা যেখান থেকে আর এগোনো যায় না। গাছটি মেমরিতে কখনো বানানো হয় না — এটি একটি implicit graph, যার প্রতিবেশী দরকারের সময় হিসাব হয়। Backtracking এই গাছে DFS চালায়, আর pruning মানে কিছু node-এ পৌঁছে বুঝে ফেলা যে তার নিচের পুরো উপ-গাছে কিছু নেই।

Pseudocode — the backtracking template (state = the partial solution)

solve(state):
    if state is a complete solution:
        record(state); return               // a leaf we want
    for each option x for the next decision:
        if not feasible(state, x): continue // prune: this child can never work
        apply x to state                    // CHOOSE
        solve(state)                        // EXPLORE the subtree below
        undo x from state                   // UN-CHOOSE: state is exactly as before

শেষ লাইনটিই backtracking-এর প্রাণ। প্রতিটি ডাকের জন্য state-এর নতুন কপি না বানিয়ে, একটিই অ্যারে বা তালিকা রাখা হয়; নিচে যাওয়ার আগে বদলানো হয়, ফিরে এসে ঠিক উল্টোভাবে বদলে আগের অবস্থায় ফেরানো হয়। তাই বাড়তি জায়গা লাগে শুধু গাছের গভীরতার সমান — recursion depth আর state মিলে সাধারণত O(n). Undo ভুলে গেলে (বা অর্ধেক করলে) পরের শাখা আগের শাখার ময়লা নিয়ে শুরু করে — backtracking কোডের সবচেয়ে প্রচলিত বাগ।

তিনটি মৌলিক গাছ — subset, permutation, combination

পর্ব ৭-এ recursion দিয়ে subset আর permutation তৈরি করেছিলেন। এখন সেগুলোকে state-space tree হিসেবে দেখুন, কারণ প্রায় সব backtracking সমস্যা এই তিনটির একটির ওপর শর্ত বসিয়ে তৈরি:

গাছপ্রতিটি স্তরের সিদ্ধান্তleaf-এর সংখ্যামোট nodeউদাহরণ
Subset (power set)i-তম জিনিস নেব, না নেব না — ২টি বিকল্প2ⁿ2ⁿ⁺¹ − 1subset sum, 0/1 knapsack
Permutationপরের ঘরে বাকি জিনিসগুলোর কোনটি বসবে — n, n − 1, … বিকল্পn!Σ n!/(n − k)! ≈ e · n!TSP, Hamiltonian cycle, N-queens (প্রতি সারিতে আলাদা কলাম)
Combinationপরের জিনিসটি আগেরটির চেয়ে বড় সূচক থেকে — ক্রম গুরুত্বহীনC(n, k)Σ C(n, j), j ≤ kk জনের দল বাছাই, k-clique

সংখ্যাগুলো কেন: subset-এ n টি স্বাধীন হ্যাঁ/না, তাই 2 · 2 · … · 2 = 2ⁿ. Permutation-এ প্রথম ঘরে n টি বিকল্প, দ্বিতীয়তে n − 1, …, তাই n!. Combination-এ ক্রম গুরুত্বহীন, তাই n!/(n − k)! টি সাজানো বাছাইয়ের প্রতিটি দল k! বার গোনা হয়েছিল: C(n, k) = n!/(k! (n − k)!). Combination গাছে ক্রম-ভেদে একই দল দুবার না আসতে, পরের জিনিস সবসময় আগেরটির পরের সূচক থেকে নেওয়া হয় — তাই {1, 3} আসে, {3, 1} আসে না।

প্রতিটি ধাপের পর
এই ধাপে
path (গভীরতা)
used[1 2 3]
ছাপা হয়েছে
দেখা node
  1. শুরু. path খালি, কোনো সংখ্যা ব্যবহৃত নয়। Root-এর ৩টি সন্তান: প্রথম ঘরে 1, 2 বা 3.
  2. Choose 1. path-এ 1 যোগ, used[1] = 1. এখন গভীরতা ১-এর node-এ আছি।
  3. Choose 2. 1 ব্যবহৃত, তাই লুপ সেটি বাদ দিয়ে 2 নেয়। path [1 2].
  4. Leaf. 3 বসিয়ে path পূর্ণ — 123 ছাপা। এখানে আর কোনো বিকল্প নেই।
  5. Un-choose. ফেরার পথে 3 মোছা (used[3] = 0), তারপর 2-এর node-ও শেষ, 2 মোছা। 1-এর node-এর লুপ পরের বিকল্প 3 নেয়: path [1 3]. লক্ষ করুন, used ঠিক “শুধু 1 আর 3” দেখাচ্ছে — undo ঠিকঠাক হয়েছে বলেই।
  6. দ্বিতীয় leaf. বাকি আছে শুধু 2: path [1 3 2], ছাপা 132.
  7. পুরো গাছ. 1-এর উপ-গাছ শেষ হলে path আবার খালি, used আবার শূন্য, আর একই নাচ 2 আর 3 দিয়ে। মোট 3! = ৬টি leaf, node 1 + 3 + 6 + 6 = ১৬, ছাপা হয় lexicographic order-এ — কারণ প্রতিটি স্তরে বিকল্পগুলো ছোট থেকে বড় ক্রমে চেষ্টা হয়।

সময়: n! টি leaf, প্রতিটি ছাপতে O(n), আর ভেতরের node মোট প্রায় (e − 1) · n!, প্রতিটিতে O(n)-এর লুপ — সব মিলে Θ(n · n!). জায়গা: path, used আর recursion — Θ(n). Subset গাছে একইভাবে 2ⁿ⁺¹ − 1 node, প্রতিটিতে O(1) কাজ, আর leaf ছাপতে O(n): সব মিলে Θ(n · 2ⁿ). এই “node গুনুন, তারপর প্রতি node-এর কাজ দিয়ে গুণ করুন” পদ্ধতিই অংশ ৭-এ সাধারণ নিয়ম হবে।

৩ · N-queens — সারি ধরে রানি বসানো

দাবার রানি নিজের সারি, কলাম আর দুই কোণাকুণি রেখায় যত দূর খুশি যেতে পারে। N-queens সমস্যা: n × n বোর্ডে n টি রানি বসান যাতে কেউ কাউকে আক্রমণ না করে। প্রতিটি সারিতে ঠিক একটি রানি থাকতেই হবে (n টি সারিতে n টি রানি, আর এক সারিতে দুটি থাকলে তারা একে অন্যকে আক্রমণ করে)। তাই সিদ্ধান্তের ধারা হলো: “সারি 0-এর রানি কোন কলামে, সারি 1-এর রানি কোন কলামে, …” — গভীরতা n-এর একটি গাছ, প্রতিটি node-এ n টি বিকল্প। নিচে ৪ × ৪ বোর্ডে প্রথম সমাধান খোঁজা (কলাম বাঁ থেকে ডানে চেষ্টা করা হয়; x = এই সারিতে এই ঘর চেষ্টা করে বাদ):

বোর্ড (সারি 0–3, কলাম 0–3; Q = রানি, x = চেষ্টা করে বাদ)সবুজ = এই ধাপে বসানো

            
প্রতিটি ধাপের পর
pos (প্রতি সারির কলাম)
ঘটনা
  1. সারি 0. প্রথম বিকল্প কলাম 0 — বোর্ড খালি, তাই নিরাপদ।
  2. সারি 1. কলাম 0 একই কলাম, কলাম 1 কোণাকুণি (0,0) থেকে — বাদ। কলাম 2 নিরাপদ, বসানো হলো।
  3. প্রথম অচল অবস্থা. সারি 2-এর চারটি ঘরই আক্রান্ত: কলাম 0 আর 2 কলাম-বরাবর, 1 আর 3 রানি (1,2)-এর কোণাকুণি। এই node একটি অচল leaf. ফেরত: সারি 1-এর রানি তুলে নেওয়া হলো।
  4. সারি 1, পরের বিকল্প. কলাম 3 নিরাপদ। (কলাম 2-এর পুরো উপ-গাছ মাত্র একটি node দেখেই শেষ হয়েছিল — এটিই pruning.)
  5. সারি 2. কলাম 0 কলাম-বরাবর আক্রান্ত, কলাম 1 নিরাপদ।
  6. আবার অচল — এবার গভীর ফেরত. সারি 3-এর চারটি ঘরই আক্রান্ত। ফিরে সারি 2-এর বাকি বিকল্প: কলাম 2 রানি (1,3)-এর কোণাকুণি, কলাম 3 একই কলাম — শেষ। সারি 1-এরও আর বিকল্প নেই। তাই সারি 0-এর রানি কলাম 0 থেকে তুলে নিতে হবে: কলাম 0-এ রানি রেখে কোনো সমাধান নেই।
  7. সারি 0 = কলাম 1. সারি 1-এ কলাম 0 আর 2 কোণাকুণি, 1 একই কলাম; কলাম 3 নিরাপদ।
  8. সমাধান. সারি 2-এ কলাম 0 নিরাপদ, সারি 3-এ কলাম 2 নিরাপদ: pos = 1 3 0 2. সব সমাধান চাইলে খোঁজা চলতে থাকে; ৪ × ৪-এ আরেকটি আছে, 2 0 3 1 — প্রথমটির আয়নার প্রতিবিম্ব। পুরো খোঁজায় মোট মাত্র ১৭টি node.

O(1)-এ নিরাপত্তা যাচাই — কলাম আর দুই কোণাকুণির অ্যারে

নতুন রানি (r, c) নিরাপদ কি না, তা আগের সব রানির সাথে মিলিয়ে দেখলে প্রতিবার O(n). তার বদলে তিনটি boolean অ্যারে রাখুন। একই কলামের সব ঘরে c সমান। একই “/” কোণাকুণিতে r + c সমান (ডানে-ওপরে গেলে r কমে, c বাড়ে), মান 0 থেকে 2n − 2. একই “\” কোণাকুণিতে r − c সমান; ঋণাত্মক সূচক এড়াতে r − c + n − 1 লিখুন, মানও 0 থেকে 2n − 2. তাহলে নিরাপত্তা-যাচাই তিনটি অ্যারে-পড়া, আর বসানো ও তোলা তিনটি করে লেখা — সব O(1).

মূল ধারণা

যাচাই সস্তা হলে প্রতিটি node সস্তা, আর মোট সময় ≈ node সংখ্যা × node প্রতি কাজ। ভালো backtracking দুটি জিনিস একসাথে কমায়: node-এর সংখ্যা (pruning দিয়ে) আর প্রতিটি node-এর কাজ (incremental state দিয়ে — পুরো বোর্ড আবার না দেখে শুধু পরিবর্তনটুকু হালনাগাদ করা)।

নিচের প্রোগ্রাম n = 1 থেকে 8 পর্যন্ত সব সমাধান গোনে, প্রতিবার কতটি node (place-এর ডাক) লাগল তা-ও, আর শেষে ৬ × ৬-এর প্রথম সমাধানটি আঁকে:

#include <iostream>
#include <vector>
using namespace std;

int n, cnt;
long long nodes;
vector<int> pos, first;              // pos[r] = column of the queen in row r
vector<bool> col, d1, d2;            // used column, "/" diagonal r+c, "\" diagonal r-c+n-1

void place(int r) {                  // put a queen in row r
    ++nodes;
    if (r == n) { if (cnt++ == 0) first = pos; return; }
    for (int c = 0; c < n; ++c) {
        if (col[c] || d1[r + c] || d2[r - c + n - 1]) continue;   // O(1) safety check
        col[c] = d1[r + c] = d2[r - c + n - 1] = true;  pos[r] = c;   // choose
        place(r + 1);                                                 // explore
        col[c] = d1[r + c] = d2[r - c + n - 1] = false;              // un-choose
    }
}
void solve(int size) {
    n = size; cnt = 0; nodes = 0; pos.assign(n, -1);
    col.assign(n, false); d1.assign(2 * n - 1, false); d2.assign(2 * n - 1, false);
    place(0);
}
int main() {
    for (int k = 1; k <= 8; ++k) {
        solve(k);
        cout << "n=" << k << " solutions=" << cnt << " nodes=" << nodes << '\n';
    }
    solve(6);
    cout << "first 6x6 board, queen columns:";
    for (int c : first) cout << ' ' << c;
    cout << '\n';
    for (int r = 0; r < 6; ++r)
        for (int c = 0; c < 6; ++c) cout << (first[r] == c ? 'Q' : '.') << (c < 5 ? ' ' : '\n');
}

আউটপুট:

n=1 solutions=1 nodes=2
n=2 solutions=0 nodes=3
n=3 solutions=0 nodes=6
n=4 solutions=2 nodes=17
n=5 solutions=10 nodes=54
n=6 solutions=4 nodes=153
n=7 solutions=40 nodes=552
n=8 solutions=92 nodes=2057
first 6x6 board, queen columns: 1 3 5 0 2 4
. Q . . . .
. . . Q . .
. . . . . Q
Q . . . . .
. . Q . . .
. . . . Q .

সমাধানের সংখ্যা 1, 0, 0, 2, 10, 4, 40, 92 — লক্ষ করুন, ক্রমটি একটানা বাড়ে না: ৬ × ৬-এ মাত্র ৪টি, ৫ × ৫-এর ১০টির চেয়েও কম। n = 2 আর 3-এ কোনো সমাধান নেই; n ≥ 4 হলে সবসময় অন্তত একটি থাকে। n = 4-এ node ১৭, ঠিক ওপরের ডায়াগ্রামের পুরো খোঁজা। ৮ × ৮-এ মাত্র ২,০৫৭টি node, অথচ brute force-এ ৮⁸ = ১,৬৭,৭৭,২১৬টি বোর্ড, আর “প্রতি সারিতে আলাদা কলাম” ধরে নিলেও ৮! = ৪০,৩২০টি। vector<bool>-এ a = b = true শিকলের মতো লেখা বৈধ। first = pos প্রথম সমাধানের একটি কপি রাখে, কারণ pos পরে বদলে যাবে। Node-এর সংখ্যা O(n!) (কলাম-শর্তেই প্রতি সারিতে আগের চেয়ে অন্তত একটি কম কলাম খোলা), প্রতি node-এ O(n)-এর লুপ — সময় O(n · n!); জায়গা Θ(n).

def solve(n):
    col, d1, d2 = [False] * n, [False] * (2 * n - 1), [False] * (2 * n - 1)
    pos, first = [-1] * n, []
    count = nodes = 0

    def place(r):                                   # put a queen in row r
        nonlocal count, nodes
        nodes += 1
        if r == n:
            if count == 0:
                first.extend(pos)                   # remember the first board
            count += 1
            return
        for c in range(n):
            if col[c] or d1[r + c] or d2[r - c + n - 1]:
                continue                            # O(1) safety check
            col[c] = d1[r + c] = d2[r - c + n - 1] = True    # choose
            pos[r] = c
            place(r + 1)                                     # explore
            col[c] = d1[r + c] = d2[r - c + n - 1] = False   # un-choose

    place(0)
    return count, nodes, first

for k in range(1, 9):
    cnt, nodes, _ = solve(k)
    print(f"n={k} solutions={cnt} nodes={nodes}")
_, _, board = solve(6)
print("first 6x6 board, queen columns:", *board)
for r in range(6):
    print(" ".join("Q" if board[r] == c else "." for c in range(6)))

আউটপুট C++-এর সাথে হুবহু এক:

n=1 solutions=1 nodes=2
n=2 solutions=0 nodes=3
n=3 solutions=0 nodes=6
n=4 solutions=2 nodes=17
n=5 solutions=10 nodes=54
n=6 solutions=4 nodes=153
n=7 solutions=40 nodes=552
n=8 solutions=92 nodes=2057
first 6x6 board, queen columns: 1 3 5 0 2 4
. Q . . . .
. . . Q . .
. . . . . Q
Q . . . . .
. . Q . . .
. . . . Q .

এখানে global ভেরিয়েবলের বদলে ভেতরের ফাংশন place বাইরের solve-এর তালিকাগুলো সরাসরি বদলায়; শুধু পূর্ণসংখ্যা count আর nodes নতুন মান পায়, তাই nonlocal. first.extend(pos) তালিকার মানগুলো কপি করে — first = pos লিখলে একই তালিকার আরেকটি নাম হতো, আর পরে বদলে যেত। Python-এ ৮ × ৮ মুহূর্তেই শেষ হয়, কিন্তু n = 12–14-এ C++-এর চেয়ে অনেক গুণ ধীর হবে; তখন নিচের bitmask সংস্করণ।

আরও গভীরে — bitmask N-queens: তিনটি boolean অ্যারের বদলে তিনটি পূর্ণসংখ্যা রাখুন (bitmask): cols, ld, rd — বর্তমান সারিতে কোন কলাম কলাম-বরাবর, বাঁ-কোণাকুণি আর ডান-কোণাকুণি থেকে আক্রান্ত। পরের সারিতে নামলে বাঁ-কোণাকুণির আক্রমণ এক ঘর বাঁয়ে সরে (<< 1), ডানেরটি ডানে (>> 1). খালি ঘরগুলো একটি mask-এ: avail = all & ~(cols | ld | rd), আর avail & -avail সবচেয়ে নিচের 1-bit (lowest set bit) আলাদা করে — লুপে শুধু খালি ঘরগুলোই ঘোরা হয়। এটি boolean সংস্করণের চেয়ে কয়েক গুণ দ্রুত, আর কনটেস্টে n ≤ 15–16 পর্যন্ত সমাধান গোনার প্রচলিত কৌশল।

// bitmask N-queens: count all solutions; call go(0, 0, 0) with all = (1 << n) - 1
long long go(int cols, int ld, int rd) {
    if (cols == all) return 1;                    // every column has a queen
    long long total = 0;
    for (int avail = all & ~(cols | ld | rd); avail; avail &= avail - 1) {
        int bit = avail & -avail;                 // lowest free square in this row
        total += go(cols | bit, ((ld | bit) << 1) & all, (rd | bit) >> 1);
    }
    return total;
}

৪ · Subset sum — দুটি সীমা দিয়ে গাছ ছাঁটা

ঈদের আগে একটি স্কুলের ছাত্রকল্যাণ তহবিলে ঠিক ১৫ হাজার টাকা দিতে হবে, আর হাতে কয়েকটি আলাদা অনুদানের খাম আছে — ৮, ৬, ৭, ৫, ৩, ১০ আর ৯ হাজার। খাম ভাঙা যাবে না। কোন কোন খাম মিলে ঠিক ১৫ হয়? এটি subset sum: n টি ধনাত্মক সংখ্যা আর লক্ষ্য T; যোগফল ঠিক T এমন সব subset বের করুন। State-space tree হলো subset গাছ — প্রতিটি স্তরে “এই খাম নেব, না নেব না”, ২⁷ = ১২৮টি leaf আর ২৫৫টি node.

Pruning-এর জন্য প্রথমে সংখ্যাগুলো ছোট থেকে বড় সাজান: 3 5 6 7 8 9 10. আর rest[i] = a[i] + a[i+1] + … + a[n−1] আগেই হিসাব করে রাখুন (এটি suffix sum — পর্ব ১৭-এর prefix sum-এরই উল্টো দিক থেকে হিসাব). এখন index i-তে চলতি যোগফল s নিয়ে দাঁড়ালে তিনটি নিয়ম:

  • s = T: একটি সমাধান! আর কিছু নেওয়ার দরকার নেই (সব ধনাত্মক, নিলে বেড়ে যাবে)।
  • সম্ভাব্যতার সীমা (feasibility): s + a[i] > T হলে a[i] নেওয়া যায় না — আর সাজানো বলে পরের সবগুলো আরও বড়, কোনোটিই নেওয়া যাবে না। পুরো উপ-গাছ বাদ।
  • বাকি-যোগফলের সীমা (bound): s + rest[i] < T হলে বাকি সব খাম নিলেও T-তে পৌঁছানো যাবে না। পুরো উপ-গাছ বাদ।

ছোট একটি উদাহরণে দেখুন: সংখ্যা 2 3 5 6 (সাজানো), T = 8, তাই rest = 16, 14, 11, 6. প্রতিটি node-এ আগে “নেব” শাখা, তারপর “নেব না”:

প্রতিটি ধাপের পর
এই ধাপে
cur (নেওয়া)
পাওয়া subset
দেখা node
  1. শুরু. i = 0, s = 0. 0 + 2 ≤ 8 আর 0 + 16 ≥ 8 — কোনো সীমা কাটে না, এগোই।
  2. 2 নিই. s = 2, i = 1. এখনো দুই সীমাই ঠিক আছে।
  3. Feasibility কাটে. 3 নিয়ে s = 5, i = 2. পরের সংখ্যা 5 নিলে 10 > 8; আর 6 তো আরও বড়। এই node-এর নিচের পুরো উপ-গাছ (আরও ৬টি node) একবারে বাদ।
  4. আবার কাটা. ফিরে 3 বাদ দিই (s = 2, i = 2), তারপর 5 নিই: s = 7, i = 3. 7 + 6 = 13 > 8 — কাটা।
  5. প্রথম সমাধান. 5 বাদ (s = 2, i = 3), 6 নিই: s = 8 = T. {2, 6} পাওয়া গেল; এখান থেকে আর নামার দরকার নেই।
  6. দ্বিতীয় সমাধান. 6 বাদ দিলে i = 4, s = 2 — সংখ্যা শেষ, ব্যর্থ leaf. 2-এর পুরো উপ-গাছ শেষ; root-এ ফিরে “2 বাদ”: s = 0, i = 1. 3 নিই (s = 3), 5 নিই (s = 8) — {3, 5}.
  7. কাটা. 5 বাদ: s = 3, i = 3; 3 + 6 = 9 > 8.
  8. কাটা. 3 বাদ: s = 0, i = 2; 5 নিই, s = 5, i = 3; 5 + 6 = 11 > 8.
  9. Bound কাটে. 5 বাদ: s = 0, i = 3. বাকি আছে শুধু 6, rest[3] = 6, আর 0 + 6 < 8 — সব নিলেও পৌঁছাবে না। খোঁজা শেষ: ১৫টি node, pruning ছাড়া লাগত 2⁵ − 1 = ৩১টি।

পুরো খাম-সমস্যায় দুই সংস্করণই চালিয়ে node গুনুন — pruning ছাড়া সাধারণ গাছ, আর দুই সীমাসহ:

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

vector<int> a = {8, 6, 7, 5, 3, 10, 9}, rest, cur;
vector<vector<int>> found;
const int T = 15;
long long nodes = 0;

void plain(size_t i, int s) {                 // no pruning: visit the whole tree
    ++nodes;
    if (i == a.size()) { if (s == T) found.push_back(cur); return; }
    cur.push_back(a[i]); plain(i + 1, s + a[i]); cur.pop_back();   // take a[i]
    plain(i + 1, s);                                               // skip a[i]
}
void pruned(size_t i, int s) {
    ++nodes;
    if (s == T) { found.push_back(cur); return; }
    if (i == a.size() || s + a[i] > T || s + rest[i] < T) return;  // too big / can never reach T
    cur.push_back(a[i]); pruned(i + 1, s + a[i]); cur.pop_back();
    pruned(i + 1, s);
}
int main() {
    sort(a.begin(), a.end());                 // small items first
    rest.assign(a.size() + 1, 0);             // rest[i] = a[i] + ... + a[n-1]
    for (size_t i = a.size(); i-- > 0;) rest[i] = rest[i + 1] + a[i];
    cout << "items:";
    for (int x : a) cout << ' ' << x;
    cout << " target: " << T << '\n';
    for (int pass = 0; pass < 2; ++pass) {
        found.clear(); nodes = 0;
        if (pass == 0) plain(0, 0); else pruned(0, 0);
        cout << (pass == 0 ? "without pruning" : "with pruning   ") << ": nodes=" << nodes << " subsets=" << found.size() << '\n';
    }
    for (auto& sub : found) {
        for (size_t j = 0; j < sub.size(); ++j) cout << (j ? " + " : "") << sub[j];
        cout << " = " << T << '\n';
    }
}

আউটপুট:

items: 3 5 6 7 8 9 10 target: 15
without pruning: nodes=255 subsets=4
with pruning   : nodes=45 subsets=4
3 + 5 + 7 = 15
5 + 10 = 15
6 + 9 = 15
7 + 8 = 15

দুই সংস্করণই ঠিক ৪টি subset পায়, কিন্তু pruning-সহ সংস্করণ ২৫৫টির জায়গায় মাত্র ৪৫টি node দেখে — প্রায় ছয় ভাগের এক ভাগ। Pruning উত্তর বদলায় না, শুধু যে উপ-গাছে উত্তর থাকা অসম্ভব তা বাদ দেয়; তাই plain হলো pruned-এর জন্য একটি প্রস্তুত brute force — ছোট এলোমেলো ইনপুটে দুটির ফল মিলিয়ে stress test করা যায়। চলতি যোগফল s parameter-এ যায়, তাই undo লাগে না; কিন্তু cur একটি ভাগ করা তালিকা, তাই push_back-এর পরে pop_back. s + a[i] > T-এ “নেব না” শাখাও কাটা বৈধ শুধু সাজানো আর ধনাত্মক সংখ্যায় — ঋণাত্মক সংখ্যা থাকলে এই দুই সীমাই ভুল। Worst case-এ সময় তবুও O(2ⁿ) node (প্রতিটিতে O(1), সমাধান ছাপতে O(n)), জায়গা O(n).

a = sorted([8, 6, 7, 5, 3, 10, 9])     # sort once: small items first
T = 15
rest = [sum(a[i:]) for i in range(len(a) + 1)]   # rest[i] = a[i] + ... + a[n-1]
cur, found, nodes = [], [], 0

def plain(i, s):                     # no pruning: visit the whole tree
    global nodes
    nodes += 1
    if i == len(a):
        if s == T:
            found.append(cur[:])
        return
    cur.append(a[i]); plain(i + 1, s + a[i]); cur.pop()   # take a[i]
    plain(i + 1, s)                                        # skip a[i]

def pruned(i, s):
    global nodes
    nodes += 1
    if s == T:
        found.append(cur[:])
        return
    if i == len(a) or s + a[i] > T or s + rest[i] < T:
        return                       # too big already / can never reach T
    cur.append(a[i]); pruned(i + 1, s + a[i]); cur.pop()
    pruned(i + 1, s)

print("items:", *a, "target:", T)
for name, f in (("without pruning", plain), ("with pruning   ", pruned)):
    found.clear(); nodes = 0
    f(0, 0)
    print(f"{name}: nodes={nodes} subsets={len(found)}")
for sub in found:
    print(" + ".join(map(str, sub)), "=", T)

আউটপুট C++-এর সাথে হুবহু এক:

items: 3 5 6 7 8 9 10 target: 15
without pruning: nodes=255 subsets=4
with pruning   : nodes=45 subsets=4
3 + 5 + 7 = 15
5 + 10 = 15
6 + 9 = 15
7 + 8 = 15

found.append(cur[:])-এ [:] জরুরি: এটি cur-এর একটি কপি রাখে। শুধু cur রাখলে চারটি এন্ট্রিই একই তালিকার দিকে নির্দেশ করত, আর শেষে সব খালি দেখাত — Python-এ backtracking-এর সবচেয়ে প্রচলিত বাগ। rest এখানে প্রতিটি i-এর জন্য আলাদা sum দিয়ে O(n²)-এ বানানো (n = 7-এ নগণ্য); বড় n-এ C++-এর মতো পেছন থেকে একটি লুপে O(n). ফাংশন দুটি for লুপে মান হিসেবে পাঠানো হয়েছে — Python-এ ফাংশনও সাধারণ object.

৫ · Sudoku আর graph colouring — শর্ত মেনে মান বসানো

N-queens আর subset sum একটি বড় পরিবারের সদস্য — constraint satisfaction problem (CSP): কিছু ভেরিয়েবল, প্রত্যেকের কিছু সম্ভাব্য মান, আর কিছু শর্ত। Backtracking-এ একটি করে ভেরিয়েবলে মান বসানো হয়, শর্ত ভাঙলেই ফেরত।

Sudoku — সংক্ষেপে

৯ × ৯ ঘর, কিছু আগে থেকে ভরা। প্রতিটি খালি ঘর একটি ভেরিয়েবল, মান 1–9; শর্ত — প্রতিটি সারি, কলাম আর ৩ × ৩ বাক্সে প্রতিটি অঙ্ক একবার। N-queens-এর মতোই প্রতিটি সারি, কলাম আর বাক্সের জন্য “কোন অঙ্ক ব্যবহৃত” অ্যারে (বা ৯-bit-এর mask) রাখলে প্রতিটি যাচাই O(1). সরল পদ্ধতি: প্রথম খালি ঘরটি নিন, 1 থেকে 9 পর্যন্ত বৈধ প্রতিটি অঙ্ক বসিয়ে বাকিটা recursively সমাধান করুন, না হলে মুছে পরের অঙ্ক। এর চেয়ে অনেক ভালো: সবসময় সেই খালি ঘরটি নিন যেখানে সবচেয়ে কম বৈধ অঙ্ক আছে — যদি একটি ঘরে মাত্র একটি অঙ্ক বসতে পারে, সেখানে কোনো শাখাই তৈরি হয় না; আর শূন্য হলে সাথে সাথে ফেরত। এই most-constrained-first নিয়মে সাধারণ পত্রিকার sudoku কয়েকশো node-এই মিলে যায়।

Graph colouring (m-colouring)

একটি জেলার পাশাপাশি উপজেলাগুলো মানচিত্রে আলাদা রঙে আঁকতে হবে, আর হাতে মাত্র m টি রং। উপজেলা = vertex, সীমানা ভাগ করা = edge. প্রশ্ন: প্রতিটি vertex-কে m টির একটি রং দেওয়া যায় কি না, যাতে কোনো edge-এর দুই প্রান্ত একই রঙের না হয় (graph colouring). Vertex 0, 1, 2, … ক্রমে রং বসান; প্রতিটিতে শুধু সেই রংগুলো চেষ্টা করুন যা কোনো ইতিমধ্যে-রং-পাওয়া প্রতিবেশীর সাথে মেলে না:

Pseudocode — m-colouring by backtracking (0-based vertices, colours 1..m, 0 = none)

colourFrom(v):
    if v == n: return true                          // every vertex has a colour
    for c = 1 .. m:
        if no neighbour u of v has colour[u] == c:  // feasibility: O(deg v)
            colour[v] = c                           // choose
            if colourFrom(v + 1): return true       // explore
            colour[v] = 0                           // un-choose
    return false                                    // no colour fits: backtrack

সময়: গভীরতা v-তে সর্বোচ্চ mᵛ টি node, প্রতিটিতে m টি রং × O(deg v) যাচাই — যোগ করলে worst case O(mⁿ · (n + E)); জায়গা O(n). দুটি বাস্তব কৌশল: (১) প্রথম vertex-এ রং 1 স্থির করে দিন — রংগুলোর নাম অদলবদল করলে একই সমাধান, তাই এতে m গুণ কাজ বাঁচে (একে বলে symmetry breaking); (২) বেশি degree-র vertex আগে রং করুন, বা যার প্রতিবেশীরা এর মধ্যেই সবচেয়ে বেশি আলাদা রং নিয়েছে (DSATUR নামের heuristic-এর ধারণা) — আবারও most-constrained-first. m = 2 হলে backtracking লাগেই না: গ্রাফটি bipartite কি না BFS দিয়ে O(V + E)-এ বলা যায় (পর্ব ৪০). কিন্তু m = 3-এই প্রশ্নটি NP-complete. আর একটি বিখ্যাত সত্য: সমতলের যেকোনো মানচিত্র (planar graph) ৪টি রঙেই রং করা যায় — four colour theorem, Appel আর Haken ১৯৭৬ সালে কম্পিউটারের সাহায্যে প্রমাণ করেন।

৬ · Hamiltonian cycle — সব vertex ঠিক একবার

একজন ডেলিভারি কর্মী গুদাম থেকে বের হয়ে কয়েকটি দোকানের প্রতিটিতে ঠিক একবার গিয়ে গুদামে ফিরতে চান, শুধু সরাসরি রাস্তা ধরে। এমন একটি পথ — যা গ্রাফের প্রতিটি vertex ঠিক একবার ছুঁয়ে শুরুতে ফেরে — হলো Hamiltonian cycle; শুরুতে না ফিরলে Hamiltonian path. পর্ব ৩৯-এর Euler circuit (প্রতিটি edge একবার) degree দেখেই O(V + E)-এ বলা যায়; কিন্তু প্রতিটি vertex একবার — এর কোনো দ্রুত পদ্ধতি জানা নেই।

Backtracking: vertex 0 থেকে শুরু করুন (cycle-এ সবাই আছে, তাই শুরু যেকোনো জায়গা থেকে)। পথের শেষ vertex-এর একটি অদেখা প্রতিবেশী যোগ করুন, এগোন; সব vertex পথে এসে গেলে দেখুন শেষ vertex থেকে 0-তে edge আছে কি না। কোনো অদেখা প্রতিবেশী না থাকলে শেষ vertex তুলে নিন। এটি permutation গাছ, শুধু শর্ত হলো “পরপর দুজনের মধ্যে edge থাকতে হবে”। গ্রাফটি এরকম (প্রতিবেশী ছোট নম্বর আগে):

        0              adj[0] = 1 2 4
      / | \            adj[1] = 0 2 3
     1--2--4           adj[2] = 0 1 4
      \    /           adj[3] = 1 4
        3              adj[4] = 0 2 3
প্রতিটি ধাপের পর
এই ধাপে
path
visited (0 1 2 3 4)
সিদ্ধান্ত
  1. শুরু. পথে শুধু 0.
  2. 0 → 1. 0-এর প্রথম প্রতিবেশী 1, অদেখা — যোগ করা হলো।
  3. 1 → 2. 1-এর প্রতিবেশী 0 পথে আছে, বাদ; 2 অদেখা।
  4. 2 → 4. 2-এর প্রতিবেশী 0 আর 1 পথে আছে; 4 অদেখা।
  5. পথ পূর্ণ. 4 → 3: পাঁচটি vertex-ই পথে — একটি Hamiltonian path পাওয়া গেল। কিন্তু cycle-এর জন্য শেষ vertex 3 থেকে 0-তে edge লাগবে।
  6. গভীর ফেরত. adj[3] = 1 4 — 0 নেই, ব্যর্থ। 3 তোলা হলো; 4-এর আর অদেখা প্রতিবেশী নেই, 4 তোলা; 2-এরও নেই, 2 তোলা। পথ আবার 0 1, আর visited-এ 2, 3, 4 আবার অদেখা — undo ঠিক হয়েছে বলেই পরের শাখা পরিষ্কারভাবে শুরু হবে।
  7. নতুন শাখা. 1-এর পরের বিকল্প 3; 3-এর প্রতিবেশী 1 পথে, 4 অদেখা। পথ 0 1 3 4.
  8. সমাধান. 4 → 0 পথে আছে, 4 → 2 অদেখা: পথ 0 1 3 4 2, পূর্ণ। adj[2]-তে 0 আছে — Hamiltonian cycle 0 → 1 → 3 → 4 → 2 → 0. মোট ৮টি পথ-অবস্থা দেখা হলো।

কেন exponential: 0 স্থির রেখে বাকি n − 1 টি vertex-এর প্রতিটি ক্রমই একটি সম্ভাব্য পথ — worst case-এ (n − 1)! টি leaf, যেমন প্রায়-complete graph-এ যেখানে প্রায় সব পথ শেষ পর্যন্ত যায়, কিন্তু একটিমাত্র “শেষ edge” না থাকায় সব ব্যর্থ হয়। প্রতি node-এ প্রতিবেশী দেখতে O(n), তাই সময় O(n · n!), জায়গা O(n). Pruning সাহায্য করে — যেমন কোনো অদেখা vertex-এর সব প্রতিবেশী পথে চলে গেলে (তার আর ঢোকার বা বেরোনোর রাস্তা নেই) সাথে সাথে ফেরত — কিন্তু worst case-কে polynomial বানায় না। পর্ব ৫৫-এর bitmask DP (Held–Karp ধাঁচের) একই প্রশ্নের উত্তর O(2ⁿ · n²)-এ দেয় — এখনো exponential, কিন্তু n! থেকে অনেক কম। আর এর চেয়ে মৌলিকভাবে ভালো কিছু কেউ জানে না: Hamiltonian cycle একটি NP-complete সমস্যা — Richard Karp-এর ১৯৭২ সালের বিখ্যাত ২১টি NP-complete সমস্যার তালিকায় আছে। এর মানে কী, তা পর্ব ৬৪-এ। ওজনসহ গ্রাফে সবচেয়ে ছোট Hamiltonian cycle-ই traveling salesman problem, যার জন্য পরের পর্বে branch and bound.

৭ · Pruning-এর কৌশল আর backtracking-এর জটিলতা

১. Feasibility — শর্ত ভাঙলেই থামুন

আংশিক সমাধান কোনো শর্ত ভাঙলে নিচে নামা অর্থহীন: আক্রান্ত ঘরে রানি, যোগফল T পার, প্রতিবেশীর সাথে একই রং। যত তাড়াতাড়ি ধরা যায় তত ভালো — শর্ত শুধু leaf-এ পরীক্ষা করলে সেটি আর backtracking নয়, brute force.

২. Bounding — সম্ভাবনা না থাকলে থামুন

শর্ত এখনো ভাঙেনি, কিন্তু একটি আশাবাদী হিসাবেও লক্ষ্যে পৌঁছানো অসম্ভব: s + rest < T. Optimization-এ: “এই শাখার সেরা সম্ভাব্য ফল এ পর্যন্ত পাওয়া সেরা উত্তরের চেয়েও খারাপ” — এটিই branch and bound (পর্ব ৫৭).

৩. Ordering — কঠিন সিদ্ধান্ত আগে

কোন ভেরিয়েবল আগে: সবচেয়ে কম বিকল্প যার (most-constrained-first) — ভুল পথ গাছের ওপরেই ধরা পড়ে। কোন মান আগে: যেটি অন্যদের জন্য সবচেয়ে বেশি জায়গা রাখে (least-constraining value) — সমাধান দ্রুত পাওয়া যায়।

৪. Symmetry — একই জিনিস দুবার নয়

N-queens-এ প্রথম সারির রানি শুধু বাঁ অর্ধেকে বসিয়ে ফল দ্বিগুণ করুন (বিজোড় n-এ মাঝের কলাম আলাদা গুনে); colouring-এ প্রথম vertex-এর রং স্থির; combination-এ শুধু বাড়ন্ত সূচক। Heuristic ক্রম বদলায়, কিন্তু উত্তরের সঠিকতা বদলায় না।

জটিলতা কীভাবে গুনবেন

Backtracking-এর সময় = (দেখা node-এর সংখ্যা) × (প্রতি node-এর কাজ)। Worst case-এ node-এর সংখ্যা state-space tree-র আকার: subset গাছে Θ(2ⁿ), permutation গাছে Θ(n!), m-colouring-এ O(mⁿ) — সবই exponential বা factorial. Pruning সাধারণত গড় বা বাস্তব ইনপুটে বিশাল লাভ দেয় (৮-রানিতে ৪০,৩২০ থেকে ২,০৫৭), কিন্তু worst-case সীমা প্রায় কখনো বদলায় না — subset sum-এ এমন ইনপুট বানানো যায় যেখানে কোনো সীমাই তাড়াতাড়ি কাটে না। তাই পরীক্ষায় “backtracking-এর সময় কত?” প্রশ্নে উত্তর হবে worst case, যেমন subset sum-এ O(2ⁿ) (সমাধান ছাপাসহ O(n · 2ⁿ)), N-queens-এ O(n!) টি leaf-এর ধাঁচে, Hamiltonian-এ O(n · n!). জায়গা প্রায় সবসময় শুধু গভীরতা আর state: O(n) — DFS-এর বড় সুবিধা।

বাস্তবে node গোনাই সবচেয়ে সৎ মাপ। ওপরের দুটি প্রোগ্রামের মতো একটি nodes কাউন্টার রাখুন, ছোট n-এ বাড়িয়ে দেখুন কত গুণ বাড়ছে: N-queens-এ n = 6, 7, 8-এ 153, 552, 2057 — প্রতি ধাপে প্রায় ৩.৬–৩.৭ গুণ। এই হার থেকে বড় n-এর সময় আন্দাজ করা যায়, আর বলা যায় আপনার সময়-সীমায় কত বড় n চলবে।

ইতিহাস। “Backtrack” শব্দটি ১৯৫০-এর দশকে মার্কিন গণিতবিদ D. H. Lehmer-এর বলে প্রচলিত। ১৯৬৫ সালে Solomon Golomb আর Leonard Baumert Journal of the ACM-এ “Backtrack Programming” প্রবন্ধে পদ্ধতিটির সাধারণ রূপ বর্ণনা করেন, আর ১৯৭৫ সালে James Bitner আর Edward Reingold Communications of the ACM-এ “Backtrack programming techniques” নামে একটি প্রভাবশালী সমীক্ষা প্রকাশ করেন — pruning, ক্রম আর symmetry-র কৌশল নিয়ে। একই বছর Donald Knuth দেখান কীভাবে কয়েকটি এলোমেলো পথ ধরে নেমে পুরো গাছের আকার আগাম আন্দাজ করা যায়। আর ৮-রানির ধাঁধা আরও পুরোনো: দাবা-ধাঁধার রচয়িতা Max Bezzel ১৮৪৮ সালে এটি প্রকাশ করেন, ১৮৫০ সালে Franz Nauck সব ৯২টি সমাধান প্রকাশ করেন, আর একই সময়ে Carl Friedrich Gauss জ্যোতির্বিদ বন্ধু Schumacher-এর সাথে চিঠিতে ধাঁধাটি নিয়ে আলোচনা করেন। “Gauss একাই সব সমাধান বের করেছিলেন” — প্রচলিত এই গল্পটি অতিরঞ্জিত; চিঠিগুলো দেখায় তিনি আগ্রহী ছিলেন আর কিছু সমাধান নিয়ে কাজ করেছিলেন।

৮ · Backtracking বনাম brute force বনাম DP

বিষয়Brute forceBacktrackingDP
কী করেসব পূর্ণ সম্ভাবনা বানিয়ে প্রতিটি যাচাইআংশিক সমাধানে যাচাই, অচল শাখা কেটে DFSএকই উপ-সমস্যার উত্তর একবার হিসাব করে মনে রাখা
কখন সবচেয়ে ভালোখুব ছোট n, বা অন্য সমাধান যাচাইয়ের জন্যসব সমাধান লাগে, বা শর্ত এত কড়া যে বেশিরভাগ শাখা দ্রুত কাটেউপ-সমস্যা কম আর বারবার আসে (অবস্থা ছোট — যেমন (i, s))
Subset sum2ⁿ টি subset × O(n)worst O(2ⁿ), বাস্তবে অনেক কমO(n · T) — T ছোট হলে দারুণ (পর্ব ৫৩)
Hamiltonian / TSPO(n · n!)O(n · n!) worst, pruning-এ কমO(2ⁿ · n²) bitmask DP (পর্ব ৫৫)
N-queens সব সমাধান ছাপাঅচলস্বাভাবিক পছন্দউপযুক্ত নয় — প্রতিটি সমাধান আলাদা ছাপতে হয়
বাড়তি জায়গাO(n)O(n)অবস্থার সংখ্যার সমান টেবিল — O(n · T), O(2ⁿ · n)

সিদ্ধান্তের একটি সহজ প্রশ্ন: “কোনো একটি অবস্থায় আমি কি দুটি ভিন্ন পথে পৌঁছাতে পারি, আর সেখান থেকে বাকি কাজ কি একই?” যদি হ্যাঁ, আর এমন অবস্থার সংখ্যা কম, তবে backtracking-এ memoization যোগ করুন — সেটিই top-down DP (পর্ব ৫২). Subset sum-এ (i, s) অবস্থা মাত্র n · T টি, তাই DP. কিন্তু N-queens-এ অবস্থা হলো “কোন কলাম আর কোণাকুণিগুলো দখল” — সংখ্যায় বিশাল, পুনরাবৃত্তি কম; আর সব সমাধান ছাপতে চাইলে উত্তরের আকারই exponential, কোনো পদ্ধতি তার চেয়ে কম সময়ে পারবে না। তখন ভালো pruning-সহ backtracking-ই সঠিক হাতিয়ার।

আরও গভীরে — Dancing Links আর SAT solver: Sudoku, N-queens আর pentomino ধাঁধাকে exact cover সমস্যা হিসেবে লেখা যায়, আর Donald Knuth ২০০০ সালে এর জন্য “Dancing Links” কৌশল দেন — একটি doubly linked list থেকে node মুছে পরে ঠিক একই জায়গায় O(1)-এ ফিরিয়ে আনা, যা “un-choose” ধাপকে প্রায় বিনামূল্যে করে দেয় (পর্ব ১২-এর linked list-এর এক সুন্দর প্রয়োগ)। আধুনিক SAT solver-ও মূলত backtracking (DPLL পদ্ধতি), সাথে conflict থেকে শেখা নতুন শর্ত আর বুদ্ধিমান ক্রম — chip-এর নকশা যাচাই বা সময়সূচি তৈরিতে লাখো ভেরিয়েবলের সমস্যাও এরা প্রায়ই মিলিয়ে ফেলে।

পরীক্ষা ও ইন্টারভিউতে:

  • Backtracking আর brute force-এর পার্থক্য কী? — backtracking আংশিক সমাধানেই শর্ত যাচাই করে অচল উপ-গাছ পুরোটা বাদ দেয়; brute force সব পূর্ণ সম্ভাবনা বানিয়ে তারপর যাচাই করে।
  • ৮ × ৮ বোর্ডে ৮-রানির কয়টি সমাধান, আর O(1) যাচাই কীভাবে? — ৯২টি (ঘোরানো-উল্টানো বাদ দিলে ১২টি মৌলিক); কলাম c, কোণাকুণি r + c আর r − c + n − 1-এর তিনটি boolean অ্যারে।
  • n টি জিনিসের সব subset আর সব permutation ছাপার সময় কত? — Θ(n · 2ⁿ) আর Θ(n · n!); জায়গা দুটিতেই Θ(n) (আউটপুট বাদে)।
  • Subset sum-এ কোন দুটি pruning, আর কেন সাজানো দরকার? — s + a[i] > T (সাজানো বলে পরের সব আরও বড়) আর s + rest[i] < T (বাকি সব নিলেও কম)।
  • “Generate all permutations” কোডে used[] undo না করলে কী হয়? — প্রথম শাখার পরে সংখ্যাগুলো চিরকাল “ব্যবহৃত” থেকে যায়, তাই শুধু প্রথম permutation-টি ছাপা হয়।

৯ · AI-এর যুগে backtracking — undo আর pruning যাচাই করুন

“N-queens solve করো” বা “sudoku solver লেখো” বললে AI সহকারী প্রায় নিশ্চিতভাবেই চলার মতো কোড দেবে — এগুলো ইন্টারনেটে সবচেয়ে বেশি লেখা সমস্যার মধ্যে পড়ে। ভুল আসে নতুন সমস্যায়, যেখানে AI-কে নিজে pruning বানাতে হয়: এমন সীমা যা আসলে সঠিক সমাধানও কেটে দেয় (ঋণাত্মক সংখ্যায় subset sum-এর সীমা), undo ভুলে যাওয়া, একই সমাধান দুবার ছাপা, বা জটিলতা নিয়ে ভুল দাবি — যেমন “pruning-এর পর এটি O(n²)”।

Backtracking-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
Recursion-এর পরে state ফেরানো হয়নি (used, বোর্ড, তালিকা)৩টি জিনিসের permutation চালান — ৬টির কম ফল এলে undo নেই
Python-এ result.append(path), কপি ছাড়াফলাফলে সব এন্ট্রি একই (প্রায়ই খালি) তালিকা দেখাবে
এমন pruning যা বৈধ সমাধানও কাটেPruning ছাড়া সংস্করণের সাথে ছোট এলোমেলো ইনপুটে ফল মেলান; ঋণাত্মক বা শূন্য মান দিন
ডুপ্লিকেট মানে একই subset বা permutation দুবার[1, 1, 2] দিন; সাজিয়ে “একই স্তরে একই মান আবার নয়” নিয়ম লাগে
“Pruning-এর পর polynomial” দাবিWorst-case ইনপুট চান; node কাউন্টার রেখে n বাড়িয়ে বৃদ্ধির হার দেখুন
N-queens-এ কোণাকুণির সূচক ভুল (r − c ঋণাত্মক)n = 1…8-এর গণনা 1, 0, 0, 2, 10, 4, 40, 92-এর সাথে মেলান

AI-কে সমাধান লিখতে না বলে আপনার গাছ, pruning আর জটিলতার যুক্তি যাচাই করতে বলুন:

আমি DSA শিখছি, এখন backtracking. নিচে আমার কোড (subset sum, দুটি pruning-সহ), একটি ছোট ইনপুটে আমার হাতে আঁকা state-space tree আর node-এর সংখ্যা দিলাম: [এখানে কোড, ইনপুট আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. প্রতিটি node-এ (i, s) আর কোন সীমার কারণে কাটা হলো তা দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. প্রতিটি pruning কেন কোনো বৈধ সমাধান হারায় না, তা যুক্তি দিয়ে প্রমাণ করো; কোন অনুমান (সাজানো, ধনাত্মক) লাগছে তা আলাদা করে বলো। ৩. worst-case সময় আর জায়গা বলো, আর এমন একটি ইনপুট দাও যেখানে pruning প্রায় কিছুই কাটে না। ৪. আমার কোড ভুল উত্তর দেবে এমন একটি ছোট ইনপুট খোঁজার চেষ্টা করো (শূন্য, ঋণাত্মক, ডুপ্লিকেট, T = 0) — না পেলে কী কী পরীক্ষা করেছ বলো।

Backtracking-এর কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১৮-রানির ধাঁধা কেন প্রায় দুই শতাব্দী ধরে গণিত আর প্রোগ্রামিং শেখানোর প্রিয় উদাহরণ?

ধাঁধাটি প্রথম প্রকাশিত হয় ১৮৪৮ সালে, একটি জার্মান দাবা-পত্রিকায়, দাবা-ধাঁধার রচয়িতা Max Bezzel-এর নামে। দুই বছর পর, ১৮৫০ সালে, Franz Nauck সব ৯২টি সমাধান প্রকাশ করেন। প্রায় একই সময়ে Carl Friedrich Gauss জ্যোতির্বিদ Heinrich Schumacher-এর সাথে চিঠিতে ধাঁধাটি নিয়ে লেখেন। পরে একটি গল্প ছড়ায় যে Gauss একাই সব সমাধান বের করে ফেলেছিলেন; ইতিহাসবিদরা সতর্ক করেন যে চিঠিগুলো এতটা দাবি সমর্থন করে না — তিনি আগ্রহী ছিলেন, পদ্ধতি নিয়ে ভেবেছিলেন, কিন্তু পূর্ণ তালিকার কৃতিত্ব Nauck-এর।

প্রোগ্রামিংয়ের ইতিহাসে ধাঁধাটির জায়গা আরও বড়। ১৯৭১ সালে Niklaus Wirth — Pascal ভাষার স্রষ্টা — “Program Development by Stepwise Refinement” প্রবন্ধে ৮-রানিকে উদাহরণ হিসেবে নেন: কীভাবে একটি বড় সমস্যাকে ধাপে ধাপে ছোট, স্পষ্ট অংশে ভাঙতে হয়। Edsger Dijkstra-ও তাঁর structured programming-এর লেখায় একই ধাঁধা ব্যবহার করেন। কারণটি সহজ: সমস্যাটি এক বাক্যে বোঝানো যায়, কিন্তু সরল brute force (৮⁸ বোর্ড) সে যুগের কম্পিউটারে অনেক ধীর, আর ভালো সমাধানে লাগে ঠিক সেই ধারণাগুলো যা প্রোগ্রামিংয়ের মূল — উপযুক্ত উপস্থাপন (প্রতি সারিতে একটি কলাম-সংখ্যা), incremental state (তিনটি অ্যারে) আর recursion.

গণিতেও ধাঁধাটি থেমে থাকেনি। n ≥ 4 হলে সবসময় সমাধান আছে, আর একটি সমাধান সরাসরি সূত্র দিয়ে O(n)-এ বানানো যায় — backtracking ছাড়াই। কিন্তু সব সমাধান গোনা অনেক কঠিন; কোনো সহজ সূত্র জানা নেই, আর n একটু বড় হলেই সংখ্যাটি বিশাল হয়ে যায়। ২০-এর ঘরের n-এর জন্য গণনা করতে গবেষকদের বিশাল সমান্তরাল কম্পিউটার-হিসাব লেগেছে, মূলত bitmask backtracking আর symmetry দিয়ে। ২০২১ সালে Michael Simkin দেখান যে বড় n-এ সমাধানের সংখ্যা মোটামুটি (0.143n)ⁿ — অর্থাৎ বৃদ্ধি n!-এর কাছাকাছি হারে।

মূল উপলব্ধি: একটি সমস্যা যত সহজে বলা যায় আর যত বিশাল তার খোঁজার জায়গা, তত ভালোভাবে সেটি দেখায় উপস্থাপন আর pruning কেন গুরুত্বপূর্ণ — ৮-রানি ঠিক সেই ভারসাম্যের উদাহরণ।

প্র ০২Lehmer-এর “backtrack” থেকে Bitner–Reingold-এর সমীক্ষা পর্যন্ত — একটি কৌশল কীভাবে একটি স্বতন্ত্র অ্যালগরিদম-নকশা পদ্ধতি হয়ে উঠল?

“সব সম্ভাবনা দেখুন, কিন্তু অচল পথ তাড়াতাড়ি ছাড়ুন” — এই বুদ্ধি মানুষের অনেক পুরোনো; গোলকধাঁধার Trémaux পদ্ধতি তার একটি রূপ (পর্ব ৪১). কিন্তু একে একটি নামযুক্ত, সাধারণ প্রোগ্রামিং-পদ্ধতি হিসেবে দেখা শুরু হয় প্রথম দিকের ইলেকট্রনিক কম্পিউটারের যুগে। মার্কিন গণিতবিদ D. H. Lehmer, যিনি সংখ্যাতত্ত্বের হিসাবে যন্ত্র ব্যবহারের পথিকৃৎ, ১৯৫০-এর দশকে “backtrack” শব্দটি চালু করেছিলেন বলে প্রচলিত। তখন কম্পিউটার ছিল অত্যন্ত ধীর আর মেমরি ছিল খুব কম — তাই এমন পদ্ধতি দরকার ছিল যা বিশাল খোঁজার জায়গা ঘুরতে পারে, কিন্তু মেমরিতে শুধু বর্তমান পথটি রাখে।

১৯৬৫ সালে Solomon Golomb (পরে shift-register sequence আর polyomino-র জন্য বিখ্যাত) আর Leonard Baumert Journal of the ACM-এ “Backtrack Programming” প্রকাশ করেন, যেখানে পদ্ধতিটির সাধারণ কাঠামো — আংশিক সমাধান, প্রতিটি ধাপের বিকল্প, শর্ত যাচাই — পরিষ্কারভাবে লেখা হয়, আর polyomino দিয়ে আয়তক্ষেত্র ভরার মতো ধাঁধায় প্রয়োগ দেখানো হয়। ১৯৭৫ সালে James Bitner আর Edward Reingold Communications of the ACM-এ “Backtrack programming techniques” প্রকাশ করেন। এই সমীক্ষায় তাঁরা দেখান যে একই অ্যালগরিদমের বিভিন্ন বাস্তবায়নে চলার সময় বহু গুণ আলাদা হতে পারে — ভেরিয়েবলের ক্রম, symmetry বাদ দেওয়া, আর incremental ডেটা স্ট্রাকচারের ওপর নির্ভর করে। এই পর্বের অংশ ৭-এর প্রায় সব কৌশল এই ধারার।

একই বছর Donald Knuth “Estimating the efficiency of backtrack programs” প্রবন্ধে একটি চমৎকার প্রশ্নের উত্তর দেন: পুরো খোঁজা চালানোর আগে কীভাবে জানব এটি এক মিনিট লাগবে, না এক শতাব্দী? তাঁর পদ্ধতি: root থেকে এলোমেলোভাবে একটি পথ ধরে নামুন, প্রতিটি স্তরে কয়টি সন্তান ছিল তা গুণ করতে থাকুন; এই গুণফলগুলোর যোগফল পুরো গাছের node-সংখ্যার একটি নিরপেক্ষ (unbiased) আন্দাজ। কয়েকশো এমন পথের গড় নিলেই প্রায়ই কাজ চলার মতো অনুমান পাওয়া যায়।

মূল উপলব্ধি: backtracking কোনো একক অ্যালগরিদম নয়, একটি নকশা-পদ্ধতি; তার দক্ষতা আসে প্রকৌশল থেকে — ক্রম, pruning আর state — আর সেই প্রকৌশল মাপার উপায় হলো node গোনা।

প্র ০৩Hamilton-এর একটি খেলনা থেকে NP-complete — Hamiltonian cycle কেন এত কঠিন, অথচ Euler circuit এত সহজ?

১৮৫৭ সালে আইরিশ গণিতবিদ William Rowan Hamilton “Icosian game” নামে একটি খেলা তৈরি করেন: একটি dodecahedron-এর (১২টি পঞ্চভুজ তলের ঘনবস্তু) ২০টি কোণ, প্রতিটি একটি শহর, আর খেলোয়াড়কে ধার বরাবর এমন একটি ভ্রমণ খুঁজতে হবে যা প্রতিটি শহর ঠিক একবার ছুঁয়ে শুরুতে ফেরে। খেলনাটি বাজারে তেমন সফল হয়নি, কিন্তু প্রশ্নটি তাঁর নাম পেয়ে গেল। প্রায় একই ধরনের প্রশ্ন আরও আগে দাবার ঘোড়ার ভ্রমণ (knight's tour) নিয়েও ভাবা হয়েছিল, যা Euler-ও অধ্যয়ন করেছিলেন।

পার্থক্যটি বিস্ময়কর। Euler circuit — প্রতিটি edge একবার — আছে কি না তা একটি সরল শর্তে বলা যায়: গ্রাফ connected আর সব vertex-এর degree জোড়। এটি একটি স্থানীয় শর্ত: প্রতিটি vertex নিজে দেখেই বলতে পারে। Hamiltonian cycle-এর এমন কোনো স্থানীয় বৈশিষ্ট্য জানা নেই; এর অস্তিত্ব পুরো গ্রাফের গঠনের ওপর নির্ভর করে। কিছু যথেষ্ট শর্ত আছে — যেমন Dirac-এর উপপাদ্য (১৯৫২): n ≥ 3 টি vertex-এর simple গ্রাফে প্রতিটি vertex-এর degree অন্তত n/2 হলে Hamiltonian cycle আছেই — কিন্তু এগুলো প্রয়োজনীয় নয়, তাই “না” উত্তর দিতে পারে না।

১৯৭২ সালে Richard Karp দেখান যে Hamiltonian cycle NP-complete: এর একটি polynomial সময়ের অ্যালগরিদম পেলে SAT, graph colouring, subset sum-সহ হাজারো সমস্যার polynomial সমাধান পাওয়া যেত। একটি “হ্যাঁ” উত্তর যাচাই করা সহজ — কেউ cycle-টি দিলে O(n)-এ মিলিয়ে দেখা যায় — কিন্তু খুঁজে পাওয়া, বা “নেই” প্রমাণ করা, আপাতদৃষ্টিতে কঠিন। তাই এই পর্বের backtracking বা পর্ব ৫৫-এর O(2ⁿ · n²) DP-ই আজকের প্রধান হাতিয়ার, আর বাস্তবে ডেলিভারি-রুট বা সার্কিট-বোর্ড ড্রিলিংয়ের মতো সমস্যায় heuristic আর approximation (পর্ব ৬৬).

মূল উপলব্ধি: প্রায় একই রকম শোনানো দুটি প্রশ্নের একটি রৈখিক সময়ে, অন্যটি সম্ভবত শুধু exponential সময়ে সমাধানযোগ্য; কোন সমস্যা কোন দলে, তা চেনাই অ্যালগরিদম-নকশার প্রথম দক্ষতা।

প্র ০৪Sudoku-র মতো ধাঁধা থেকে SAT solver পর্যন্ত — backtracking কীভাবে আজকের শিল্পে লাখো ভেরিয়েবলের সমস্যা সামলায়?

Sudoku প্রথম দেখায় একটি সংবাদপত্রের ধাঁধা, কিন্তু কম্পিউটার বিজ্ঞানে এটি constraint satisfaction-এর আদর্শ ছোট উদাহরণ। সাধারণ n² × n² sudoku NP-complete — জাপানি গবেষক Takayuki Yato আর Takahiro Seta ২০০৩ সালে তা দেখান। আর ৯ × ৯ sudoku-তে একটি অনন্য সমাধানের জন্য কমপক্ষে কয়টি ঘর আগে থেকে ভরা থাকতে হয়, এই প্রশ্নের উত্তর (১৭) ২০১২ সালে Gary McGuire-এর দল একটি বিশাল কম্পিউটার-খোঁজা দিয়ে প্রমাণ করে: তাঁরা দেখান যে ১৬টি দেওয়া ঘরের কোনো ধাঁধার অনন্য সমাধান নেই — সেটিও আসলে চতুর pruning-সহ একটি exhaustive search.

শিল্পে সবচেয়ে বড় backtracking-যন্ত্র হলো SAT solver: হাজারো বা লাখো boolean ভেরিয়েবল আর শর্তের একটি সূত্র — সব শর্ত একসাথে সত্য করা যায় কি না। এর ভিত্তি ১৯৬২ সালের DPLL পদ্ধতি (Davis, Putnam, Logemann, Loveland): একটি ভেরিয়েবলে মান বসানো, যেসব শর্তে মাত্র একটি বিকল্প বাকি সেগুলো সাথে সাথে বসানো (unit propagation — sudoku-র “এই ঘরে একটিমাত্র অঙ্ক বসে” নিয়মের মতো), আর বিরোধ হলে ফেরত। ১৯৯০-এর দশক থেকে এর সাথে যুক্ত হয় conflict-driven clause learning: প্রতিটি ব্যর্থতা থেকে একটি নতুন শর্ত শিখে নেওয়া, যাতে একই কারণে আর কখনো ব্যর্থ না হতে হয়, আর অনেক স্তর একসাথে পেছনে লাফ দেওয়া (non-chronological backtracking).

ফলাফল চমকপ্রদ: সমস্যাটি তত্ত্বে NP-complete হলেও, chip-এর নকশা যাচাই, সফটওয়্যারের বাগ খোঁজা, বিমানের সময়সূচি বা পরীক্ষার রুটিন তৈরির মতো বাস্তব সমস্যায় এই solver-গুলো প্রায়ই সেকেন্ডে উত্তর দেয়। কারণ বাস্তব ইনপুটের গঠন থাকে, আর ভালো pruning ও ক্রম সেই গঠন কাজে লাগায়। কিন্তু নিশ্চয়তা নেই — কিছু ছোট কৃত্রিম সমস্যাও এদের কাছে অসম্ভব কঠিন।

মূল উপলব্ধি: worst-case exponential হওয়া মানেই অব্যবহারযোগ্য নয়; সঠিক pruning, ক্রম আর শেখার কৌশলে backtracking বাস্তবের বহু “কঠিন” সমস্যা নিয়মিত সমাধান করে।

প্র ০৫কনটেস্টে সময়-সীমা দেখে কীভাবে বুঝবেন backtracking চলবে কি না — আর না চললে কোন দিকে যাবেন?

কনটেস্টে সাধারণ হিসাব হলো, একটি C++ প্রোগ্রাম প্রতি সেকেন্ডে মোটামুটি ১০৮টি সরল কাজ করে (মেশিন আর কাজের ধরনভেদে কয়েক গুণ কম-বেশি)। এখান থেকে উল্টো দিকে ভাবুন। 2ⁿ: n = 20-এ প্রায় ১০ লাখ, n = 25-এ প্রায় ৩ কোটি ৩৫ লাখ — ঠিক আছে; n = 30-এ ১০০ কোটির বেশি — বিপজ্জনক। n!: 10! প্রায় ৩৬ লাখ, ঠিক আছে; 12! প্রায় ৪৮ কোটি — সীমায়; 13! প্রায় ৬২৩ কোটি — অচল। তাই সমস্যায় n ≤ 10–12 লেখা থাকলে প্রায়ই permutation-backtracking প্রত্যাশিত, n ≤ 20–25 মানে subset বা bitmask, আর n ≤ 40 দেখলে ভাবুন “meet in the middle” — অর্ধেক অর্ধেক করে দুটি 2²⁰-এর তালিকা বানিয়ে মেলানো।

কিন্তু pruning-যুক্ত backtracking-এর node-সংখ্যা এই সীমার অনেক নিচে থাকতে পারে, আর তা আগে থেকে বলা কঠিন। নিরাপদ পদ্ধতি: একটি node কাউন্টার রাখুন, worst-case ধাঁচের ইনপুট বানিয়ে চালান (সব সমান মান, সমাধান নেই এমন ইনপুট — যেখানে pruning সবচেয়ে কম কাটে)। আরেকটি পরিচিত কৌশল: সমাধান শুধু ছোট কয়েকটি n-এর জন্য, যেমন N-queens-এ n ≤ 13 — তখন সব উত্তর আগেই হিসাব করে কোডে একটি টেবিল হিসেবে রেখে দেওয়া।

না চললে তিনটি দিক। (১) DP: একই (i, s) বা (mask, শেষ vertex) অবস্থায় বারবার আসছেন? তবে memoization — subset sum-এ O(n · T), Hamiltonian-এ O(2ⁿ · n²). (২) ভালো bound: optimization-এ এ পর্যন্ত সেরা উত্তর রেখে, প্রতিটি শাখার আশাবাদী হিসাব দিয়ে কাটা — পর্ব ৫৭-এর branch and bound. (৩) সমস্যার বিশেষ গঠন: গ্রাফটি tree, বা DAG, বা bipartite হলে প্রায়ই polynomial সমাধান আছে।

মূল উপলব্ধি: backtracking বাছার আগে সীমা দেখে আকার আন্দাজ করুন, বাছার পরে node গুনে যাচাই করুন — আর আটকালে প্রশ্ন করুন, অবস্থা কি পুনরাবৃত্ত হচ্ছে, না কি একটি ভালো bound সম্ভব?

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: সংখ্যা 1 2 4 5 (সাজানো), T = 6. অংশ ৪-এর দুই সীমাসহ (“নেব” আগে, তারপর “নেব না”) কোন subset-গুলো পাওয়া যায়, আর কোন ক্রমে? কোথায় কোথায় কাটা হয়?

    rest = 12, 11, 9, 5. পথ: 1 নিই (s = 1), 2 নিই (s = 3), 4 নিলে 7 > 6 → কাটা। 2 বাদ (s = 1, i = 2): 4 নিই (s = 5), 5 নিলে 10 > 6 → কাটা; 4 বাদ (s = 1, i = 3): 5 নিই → s = 6, {1, 5}; 5 বাদ → সংখ্যা শেষ, ব্যর্থ। 1 বাদ (s = 0, i = 1): 2 নিই (s = 2), 4 নিই → s = 6, {2, 4}; 4 বাদ (s = 2, i = 3): 2 + 5 = 7 > 6 → কাটা। 2 বাদ (s = 0, i = 2): 0 + rest[2] = 9 ≥ 6, তাই চলে; 4 নিই (s = 4), 4 + 5 = 9 > 6 → কাটা; 4 বাদ (s = 0, i = 3): 0 + 5 < 6 → bound কাটে।

    ফল ক্রমানুসারে {1, 5}, তারপর {2, 4}. Feasibility সীমা চারবার কাটে, bound একবার। আর {1, 2, …}-এর মতো কোনো তিন-সংখ্যার দল সম্ভব নয় (1 + 2 + 4 = 7 > 6) — গাছ সেটি নিজেই বুঝে নেয়।

  2. ভেবে বলুন: (ক) ৫ × ৫ বোর্ডে প্রথম সারির রানি কলাম 0-এ রেখে প্রথম যে সমাধান পাওয়া যায় (কলাম বাঁ থেকে ডানে চেষ্টা) সেটি কী? (খ) N-queens-এ “প্রথম সারির রানি শুধু বাঁ অর্ধেকে, তারপর ফল দ্বিগুণ” — বিজোড় n-এ এটি ঠিকভাবে করতে কী সাবধানতা লাগে?

    (ক) সারি 0 → 0; সারি 1: কলাম 0 কলাম-বরাবর, 1 কোণাকুণি, 2 নিরাপদ; সারি 2: কলাম 0, 2 কলাম, 1 আর 3 রানি (1,2)-এর কোণাকুণি, 4 নিরাপদ; সারি 3: কলাম 0 কলাম, 1 নিরাপদ? (3,1): r + c = 4, আগের 0, 3, 6 নয়; r − c = 2, আগের 0, −1, −2 নয় — নিরাপদ। সারি 4: কলাম 3 — (4,3): r + c = 7, r − c = 1 — নিরাপদ। সমাধান 0 2 4 1 3, কোনো ফেরত ছাড়াই।

    (খ) n বিজোড় হলে মাঝের কলাম (n − 1)/2 নিজের আয়নার প্রতিবিম্ব। তাই প্রথম সারির রানি কলাম 0 থেকে (n − 1)/2 − 1 পর্যন্ত রেখে গণনা দ্বিগুণ করুন, আর মাঝের কলামে রেখে পাওয়া গণনা একবার যোগ করুন। মাঝেরটিও দ্বিগুণ করলে ভুল উত্তর আসবে — যেমন n = 5-এ ১০-এর বদলে ১২ (প্রথম সারির প্রতিটি কলামে ২টি করে সমাধান)।

  3. গুনে বলুন: {a, b, c, d, e} থেকে ৩টির সব combination তৈরি করা গাছে কয়টি leaf? permutation গাছে (৫টির সব ক্রম) মোট কয়টি node (root সহ)? আর ৬টি vertex-এর গ্রাফে Hamiltonian cycle খুঁজতে, প্রথম vertex স্থির রেখে, সবচেয়ে বেশি কয়টি পূর্ণ পথ (সব vertex ছোঁয়া পথ) পরীক্ষা করতে হতে পারে?

    Combination leaf = C(5, 3) = 5!/(3! · 2!) = ১০. Permutation গাছের node = 1 + 5 + 5·4 + 5·4·3 + 5·4·3·2 + 5! = 1 + 5 + 20 + 60 + 120 + 120 = ৩২৬ (≈ e · 5! = ৩২৬.২). Hamiltonian: প্রথম vertex স্থির, বাকি ৫টির সব ক্রম = 5! = ১২০টি পূর্ণ পথ। (Complete graph-এ প্রথমটিই সফল হয়, তাই worst case আসে প্রায়-complete গ্রাফে যেখানে শেষ edge-গুলো অনুপস্থিত; আর প্রতিটি cycle দুই দিকে দুবার গোনা হয়, তাই আলাদা cycle ৬০টি।)

  4. কোড লিখুন: অংশ ৫-এর m-colouring pseudocode C++-এ লিখুন, আর অংশ ৬-এর ৫-vertex গ্রাফে m = 2 আর m = 3 দিয়ে চালান। প্রতিটির ফল আর একটি বৈধ রং-বিন্যাস দিন। সময় ও জায়গার জটিলতা কত?
    vector<vector<int>> adj = {{1, 2, 4}, {0, 2, 3}, {0, 1, 4}, {1, 4}, {0, 2, 3}};
    vector<int> colour(5, 0);                     // 0 = no colour yet
    bool colourFrom(int v, int m) {
        if (v == (int)adj.size()) return true;
        for (int c = 1; c <= m; ++c) {
            bool ok = true;
            for (int u : adj[v]) if (colour[u] == c) ok = false;   // feasibility
            if (!ok) continue;
            colour[v] = c;                                         // choose
            if (colourFrom(v + 1, m)) return true;                 // explore
            colour[v] = 0;                                         // un-choose
        }
        return false;
    }

    m = 2: অসম্ভব — 0, 1, 2 একটি ত্রিভুজ (তিনজনই পরস্পর যুক্ত), তাই অন্তত ৩টি রং লাগে; ফাংশন false দেয়। m = 3: প্রথম পাওয়া বিন্যাস 0 → 1, 1 → 2, 2 → 3, 3 → 1, 4 → 2 (যাচাই: 3–1 আলাদা, 3–4 আলাদা, 4–0, 4–2 আলাদা)। সময় worst case O(mⁿ · (n + E)) — প্রতিটি vertex-এ m টি রং, প্রতিটিতে O(deg v) যাচাই; জায়গা O(n) (colour অ্যারে আর recursion). প্রতিবার m বদলানোর আগে colour আবার শূন্য করতে ভুলবেন না — ব্যর্থ খোঁজা নিজেই সব শূন্য করে ফেরে, কিন্তু সফল খোঁজা রং রেখে দেয়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি backtracking ফাংশনের এক ডাকের ভেতরের কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. আংশিক সমাধানটি পূর্ণ কি না দেখা, পূর্ণ হলে রেখে ফেরা
  2. পরের সিদ্ধান্তের একটি বিকল্প নেওয়া আর শর্ত মানে কি না যাচাই করা
  3. বিকল্পটি state-এ বসানো (choose)
  4. নিজেকে ডেকে পরের সিদ্ধান্তে যাওয়া (explore)
  5. ফিরে এসে বিকল্পটি state থেকে মুছে পরের বিকল্পে যাওয়া (un-choose)

৮ × ৮ দাবার বোর্ডে ৮টি রানি এমনভাবে বসানোর কয়টি উপায় আছে যাতে কেউ কাউকে আক্রমণ না করে?

  • ১২
  • ৪০
  • ৯২
  • ৪০,৩২০

N-queens-এ সারি r আর কলাম c-এর একটি ঘর। কোন রাশিটি একই “\” কোণাকুণির (ওপর-বাঁ থেকে নিচ-ডানে) সব ঘরে সমান থাকে?

  • r + c
  • r − c
  • r · c
  • c

ধনাত্মক সংখ্যার subset sum-এ সংখ্যাগুলো ছোট থেকে বড় সাজানো, চলতি যোগফল s, লক্ষ্য T, আর rest[i] = a[i] + … + a[n−1]. কোন অবস্থায় index i-এর পুরো উপ-গাছ নিরাপদে বাদ দেওয়া যায়?

  • s < T হলে
  • a[i] < T হলে
  • s + rest[i] ≥ T হলে
  • s + a[i] > T হলে, অথবা s + rest[i] < T হলে

n টি আলাদা জিনিসের সব permutation backtracking দিয়ে ছাপালে worst-case সময় আর বাড়তি জায়গা (আউটপুট বাদে) কত?

  • সময় Θ(n · n!), জায়গা Θ(n)
  • সময় Θ(n · 2ⁿ), জায়গা Θ(n)
  • সময় Θ(n²), জায়গা Θ(n)
  • সময় Θ(n!), জায়গা Θ(n · n!)

Python-এ সব subset সংগ্রহের একটি backtracking ফাংশন result.append(path) লেখে (path একটি তালিকা, পরে path.pop() হয়)। শেষে result-এ কী দেখা যাবে?

  • সব subset সঠিকভাবে
  • সঠিক সংখ্যক এন্ট্রি, কিন্তু সবগুলো একই (খালি) তালিকা
  • শুধু একটি এন্ট্রি
  • RecursionError

একটি গ্রাফে Hamiltonian cycle আছে কি না — এই প্রশ্ন সম্পর্কে কোন বক্তব্যটি সঠিক?

  • সব vertex-এর degree জোড় কি না দেখে O(V + E)-এ বলা যায়
  • একবার DFS চালালেই উত্তর পাওয়া যায়
  • এটি NP-complete; জানা পদ্ধতিগুলো worst case-এ exponential সময় নেয়
  • ভালো pruning দিলে backtracking-এ এটি polynomial সময়ে হয়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Backtracking = state-space tree-তে DFS: choose → explore → un-choose; একটিই state, ফেরার সময় ঠিক উল্টো করে মোছা; জায়গা O(গভীরতা).
  2. তিনটি মৌলিক গাছ: subset (2ⁿ), permutation (n!), combination (C(n, k)); সময় = node সংখ্যা × node প্রতি কাজ।
  3. N-queens: সারি ধরে বসানো, কলাম c, কোণাকুণি r + c আর r − c + n − 1-এর অ্যারে দিয়ে O(1) যাচাই; n = 1…8-এ 1, 0, 0, 2, 10, 4, 40, 92.
  4. Pruning: feasibility (শর্ত ভাঙলেই থামুন), bound (পৌঁছানো অসম্ভব হলে থামুন), ordering (most-constrained-first), symmetry; subset sum-এ ২৫৫ থেকে ৪৫ node — উত্তর একই।
  5. Worst case exponential; Hamiltonian cycle, 3-colouring, sudoku NP-complete (পর্ব ৬৪). অবস্থা পুনরাবৃত্ত হলে DP, optimization-এ bound দিয়ে branch and bound.

পরের পর্বে: শুধু “সমাধান আছে কি না” নয়, “সবচেয়ে ভালো সমাধান কোনটি” — প্রতিটি শাখার আশাবাদী সীমা হিসাব করে বড় অংশ কেটে ফেলা, branch and bound আর traveling salesman problem.

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

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান