পাঠ ৩৬ · ৫৭-এর মধ্যে · মডিউল ৮
Home / Courses / Design and Analysis of Algorithms / ব্যাকট্র্যাকিং

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

The backtracking paradigm & N-Queens
১২ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ব্যাকট্র্যাকিং-এর সাধারণ কাঠামো — চয়েস করো, এক্সপ্লোর করো, প্রয়োজনে আনডু করো (undo)
  • N-Queens-কে "প্রতি সারিতে একটি কলাম" সমস্যা হিসেবে মডেল করা এবং $O(1)$-এ কলাম/ডায়াগোনাল কনফ্লিক্ট চেক করা
  • একটি সত্যিকারের নোড-কাউন্টিং ইমপ্লিমেন্টেশন — প্রতিটি রিকার্সিভ কল একটি সার্চ-ট্রি নোড হিসেবে গোনা
  • প্রুনিং আসলে কতটা কাজ বাঁচায় তার একটি জেনুইন, কম্পিউট করা সংখ্যাগত প্রমাণ — শুধু "অনেক দ্রুত" বলে ছেড়ে দেওয়া নয়

১ · ব্যাকট্র্যাকিং প্যারাডাইম কী

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

পরবর্তী সারিতে একটি কলাম বেছে নাও কনফ্লিক্ট আছে কি? কলাম/ডায়াগোনাল চেক না -> বসাও, পরের সারিতে recurse সব সারি পূর্ণ? হ্যাঁ হলে সমাধান রেকর্ড হ্যাঁ -> প্রুন (এই কলাম আর নয়) সাবট্রি প্রুন recurse করা হবে না ব্যাকট্র্যাক -> পরের কলাম চেষ্টা করো
প্রতিটি সারিতে একটি কলাম চেষ্টা করা হয়; কনফ্লিক্ট পাওয়া গেলে সেই শাখাটি তৎক্ষণাৎ প্রুন হয়ে যায় — কখনো সেই শাখার নিচে recurse করা হয় না, ফলে বিপুল সংখ্যক অবৈধ প্লেসমেন্ট কখনোই এক্সপ্লোর করতে হয় না।

২ · N-Queens মডেলিং — "প্রতি সারিতে একটি কলাম"

একটি বৈধ N-Queens সমাধানে প্রতিটি সারিতে ঠিক একটি কুইন থাকতেই হবে (দুটি কুইন একই সারিতে থাকলে তারা একে অপরকে আক্রমণ করবে)। তাই সমাধান খোঁজার সময় আমরা প্রতিটি সারির জন্য ঠিক একটি কলাম বেছে নেওয়ার সমস্যা হিসেবে মডেল করতে পারি — এতে কোনো বৈধ সমাধান বাদ পড়ে না, কারণ প্রতিটি বৈধ সমাধানই এই আকারে প্রকাশযোগ্য। ফলাফলকে placement[row] = col আকারের একটি অ্যারে হিসেবে রাখা হয়।

এই মডেলিং-এর পরেও, যদি কোনো কনফ্লিক্ট-চেক ছাড়াই প্রতিটি সারিতে $N$টি কলামের যেকোনো একটি বেছে নেওয়া হতো, তাহলে মোট সম্ভাব্য প্লেসমেন্ট সংখ্যা হতো:

$$\text{নেইভ স্পেস} = \underbrace{N \times N \times \cdots \times N}_{N \text{ বার}} = N^N$$

কারণ প্রতিটি $N$টি সারির প্রতিটির জন্য স্বাধীনভাবে $N$টি কলামের যেকোনো একটি বেছে নেওয়া যায়। $N=8$-এ এটি $8^8 = 16{,}777{,}216$ — প্রায় ১.৬ কোটি সম্ভাব্য প্লেসমেন্ট, যার বেশিরভাগই অবশ্যই অবৈধ (একাধিক কুইন একই কলামে বা ডায়াগোনালে)।

একই কলাম
দুটি কুইন $(r_1, c_1)$ ও $(r_2, c_2)$ একই কলামে আছে যদি $c_1 = c_2$।
একই ডায়াগোনাল
$|r_1 - r_2| = |c_1 - c_2|$ হলে তারা একই ডায়াগোনালে আছে (দুই দিকেই — "\" এবং "/")।

নিচের কোডে তিনটি সেট ব্যবহার করা হয়েছে — cols, diag1 ($r - c$, একটি ডায়াগোনাল দিক), এবং diag2 ($r + c$, অন্য দিক) — যাতে প্রতিটি কনফ্লিক্ট চেক $O(1)$ সময়ে করা যায়, পুরনো সব কুইনের সাথে সরাসরি তুলনা না করে।

৩ · নোড কাউন্টিং সহ সম্পূর্ণ ইমপ্লিমেন্টেশন

নিচের কোডে backtrack(row) ফাংশনটি যতবার কল হয় ততবার node_count বাড়ানো হয় — অর্থাৎ সার্চ ট্রি-র প্রতিটি নোড (একটি নির্দিষ্ট আংশিক প্লেসমেন্ট স্টেট) ভিজিট করার সাথে সাথে গোনা হয়। এটি সত্যিই চলমান একটি ব্যাকট্র্যাকিং সমাধান — কোনো সংখ্যা অনুমান করা নয়।

Python
def solve_n_queens(n):
    # নোড কাউন্টার: backtrack() যতবার কল হয়, ততবার +1
    # -- প্রতিটি কল মানেই সার্চ ট্রি-র একটি নোড (একটি আংশিক প্লেসমেন্ট) ভিজিট করা
    node_count = 0
    solutions = []
    cols = set()          # ব্যবহৃত কলামসমূহ
    diag1 = set()         # row - col (সমান হলে একই "\" ডায়াগোনাল)
    diag2 = set()         # row + col (সমান হলে একই "/" ডায়াগোনাল)
    placement = []        # placement[row] = সেই সারিতে কুইনের কলাম

    def backtrack(row):
        nonlocal node_count
        node_count += 1
        if row == n:
            solutions.append(placement.copy())
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue  # কনফ্লিক্ট -- এই শাখা প্রুন, কখনো recurse করা হবে না
            cols.add(col); diag1.add(row - col); diag2.add(row + col)
            placement.append(col)
            backtrack(row + 1)
            placement.pop()
            cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)

    backtrack(0)
    return solutions, node_count


print(f"{'N':>3} | {'সমাধান':>8} | {'নোড এক্সপ্লোরড':>15} | {'নেইভ N^N':>15} | {'অনুপাত':>12}")
for n in [6, 8]:
    solutions, nodes = solve_n_queens(n)
    naive_space = n ** n
    ratio = nodes / naive_space
    print(f"{n:>3} | {len(solutions):>8} | {nodes:>15} | {naive_space:>15} | {ratio:.6%}")

# প্রতিটি সমাধান সত্যিই বৈধ কিনা হাতে-কলমে যাচাই (কোনো দুটি কুইন কনফ্লিক্টে নেই তো)
def is_valid_solution(placement):
    n = len(placement)
    for r1 in range(n):
        for r2 in range(r1 + 1, n):
            c1, c2 = placement[r1], placement[r2]
            if c1 == c2 or abs(r1 - r2) == abs(c1 - c2):
                return False
    return True

sample_solutions, _ = solve_n_queens(8)
print("\nN=8-এর সবগুলো সমাধান সত্যিই বৈধ কিনা:", all(is_valid_solution(s) for s in sample_solutions))

    
আউটপুটে দেখা যাচ্ছে — $N=6$-এ ব্যাকট্র্যাকিং মাত্র $153$টি নোড ভিজিট করে ৪টি সমাধান খুঁজে পায়, যেখানে নেইভ স্পেস $6^6 = 46{,}656$ — অর্থাৎ মাত্র ০.৩৩% এক্সপ্লোর করা হয়েছে। $N=8$-এ নোড সংখ্যা $2057$, নেইভ স্পেস $8^8 = 16{,}777{,}216$ — মাত্র ০.০১২৩%! লক্ষ্য করুন $N$ বাড়ার সাথে সাথে এই অনুপাত আরও ছোট হচ্ছে — প্রুনিং-এর কার্যকারিতা ইনপুট বড় হলে আরও বৃদ্ধি পায়, কারণ প্রতিটি সারিতে একটি কনফ্লিক্ট ধরা পড়লে সেই এক মুহূর্তেই বাকি $N - \text{row}$ সারির জন্য সম্ভাব্য $N^{N-\text{row}}$ শাখা একসাথে বাতিল হয়ে যায়।
মূল কথা · Key takeaway

ব্যাকট্র্যাকিং এখনো ওয়ার্স্ট-কেসে এক্সপোনেনশিয়াল (N-Queens NP-hard নয়, কিন্তু কোনো পলিনোমিয়াল-টাইম অ্যালগরিদম জানা নেই) — কিন্তু "এক্সপোনেনশিয়াল" মানে "নেইভ এক্সপোনেনশিয়াল স্পেসের সমান খারাপ" নয়। উপরের সংখ্যাগুলো দেখাচ্ছে বাস্তবে এক্সপ্লোর করা নোড সংখ্যা নেইভ স্পেসের তুলনায় বিপুলভাবে ছোট হতে পারে — এবং এই পার্থক্যটাই ব্যাকট্র্যাকিং-কে ব্যবহারযোগ্য করে তোলে ছোট-থেকে-মাঝারি ইনপুটে, যেখানে খাঁটি ব্রুট-ফোর্স ব্যবহারিকভাবে অসম্ভব হয়ে যেত। L37-এ আমরা এই একই নোড-কাউন্টিং কৌশল সাবসেট সাম ও হ্যামিল্টোনিয়ান সাইকেলে প্রয়োগ করব।

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

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

প্র ০১ আমরা "প্রতি সারিতে একটি কলাম" মডেল ব্যবহার করেছি, পুরো $N^2$ ঘরের মধ্যে $N$টি ঘর বেছে নেওয়ার সাধারণ মডেল নয়। এতে কি কোনো বৈধ সমাধান বাদ পড়ে যাচ্ছে?

না। যেকোনো বৈধ N-Queens সমাধানে প্রতিটি সারিতে ঠিক একটি কুইন থাকতে হয় — দুটি কুইন একই সারিতে থাকলে তারা একে অপরকে সরাসরি আক্রমণ করবে, তাই এটি কখনোই বৈধ হতে পারে না। ফলে প্রতিটি বৈধ সমাধানই "প্রতি সারিতে একটি কলাম" আকারে প্রকাশ করা সম্ভব — এই মডেলিং কোনো তথ্য হারায় না, বরং নেইভ স্পেসকে $\binom{N^2}{N}$ থেকে অনেক ছোট $N^N$-এ নামিয়ে আনে, ব্যাকট্র্যাকিং শুরু করার আগেই।

প্র ০২ কোডে node_count বাড়ানো হয় backtrack()-এর একদম শুরুতে, লুপের ভেতরে নয়। এর মানে কি প্রতিটি কনফ্লিক্ট-চেক আলাদাভাবে গোনা হচ্ছে না?

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

প্র ০৩ $N=6$-এ সমাধান সংখ্যা ৪টি এবং $N=8$-এ ৯২টি — সমাধান সংখ্যা কি নোড এক্সপ্লোরেশনের পরিমাণ নির্ধারণ করে?

না, সরাসরি নয়। সমাধান সংখ্যা মানে শুধু কতগুলো সম্পূর্ণ, বৈধ প্লেসমেন্ট আছে তা বলে — কিন্তু নোড এক্সপ্লোরেশনের বেশিরভাগই ব্যয় হয় অবৈধ আংশিক প্লেসমেন্ট আবিষ্কার করে দ্রুত বাতিল করাতে, সমাধান খুঁজে পাওয়াতে নয়। দুটি ভিন্ন $N$-এ সমাধান সংখ্যার অনুপাত এবং নোড সংখ্যার অনুপাত সম্পূর্ণ ভিন্ন হতে পারে — এখানেই যেমন দেখা যাচ্ছে, সমাধান ৪ থেকে ৯২ (২৩ গুণ) বাড়লেও নোড সংখ্যা ১৫৩ থেকে ২০৫৭ (প্রায় ১৩.৪ গুণ) বেড়েছে, আর নেইভ স্পেস বেড়েছে $8^8/6^6 \approx 360$ গুণ — তিনটি সম্পূর্ণ ভিন্ন গ্রোথ রেট।

অনুশীলন

  1. চিন্তা করুন: $N=10$-এ নোড/$N^N$ অনুপাত কি $N=8$-এর অনুপাতের ($0.0123\%$) চেয়ে বড় হবে, নাকি ছোট? কেন?

    ছোট হবে — কারণ $N$ বাড়ার সাথে সাথে নেইভ স্পেস $N^N$ বাড়ে অনেক দ্রুত হারে (এক্সপোনেনশিয়ালের চেয়েও দ্রুত, কারণ বেসও বাড়ছে), অথচ ব্যাকট্র্যাকিং-এর প্রুনিং প্রতিটি সারিতে আরও বেশি কলাম বাদ দেওয়ার সুযোগ পায় (প্রথম কয়েকটি কুইন বসে যাওয়ার পর পরবর্তী সারিতে বৈধ কলামের সংখ্যা কমে যায়)। তাই অনুপাত প্রতি $N$-এ আরও ক্ষুদ্র হতেই থাকে।

  2. পরীক্ষা করুন: উপরের কোড সেলে for n in [6, 8]: লাইনটি বদলে for n in [6, 8, 10]: করে Run চেপে দেখুন প্রকৃত অনুপাত কত আসে।

    আউটপুটে দেখা যাবে $N=10$-এ ব্যাকট্র্যাকিং মাত্র $35{,}539$টি নোড ভিজিট করে ৭২৪টি সমাধান খুঁজে পায়, যেখানে নেইভ স্পেস $10^{10} = 10{,}000{,}000{,}000$ — অনুপাত মাত্র $0.00036\%$, অর্থাৎ $N=8$-এর $0.0123\%$-এর চেয়েও প্রায় ৩৪ গুণ ছোট। এটাই নিশ্চিত করে অনুমানটি ঠিক ছিল।

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

আগের পাঠ
ম্যাক্স ফ্লো — ফোর্ড-ফুলকারসন ও ম্যাক্স-ফ্লো মিন-কাট