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

অ্যালগরিদম ঠিক কি না — loop invariant আর induction

Algorithm Correctness — Loop Invariants, Induction, Termination & Stress Testing

Taught in Bangla, this lesson shows why passing tests is not proof and defines partial and total correctness. It proves maximum finding, insertion sort and binary search with three-step loop invariants, proves recursive algorithms with weak and strong induction, shows termination with variant functions, uses counterexamples to break wrong algorithms and applies assert and stress testing in code.

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

  • কেন শুধু testing দিয়ে অ্যালগরিদমের সঠিকতা নিশ্চিত করা যায় না, আর “ঠিক” কথাটির নিখুঁত অর্থ
  • Loop invariant লিখে তিন ধাপে প্রমাণ — maximum খোঁজা, insertion sort আর binary search-এর ওপর
  • Weak ও strong induction দিয়ে রিকার্সিভ অ্যালগরিদম (যোগফল, power, Euclid-এর GCD) প্রমাণ
  • Variant function দিয়ে লুপ যে থামবে তা দেখানো, আর counterexample দিয়ে ভুল অ্যালগরিদম ধরা
  • কোডে assert আর stress testing দিয়ে প্রমাণকে কাজে লাগানো — AI-এর কোড যাচাইসহ

১ · “চলছে” মানেই “ঠিক” নয় — testing-এর সীমা

একটি দৃশ্য ভাবুন। তানিয়া একটি সর্টিং ফাংশন লিখল। পাঁচটি ইনপুটে চালিয়ে দেখল — প্রতিবারই অ্যারে সুন্দরভাবে সাজানো আসছে। সে খুশি হয়ে কোডটি কনটেস্টে জমা দিল। ফল এলো: Wrong answer on test 37. পরীক্ষা করে দেখা গেল, অ্যারেতে একই সংখ্যা দুবার থাকলে (যেমন 3 1 3) তার ফাংশন একটি সংখ্যা হারিয়ে ফেলে। তানিয়ার পাঁচটি ইনপুটের কোনোটিতেই পুনরাবৃত্ত সংখ্যা ছিল না।

এই ঘটনার শিক্ষাটি সহজ, কিন্তু গভীর। একটি ইনপুটে ঠিক উত্তর পাওয়া মানে শুধু ওই ইনপুটে ঠিক। ১০টি সংখ্যার অ্যারেতেই সম্ভাব্য ইনপুট অসংখ্য; সবগুলো চালিয়ে দেখা অসম্ভব। তাহলে আমরা কীভাবে নিশ্চিত হব যে একটি অ্যালগরিদম correct — অর্থাৎ প্রতিটি বৈধ ইনপুটে ঠিক উত্তর দেয়?

“Program testing can be used to show the presence of bugs, but never to show their absence!”
— Edsger W. Dijkstra

ডাচ কম্পিউটার বিজ্ঞানী Edsger Dijkstra ১৯৬৯-৭০ সালের দিকে এই কথাটি বলেন ও লেখেন (তাঁর “Notes on Structured Programming”-এ)। বাংলায়: testing দেখাতে পারে যে বাগ আছে, কিন্তু কখনো দেখাতে পারে না যে বাগ নেই। ১০০টি test পাস করা মানে ১০০টি ইনপুটে বাগ পাওয়া যায়নি — ১০১ নম্বরে কী হবে, test তা বলে না।

ঢাকার একটি নতুন ফ্লাইওভারের কথা ভাবুন। প্রকৌশলী যদি বলেন, “গতকাল ২০টি বাস চালিয়ে দেখেছি, ভাঙেনি — তাই নিরাপদ”, আপনি কি নিশ্চিন্ত হবেন? না। আপনি চাইবেন হিসাব — কত টন ভার পর্যন্ত কাঠামো টিকবে, তার গাণিতিক নকশা। পরীক্ষামূলক চালানো (testing) দরকার, কিন্তু নিশ্চয়তা দেয় হিসাব (proof). অ্যালগরিদমেও তাই।

এই পর্বে আমরা সেই “হিসাব” শিখব — অ্যালগরিদমের সঠিকতার প্রমাণ। ভয় পাওয়ার কিছু নেই; এখানে কঠিন গণিত লাগবে না। লাগবে তিনটি হাতিয়ার: loop invariant (লুপের জন্য), induction (রিকার্শনের জন্য) আর counterexample (ভুল ধরার জন্য)। সাথে থাকবে দুটি ব্যবহারিক কৌশল — assert আর stress testing — যা প্রমাণের চিন্তাকে সরাসরি কোডে নিয়ে আসে।

প্রোগ্রাম প্রমাণের ধারণা কম্পিউটারের মতোই পুরোনো। ১৯৪৯ সালে Alan Turing “Checking a Large Routine” নামে একটি ছোট লেখায় দেখান, একটি প্রোগ্রামের মাঝে মাঝে “এখানে এই কথাগুলো সত্য” লিখে রাখলে পুরো প্রোগ্রাম যাচাই সহজ হয়। ১৯৬৭ সালে Robert Floyd (“Assigning Meanings to Programs”) আর ১৯৬৯ সালে Tony Hoare (“An Axiomatic Basis for Computer Programming”) এই চিন্তাকে নিয়মবদ্ধ রূপ দেন। আজ যাকে আমরা loop invariant বলি, তার শিকড় এখানেই। Floyd, Hoare আর Dijkstra — তিনজনই পরে Turing Award পান।

২ · “ঠিক” মানে কী — specification, partial আর total correctness

“অ্যালগরিদমটি ঠিক” বলার আগে বলতে হবে কীসের তুলনায় ঠিক। সেই মাপকাঠির নাম specification. এর দুটি অংশ:

Precondition

শুরুর আগে ইনপুট সম্পর্কে যা ধরে নেওয়া হয়। Precondition না মানলে অ্যালগরিদমের কোনো দায় নেই।

যেমন: binary search-এ অ্যারে সাজানো

Postcondition

শেষ হওয়ার পর আউটপুট সম্পর্কে যা সত্য হতে হবে। Postcondition-ই আসলে “ঠিক উত্তর”-এর সংজ্ঞা।

যেমন: আউটপুট সাজানো, আর ইনপুটের সব উপাদানই আছে

সর্টিংয়ের উদাহরণটি খেয়াল করুন। শুধু “আউটপুট সাজানো” বললে specification অসম্পূর্ণ — যে ফাংশন সব ঘরে 0 বসিয়ে দেয়, তার আউটপুটও তো সাজানো! তাই পুরো postcondition হলো: আউটপুট সাজানো, এবং এটি ইনপুটেরই একটি পুনর্বিন্যাস (permutation) — কোনো সংখ্যা হারায়নি, নতুনও আসেনি। তানিয়ার বাগ ঠিক এই দ্বিতীয় অংশটি ভেঙেছিল।

দুই স্তরের সঠিকতা

একটি অ্যালগরিদম দুইভাবে ব্যর্থ হতে পারে: ভুল উত্তর দিয়ে, অথবা কোনো উত্তরই না দিয়ে (চিরকাল লুপে ঘুরে)। তাই সঠিকতাকে দুই ভাগে দেখা হয়:

ধারণাদাবিকীভাবে প্রমাণ
Partial correctnessprecondition মানলে, অ্যালগরিদম যদি থামে, উত্তর postcondition মানবেLoop invariant, induction
Terminationprecondition মানলে অ্যালগরিদম সসীম ধাপে থামবেইকমতে থাকা মাপ (variant function)
Total correctnessথামবে এবং ঠিক উত্তর দেবেউপরের দুটি একসাথে

Partial correctness একা প্রায় অর্থহীন হতে পারে। while (true) {} — এই “অ্যালগরিদম” কখনো থামে না, তাই কখনো ভুল উত্তরও দেয় না। সংজ্ঞা অনুযায়ী এটি যেকোনো specification-এর জন্য partially correct! এজন্যই termination আলাদা করে প্রমাণ করতে হয়।

Tony Hoare এই ভাবনাকে একটি ছোট সংকেতে লেখেন, যাকে বলে Hoare triple: {P} S {Q} — “P সত্য থাকা অবস্থায় S চালালে, S থামলে Q সত্য হবে।” এখানে P হলো precondition, Q postcondition. পুরো কোর্সে আমরা এই সংকেত না লিখলেও চিন্তাটি বারবার ব্যবহার করব।

৩ · Loop invariant — তিন ধাপের প্রমাণ

বেশিরভাগ অ্যালগরিদমের প্রাণ একটি লুপ। লুপ হাজার, লাখ বার ঘুরতে পারে — প্রতিটি ঘোরা আলাদা করে দেখা সম্ভব নয়। তাই আমরা এমন একটি কথা খুঁজি, যা প্রতিটি ঘোরার শুরুতে (লুপের শর্ত যাচাইয়ের মুহূর্তে) সবসময় সত্য থাকে। সেটিই loop invariant — “অপরিবর্তনীয় সত্য”।

পরীক্ষার হলে শিক্ষক ৫০টি খাতা রোল নম্বর অনুযায়ী সাজাচ্ছেন। বাঁ হাতে একটি গাদা — সবসময় সাজানো। ডান দিকের স্তূপ থেকে একটি করে খাতা নিয়ে বাঁ গাদার ঠিক জায়গায় ঢোকান। যেকোনো মুহূর্তে থামিয়ে জিজ্ঞেস করুন — “বাঁ হাতের গাদা কি সাজানো?” উত্তর সবসময় “হ্যাঁ”। এটাই invariant. আর ডান দিকের স্তূপ শেষ হলে? বাঁ হাতে সব খাতা, এবং সাজানো — কাজ শেষ!

একটি invariant দিয়ে প্রমাণ করতে তিনটি জিনিস দেখাতে হয় (CLRS-এর মতো বিখ্যাত পাঠ্যবইও ঠিক এই কাঠামো ব্যবহার করে):

১. Initialization

লুপের প্রথম ঘোরার ঠিক আগে invariant সত্য। (Induction-এর base case-এর মতো।)

২. Maintenance

কোনো ঘোরার শুরুতে সত্য থাকলে, ওই ঘোরা শেষে পরের ঘোরার শুরুতেও সত্য। (Inductive step-এর মতো।)

৩. Termination

লুপ থামলে invariant আর থামার শর্ত মিলিয়ে postcondition বের হয়ে আসে।

খেয়াল করুন, প্রথম দুটি ধাপ আসলে induction — ঘোরার সংখ্যার ওপর। তৃতীয় ধাপটিই invariant-কে “কাজের” বানায়: যে invariant থেকে শেষে উত্তর বের হয় না, তা সত্য হলেও অকেজো (যেমন “i ≥ 0” — সত্য, কিন্তু কিছুই প্রমাণ করে না)।

প্রথম উদাহরণ: অ্যারের সবচেয়ে বড় সংখ্যা

ধরুন একটি অ্যারে a[0..n-1] (এই পর্বে সব index ০ থেকে শুরু, আর a[l..r] মানে l থেকে r পর্যন্ত দুই প্রান্তসহ)। Precondition: n ≥ 1. Postcondition: ফেরত দেওয়া মান = অ্যারের সর্বোচ্চ। Invariant:

Invariant

লুপের শর্ত যাচাইয়ের প্রতিটি মুহূর্তে best = max(a[0..i-1]) — অর্থাৎ এ পর্যন্ত দেখা ঘরগুলোর মধ্যে সবচেয়ে বড়টি best-এ আছে।

নিচে a = [3, 8, 2, 9, 4] দিয়ে লুপটি চালিয়ে দেখুন। প্রতিটি ধাপে “এ পর্যন্ত দেখা অংশ” আর best মিলিয়ে দেখুন invariant সত্য কি না:

1int best = a[0];2for (int i = 1; i < n; i++)3    if (a[i] > best) best = a[i];4return best;
শর্ত যাচাইয়ের মুহূর্তে
i
a[0..i-1]
best
invariant
  1. Initialization. লাইন ১ চলল: best = a[0] = 3, আর লুপ শুরু হবে i = 1 দিয়ে। এ পর্যন্ত দেখা অংশ a[0..0] = শুধু 3, তার সর্বোচ্চ 3 — invariant সত্য। একটি মাত্র সংখ্যার সর্বোচ্চ সেটিই, তাই এটি যেকোনো অ্যারেতেই সত্য।
  2. Maintenance, i = 1. a[1] = 8 > 3, তাই best = 8. এরপর i বেড়ে 2. নতুন দেখা অংশ 3 8, সর্বোচ্চ 8 = best ✓. যুক্তিটি সাধারণ: নতুন সর্বোচ্চ = max(পুরোনো সর্বোচ্চ, নতুন সংখ্যা), আর লাইন ৩ ঠিক এটাই হিসাব করে।
  3. i = 2. a[2] = 2, যা 8-এর চেয়ে বড় নয় — best বদলায় না। দেখা অংশ 3 8 2, সর্বোচ্চ এখনো 8 ✓.
  4. i = 3. a[3] = 9 > 8, তাই best = 9. দেখা অংশ 3 8 2 9, সর্বোচ্চ 9 ✓.
  5. i = 4. a[4] = 4 বড় নয়। i বেড়ে হলো 5. দেখা অংশ পুরো অ্যারে, সর্বোচ্চ 9 ✓.
  6. Termination. i = 5 = n, শর্ত i < n মিথ্যা — লুপ থামল। Invariant বলছে best = max(a[0..n-1]), অর্থাৎ পুরো অ্যারের সর্বোচ্চ। এটাই postcondition — প্রমাণ শেষ। আর লুপটি থামবেই, কারণ i প্রতিবার ১ করে বাড়ে আর n-এ পৌঁছালেই থামে।

লক্ষ্য করুন, প্রমাণের কোথাও “3, 8, 2, 9, 4” সংখ্যাগুলো লাগেনি। Stepper শুধু আপনাকে invariant-টি চোখে দেখাল; আসল প্রমাণ সাধারণ যুক্তি — যেকোনো অ্যারে, যেকোনো n-এর জন্য। এখানেই test আর proof-এর পার্থক্য।

৪ · Insertion sort-এর সম্পূর্ণ প্রমাণ

এবার একটু বড় উদাহরণ — insertion sort. এটি ঠিক পরীক্ষার খাতা সাজানোর পদ্ধতি: বাঁ দিকে একটি সাজানো অংশ, ডান দিক থেকে একটি করে উপাদান (key) নিয়ে সাজানো অংশে তার ঠিক জায়গায় ঢোকানো। (সর্টিংয়ের বিস্তারিত তুলনা পর্ব ২০-এ।) প্রথমে pseudocode (০-ভিত্তিক index):

Pseudocode: INSERTION-SORT(a[0..n-1])
for i = 1 to n-1:
    // invariant: a[0..i-1] holds the original first i items, sorted
    key = a[i]
    j = i
    while j > 0 and a[j-1] > key:
        a[j] = a[j-1]          // shift bigger item one step right
        j = j - 1
    a[j] = key
Invariant

বাইরের লুপের প্রতিটি ঘোরার শুরুতে a[0..i-1] অংশে আছে মূল অ্যারের প্রথম i-টি উপাদানই, এবং সেগুলো সাজানো।

Invariant-এর দুটি অংশই জরুরি — “সাজানো” আর “সেই উপাদানগুলোই” — কারণ postcondition-ও দুই অংশের (সেকশন ২ মনে করুন)। নিচে CLRS বইয়ের বিখ্যাত উদাহরণ [5, 2, 4, 6, 1, 3] দিয়ে দেখুন। বন্ধনীর ভেতরের অংশ […] হলো invariant-এর সাজানো অংশ:

1for (i = 1; i < n; i++) {2    key = a[i]; j = i;3    while (j > 0 && a[j-1] > key)4        { a[j] = a[j-1]; j--; }5    a[j] = key;6}
বাইরের লুপের শর্ত যাচাইয়ে
i
শেষ key
সরানো হলো
a
invariant
  1. Initialization (i = 1). সাজানো অংশ a[0..0] = শুধু [5]. একটি উপাদানের তালিকা সবসময়ই সাজানো, আর এটি মূল অ্যারের প্রথম উপাদানই — invariant সত্য।
  2. i = 1, key = 2. 5 > 2, তাই 5 এক ঘর ডানে সরল; তারপর j = 0-তে থেমে 2 বসল। এখন [2 5] — প্রথম দুটি উপাদান, সাজানো ✓.
  3. i = 2, key = 4. শুধু 5 বড়, সেটি সরল; 2 ছোট তাই ভেতরের লুপ থামল, 4 বসল 2-এর পরে: [2 4 5] ✓.
  4. i = 3, key = 6. a[2] = 5 ছোট — কিছুই সরল না, 6 নিজের জায়গাতেই থাকল: [2 4 5 6] ✓. (আগে থেকে সাজানো অ্যারেতে প্রতিবার এমনই হয় — তাই তখন insertion sort মাত্র O(n) সময় নেয়।)
  5. i = 4, key = 1. 6, 5, 4, 2 — চারটিই বড়, সবাই এক ঘর করে ডানে সরল; 1 বসল একদম শুরুতে: [1 2 4 5 6] ✓. Maintenance-এর মূল যুক্তি: key-এর চেয়ে বড়গুলো ক্রম না ভেঙে ডানে সরে, key বসে প্রথম “ছোট বা সমান” উপাদানের ঠিক পরে — তাই অংশটি সাজানোই থাকে, আর কোনো উপাদান হারায় না।
  6. i = 5, key = 3. 6, 5, 4 সরল; 2 ছোট, তাই 3 বসল 2-এর পরে: [1 2 3 4 5 6] ✓.
  7. Termination. i = 6 = n, লুপ থামল। Invariant বলছে a[0..n-1] — অর্থাৎ পুরো অ্যারে — মূল উপাদানগুলোই, সাজানো অবস্থায়। এটাই postcondition. Partial correctness প্রমাণিত।

প্রমাণটি সাধারণ ভাষায় লিখলে

Initialization: i = 1-এ a[0..0] একটি উপাদান, তাই সাজানো। Maintenance: ধরা যাক ঘোরার শুরুতে a[0..i-1] সাজানো। ভেতরের লুপ a[i-1], a[i-2], … থেকে key-এর চেয়ে বড় প্রতিটি উপাদানকে এক ঘর ডানে সরায় এবং প্রথম যে ঘরে a[j-1] ≤ key (বা j = 0) সেখানে থামে। Key সেখানে বসলে তার বাঁয়ের সবাই ≤ key, ডানের সবাই > key, আর দুই পাশ আগে থেকেই সাজানো — তাই a[0..i] সাজানো, এবং উপাদানগুলো শুধু জায়গা বদলেছে। i এক বাড়লে invariant আবার সত্য। Termination: i = n-এ invariant-ই postcondition.

পুরোপুরি কঠোর হতে চাইলে ভেতরের while লুপেরও আলাদা invariant লাগে (যেমন “a[j+1..i]-এর সবাই key-এর চেয়ে বড় এবং সাজানো”)। বিশ্ববিদ্যালয়ের পরীক্ষায় সাধারণত বাইরের লুপের তিন ধাপই চাওয়া হয়, তবে ভেতরের লুপের যুক্তিটি এক লাইনে বলে দিলে উত্তর সম্পূর্ণ হয়।

Invariant-কে কোডে পাহারায় বসানো

প্রমাণ কাগজে থাকে; কিন্তু কোড বদলায়। কেউ পরে কোড বদলে ভুল করলে কি প্রমাণ আমাদের সতর্ক করবে? করবে, যদি invariant-কে assertion হিসেবে কোডেই লিখে রাখি। assert(শর্ত) চলার সময় শর্তটি যাচাই করে; মিথ্যা হলে প্রোগ্রাম সঙ্গে সঙ্গে থেমে কোন লাইনে ভাঙল তা জানায়। নিচে insertion sort, প্রতিটি ঘোরার শুরুতে invariant যাচাইসহ। আপনার ভাষার ট্যাবটি বেছে নিন:

#include <bits/stdc++.h>
#include <cassert>
using namespace std;
void show(const vector<int>& a, size_t i) {
    cout << "i=" << i << ":";
    for (size_t k = 0; k < a.size(); ++k) cout << (k == i ? " | " : " ") << a[k];
    cout << '\n';
}

int main() {
    vector<int> a = {5, 2, 4, 6, 1, 3};
    for (size_t i = 1; i < a.size(); ++i) {
        assert(is_sorted(a.begin(), a.begin() + i));   // invariant: a[0..i-1] sorted
        show(a, i);
        int key = a[i];
        size_t j = i;
        while (j > 0 && a[j - 1] > key) { a[j] = a[j - 1]; --j; }
        a[j] = key;
    }
    assert(is_sorted(a.begin(), a.end()));             // postcondition
    show(a, a.size());
    cout << "invariant held at every step\n";
}

আউটপুট (| চিহ্নের বাঁয়ে invariant-এর সাজানো অংশ):

i=1: 5 | 2 4 6 1 3
i=2: 2 5 | 4 6 1 3
i=3: 2 4 5 | 6 1 3
i=4: 2 4 5 6 | 1 3
i=5: 1 2 4 5 6 | 3
i=6: 1 2 3 4 5 6
invariant held at every step

std::is_sorted(first, last) পরিসরটি সাজানো কি না বলে দেয়; একে assert-এর ভেতরে রেখে আমরা invariant-কে “চলমান প্রমাণ” বানিয়েছি।

def show(a, i):
    parts = [(" | " if k == i else " ") + str(x) for k, x in enumerate(a)]
    print(f"i={i}:" + "".join(parts))

def insertion_sort(a):
    for i in range(1, len(a)):
        # invariant: a[0..i-1] is sorted
        assert all(a[k] <= a[k + 1] for k in range(i - 1)), "invariant broken"
        show(a, i)
        key, j = a[i], i
        while j > 0 and a[j - 1] > key:
            a[j] = a[j - 1]
            j -= 1
        a[j] = key

a = [5, 2, 4, 6, 1, 3]
insertion_sort(a)
assert a == sorted(a)  # postcondition
show(a, len(a))
print("invariant held at every step")

আউটপুট C++ সংস্করণের হুবহু এক:

i=1: 5 | 2 4 6 1 3
i=2: 2 5 | 4 6 1 3
i=3: 2 4 5 | 6 1 3
i=4: 2 4 5 6 | 1 3
i=5: 1 2 4 5 6 | 3
i=6: 1 2 3 4 5 6
invariant held at every step

all(...) পাশাপাশি প্রতিটি জোড়া a[k] <= a[k+1] কি না দেখে; i = 1-এ পরিসর ফাঁকা, তাই all সত্য দেয় — ঠিক initialization-এর মতো।

দুই সংস্করণেই প্রতিটি লাইনের শুরুতে |-এর বাঁ অংশ সাজানো — stepper-এ যা দেখেছেন, কোড নিজেই তা যাচাই করছে। এবার একটি পরীক্ষা করুন: ভেতরের লুপের শর্তে a[j - 1] > key-এর জায়গায় a[j - 1] < key লিখে চালান। দ্বিতীয় ঘোরাতেই assert ভেঙে প্রোগ্রাম থেমে যাবে — ভুলটি যেখানে জন্ম নিল, ঠিক সেখানেই ধরা পড়ল, শেষের ভুল আউটপুটে নয়।

খরচের হিসাব: Insertion sort নিজে সবচেয়ে খারাপ ক্ষেত্রে (উল্টো সাজানো ইনপুট) Θ(n²) সময় আর O(1) বাড়তি জায়গা নেয় — ভেতরের লুপ i নম্বর ঘোরায় সর্বোচ্চ i বার চলে, মোট 1 + 2 + … + (n−1) = n(n−1)/2. কিন্তু প্রতিটি ঘোরায় is_sorted দিয়ে পুরো prefix যাচাই করলে আরও Θ(i) কাজ যোগ হয় — সাজানো ইনপুটে যেখানে sort-টি Θ(n) সময়ে শেষ হতো, সেখানে যাচাইসহ লাগে Θ(n²). তাই এমন ভারী assert শুধু debug আর test-এর সময়ের জন্য; বিস্তারিত সেকশন ৯-এ।

৫ · Binary search — invariant ছাড়া off-by-one অনিবার্য

Binary search-এর ধারণা এক বাক্যের: সাজানো অ্যারের মাঝখানে দেখুন, লক্ষ্য ছোট হলে বাঁ অর্ধেকে, বড় হলে ডান অর্ধেকে খুঁজুন। অথচ এটি সঠিকভাবে লেখা কুখ্যাতভাবে কঠিন। Jon Bentley তাঁর “Programming Pearls” বইয়ে লিখেছেন, পেশাদার প্রোগ্রামারদের ক্লাসে binary search লিখতে দিয়ে তিনি দেখেছেন বেশিরভাগের কোডেই বাগ ছিল। Donald Knuth উল্লেখ করেছেন, ধারণাটি ১৯৪৬ সালে (John Mauchly) প্রথম বর্ণিত হওয়ার পর সব n-এর জন্য সঠিক সংস্করণ আসতে আরও প্রায় দেড় দশক লেগেছিল — সাধারণত এর কৃতিত্ব দেওয়া হয় D. H. Lehmer-এর ১৯৬০ সালের সংস্করণকে।

সমস্যার মূলে off-by-one ভুল: lo = mid নাকি mid + 1? শর্ত lo < hi নাকি lo <= hi? এসব প্রশ্নের উত্তর মুখস্থ না করে একটি invariant ঠিক করে নিলেই প্রতিটি লাইন নিজে থেকে বেরিয়ে আসে। আমরা দুই প্রান্তসহ বন্ধ পরিসর [lo, hi] নেব:

Invariant

যদি x অ্যারেতে থাকে, তবে সেটি a[lo..hi]-এর ভেতরেই আছে। (Precondition: a ছোট থেকে বড় ক্রমে সাজানো।)

এখন প্রতিটি লাইন যাচাই করুন: শুরুতে lo = 0, hi = n-1 — পুরো অ্যারে, invariant সত্য। a[mid] < x হলে a[0..mid]-এর সবাই ≤ a[mid] < x, তাই x সেখানে থাকতে পারে না — lo = mid + 1 (শুধু mid নয়!)। উল্টোটিতে hi = mid - 1. পরিসর ফাঁকা হলে (lo > hi) invariant বলছে “x থাকলে ফাঁকা পরিসরে আছে” — অর্থাৎ নেই। নিচে a = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]-এ x = 20 খুঁজে দেখুন (20 অ্যারেতে নেই — এটাই কঠিন ক্ষেত্র):

1int lo = 0, hi = n - 1;2while (lo <= hi) {3    int mid = lo + (hi - lo) / 2;4    if (a[mid] == x) return mid;5    if (a[mid] < x) lo = mid + 1;6    else hi = mid - 1;7}8return -1;
x = 20 খোঁজা
mid
a[mid]
lo, hi
a[lo..hi]
hi − lo + 1
  1. Initialization. lo = 0, hi = 9 — পুরো অ্যারে। 20 যদি থাকে, এর ভেতরেই আছে; invariant সত্য। পরিসরের আকার ১০।
  2. mid = 4, a[4] = 16 < 20. সাজানো বলে index ০ থেকে ৪-এর সবাই ≤ 16, তাই 20 সেখানে নেই। lo = mid + 1 = 5. Invariant রক্ষা পেল, পরিসর ১০ থেকে ৫-এ নামল।
  3. mid = 7, a[7] = 56 > 20. Index ৭ থেকে ডানের সবাই ≥ 56, তাই hi = mid - 1 = 6. পরিসর এখন 23 38, আকার ২।
  4. mid = 5, a[5] = 23 > 20. আবার hi = mid - 1 = 4. এখন lo = 5 > hi = 4 — পরিসর ফাঁকা, আকার ০।
  5. Termination. শর্ত lo <= hi মিথ্যা, লুপ শেষ। Invariant: “20 থাকলে ফাঁকা পরিসরে আছে” — অসম্ভব, তাই 20 অ্যারেতে নেই; -1 ফেরত দেওয়া ঠিক। আর ডান কলামের আকার ১০ → ৫ → ২ → ০ প্রতিবার কঠোরভাবে কমেছে — সেজন্যই লুপ থামতে বাধ্য (পরের সেকশন)।

এবার দেখুন ভুল সংস্করণ কেন ভুল। যদি লেখেন lo = mid, তাহলে lo = 5, hi = 6-এ mid = 5; a[5] < x হলে (ধরুন x = 30) আবার lo = 5 — পরিসর একটুও ছোট হয় না, লুপ চিরকাল চলে। Invariant ঠিক থাকলেও termination ভেঙেছে! আর শর্ত lo < hi লিখলে একটি-ঘরের পরিসর (lo == hi) কখনো যাচাই হয় না — invariant-এর termination ধাপ “ফাঁকা পরিসর” দাবি করতে পারে না, তাই উত্তর ভুল।

জটিলতা: প্রতিটি ঘোরায় পরিসরের আকার অন্তত অর্ধেক হয় (k আকারের পরিসর থেকে সর্বোচ্চ ⌊k/2⌋), তাই সর্বোচ্চ ⌊log₂ n⌋ + 1 বার ঘোরে — সময় O(log n), বাড়তি জায়গা O(1). আর mid = lo + (hi - lo) / 2 লেখার কারণ ভাবনার প্রশ্ন ২-এ। Binary search-এর সব রূপ (lower bound, upper bound) পর্ব ১৮-এ, আর “উত্তরের ওপর binary search” পর্ব ১৯-এ।

৬ · লুপ কি থামবে? — variant function দিয়ে termination

Invariant বলে “থামলে ঠিক”; থামার নিশ্চয়তা আলাদা যুক্তি চায়। সবচেয়ে প্রচলিত হাতিয়ার variant function (বা “কমতে থাকা মাপ”): এমন একটি পূর্ণসংখ্যা খুঁজুন যা

  • লুপ চলাকালীন কখনো ঋণাত্মক হয় না (নিচে একটি দেয়াল আছে), এবং
  • প্রতিটি ঘোরায় অন্তত ১ করে কঠোরভাবে কমে।

একটি অঋণাত্মক পূর্ণসংখ্যা অসীমবার কমতে পারে না — ১০ থেকে শুরু করলে সর্বোচ্চ ১০ বার। তাই লুপ থামবেই, এবং variant-এর শুরুর মান ঘোরার সংখ্যার একটি ঊর্ধ্বসীমাও দেয়।

বাজারের সারিতে দাঁড়িয়ে আছেন, সামনে ১২ জন। প্রতি মিনিটে অন্তত একজন কেনাকাটা সেরে বেরিয়ে যান, আর কেউ মাঝখানে ঢুকতে পারেন না। আপনি নিশ্চিত — বড়জোর ১২ মিনিটে আপনার পালা আসবেই। “সামনে কতজন” এখানে variant: অঋণাত্মক, আর প্রতি ধাপে কমে। কিন্তু কেউ যদি লাইনে ঢুকে পড়তে পারত (variant বাড়ত), তাহলে কোনো নিশ্চয়তা থাকত না — ঢাকার জ্যামে যেমন থাকে না!

লুপVariantকেন কমে
Maximum খোঁজাn − ii প্রতিবার ১ বাড়ে
Insertion sort-এর ভেতরের লুপjপ্রতিবার j--, আর j > 0 শর্ত
Binary searchhi − lo + 1mid পরিসরের ভেতরে, আর lo = mid+1 বা hi = mid−1 — অন্তত ১ কমে
Euclid-এর GCDba mod b < b

শেষ সারিটি দেখুন। Euclid-এর পদ্ধতি — প্রায় ২,৩০০ বছর আগে তাঁর “Elements” গ্রন্থে লেখা, এখনো ব্যবহৃত সবচেয়ে পুরোনো অ্যালগরিদমগুলোর একটি:

Pseudocode: GCD(a, b)        // precondition: a ≥ 0, b ≥ 0
if b == 0: return a
return GCD(b, a mod b)

এখানে দুটি জিনিস প্রমাণ করতে হবে। Termination: প্রতিটি ডাকে দ্বিতীয় আর্গুমেন্ট b থেকে a mod b হয়, যা সবসময় b-এর চেয়ে ছোট এবং ≥ ০ — variant b কঠোরভাবে কমে, তাই একসময় ০ হবেই। Correctness: মূল সূত্র gcd(a, b) = gcd(b, a mod b) — কারণ a mod b = a − q·b, তাই যে সংখ্যা a আর b দুটোকেই ভাগ করে সে a mod b-কেও ভাগ করে, আর উল্টোটাও সত্য। দুই জোড়ার সাধারণ গুণনীয়কের সেট হুবহু এক, তাই GCD-ও এক। এটি একরকম “রিকার্শনের invariant”: প্রতিটি ডাকে উত্তরটি বদলায় না।

  1. gcd(48, 18)48 mod 18 = 12b = 18
  2. gcd(18, 12)18 mod 12 = 6b = 12
  3. gcd(12, 6)12 mod 6 = 0b = 6
  4. gcd(6, 0)b == 0 → 6base case
  1. প্রথম ডাক gcd(48, 18). b = 18 ≠ 0, তাই 48 mod 18 = 12 হিসাব করে ডাকা হবে gcd(18, 12). দাবি: gcd(48, 18) = gcd(18, 12) — যে সংখ্যা 48 আর 18-কে ভাগ করে, সে 48 − 2·18 = 12-কেও ভাগ করে।
  2. gcd(18, 12). Variant b ১৮ থেকে নেমে ১২। 18 mod 12 = 6, পরের ডাক gcd(12, 6). উত্তর এখনো একই — সাধারণ গুণনীয়কের সেট বদলায়নি।
  3. gcd(12, 6). b নামল ৬-এ। 12 mod 6 = 0, পরের ডাক gcd(6, 0).
  4. Base case gcd(6, 0) = 6. b = 0, আর যেকোনো সংখ্যা ০-কে ভাগ করে, তাই gcd(6, 0) = 6. প্রতিটি ডাকে উত্তর অপরিবর্তিত ছিল, তাই gcd(48, 18) = 6. Variant ১৮ → ১২ → ৬ → ০: মাত্র চার ডাকে শেষ। (আরও গভীরে: Euclid-এর ধাপ সংখ্যা O(log min(a, b)) — সবচেয়ে খারাপ ইনপুট পরপর দুটি Fibonacci সংখ্যা।)

সব লুপের variant খুঁজে পাওয়া যায় না। while (n != 1) n = (n % 2 == 0) ? n / 2 : 3 * n + 1; — এই সরল লুপটি (Collatz সমস্যা) সব ধনাত্মক n-এ থামে কি না, আজও কেউ প্রমাণ করতে পারেননি; কম্পিউটারে বিশাল পরিসর পর্যন্ত যাচাই হয়েছে, কিন্তু প্রমাণ নেই। আর ১৯৩৬ সালে Alan Turing দেখিয়েছেন, যেকোনো প্রোগ্রাম থামবে কি না বলে দেওয়ার সাধারণ পদ্ধতি অসম্ভব (halting problem). তাই termination প্রমাণ সবসময় নির্দিষ্ট অ্যালগরিদমের নির্দিষ্ট যুক্তি দিয়ে।

৭ · Mathematical induction — রিকার্সিভ অ্যালগরিদমের প্রমাণ

লুপের জন্য invariant; রিকার্শনের জন্য induction. আসলে দুটো একই চিন্তার দুই রূপ। Induction-এর কাঠামো:

  1. Base case: সবচেয়ে ছোট ইনপুটে (যেমন n = 0) দাবিটি সত্য।
  2. Inductive step: n = k-তে সত্য ধরে নিয়ে (একে বলে inductive hypothesis) n = k + 1-এ সত্য দেখানো।

বিশ্বকাপ জয়ের পর রাস্তায় সারি করে দাঁড় করানো ডমিনোর কথা ভাবুন। দুটি জিনিস নিশ্চিত হলে সব ডমিনো পড়বে: প্রথমটি ধাক্কা খাবে (base case), আর যেকোনো একটি পড়লে তার পরেরটিও পড়বে (inductive step). লক্ষটি ডমিনো আলাদা করে দেখার দরকার নেই।

Weak induction: রিকার্সিভ যোগফল

Pseudocode: SUM(n)           // precondition: n ≥ 0
if n == 0: return 0
return n + SUM(n - 1)

দাবি: সব n ≥ ০-এর জন্য SUM(n) = n(n+1)/2. Base: SUM(0) = 0 = 0·1/2 ✓. Step: ধরা যাক SUM(k) = k(k+1)/2. তাহলে SUM(k+1) = (k+1) + SUM(k) = (k+1) + k(k+1)/2 = (k+1)(k+2)/2 ✓. Induction অনুযায়ী দাবিটি সব n-এর জন্য সত্য। (Termination: প্রতিটি ডাকে n এক কমে, ০-তে থামে — variant n.)

Strong induction: দ্রুত power

কখনো রিকার্শন ঠিক আগের মান নয়, অনেক ছোট কোনো মান ডাকে — যেমন n/2. তখন লাগে strong induction: k + 1-এর জন্য প্রমাণ করতে ০ থেকে k পর্যন্ত সব মানে দাবি সত্য ধরে নেওয়া যায়।

Pseudocode: POWER(x, n)      // precondition: n ≥ 0; returns x^n
if n == 0: return 1
h = POWER(x, n div 2)        // n div 2 = floor(n / 2)
if n is even: return h * h
else:         return h * h * x

দাবি: সব n ≥ ০-এ POWER(x, n) = xⁿ. Base: n = ০-তে ১ = x⁰ ✓. Step: ধরা যাক n ≥ ১, আর n-এর চেয়ে ছোট সব m-এ POWER(x, m) = xᵐ. যেহেতু m = ⌊n/2⌋ < n, hypothesis অনুযায়ী h = x^⌊n/2⌋. n জোড় হলে h·h = x^(n/2 + n/2) = xⁿ; বিজোড় হলে ⌊n/2⌋ = (n−1)/2, তাই h·h·x = x^(n−1+1) = xⁿ ✓. লক্ষ করুন, weak induction এখানে যথেষ্ট নয় — n − ১-এ সত্য জেনে লাভ নেই, কারণ ডাকটি যায় ⌊n/2⌋-এ। প্রতিটি ডাকে n অর্ধেক হয়, তাই ডাকের সংখ্যা ⌊log₂ n⌋ + ২-এর বেশি নয় — সময় O(log n), আর call stack-এর জন্য জায়গা O(log n).

Euclid-এর GCD-ও strong induction-এর উদাহরণ: দ্বিতীয় আর্গুমেন্ট b-এর ওপর induction. b = ০-তে উত্তর a — ঠিক। b > ০ হলে পরের ডাকের দ্বিতীয় আর্গুমেন্ট a mod b < b, তাই hypothesis অনুযায়ী সেই ডাক gcd(b, a mod b) ঠিক ফেরত দেয়, আর সূত্র অনুযায়ী সেটিই gcd(a, b). রিকার্শন আর call stack-এর ভেতরের কাজ পর্ব ০৭-এ, আর রিকার্শনের সময় হিসাবের (recurrence) পদ্ধতি পর্ব ০৮-এ দেখবেন।

সংক্ষেপে: proof by contradiction

আরেকটি প্রচলিত কৌশল proof by contradiction: দাবিটি মিথ্যা ধরে নিন, তারপর দেখান এতে অসম্ভব কিছু ঘটে। উদাহরণ — “অসাজানো অ্যারের সর্বোচ্চ খুঁজতে প্রতিটি ঘর অন্তত একবার পড়তেই হবে।” ধরুন কোনো অ্যালগরিদম কোনো একটি ঘর a[k] না পড়েই উত্তর দেয়। তাহলে a[k]-এ অন্য সবার চেয়ে বড় একটি সংখ্যা বসিয়ে দিন — অ্যালগরিদমের কাছে ইনপুট “একই রকম দেখায়”, তাই সে আগের উত্তরই দেবে, যা এখন ভুল। বিরোধ! সুতরাং যেকোনো সঠিক অ্যালগরিদমকে n-টি ঘরই পড়তে হয় — সময় Ω(n). এই ধরনের যুক্তি দিয়েই পর্ব ২৪-এ তুলনা-ভিত্তিক সর্টিংয়ের নিম্নসীমা দেখানো হবে।

৮ · একটি counterexample-ই যথেষ্ট — ভুল অ্যালগরিদম ধরা

“ঠিক” প্রমাণ করতে সব ইনপুটের জন্য যুক্তি লাগে। কিন্তু “ভুল” প্রমাণ করতে লাগে মাত্র একটি ইনপুট, যেখানে অ্যালগরিদম ভুল উত্তর দেয়। একেই বলে counterexample. এটি অসমতার একটি সুন্দর দিক: প্রমাণ কঠিন, খণ্ডন সহজ।

ক্লাসিক উদাহরণ — খুচরা দেওয়ার সমস্যা: কিছু কয়েনের মান দেওয়া আছে, একটি অঙ্ক বানাতে সবচেয়ে কম কয়টি কয়েন লাগে? স্বাভাবিক বুদ্ধি বলে একটি greedy নিয়ম: “প্রতিবার সবচেয়ে বড় যে কয়েনটি ধরে, সেটি নাও।” দোকানে ৮৭ টাকা ফেরত দিতে আমরা ঠিক এভাবেই ৫০, ২০, ১০, ৫, ২ নিই — এবং বাংলাদেশের টাকার মানের ক্ষেত্রে এটি আসলেই সবচেয়ে কম নোট দেয়। কিন্তু এটি কি সবসময় কাজ করে?

Counterexample

কয়েন {1, 3, 4}, অঙ্ক 6. Greedy: আগে 4 (বাকি 2), তারপর 1, 1 → ৩টি কয়েন (4 + 1 + 1). কিন্তু 3 + 3 = 6 → ২টি কয়েন। Greedy ভুল — একটি উদাহরণেই প্রমাণ শেষ।

কিন্তু counterexample খুঁজে পাব কীভাবে? মাথা খাটিয়ে, অথবা — আরও নির্ভরযোগ্যভাবে — কম্পিউটারকে দিয়ে। একটি brute force সমাধান লিখুন যা ধীর হলেও নিশ্চিতভাবে ঠিক (এখানে প্রতিটি অঙ্কের জন্য “শেষ কয়েনটি কোনটি” সব সম্ভাবনা যাচাই), তারপর ছোট ছোট সব ইনপুটে দুটির উত্তর মিলিয়ে দেখুন। নিচের কোডটি পাতাতেই চলবে — “Run” চাপুন:

const coins = [4, 3, 1];            // greedy tries the largest coin first
function greedy(amount) {
  let cnt = 0;
  for (const c of coins) { cnt += Math.floor(amount / c); amount %= c; }
  return cnt;
}
function optimal(amount) {          // brute force over the last coin (DP)
  const best = [0];
  for (let v = 1; v <= amount; v++) {
    best[v] = Infinity;
    for (const c of coins) if (c <= v) best[v] = Math.min(best[v], best[v - c] + 1);
  }
  return best[amount];
}
let bad = 0;
for (let amount = 1; amount <= 15; amount++) {
  const g = greedy(amount), b = optimal(amount);
  if (g !== b) { bad++; console.log(`amount ${amount}: greedy ${g} coins, optimal ${b} coins`); }
}
console.log(bad ? `${bad} counterexamples in 1..15` : "no counterexample in 1..15");

আউটপুট:

amount 6: greedy 3 coins, optimal 2 coins
amount 10: greedy 4 coins, optimal 3 coins
amount 14: greedy 5 coins, optimal 4 coins
3 counterexamples in 1..15

প্রথম counterexample-ই 6 — হাতে যা পেয়েছিলাম। 10-এ greedy নেয় 4 + 4 + 1 + 1, অথচ 4 + 3 + 3 যথেষ্ট; 14-এ 4 + 4 + 4 + 1 + 1 বনাম 4 + 4 + 3 + 3. এবার coins বদলে [50, 20, 10, 5, 2, 1] (টাকার নোট) করে চালান — 1 থেকে 15-এ কোনো counterexample পাবেন না। খেয়াল রাখুন, “পাইনি” মানে “নেই” প্রমাণ হয়নি — Dijkstra-র কথা মনে করুন! সঠিক সমাধান (dynamic programming) পর্ব ৫৩-এ, আর greedy কখন নিরাপদ তার প্রমাণ-পদ্ধতি (exchange argument) পর্ব ৫০-এ।

এই কোডটি আসলে একটি ছোট stress test: দ্রুত (কিন্তু সন্দেহজনক) সমাধানকে ধীর (কিন্তু নিশ্চিত) সমাধানের সাথে অনেক ইনপুটে মেলানো। এখানে brute force-এর সময় O(A·k) — A অঙ্ক, k কয়েনের সংখ্যা — যা ছোট A-তে নগণ্য। কনটেস্ট প্রোগ্রামাররা ঠিক এই কৌশলে নিজের “Wrong answer”-এর কারণ খুঁজে বের করেন। পরের সেকশনে কৌশলটি সাধারণভাবে দেখব।

৯ · কোডের ভেতরে প্রমাণ — assert আর stress testing

প্রমাণ মাথায় বা খাতায় থাকে; বাগ থাকে কোডে। দুটিকে জোড়ার দুটি ব্যবহারিক উপায় আছে।

১. Assertion: চলমান invariant

যেখানে আপনার যুক্তি বলে “এখানে এটা সত্য হতেই হবে”, সেখানে assert লিখুন। C++-এ <cassert>-এর assert(cond), Python-এ assert cond, "message". সাধারণ তিনটি জায়গা: ফাংশনের শুরুতে precondition, লুপের ভেতরে invariant, আর ফেরত দেওয়ার আগে postcondition.

int binary_search(const vector<int>& a, int x) {
    assert(is_sorted(a.begin(), a.end()));        // precondition (debug only: O(n))
    int lo = 0, hi = (int)a.size() - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        assert(lo <= mid && mid <= hi);           // mid stays inside the range
        if (a[mid] == x) return mid;
        if (a[mid] < x) lo = mid + 1; else hi = mid - 1;
    }
    return -1;
}

দুটি সাবধানতা। (১) assert বন্ধ করা যায়: C++-এ -DNDEBUG দিয়ে compile করলে, আর Python-এ python -O দিয়ে চালালে সব assert মুছে যায়। তাই ব্যবহারকারীর ইনপুট যাচাই (যেমন bKash নম্বর ঠিক আছে কি না) কখনো assert দিয়ে করবেন না — সেখানে সাধারণ if আর error message লাগে। Assert হলো প্রোগ্রামারের নিজের যুক্তির পাহারাদার। (২) Assert-এর ভেতরে কোনো কাজ (side effect) রাখবেন না, যেমন assert(++count < n) — assert বন্ধ হলে কাজটিও উধাও হবে।

২. Stress testing: brute force-এর সাথে হাজারো তুলনা

কনটেস্ট আর ইন্টারভিউতে সবচেয়ে কাজের যাচাই-কৌশল এটি। চারটি অংশ:

  1. দ্রুত সমাধান — যেটি আপনি যাচাই করতে চান।
  2. Brute force — সরলতম, ধীর, কিন্তু স্পষ্টভাবে ঠিক সমাধান (সব সম্ভাবনা যাচাই)।
  3. ছোট এলোমেলো ইনপুট তৈরি — যেমন ১ থেকে ৮ আকারের অ্যারে, মান ০ থেকে ৫; ছোট মানে পুনরাবৃত্ত সংখ্যা, শূন্য আর সীমানার ক্ষেত্র ঘন ঘন আসে।
  4. মিলিয়ে দেখা — অমিল হলেই সেই ইনপুট ছাপিয়ে থামুন। ছোট ইনপুট বলে হাতে trace করা সহজ।
import random

def fast(a): ...            # your clever solution
def brute(a): ...           # obviously-correct slow solution

for t in range(10000):
    a = [random.randint(0, 5) for _ in range(random.randint(1, 8))]
    if fast(a[:]) != brute(a[:]):
        print("counterexample:", a, fast(a[:]), brute(a[:]))
        break
else:
    print("10000 random tests passed")

তানিয়ার পুনরাবৃত্ত সংখ্যার বাগ এই পদ্ধতিতে কয়েক মিলিসেকেন্ডেই ধরা পড়ত — মান ০ থেকে ৫-এর মধ্যে রাখলে দশ হাজার অ্যারের অনেকগুলোতেই একই সংখ্যা দুবার আসবে। মনে রাখবেন, stress testing প্রমাণ নয়; এটি counterexample খোঁজার যন্ত্র। প্রমাণ আর stress test একে অপরের পরিপূরক: প্রমাণ বলে “কেন ঠিক”, stress test ধরে “প্রমাণে বা কোডে কোথায় ফাঁক”।

পদ্ধতিযা নিশ্চিত করেসীমাবদ্ধতা
হাতে কয়েকটি testওই ইনপুটগুলোতে ঠিকবাকি সব ইনপুট অজানা
Stress testingহাজারো ছোট ইনপুটে brute force-এর সাথে মিলবড় ইনপুটের ভুল (overflow, সময়) ধরা পড়ে না
Assertionচলার সময় invariant ভাঙলেই সেখানে থামেশুধু যে ইনপুট চালানো হলো তাতেই কাজ করে
Invariant/induction প্রমাণসব বৈধ ইনপুটে ঠিকপ্রমাণে ভুল হতে পারে; কোড প্রমাণের সাথে না মিলতে পারে
Formal verificationকম্পিউটার-যাচাইকৃত প্রমাণখুব শ্রমসাধ্য; গুরুত্বপূর্ণ সিস্টেমেই ব্যবহার হয়

আরও গভীরে: Invariant-এর চিন্তা আজ শিল্পেও সরাসরি কাজে লাগে। seL4 নামের একটি operating system kernel-এর C কোড ২০০৯ সালে machine-checked প্রমাণ দিয়ে specification-এর সাথে মিলিয়ে যাচাই করা হয়; Xavier Leroy-র CompCert হলো প্রমাণসহ একটি C compiler. Amazon Web Services তাদের distributed সিস্টেমের নকশা TLA+ নামের ভাষায় লিখে model checker দিয়ে যাচাই করে বলে প্রকাশ্যে জানিয়েছে। আর Python-এর Hypothesis বা Haskell-এর QuickCheck-এর মতো property-based testing লাইব্রেরি stress testing-কে স্বয়ংক্রিয় করে — এমনকি counterexample পেলে সেটিকে সবচেয়ে ছোট রূপে “সংকুচিত” (shrink) করে দেখায়।

পরীক্ষা ও ইন্টারভিউতে: এই ধরনের প্রশ্ন প্রায়ই আসে —

  • Loop invariant কী? এর তিনটি ধাপ লিখুন। — প্রতি ঘোরার শুরুতে সত্য একটি দাবি; initialization, maintenance, termination.
  • Insertion sort-এর invariant লিখে সঠিকতা প্রমাণ করুন। — “a[0..i-1] মূল প্রথম i-টি উপাদান, সাজানো”; তিন ধাপ সেকশন ৪-এর মতো।
  • Partial আর total correctness-এর পার্থক্য কী? — total = partial + termination.
  • Greedy coin change সবসময় সর্বোত্তম কি না? — না; {1, 3, 4}-এ 6 হলো counterexample.
  • আপনার সমাধান কিছু hidden test-এ fail করছে, কী করবেন? — brute force লিখে ছোট এলোমেলো ইনপুটে stress test, আর edge case হাতে যাচাই।

১০ · AI-এর যুগে — কোড নয়, প্রমাণ চান

AI এখন সেকেন্ডে binary search, DP বা greedy সমাধান লিখে দেয়, সাথে আত্মবিশ্বাসী ব্যাখ্যাও। সমস্যা হলো, আত্মবিশ্বাস আর সঠিকতা এক জিনিস নয়। DSA-তে AI-এর সবচেয়ে প্রচলিত ভুলগুলো ঠিক এই পর্বের বিষয়: সীমানায় < বনাম <= (off-by-one), প্রমাণ ছাড়াই greedy, আর ভুল complexity দাবি। এই পর্বের হাতিয়ারগুলো দিয়েই এগুলো ধরা যায়।

AI উত্তর দেয়; invariant আর counterexample বলে উত্তরটি বিশ্বাসযোগ্য কি না।
AI-এর ভুলকীভাবে ধরবেন
Binary search-এ lo = mid আর while (lo < hi)-এর অসংগত মিশ্রণInvariant লিখে প্রতিটি লাইন মেলান; দুই-ঘরের অ্যারেতে হাতে trace
“Greedy কাজ করবে” — কোনো প্রমাণ ছাড়াBrute force লিখে ছোট ইনপুটে counterexample খুঁজুন
প্রমাণে শুধু উদাহরণ দেখানো (“[3,1,2]-এ কাজ করে, তাই ঠিক”)Initialization, maintenance, termination আলাদা করে চান
ফাঁকা অ্যারে, এক উপাদান, সব সমান — এসব edge case বাদএই ইনপুটগুলো নিজে চালান; precondition স্পষ্ট করুন

AI-কে কোড চাওয়ার সময় সাথে invariant আর যাচাইয়ের উপকরণও চান। তখন তার যুক্তি আপনি নিজে পরীক্ষা করতে পারবেন:

আমি একটি সাজানো array-তে x-এর প্রথম অবস্থান (lower bound) খুঁজতে binary search লিখতে চাই। ১. আগে loop invariant-টি এক বাক্যে লেখো, আর lo, hi কোন পরিসর বোঝায় (বন্ধ নাকি অর্ধ-খোলা) তা বলো। ২. Initialization, maintenance আর termination — তিন ধাপে সংক্ষেপে প্রমাণ দাও, আর variant function কী তা বলো। ৩. তারপর C++ কোড দাও, invariant-টি assert হিসেবে কোডে রাখো। ৪. শেষে একটি brute force (linear scan) আর একটি stress test লিখে দাও যা ১০০০০ ছোট এলোমেলো array-তে দুটির উত্তর মেলায়। উদাহরণ দেখিয়ে “কাজ করে” বলবে না — প্রমাণ দাও।

AI-এর অ্যালগরিদম হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Dijkstra বললেন testing বাগের অনুপস্থিতি দেখাতে পারে না। তাহলে সফটওয়্যার কোম্পানিগুলো প্রমাণ না লিখে testing-এই এত জোর দেয় কেন?

দুটি পদ্ধতি দুই রকম প্রশ্নের উত্তর দেয়, আর খরচও আলাদা। প্রমাণ বলে “specification অনুযায়ী অ্যালগরিদমটি ঠিক”; testing বলে “এই আসল কোড, এই আসল compiler আর মেশিনে, এই ইনপুটগুলোতে ঠিক চলছে”। দুটোর ফাঁক অনেক বড়।

Donald Knuth ১৯৭৭ সালে এক সহকর্মীকে লেখা চিঠিতে মজা করে লিখেছিলেন: “Beware of bugs in the above code; I have only proved it correct, not tried it.” অর্থাৎ প্রমাণ করা কোডেও বাগ থাকতে পারে — কারণ প্রমাণ হয় অ্যালগরিদমের একটি আদর্শ মডেলের ওপর। বাস্তব কোডে টাইপের ভুল থাকে, integer overflow হয়, specification নিজেই ভুল হতে পারে, বা যা প্রমাণ করা হলো তা আসল দরকার নয়।

আবার উল্টো দিকে, শুধু testing-এর ভরসায় থাকলে বিরল ইনপুটের বাগ বছরের পর বছর লুকিয়ে থাকে। বাস্তব প্রকৌশল তাই স্তরে স্তরে কাজ করে:

  • নকশার স্তরে যুক্তি: জটিল অ্যালগরিদমের মূল invariant লিখে রাখা, code review-তে সেটি নিয়ে আলোচনা।
  • কোডে assertion: সেই invariant-কে চলমান পাহারাদার বানানো।
  • Unit test, stress test, property-based test, fuzzing: হাজার-লক্ষ ইনপুটে যন্ত্র দিয়ে counterexample খোঁজা।
  • সবচেয়ে ঝুঁকিপূর্ণ অংশে formal verification: বিমান, চিপ, cryptography, kernel.

খরচের হিসাবটিও গুরুত্বপূর্ণ। একটি সাধারণ ওয়েবসাইটের বোতামের কোড আনুষ্ঠানিকভাবে প্রমাণ করার মূল্য নেই; কিন্তু ব্যাংকের লেনদেনের হিসাব বা বিমানের নিয়ন্ত্রণ সফটওয়্যারে ভুলের দাম এত বেশি যে প্রমাণের খরচ সার্থক। Dijkstra-র কথার আসল বার্তা “test করবেন না” নয় — বরং “test পাস করাকে প্রমাণ ভাববেন না”।

মূল উপলব্ধি: Testing আর proof প্রতিযোগী নয়, সহযোগী। প্রমাণ বলে কেন ঠিক হওয়া উচিত; testing ধরে কোথায় বাস্তব কোড প্রমাণ থেকে সরে গেছে। একজন ভালো প্রকৌশলী জানেন কোন অংশে কতটা নিশ্চয়তা দরকার।

প্র ০২Binary search-এর প্রমাণ তো নিখুঁত — তাহলে ২০০৬ সালে Java-র লাইব্রেরিতে binary search-এর বাগ ধরা পড়ল কীভাবে?

২০০৬ সালে Joshua Bloch (Google-এ কর্মরত, আগে Sun-এ Java-র লাইব্রেরির অন্যতম লেখক) একটি ব্লগ লেখেন, যার শিরোনামের সারকথা: “প্রায় সব binary search আর mergesort ভাঙা।” Java-র Arrays.binarySearch-এ মাঝের index হিসাব করা হতো int mid = (low + high) / 2; দিয়ে — বহু বছর ধরে, লক্ষ লক্ষ প্রোগ্রামে।

সমস্যা: int সর্বোচ্চ প্রায় ২১৪ কোটি (2³¹ − 1) ধরতে পারে। Array-তে প্রায় ১০৭ কোটির (2³⁰) বেশি উপাদান থাকলে low + high এই সীমা ছাড়িয়ে যেতে পারে — integer overflow — আর যোগফল ঋণাত্মক হয়ে যায়। ঋণাত্মক mid দিয়ে array-তে ঢুকলে প্রোগ্রাম crash করে। ১৯৯০-এর দশকে এত বড় array প্রায় অকল্পনীয় ছিল, তাই কেউ টের পায়নি; মেমরি সস্তা হতেই বাগটি বাস্তব হয়ে উঠল।

তাহলে প্রমাণ কোথায় ভুল করল? প্রমাণ ভুল করেনি — প্রমাণের মডেল বাস্তবের সাথে মেলেনি। গণিতে পূর্ণসংখ্যা অসীম; (low + high) / 2 সবসময় low আর high-এর মাঝে থাকে। কিন্তু কম্পিউটারের int সসীম। প্রমাণে একটি অনুচ্চারিত precondition ছিল: “low + high overflow করবে না”। সমাধান হলো এমনভাবে লেখা যাতে মধ্যবর্তী মানও সীমার ভেতরে থাকে: mid = low + (high - low) / 2 — এজন্যই এই পর্বের কোডে এভাবে লেখা হয়েছে।

এই ঘটনা থেকে কয়েকটি শিক্ষা: (১) প্রমাণ লেখার সময় ডেটা টাইপের সীমাও precondition-এর অংশ। (২) Stress testing ছোট ইনপুটে চালানো হয় বলে এই বাগ কখনো ধরতে পারত না — বড় ইনপুটের ঝুঁকি আলাদা করে ভাবতে হয়। (৩) “বহু বছর ধরে চলছে” মানেই “ঠিক” নয় — Dijkstra-র কথাটি আবারও।

মূল উপলব্ধি: প্রতিটি প্রমাণ একটি মডেলের ওপর দাঁড়ায়। মডেলের অনুমানগুলো (অসীম integer, সীমাহীন মেমরি, ঠিক ইনপুট) স্পষ্ট করে লিখুন, আর দেখুন বাস্তব সেগুলো মানে কি না।

প্র ০৩Termination প্রমাণ যদি variant দিয়ে এত সহজ হয়, তাহলে কম্পিউটার নিজেই কেন বলে দিতে পারে না যে একটি প্রোগ্রাম থামবে কি না?

কারণ এটি গাণিতিকভাবে অসম্ভব — এবং এই আবিষ্কারটি কম্পিউটার বিজ্ঞানের জন্মের সাথেই জড়িত। ১৯৩৬ সালে, প্রথম ইলেকট্রনিক কম্পিউটার তৈরিরও আগে, Alan Turing প্রমাণ করেন: এমন কোনো সাধারণ প্রোগ্রাম HALTS(P, x) লেখা সম্ভব নয় যা যেকোনো প্রোগ্রাম P আর ইনপুট x দেখে সবসময় নির্ভুলভাবে বলবে P থামবে কি না।

প্রমাণটি proof by contradiction-এর একটি অসাধারণ উদাহরণ। ধরুন HALTS আছে। তাহলে একটি দুষ্টু প্রোগ্রাম বানান D(P): “যদি HALTS(P, P) বলে থামবে, তাহলে আমি চিরকাল লুপে ঘুরব; আর যদি বলে থামবে না, তাহলে আমি সঙ্গে সঙ্গে থামব।” এবার জিজ্ঞেস করুন — D(D) কি থামে? থামলে, সংজ্ঞা অনুযায়ী থামে না; না থামলে, সংজ্ঞা অনুযায়ী থামে। দুই দিকেই বিরোধ। সুতরাং HALTS থাকতে পারে না।

তাহলে variant দিয়ে প্রমাণ কেন কাজ করে? কারণ আমরা নির্দিষ্ট অ্যালগরিদমের জন্য মানুষের বুদ্ধি দিয়ে একটি variant খুঁজে নিচ্ছি। অসম্ভবতা শুধু বলে: সব প্রোগ্রামের জন্য একটি যান্ত্রিক পদ্ধতি নেই। বাস্তবে অনেক টুল (যেমন কিছু static analyzer আর proof assistant) বেশিরভাগ সাধারণ লুপের variant নিজেই খুঁজে পায়, আর না পারলে “জানি না” বলে।

Collatz সমস্যা দেখায় এই কঠিনতা কত বাস্তব। নিয়মটি শিশুও বোঝে — জোড় হলে অর্ধেক, বিজোড় হলে তিনগুণ যোগ এক — কিন্তু এটি সব সংখ্যায় ১-এ পৌঁছায় কি না, প্রায় ৯০ বছর ধরে গণিতবিদেরা প্রমাণ করতে পারেননি। এখানে কোনো সহজ variant নেই: n কখনো বাড়ে, কখনো কমে।

মূল উপলব্ধি: কম্পিউটার সবকিছু করতে পারে না — এটি প্রমাণিত সত্য। তাই নিজের লেখা প্রতিটি লুপের জন্য “কোন মাপটি কমছে?” প্রশ্নটি করার অভ্যাস আপনার নিজের দায়িত্ব; কোনো টুল সবসময় এটি আপনার হয়ে করে দেবে না।

প্র ০৪একটি ভাগ-অঙ্কের ভুল Intel-কে বিপুল ক্ষতিতে ফেলেছিল। এরপর হার্ডওয়্যার আর সফটওয়্যার জগৎ “প্রমাণ” নিয়ে কী শিখল?

১৯৯৪ সালে গণিতের অধ্যাপক Thomas Nicely মৌলিক সংখ্যা নিয়ে গবেষণার সময় লক্ষ করেন, Intel-এর নতুন Pentium প্রসেসর কিছু নির্দিষ্ট ভাগ-অঙ্কে সামান্য ভুল ফল দিচ্ছে। কারণ: ভাগের হার্ডওয়্যারে একটি lookup table-এর কয়েকটি ঘর ফাঁকা ছিল। ভুলটি খুব বিরল ইনপুটে ঘটত — সাধারণ testing-এ ধরা পড়েনি। জনমতের চাপে Intel প্রসেসর বদলে দিতে বাধ্য হয়, আর কোম্পানিটি এ বাবদ প্রায় ৪৭৫ মিলিয়ন ডলারের খরচ ঘোষণা করে।

এই ঘটনা চিপ শিল্পে একটি মোড় ঘোরানো মুহূর্ত। একটি চিপ বাজারে যাওয়ার পর আর “আপডেট” করা যায় না, তাই বড় চিপ নির্মাতারা ধীরে ধীরে formal verification-এ বিপুল বিনিয়োগ করে — গাণিতিক প্রমাণ দিয়ে দেখানো যে ভাগ, গুণ বা floating-point ইউনিট specification হুবহু মানে।

সফটওয়্যারেও একই ধারা দেখা যায়:

  • seL4 (২০০৯): একটি operating system kernel-এর C কোড machine-checked প্রমাণ দিয়ে specification-এর সাথে মেলানো হয়।
  • CompCert: Xavier Leroy-র নেতৃত্বে তৈরি একটি C compiler, যার অনুবাদ-প্রক্রিয়ার মূল অংশ প্রমাণিত। ২০১১ সালে গবেষকেরা Csmith নামের একটি এলোমেলো-প্রোগ্রাম-তৈরির টুল দিয়ে GCC আর LLVM-এ শত শত বাগ খুঁজে পান, কিন্তু CompCert-এর প্রমাণিত অংশে কোনো ভুল-কোড বাগ পাননি।
  • Amazon Web Services: তাদের distributed সিস্টেমের নকশা TLA+ দিয়ে যাচাই করে সূক্ষ্ম বাগ ধরেছে বলে প্রকাশ্যে জানিয়েছে।

লক্ষ করুন Csmith-এর গল্পে দুটি কৌশলই আছে: এলোমেলো ইনপুট দিয়ে counterexample খোঁজা (stress testing-এর বড় রূপ, যাকে বলে fuzzing), আর প্রমাণ — এবং প্রমাণিত অংশই টিকে গেছে।

মূল উপলব্ধি: ভুলের দাম যত বেশি, প্রমাণের মূল্য তত বেশি। এই পর্বের loop invariant সেই বিশাল জগতের প্রথম ধাপ — একই চিন্তা চিপ, kernel আর cloud-এর নিরাপত্তা রক্ষা করছে।

প্র ০৫টাকার নোটে greedy সবসময় সবচেয়ে কম নোট দেয়, অথচ {1, 3, 4}-এ ব্যর্থ হয়। কোন মুদ্রাব্যবস্থায় greedy নিরাপদ, আর সেটা জানব কীভাবে?

যে মুদ্রাব্যবস্থায় প্রতিটি অঙ্কের জন্য greedy সবচেয়ে কম কয়েন দেয়, তাকে বলে canonical coin system. বাংলাদেশসহ বেশিরভাগ দেশের মুদ্রা ১-২-৫ ধাঁচে সাজানো (১, ২, ৫, ১০, ২০, ৫০, ১০০, ২০০, ৫০০, ১০০০) — এগুলো canonical, তাই দোকানির “আগে বড় নোট” কৌশল নিখুঁত কাজ করে। কিন্তু {1, 3, 4}-এর মতো এলোমেলো সেটে তা নয়।

একটি সেট canonical কি না জানতে কি অসীম অঙ্ক পরীক্ষা করতে হবে? না — এখানে গণিত সাহায্য করে। ১৯৯৪ সালে Dexter Kozen আর Shmuel Zaks দেখান, greedy যদি কোথাও ব্যর্থ হয়, তাহলে সবচেয়ে ছোট counterexample-টি সবচেয়ে বড় দুটি কয়েনের যোগফলের চেয়ে ছোট হবে। {1, 3, 4}-এ এই সীমা 3 + 4 = 7, আর সত্যিই প্রথম counterexample 6. অর্থাৎ সসীম কয়েকটি অঙ্কে brute force-এর সাথে মিলিয়ে দেখলেই নিশ্চিত হওয়া যায় — এখানে stress testing প্রমাণে পরিণত হয়েছে! পরে David Pearson একটি আরও দ্রুত, polynomial সময়ের পরীক্ষা পদ্ধতি দেন।

এখান থেকে একটি গভীর শিক্ষা: ছোট ইনপুটে যাচাই সাধারণত প্রমাণ নয়, কিন্তু কখনো কখনো একটি তাত্ত্বিক ফল বলে দেয় “এই সীমার পরে নতুন কিছু ঘটবে না” — তখন সীমিত পরীক্ষাই পূর্ণ প্রমাণ। কনটেস্টেও এমন “ছোট ক্ষেত্রই যথেষ্ট” ধরনের যুক্তি মাঝে মাঝে কাজে লাগে।

সাধারণ greedy-র জন্য প্রমাণের প্রচলিত কৌশল হলো exchange argument: ধরুন একটি সর্বোত্তম সমাধান আছে যা greedy-র প্রথম পছন্দ নেয়নি; দেখান যে সেটির একটি অংশ বদলে greedy-র পছন্দ বসালে সমাধান খারাপ হয় না। {1, 3, 4}-এ এই বদল ব্যর্থ হয় — 3 + 3-কে 4 + … দিয়ে বদলালে কয়েন বেড়ে যায়। এই কৌশল পর্ব ৫০-এ বিস্তারিত, আর যেকোনো কয়েন সেটে সঠিক উত্তরের জন্য dynamic programming পর্ব ৫৩-এ।

মূল উপলব্ধি: Greedy-কে কখনো “স্বাভাবিক বলে ঠিক” ধরে নেবেন না। হয় প্রমাণ (exchange argument বা তাত্ত্বিক সীমা), নয়তো brute force দিয়ে counterexample খোঁজা — এই দুইয়ের একটি ছাড়া greedy শুধু একটি অনুমান।

অনুশীলন

খাতা-কলম নিয়ে বসুন — প্রমাণ লেখা অনুশীলনেই সহজ হয়।

  1. হাতে trace করুন: a = [1, 4, 6, 9, 11, 15, 20]-এ বন্ধ পরিসরের binary search দিয়ে x = 11 খুঁজুন। প্রতিটি ঘোরায় lo, hi, mid, a[mid] আর variant hi − lo + 1 লিখুন।

    শুরু lo = 0, hi = 6, variant ৭। ঘোরা ১: mid = 3, a[3] = 9 < 11 → lo = 4, variant ৩। ঘোরা ২: mid = 4 + (6 − 4)/2 = 5, a[5] = 15 > 11 → hi = 4, variant ১। ঘোরা ৩: mid = 4, a[4] = 11 — পাওয়া গেছে, index ৪ ফেরত। প্রতিটি ধাপে 11 ছিল a[lo..hi]-এর ভেতরে (invariant), আর variant ৭ → ৩ → ১ কঠোরভাবে কমেছে।

  2. Invariant খুঁজুন ও প্রমাণ করুন: নিচের লুপ অ্যারের যোগফল বের করে — s = 0; for (i = 0; i < n; i++) s += a[i];. একটি invariant লিখে তিন ধাপে প্রমাণ করুন, আর variant বলুন।

    Invariant: শর্ত যাচাইয়ের মুহূর্তে s = a[0] + … + a[i-1] (i = ০ হলে ফাঁকা যোগফল = ০)। Initialization: i = 0, s = 0 — ফাঁকা যোগফল ০ ✓. Maintenance: শুরুতে s = a[0..i-1]-এর যোগফল হলে s += a[i]-এর পর s = a[0..i]-এর যোগফল; i এক বাড়লে invariant আবার সত্য। Termination: i = n-এ s = a[0] + … + a[n-1] — পুরো যোগফল। Variant: n − i, প্রতিবার ১ কমে, ০-তে থামে।

  3. Counterexample দিন: কয়েন {1, 5, 6, 9}-এ greedy খুচরা ভুল করে এমন একটি অঙ্ক খুঁজুন। আর দেখান, “সবচেয়ে বড় দুটি কয়েনের যোগফলের চেয়ে ছোট” সীমার ভেতরেই সেটি আছে।

    অঙ্ক 11: greedy নেয় 9, তারপর 1, 1 → ৩টি কয়েন; অথচ 5 + 6 = 11 → ২টি কয়েন। সীমা 6 + 9 = 15, আর 11 < 15 ✓. (আরও আছে: 12-তে greedy 9 + 1 + 1 + 1 = ৪টি, কিন্তু 6 + 6 = ২টি। তবে সবচেয়ে ছোট counterexample 11 — 10 পর্যন্ত greedy ঠিক থাকে, যেমন 10 = 9 + 1 = ২টি।)

  4. কোড লিখুন — stress tester: C++ বা Python-এ একটি stress test লিখুন যা আপনার নিজের লেখা insertion sort-কে লাইব্রেরির sort-এর সাথে ১০০০০টি এলোমেলো ছোট অ্যারেতে (আকার ০–৮, মান ০–৫) মেলায়, অমিল হলে অ্যারেটি ছাপিয়ে থামে।

    একটি সম্ভাব্য C++ সমাধান (mt19937 দিয়ে নির্দিষ্ট seed, যাতে বারবার একই ইনপুট আসে):

    #include <bits/stdc++.h>
    using namespace std;
    
    void my_sort(vector<int>& a) {
        for (size_t i = 1; i < a.size(); ++i) {
            int key = a[i]; size_t j = i;
            while (j > 0 && a[j - 1] > key) { a[j] = a[j - 1]; --j; }
            a[j] = key;
        }
    }
    
    int main() {
        mt19937 rng(12345);
        for (int t = 0; t < 10000; ++t) {
            vector<int> a(rng() % 9);
            for (int& x : a) x = rng() % 6;
            vector<int> mine = a, ref = a;
            my_sort(mine); sort(ref.begin(), ref.end());
            if (mine != ref) { cout << "mismatch on test " << t << '\n'; return 0; }
        }
        cout << "10000 random tests passed\n";
    }

    খেয়াল করুন: আকার ০ আর ১-এর অ্যারেও আসে (edge case), আর মান ছোট রাখায় পুনরাবৃত্ত সংখ্যা প্রচুর। এবার ইচ্ছে করে বাগ ঢোকান — যেমন a[j - 1] > key-এর জায়গায় a[j - 1] >= key (এটি আসলে এখনো ঠিক, শুধু stable নয়!), তারপর j > 0-এর জায়গায় j > 1 লিখুন — দ্বিতীয়টি সঙ্গে সঙ্গে mismatch দেখাবে।

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

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

ক্রমLoop invariant দিয়ে একটি অ্যালগরিদম প্রমাণের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Precondition আর postcondition লেখা
  2. এমন একটি invariant বেছে নেওয়া যা শেষে postcondition দেবে
  3. Initialization — প্রথম ঘোরার আগে invariant সত্য দেখানো
  4. Maintenance — এক ঘোরা পরেও invariant সত্য থাকে দেখানো
  5. Termination — লুপ থামলে invariant থেকে postcondition বের করা

একটি সর্টিং ফাংশন ৫০০টি ভিন্ন ইনপুটে সঠিক আউটপুট দিয়েছে। এ থেকে কোন সিদ্ধান্তটি যুক্তিসংগত?

  • ফাংশনটি সব ইনপুটে সঠিক — প্রমাণিত
  • ওই ৫০০টি ইনপুটে বাগ পাওয়া যায়নি, কিন্তু সব ইনপুটে সঠিকতা প্রমাণিত হয়নি
  • ফাংশনটি নিশ্চয়ই অন্য কোনো ইনপুটে ভুল
  • ফাংশনটি সব ইনপুটে থামবে, তবে উত্তর ভুল হতে পারে

Total correctness বলতে কী বোঝায়?

  • অ্যালগরিদম থামলে উত্তর ঠিক হবে
  • অ্যালগরিদম সব ইনপুটে থামবে
  • প্রতিটি বৈধ ইনপুটে অ্যালগরিদম থামবে এবং উত্তর ঠিক হবে
  • সব test case পাস করবে

Insertion sort-এর বাইরের লুপের (i = 1 থেকে n−1) সঠিক loop invariant কোনটি?

  • a[0..i-1]-এ মূল অ্যারের প্রথম i-টি উপাদান সাজানো অবস্থায় আছে
  • a[0..i-1]-এ পুরো অ্যারের সবচেয়ে ছোট i-টি উপাদান আছে
  • a[i..n-1] সাজানো
  • i ≥ 1

সাজানো অ্যারেতে বন্ধ পরিসর [lo, hi] দিয়ে binary search চলছে; a[mid] < x হলে কী করা ঠিক, আর কেন?

  • lo = mid, কারণ mid-ও উত্তর হতে পারে
  • hi = mid - 1
  • hi = mid
  • lo = mid + 1, কারণ a[0..mid]-এর কোনোটিই x হতে পারে না

কয়েন {1, 3, 4} দিয়ে 6 বানাতে “সবচেয়ে বড় কয়েন আগে” greedy কয়টি কয়েন নেয়, আর সর্বোত্তম উত্তর কত?

  • Greedy ২টি, সর্বোত্তম ২টি
  • Greedy ৩টি, সর্বোত্তম ২টি
  • Greedy ৩টি, সর্বোত্তম ৩টি
  • Greedy ৬টি, সর্বোত্তম ২টি

রিকার্সিভ POWER(x, n) নিজেকে POWER(x, n div 2) দিয়ে ডাকে। এর সঠিকতা প্রমাণে কোন পদ্ধতি সবচেয়ে উপযুক্ত?

  • কয়েকটি n-এর জন্য চালিয়ে দেখা
  • শুধু n−1-এর ওপর weak induction
  • Strong induction — n-এর চেয়ে ছোট সব মানে সত্য ধরে নিয়ে
  • একটি counterexample দেখানো

C++-এ -DNDEBUG দিয়ে compile করলে assert-এর কী হয়, আর এর ফলে কোন ব্যবহারটি ভুল?

  • সব assert বাদ পড়ে — তাই ব্যবহারকারীর ইনপুট যাচাই assert দিয়ে করা ভুল
  • assert শুধু warning দেখায় — তাই crash হয় না
  • assert আরও কঠোর হয় — তাই loop-এর ভেতরে assert রাখা ভুল
  • Compile error হয় — তাই assert ব্যবহারই ভুল

শব্দকোষ

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

মনে রাখুন

  1. Testing বাগ দেখাতে পারে, অনুপস্থিতি নয় — সব ইনপুটের নিশ্চয়তা দেয় শুধু প্রমাণ।
  2. Total correctness = partial correctness + termination; শুরুতেই precondition আর postcondition স্পষ্ট করুন।
  3. Loop invariant: initialization, maintenance, termination — আর invariant এমন হোক যা শেষে postcondition দেয়।
  4. রিকার্শন প্রমাণে induction (ছোট যেকোনো মান ডাকলে strong induction); থামার প্রমাণে কঠোরভাবে কমতে থাকা অঋণাত্মক variant.
  5. একটি counterexample-ই ভুল প্রমাণে যথেষ্ট; কোডে assert দিয়ে invariant পাহারা দিন, brute force-এর সাথে stress test চালান।

পরের পর্বে: অ্যালগরিদম ঠিক কি না, তা এখন প্রমাণ করতে পারেন। কিন্তু ঠিক অ্যালগরিদমও যদি এক কোটি ইনপুটে এক সপ্তাহ নেয়? পর্ব ০৪-এ শিখব বৃদ্ধির হার আর Big-O, Ω, Θ — অ্যালগরিদম কত দ্রুত, তা মাপার ভাষা।

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

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