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

সার্চিং ও Divide & Conquer — অর্ধেক বাদ দিয়ে খোঁজা, ভাগ করে সমাধান

Searching & Divide and Conquer — Binary Search, Bounds, Searching the Answer & Recursion Trees

Taught in Bangla, this lesson covers searching and divide and conquer: linear versus binary search, invariants, off-by-one and overflow bugs, lower and upper bounds, binary search on the answer, the C bsearch function, and recursion-tree cost analysis.

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

  • কখন একটি একটি করে খোঁজাই ঠিক, আর কখন সাজিয়ে নিয়ে binary search করা লাভজনক
  • Invariant দিয়ে binary search লেখা — যাতে off-by-one আর অসীম লুপ না হয়
  • একই মান অনেকবার থাকলে প্রথমটি, শেষটি আর মোট সংখ্যা বের করা
  • “সবচেয়ে কম কত হলে চলবে?” ধরনের প্রশ্নকে binary search-এ বদলে ফেলা
  • Divide and conquer-এর তিন ধাপ, আর recursion tree দেখে খরচ আন্দাজ করা

১ · দুইভাবে খোঁজা — একটি একটি করে, নাকি মাঝখান থেকে

এসএসসি-র ফল বেরিয়েছে। স্কুলের নোটিশ বোর্ডে ৮০০ জন পরীক্ষার্থীর রোল নম্বর আর গ্রেডের লম্বা তালিকা টাঙানো। আপনার রোল ৪১৭২৩৫. আপনি কী করবেন? প্রথম লাইন থেকে একটি একটি করে পড়বেন না — কারণ তালিকাটি রোল অনুযায়ী সাজানো. আপনি চোখ বুলিয়ে মাঝামাঝি কোথাও যাবেন, দেখবেন সেখানকার রোল বড় না ছোট, তারপর সেই অর্ধেকে নামবেন।

পর্ব ০২-এ ১ থেকে ১০০-এর অনুমানের খেলায় আমরা এই কৌশলটি প্রথম দেখেছিলাম। এই পর্বে সেটিকে পেশাদার স্তরে নিয়ে যাব: কীভাবে নির্ভুলভাবে লিখবেন, একই মান অনেকবার থাকলে কী করবেন, আর এমন সমস্যায় কীভাবে লাগাবেন যেখানে কোনো তালিকাই নেই।

🚶 Linear search

Linear search শুরু থেকে শেষ পর্যন্ত প্রতিটি ঘর দেখে। তালিকা সাজানো হোক বা না হোক, কাজ করে। কিন্তু n ঘরে সবচেয়ে খারাপ ক্ষেত্রে n বার তুলনা।

এলোমেলো তালিকা, ছোট তালিকা, একবারের খোঁজা

✂️ Binary search

মাঝখানের ঘর দেখে অর্ধেক বাদ দেয়। n ঘরে সবচেয়ে খারাপ ক্ষেত্রে মোটামুটি log₂ n বার তুলনা। কিন্তু তালিকা অবশ্যই সাজানো থাকতে হবে।

সাজানো তালিকা, বারবার খোঁজা
ঘরের সংখ্যা (n)Linear search — সবচেয়ে খারাপেBinary search — সবচেয়ে খারাপে
৮০০ (এক স্কুলের ফল)৮০০ তুলনা১০ তুলনা
১০ লাখ১০ লাখ তুলনা২০ তুলনা
১০০ কোটি১০০ কোটি তুলনা৩০ তুলনা

পর্ব ৩৯-এ আমরা এই পার্থক্যকে নাম দিয়েছিলাম: linear search হলো O(n), binary search হলো O(log n). তালিকা দ্বিগুণ হলে প্রথমটির কাজও দ্বিগুণ হয়; দ্বিতীয়টির বাড়ে মাত্র একটি তুলনা।

“সাজানো” শর্তটি আলোচনার বাইরে নয়। এলোমেলো তালিকায় binary search চালালে প্রোগ্রাম কোনো error দেয় না — চুপচাপ ভুল উত্তর দেয়: “নেই”, অথচ মানটি আছে। আর তালিকা যে ক্রমে সাজানো, খুঁজতেও হবে সেই ক্রমেই। নাম অনুযায়ী সাজানো তালিকায় রোল দিয়ে binary search চলবে না।

তাহলে কি সবসময় সাজিয়ে নিয়ে খুঁজব? না। সাজাতে খরচ হয় প্রায় n log n (পর্ব ৪০). শুধু একবার খুঁজলে সরাসরি linear search-ই সস্তা। কিন্তু একই তালিকায় হাজারবার খুঁজতে হলে একবার সাজানোর খরচ সহজেই উঠে আসে। ভাবনার প্রশ্ন ৪-এ এই হিসাবটি করব।

২ · Invariant ধরে রাখুন — উত্তর থাকলে lo থেকে hi-এর ভেতরেই

Binary search-এর কোড মাত্র কয়েক লাইনের। তবু এটি প্রোগ্রামিংয়ের সবচেয়ে বেশি ভুল লেখা অ্যালগরিদমগুলোর একটি। কারণ প্রতিটি লাইনে একটি ছোট সিদ্ধান্ত থাকে — < নাকি <=, mid নাকি mid - 1. আন্দাজে লিখলে কোনো না কোনো কোণায় ভুল থেকে যায়।

সমাধান হলো আন্দাজ বাদ দিয়ে একটি প্রতিজ্ঞা ঠিক করা। পর্ব ০২-এ এর নাম শিখেছিলেন — loop invariant, লুপের প্রতিটি ঘোরার শুরুতে যে কথাটি সবসময় সত্য। Binary search-এর প্রতিজ্ঞা:

“x যদি array-তে থাকে, তাহলে সেটি a[lo] থেকে a[hi]-এর মধ্যেই আছে।”

এখন প্রতিটি লাইন লেখার সময় শুধু একটি প্রশ্ন করুন: এই লাইনের পরেও কি প্রতিজ্ঞাটি সত্য থাকল? যদি a[mid] < x হয়, তাহলে mid আর তার বাঁয়ের সবগুলো x-এর চেয়ে ছোট — উত্তর সেখানে থাকতেই পারে না। তাই lo = mid + 1. ঠিক উল্টো যুক্তিতে a[mid] > x হলে hi = mid - 1. আর পরিসর ফাঁকা হয়ে গেলে (lo > hi) প্রতিজ্ঞা বলছে — x কোথাও নেই।

নিচে ১২টি সাজানো সংখ্যায় 47 খোঁজা হচ্ছে। প্রতিটি ঘরের নিচে তার index আর lo, mid, hi কোথায় আছে, দেখানো হয়েছে; লাল ঘরটি এই ধাপের mid. “পরে” চাপুন:

খোঁজার অবস্থা · খুঁজছি x = 47
lo
hi
mid = lo + (hi - lo) / 2
a[mid]
সিদ্ধান্ত
বাকি ঘর
  1. শুরু। পুরো array-ই সম্ভাব্য জায়গা: lo = 0, hi = 11. প্রতিজ্ঞাটি এখন নিশ্চিতভাবে সত্য — 47 থাকলে এই ১২টি ঘরের কোথাও আছে। প্রতিটি ধাপে আমরা প্রতিজ্ঞাটি ভাঙব না, শুধু পরিসর ছোট করব।
  2. মাঝখান: index 5, মান 26. 26 < 47, আর তালিকা সাজানো — তাই index 0 থেকে 5 পর্যন্ত সবগুলোই 47-এর চেয়ে ছোট। উত্তর সেখানে থাকতে পারে না। lo = mid + 1 = 6. এক তুলনায় ৬টি ঘর বাদ।
  3. পরিসর [6, 11], মাঝখান index 8, মান 41. আবার ছোট। lo = 9. খেয়াল করুন, mid হিসাব হলো 6 + (11 - 6) / 2 — integer division-এ 5/2 = 2, তাই 8. বাকি মাত্র ৩টি ঘর।
  4. পরিসর [9, 11], মাঝখান index 10, মান 52. 52 > 47, তাই index 10 আর তার ডানের সব বড় — বাদ। hi = mid - 1 = 9. এখন lo আর hi একই ঘরে।
  5. পরিসর [9, 9], মান 47 — পাওয়া গেছে! মোট ৪টি তুলনা। ১২টি ঘরে linear search করলে লাগত ১০টি। পরিসর একটি ঘরে নেমে এলেও লুপ চলে, কারণ শর্ত lo <= hi — সমান হলেও সেই একটি ঘর দেখা বাকি।
  6. যদি 48 খুঁজতাম? আগের ধাপে 47 < 48 হতো, তাই lo = 10. এখন lo = 10 > hi = 9 — পরিসর ফাঁকা, লুপ থামে, উত্তর “নেই”। একটি মজার তথ্য লক্ষ করুন: থামার সময় lo ঠিক সেই জায়গায় আছে, যেখানে 48 বসালে তালিকা সাজানোই থাকত। এই সূত্র ধরেই অংশ ৪-এ lower_bound আসবে।
মূল ধারণা

Binary search আসলে একটি খোঁজার জায়গা নিয়ে কাজ করে — যেখানে উত্তর থাকতে পারে, সেই অংশ। প্রতি ধাপে এই জায়গা অন্তত একটি ঘর ছোট হতে হবে (না হলে লুপ থামবে না), আর কখনো উত্তরকে বাইরে ফেলে দেওয়া যাবে না (না হলে ভুল উত্তর)। এই দুটি শর্ত মিললে কোড ঠিক — বাকি সব খুঁটিনাটি।

৩ · ছোট্ট ভুল, বড় বিপদ — off-by-one, অসীম লুপ আর overflow

Binary search লেখার দুটি জনপ্রিয় “রীতি” আছে। দুটিই ঠিক — সমস্যা হয় যখন একটির অর্ধেক আর অন্যটির অর্ধেক মিশিয়ে ফেলা হয়। AI-এর লেখা কোডেও ঠিক এই মিশ্রণ প্রায়ই দেখা যায়।

বিষয়রীতি ক — বন্ধ পরিসর [lo, hi]রীতি খ — অর্ধ-খোলা পরিসর [lo, hi)
শুরুlo = 0, hi = n - 1lo = 0, hi = n
hi-এর অর্থশেষ সম্ভাব্য ঘর (ভেতরে)শেষ সম্ভাব্য ঘরের পরের ঘর (বাইরে)
লুপের শর্তwhile (lo <= hi)while (lo < hi)
ডান অর্ধেক বাদ দিতেhi = mid - 1hi = mid
বাঁ অর্ধেক বাদ দিতেlo = mid + 1lo = mid + 1

রীতি খ-এর ভিত্তি half-open range — পর্ব ১২-এর for (i = 0; i < n; i++)-এর মতোই। এবার দেখুন মিশিয়ে ফেললে কী হয়:

  • while (lo <= hi) আর hi = mid: ধরুন lo = hi = 4, আর a[4] > x. mid = 4, hi = mid = 4 — কিছুই বদলাল না! পরের ঘোরাতেও একই অবস্থা। এটি একটি infinite loop; প্রোগ্রাম আটকে থাকে, কোনো error-ও দেখায় না।
  • while (lo < hi) আর hi = mid - 1: লুপ থামে, কিন্তু কিছু ঘর কখনো দেখাই হয় না। ফল হলো off-by-one ভুল — বিশেষ করে শেষ বা প্রথম ঘরের মান খুঁজলে “নেই” বলে।
  • lo = mid (শুধু mid, + 1 ছাড়া): দুই ঘর বাকি থাকলে, যেমন lo = 4, hi = 5, তখন mid = 4 = lo. বাঁ দিক বাদ দিতে গিয়ে lo = 4 — আবার একই জায়গায়। আবার অসীম লুপ।

একটি সহজ পরীক্ষা মনে রাখুন: এক ঘর আর দুই ঘরের array-তে কোডটি হাতে চালিয়ে দেখুন। প্রায় সব binary search bug এই দুটি ক্ষুদ্র অবস্থাতেই ধরা পড়ে, কারণ তখন mid আর lo বা hi একই ঘরে এসে পড়ে।

যে bug প্রায় বিশ বছর লুকিয়ে ছিল

পরের সমস্যাটি আরও সূক্ষ্ম। মাঝখান বের করার সবচেয়ে স্বাভাবিক লেখা mid = (lo + hi) / 2. গণিতে এটি নির্ভুল। কিন্তু C-তে int-এর একটি সীমা আছে — সাধারণত প্রায় ২১৪ কোটি (পর্ব ০৫). array-তে যদি ১৫০ কোটি ঘর থাকে, আর lo ও hi দুটিই ১১০ কোটির বেশি হয়, তাহলে যোগফল সীমা ছাড়িয়ে যায়। একে বলে overflow. C-তে signed int-এর overflow হলো undefined behavior — বাস্তবে সাধারণত একটি ঋণাত্মক সংখ্যা তৈরি হয়, আর a[mid] পড়তে গিয়ে প্রোগ্রাম ভেঙে পড়ে।

নিরাপদ লেখা হলো mid = lo + (hi - lo) / 2. গণিতে একই মান, কিন্তু hi - lo কখনো সীমা ছাড়ায় না, আর তার অর্ধেক lo-তে যোগ করলেও ফল hi-এর চেয়ে বড় হয় না।

২০০৬ সালে Google-এর প্রকৌশলী Joshua Bloch Google-এর ব্লগে একটি লেখা প্রকাশ করেন, যার শিরোনামের মূল কথা ছিল — প্রায় সব binary search আর merge sort-এর কোডেই ভুল আছে। Java-র standard library-র Arrays.binarySearch-এ (low + high) / 2 লেখা ছিল, আর সেটি বহু বছর ধরে কেউ ধরতে পারেনি — কারণ এত বড় array তখন খুব কমই কেউ ব্যবহার করত। মজার ব্যাপার, Bloch নিজেই সেই কোড লিখেছিলেন। তিনি আরও দেখান, Jon Bentley-র বিখ্যাত বই Programming Pearls-এ “প্রমাণসহ নির্ভুল” হিসেবে দেখানো binary search-এও হুবহু একই লাইন ছিল। প্রমাণটি গণিতের সংখ্যার জন্য ঠিক ছিল; কম্পিউটারের সীমিত int-এর জন্য নয়।

৪ · একই মান অনেকবার — lower_bound ও upper_bound

ক্লাসের ১০ জন ছাত্রের নম্বর সাজানো আছে: 12, 25, 25, 25, 25, 25, 38, 40, 47, 55. প্রশ্ন: ঠিক 25 পেয়েছে কতজন? সাধারণ binary search বলবে “25 আছে” — কিন্তু কোন 25? প্রথম ধাপেই মাঝের কোনো একটি 25 পেয়ে থেমে যাবে। গণনার জন্য আমাদের লাগবে প্রথম 25 কোথায় আর শেষ 25-এর পরে কী।

⬅️ lower_bound(x)

প্রথম যে ঘরের মান ≥ x, তার index. x থাকলে এটিই প্রথম x; না থাকলে এখানেই x বসানো যেত। সব মান x-এর ছোট হলে উত্তর n.

lower_bound(25) = 1

➡️ upper_bound(x)

প্রথম যে ঘরের মান > x, তার index. অর্থাৎ শেষ x-এর ঠিক পরের ঘর।

upper_bound(25) = 6
x কতবার আছে = upper_bound(x) − lower_bound(x) = 6 − 1 = 5

এখানে প্রতিজ্ঞাটি একটু বদলাতে হয়। lower_bound-এর জন্য রীতি খ ([lo, hi)) সবচেয়ে পরিষ্কার, আর invariant দুই ভাগে: “lo-এর বাঁয়ের সব মান < x, আর hi ও তার ডানের সব মান ≥ x.” মাঝের [lo, hi) অংশটি অজানা। প্রতি ধাপে অজানা অংশ ছোট হয়; শেষে lo == hi হলে আর কিছু অজানা নেই, আর সেই সীমানাটিই উত্তর।

lower_bound(25) · পরিসর [lo, hi)
lo
hi
mid
a[mid] < 25 ?
যা নিশ্চিত জানি
  1. শুরু: [0, 10). hi = 10 — array-র শেষের বাইরের ঘর। কারণ উত্তর n-ও হতে পারে (সব মান x-এর ছোট হলে)। কোনো কিছুই এখনো জানা নেই।
  2. mid = 5, মান 25. প্রশ্ন “25 < 25?” — না। অর্থাৎ index 5-এ মান ≥ 25; উত্তর 5 বা তার বাঁয়ে। কিন্তু 5 নিজেও উত্তর হতে পারে, তাই hi = mid, mid - 1 নয়। এখানেই সাধারণ binary search থেমে যেত।
  3. [0, 5), mid = 2, মান 25. আবার “না”, তাই hi = 2. প্রতিজ্ঞা বলছে: index 2 থেকে শেষ পর্যন্ত সবাই ≥ 25.
  4. [0, 2), mid = 1, মান 25. “না” — hi = 1. অজানা অংশে এখন শুধু index 0.
  5. [0, 1), mid = 0, মান 12. 12 < 25 — হ্যাঁ! তাই index 0 উত্তর হতে পারে না: lo = mid + 1 = 1.
  6. lo == hi == 1 — থামুন। বাঁয়ে সব < 25, ডানে সব ≥ 25, মাঝে কিছু নেই। প্রথম 25 আছে index 1-এ। খেয়াল করুন, লুপের ভেতরে কখনো “সমান কি না” পরীক্ষাই করা হয়নি — শুধু একটি প্রশ্ন, প্রতিবার।
  7. upper_bound ও গণনা। একই লুপে প্রশ্নটি “a[mid] <= 25?” করলে থামবে index 6-এ — প্রথম যে মান 25-এর চেয়ে বড় (38). তাই 25 আছে 6 − 1 = 5 বার। এই হিসাব log n সময়ে হয়, তালিকায় লাখ লাখ 25 থাকলেও।

এবার কোডে। একটি lower_bound ফাংশন লিখে তা দিয়েই গণনা করা হয়েছে। integer-এর ক্ষেত্রে “প্রথম > 25” আর “প্রথম ≥ 26” একই জিনিস — তাই upper_bound-এর জন্য আলাদা ফাংশন না লিখে lower_bound(26) ডাকা হয়েছে:

#include <stdio.h>

/* first i with a[i] >= x (n if none). invariant: a[<lo] < x, a[>=hi] >= x */
int lower_bound(const int a[], int n, int x) {
    int lo = 0, hi = n;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;      /* safe from overflow */
        if (a[mid] < x) lo = mid + 1;      /* mid is too small */
        else            hi = mid;          /* mid may be the answer */
    }
    return lo;                             /* here lo == hi */
}

int main(void) {
    int marks[] = {12, 25, 25, 25, 25, 25, 38, 40, 47, 55};
    int n = sizeof marks / sizeof marks[0];
    int first = lower_bound(marks, n, 25), past = lower_bound(marks, n, 26);
    printf("first 25 at index %d, count = %d\n", first, past - first);
    printf("lb(30) = %d, lb(99) = %d\n", lower_bound(marks, n, 30), lower_bound(marks, n, 99));
    return 0;
}

আউটপুট: first 25 at index 1, count = 5, তারপর lb(30) = 6, lb(99) = 10 (lb = lower_bound). শেষ দুটি লক্ষ করুন। 30 তালিকায় নেই, তবু ফাংশন বলছে 6 — ঠিক যেখানে 30 বসালে তালিকা সাজানো থাকত। 99 সবার চেয়ে বড়, তাই উত্তর n = 10, অর্থাৎ “শেষের পরে”। এজন্যই hi শুরু হয় n থেকে, n - 1 থেকে নয়। ফলাফল যে ঘরের দিকে নির্দেশ করে, ব্যবহারের আগে দুটি জিনিস যাচাই করতে হবে: first < n কি না, আর marks[first] == 25 কি না — না হলে মানটি নেই। একটি সতর্কতা: lower_bound(x + 1) কৌশলটি শুধু integer-এ চলে, আর x যদি INT_MAX হয় তাহলে x + 1 নিজেই overflow. আসল upper_bound লিখতে লুপের <-কে <= করুন — বাকি সব একই।

৫ · উত্তরের ওপর binary search — হ্যাঁ/না প্রশ্নের সীমানা খোঁজা

এখন পর্যন্ত আমরা একটি array-তে খুঁজেছি। এবার এমন একটি সমস্যা দেখি, যেখানে কোনো সাজানো তালিকাই নেই — অথচ binary search সবচেয়ে সুন্দরভাবে কাজ করে।

গাজীপুরের একটি গার্মেন্টস কারখানা থেকে ৭টি চালান চট্টগ্রাম বন্দরে যাবে। ওজন (টনে), জাহাজে তোলার ক্রম অনুযায়ী: 5, 8, 3, 6, 4, 7, 2. একটি ট্রাক দিনে একবার যায়, আর চালানগুলো ক্রম ভেঙে পাঠানো যাবে না। সব চালান ৩ দিনের মধ্যে পৌঁছাতে হবে। প্রশ্ন: সবচেয়ে কম কত টনের ট্রাক ভাড়া নিলে চলবে? বড় ট্রাকের ভাড়া বেশি, তাই যত ছোট সম্ভব।

মূল কৌশল হলো প্রশ্নটি উল্টে দেওয়া। “সবচেয়ে কম কত?” না জিজ্ঞেস করে জিজ্ঞেস করুন: “C টনের ট্রাকে কি ৩ দিনে হবে?” এই হ্যাঁ/না প্রশ্নের উত্তর দেওয়া সহজ: চালানগুলো ক্রমে ট্রাকে তুলুন, ধরে না রাখলে পরের দিন — শেষে দিন গুনুন।

আর এই প্রশ্নের একটি অসাধারণ বৈশিষ্ট্য আছে: ১২ টনে যদি হয়, ১৩ টনে অবশ্যই হবে — বড় ট্রাক কখনো ক্ষতি করে না। অর্থাৎ ক্ষমতা বাড়ালে উত্তর একবার “না” থেকে “হ্যাঁ” হলে আর কখনো “না” হয় না:

ক্ষমতা: 8 9 10 11 12 13 14 … 35
উত্তর:  ✗ ✗ ✗  ✗  ✗  ✓  ✓ … ✓

এমন একমুখী আচরণকে বলে monotonic. আর monotonic হ্যাঁ/না প্রশ্নের প্রথম “হ্যাঁ”-টি খোঁজা আর সাজানো তালিকায় lower_bound খোঁজা হুবহু একই কাজ! এই কৌশলের নাম binary search on the answer. সম্ভাব্য উত্তরের সীমাও সহজে পাওয়া যায়: সবচেয়ে ভারী চালান 8 টন, তাই 8-এর কম চলবেই না; আর মোট ওজন 35 টন হলে একদিনেই সব যায়, তাই 35-এ অবশ্যই চলবে।

বাঁ থেকে ডানে ক্ষমতা ৮ … ৩৫ টন · ✗ = চলবে না · ✓ = চলবে · · = এখনো অজানা

ট্রাকের ক্ষমতা খোঁজা · ৩ দিনে সব চালান
lo
hi
যাচাই করছি C = mid
দিন লাগল
ফল
  1. খোঁজার জায়গা: ৮ থেকে ৩৫ টন। এখানে কোনো array নেই — “array” হলো সম্ভাব্য উত্তরগুলো নিজেই। ৩৫ নিশ্চিতভাবে চলবে, বাকিগুলো অজানা। প্রতিজ্ঞা: “সবচেয়ে ছোট চলার মতো ক্ষমতা [lo, hi]-এর মধ্যে।”
  2. যাচাই: 21 টন। দিন ১-এ 5 + 8 + 3 = 16 (6 যোগ করলে 22, ধরবে না)। দিন ২-এ 6 + 4 + 7 + 2 = 19. মাত্র ২ দিন — চলবে! তাহলে উত্তর 21 বা তার কম: hi = 21. এক যাচাইয়ে ২২ থেকে ৩৫ পর্যন্ত ১৪টি সম্ভাবনা বাদ।
  3. যাচাই: 14 টন। দিন ১: 5 + 8 = 13. দিন ২: 3 + 6 + 4 = 13. দিন ৩: 7 + 2 = 9. ঠিক ৩ দিন — চলবে। hi = 14.
  4. যাচাই: 11 টন। দিন ১-এ শুধু 5 (8 যোগ করলে 13). দিন ২-এ 8 + 3, দিন ৩-এ 6 + 4, দিন ৪-এ 7 + 2. ৪ দিন লাগছে — চলবে না। monotonic বলে 11-এর নিচের কোনোটিই চলবে না: lo = 12.
  5. যাচাই: 13 টন। 5 + 8, 3 + 6 + 4, 7 + 2 — ৩ দিন। চলবে। hi = 13.
  6. যাচাই: 12 টন। 5, 8 + 3, 6 + 4, 7 + 2 — ৪ দিন। চলবে না। lo = 13.
  7. lo == hi == 13 — উত্তর ১৩ টন। ২৮টি সম্ভাব্য ক্ষমতার মধ্যে মাত্র ৫টি যাচাই করতে হলো। প্রতিটি যাচাইয়ে ৭টি চালান দেখা — মোট কাজ মোটামুটি (চালানের সংখ্যা) × log(সম্ভাব্য উত্তরের পরিসর)। চালান ১০ লাখ আর ওজন কোটির ঘরে হলেও এই কাজ চোখের পলকে শেষ হয়।

কোডে দুটি অংশ পরিষ্কার আলাদা: একটি সাধারণ হ্যাঁ/না ফাংশন, আর তার ওপর lower_bound-এর মতো একটি লুপ। এই ধরনের হ্যাঁ/না ফাংশনকে বলে predicate:

#include <stdio.h>

/* yes/no: can a `cap`-ton truck ship all, in order, within `days`? (cap >= heaviest) */
int can_ship(const int w[], int n, int cap, int days) {
    int used = 1, load = 0;
    for (int i = 0; i < n; i++)                       /* new day if it does not fit */
        if (load + w[i] > cap) { used++; load = w[i]; } else load += w[i];
    return used <= days;
}

int main(void) {
    int w[] = {5, 8, 3, 6, 4, 7, 2}, n = 7, days = 3;
    int lo = 8, hi = 35;                 /* heaviest shipment .. total weight */
    while (lo < hi) {                    /* smallest good cap is in [lo, hi] */
        int mid = lo + (hi - lo) / 2;
        if (can_ship(w, n, mid, days)) hi = mid;   /* mid works: maybe smaller works */
        else                           lo = mid + 1; /* mid fails: need bigger */
    }
    printf("minimum truck capacity = %d tons\n", lo);
    return 0;
}

আউটপুট: minimum truck capacity = 13 tons. লুপটি অংশ ৪-এর lower_bound-এর যমজ ভাই — শুধু a[mid] < x-এর জায়গায় এসেছে !can_ship(...). hi = mid কারণ চলার মতো mid নিজেই উত্তর হতে পারে; lo = mid + 1 কারণ না চলা mid কখনো উত্তর নয়। can_ship ধরে নেয় ক্ষমতা অন্তত সবচেয়ে ভারী চালানের সমান — এজন্যই lo = 8. ক্ষমতা 8-এর কম দিলে এই ফাংশন ভুল বলবে, কারণ একটি চালান কোনো দিনেই ট্রাকে আঁটবে না; ব্যবহারের আগে এই শর্তটি মনে রাখুন। days-এর মান বদলে আবার চালিয়ে দেখুন উত্তর কীভাবে বদলায় (2 দিনে 19, 7 দিনে 8, 1 দিনে 35).

একই ছাঁচে পড়ে এমন আরও প্রশ্ন: “৫ জন দর্জি থাকলে ২০০টি শার্টের অর্ডার সবচেয়ে কম কত ঘণ্টায় শেষ হবে?” (T ঘণ্টায় কি হবে? — যত বেশি সময়, তত সহজ।) “বাসস্ট্যান্ডগুলোর মধ্যে সবচেয়ে কম কত দূরত্ব রাখলে ৪টি চার্জিং স্টেশন বসানো যায়?” কিংবা “মাসিক কিস্তি সবচেয়ে কম কত হলে ২ বছরে ঋণ শোধ হবে?” প্রতিবার একটাই খোঁজ: উত্তর বাড়ালে কি হ্যাঁ/না একমুখী থাকে? থাকলে binary search চলবে।

৬ · C library-র bsearch() — তৈরি করা হাতিয়ার

পর্ব ২৮-এ আপনি <stdlib.h>-এর qsort দেখেছিলেন, যে comparator ফাংশন নিয়ে যেকোনো টাইপের array সাজায়। একই ঘরানার একটি খোঁজার ফাংশনও আছে — bsearch:

void *bsearch(const void *key, const void *base, size_t n, size_t size,
              int (*cmp)(const void *, const void *));

int cmp_int(const void *p, const void *q) {
    int a = *(const int *)p, b = *(const int *)q;
    return (a > b) - (a < b);            /* -1, 0 or 1, never overflows */
}
int x = 25;
int *hit = bsearch(&x, marks, n, sizeof marks[0], cmp_int);   /* NULL if absent */

এটি পাওয়া গেলে মিলে যাওয়া ঘরের একটি pointer দেয়, না পেলে NULL. index দরকার হলে hit - marks (পর্ব ১৮-এর pointer arithmetic). আর qsort-এর জন্য লেখা comparator-টিই এখানে কাজ করবে — শুধু খেয়াল রাখুন, array ঠিক সেই comparator দিয়েই সাজানো থাকতে হবে।

bsearch-এর দুটি সীমাবদ্ধতা জেনে রাখুন। এক, একই মান কয়েকবার থাকলে এটি যেকোনো একটির pointer দিতে পারে — C standard বলে দেয়নি কোনটি। তাই প্রথম ঘর বা গণনার কাজে এটি চলবে না; সেখানে নিজের lower_bound লাগবে। দুই, না পেলে শুধু NULL দেয় — “কোথায় বসালে সাজানো থাকত” জানায় না। comparator-এ return a - b; লেখাও বিপজ্জনক: খুব বড় আর খুব ছোট সংখ্যার বিয়োগ overflow করতে পারে; উপরের (a > b) - (a < b) রূপটি নিরাপদ।

৭ · Divide & Conquer — ভাগ করুন, সমাধান করুন, জোড়া দিন

Binary search আসলে একটি বড় চিন্তার সবচেয়ে সরল রূপ: সমস্যাটিকে ছোট করা, তারপর ছোটটির সমাধান থেকে বড়টির সমাধান বানানো। এই চিন্তার নাম divide and conquer, আর এর একটি নির্দিষ্ট রেসিপি আছে:

Divide & Conquer-এর তিন ধাপ

Divide → Conquer → Combine

  1. ভাগ (Divide)n → n/2 + n/2একই ধরনের ছোট সমস্যা
  2. সমাধান (Conquer)নিজেকেই ডাকাখুব ছোট হলে সরাসরি উত্তর
  3. জোড়া (Combine)দুই উত্তর → একআসল বুদ্ধি প্রায়ই এখানে
ছবি ৪১.১ — দ্বিতীয় ধাপে সমস্যা নিজেকেই ডাকে, তাই divide and conquer প্রায় সবসময় recursion দিয়ে লেখা হয় (পর্ব ১৫).

পর্ব ৪০-এর merge sort এই রেসিপির নিখুঁত উদাহরণ: array-কে দুই ভাগ করুন, প্রতিটি ভাগ merge sort দিয়েই সাজান, তারপর দুটি সাজানো ভাগকে একটি চিরুনির মতো মিলিয়ে দিন। ভাগ করাটা সহজ, জোড়া দেওয়াটাই আসল কাজ। Binary search-এ উল্টো — ভাগের পর একটি ভাগই রাখা হয়, অন্যটি ফেলে দেওয়া হয়, আর জোড়ার কাজ নেই।

দ্রুত ঘাত — xn মাত্র log n ধাপে

313 বের করতে সাধারণ লুপে ১২টি গুণ লাগে। কিন্তু একটি পর্যবেক্ষণ খেয়াল করুন: n জোড় হলে xn = (xn/2)2. অর্থাৎ xn/2 একবার হিসাব করে নিজের সাথে গুণ করলেই হলো। n বিজোড় হলে একটি বাড়তি x গুণ করতে হয়। এই কৌশলের নাম fast power (exponentiation by squaring):

long long power(long long x, int n) {        /* n >= 0 */
    if (n == 0) return 1;                     /* base case */
    long long half = power(x, n / 2);         /* divide: ONE smaller problem */
    return (n % 2 == 0) ? half * half         /* combine */
                        : half * half * x;
}
ডাকভেতরে যা হিসাব হয়ফল
power(3, 1)half = power(3, 0) = 1 → 1 × 1 × 33
power(3, 3)half = 3 → 3 × 3 × 327
power(3, 6)half = 27 → 27 × 27729
power(3, 13)half = 729 → 729 × 729 × 31594323

১২টির জায়গায় মোট ৭টি গুণ, তার দুটি আবার 1 × 1-এর মতো তুচ্ছ। পার্থক্যটা বড় n-এ নাটকীয়: n = ১০ লাখ হলে সাধারণ লুপে প্রায় ১০ লাখ গুণ, আর এখানে বড়জোর ৪০টির মতো — কারণ ১০ লাখকে বারবার অর্ধেক করলে ২০ ধাপেই ১-এ নামে। খেয়াল করুন, half ভ্যারিয়েবলটিই আসল চাবি: power(x, n/2) * power(x, n/2) লিখলে একই কাজ দুবার হতো, আর সব লাভ হারিয়ে যেত। (এত বড় ঘাতে long long অবশ্যই overflow করবে; বাস্তবে প্রতিটি গুণের পর % m নেওয়া হয় — এই “modular exponentiation” RSA-র মতো এনক্রিপশনের ভিত্তি।)

আরও কয়েকটি বিখ্যাত divide and conquer

সমস্যাভাগজোড়ার বুদ্ধি
Merge sortদুই অর্ধেকদুটি সাজানো তালিকা মেলানো
Inversion গোনা (কতগুলো জোড়া উল্টো ক্রমে আছে)দুই অর্ধেকmerge করার সময়ই ডান থেকে আসা উপাদান বাঁয়ের কতজনকে টপকাল, গুনে ফেলা
Maximum subarray (সবচেয়ে বড় যোগফলের টানা অংশ)দুই অর্ধেকউত্তর বাঁয়ে, ডানে, নাকি মাঝখান পেরিয়ে — তিনটির সেরা
বড় সংখ্যার গুণ (Karatsuba)সংখ্যার দুই অর্ধেকচারটির বদলে তিনটি ছোট গুণ

Divide and conquer সবসময় সেরা নয়। Maximum subarray-র জন্য একটি সরল O(n) পদ্ধতি আছে (Kadane-এর অ্যালগরিদম), যা divide and conquer-এর O(n log n)-কে হারায়। আর ভাগ করা টুকরোগুলো যদি একই ছোট সমস্যা বারবার তৈরি করে — যেমন পর্ব ১৫-এর Fibonacci — তাহলে দরকার অন্য কৌশল। সেটিই পরের পর্বের বিষয়।

৮ · খরচের হিসাব — recursion tree আর n log n

Recursive অ্যালগরিদমের খরচ কীভাবে মাপবেন? নিজেকে ডাকা ফাংশনের খরচ নিজের ভাষাতেই লেখা যায়। merge sort-এ n আকারের কাজ = দুটি n/2 আকারের কাজ + জোড়ার জন্য প্রায় n ধাপ:

T(n) = 2 · T(n/2) + n

এ রকম সমীকরণকে বলে recurrence. এটি সমাধানের সবচেয়ে সহজ ও চোখে দেখার মতো উপায় হলো একটি recursion tree আঁকা — প্রতিটি ডাক একটি ঘর, তার নিচে সে যাদের ডাকে। তারপর প্রতিটি স্তরের কাজ যোগ করুন। n = 8 দিয়ে দেখি:

n=8
স্তর ০কাজ = ৮
n=4
n=4
স্তর ১কাজ = ৮
n=2
n=2
n=2
n=2
স্তর ২কাজ = ৮
n=1
n=1
n=1
n=1
n=1
n=1
n=1
n=1
স্তর ৩কাজ = ৮
কাজের হিসাব · n = 8
এই স্তরে টুকরো
প্রতিটির আকার
এই স্তরের মোট জোড়ার কাজ
এ পর্যন্ত মোট
  1. স্তর ০: একটি সমস্যা, আকার 8. এখানে দুটি সাজানো অর্ধেককে মেলাতে প্রায় 8 ধাপ লাগবে। (অর্ধেকগুলো কীভাবে সাজানো হলো, সেটা নিচের স্তরের দায়িত্ব।)
  2. স্তর ১: দুটি সমস্যা, প্রতিটি আকার 4. প্রতিটির জোড়ায় 4 ধাপ, মোট 4 + 4 = 8. লক্ষ করুন: সমস্যার সংখ্যা দ্বিগুণ হলো, কিন্তু প্রতিটির আকার অর্ধেক — স্তরের মোট কাজ বদলাল না।
  3. স্তর ২: চারটি সমস্যা, প্রতিটি আকার 2. আবার মোট 2 × 4 = 8. প্যাটার্নটি পরিষ্কার: প্রতি স্তরে মোট কাজ ≈ n.
  4. স্তর ৩: আটটি সমস্যা, আকার 1. এটি base case — একটি সংখ্যা এমনিতেই সাজানো। প্রতিটির খরচ সামান্য, মোট 8.
  5. মোট: স্তরের সংখ্যা × প্রতি স্তরের কাজ। n-কে অর্ধেক করতে করতে 1-এ নামাতে log₂ n বার লাগে (8 → 4 → 2 → 1, তিনবার), তাই স্তর log₂ n + 1টি। মোট কাজ ≈ n × log₂ n. n = ১০ লাখ হলে প্রায় ২ কোটি ধাপ — যেখানে bubble sort-এর মতো n² পদ্ধতিতে লাগত ১ লাখ কোটি।

একই কৌশলে binary search-এর হিসাবও মেলে: প্রতি ধাপে একটি অর্ধেক সমস্যা আর একটি তুলনা — T(n) = T(n/2) + 1. Recursion tree-টি একটি সোজা লাঠি, log n স্তর, প্রতি স্তরে ১টি কাজ — মোট log n. দ্রুত ঘাতেরও একই গঠন।

Recurrenceকোথায়Recursion tree-এর আকারমোট খরচ
T(n) = T(n/2) + 1Binary search, দ্রুত ঘাতlog n স্তর, প্রতি স্তরে ১টি ঘরO(log n)
T(n) = 2T(n/2) + nMerge sort, inversion গোনাlog n স্তর, প্রতি স্তরে মোট nO(n log n)
T(n) = 2T(n/2) + 1Array-র সর্বোচ্চ মান ভাগ করে খোঁজানিচের স্তরগুলোই ভারীO(n)
T(n) = T(n − 1) + nখারাপ pivot-এ quicksortn স্তর, শুধু ১টি করে ঘর ছোট হয়O(n²)
মূল ধারণা

Recursion tree-তে সবসময় তিনটি প্রশ্ন করুন: কয়টি স্তর? প্রতি স্তরে কয়টি ঘর? প্রতিটি ঘরে কত কাজ? ঘরের কাজ নিচে নামতে নামতে কমে, বাড়ে, নাকি সমান থাকে — তার ওপরই নির্ভর করে খরচ মূলত ওপরের স্তরে, নিচের স্তরে, নাকি সব স্তরে সমানভাবে। এই তিন অবস্থাকে একটি সূত্রে বেঁধেছে Master theorem — অ্যালগরিদমের বইয়ে আপনি এটি পাবেন। সূত্র মুখস্থ না করেও গাছটি আঁকতে পারলে বেশিরভাগ উত্তর নিজেই বের করতে পারবেন।

৯ · বাস্তব জগতে — git bisect, অর্ধেক করে debugging, ডেটাবেস index

Binary search শুধু array-র অ্যালগরিদম নয় — এটি একটি চিন্তার অভ্যাস। পেশাদার প্রকৌশলীরা প্রতিদিন এমন জায়গায় এটি ব্যবহার করেন, যেখানে কোনো কোডই লেখা নেই।

git bisect — কোন commit bug আনল?

পর্ব ২৯-এ আপনি git দেখেছিলেন। ধরুন গত মাসে আপনার প্রজেক্ট ঠিক চলছিল, আজ চলছে না, আর মাঝে ৫০০টি commit হয়েছে। কোনটি ভুল ঢোকাল? প্রতিটি commit পরীক্ষা করার বদলে git bisect মাঝখানের commit-এ নিয়ে যায়; আপনি চালিয়ে বলেন “ঠিক” বা “ভুল”, আর git অর্ধেক বাদ দেয়। ৫০০টি commit-এ মোটামুটি ৯টি পরীক্ষাতেই দোষী commit ধরা পড়ে।

git bisect start
git bisect bad                 # current version is broken
git bisect good v2.3           # this older version worked
# git checks out a middle commit; build, test, then tell it:
git bisect good                # or: git bisect bad
# ... repeat until git prints "<hash> is the first bad commit"
git bisect run ./test.sh       # or let a script answer good/bad automatically
git bisect reset               # go back to where you started

লক্ষ করুন, এখানেও একটি monotonic প্রশ্ন আছে: “এই commit-এ কি bug আছে?” bug একবার ঢুকলে পরের সব commit-এই থাকে (যদি মাঝে কেউ ঠিক না করে)। তাই “ঠিক, ঠিক, ঠিক, ভুল, ভুল, ভুল” — আর প্রথম “ভুল”-টি খোঁজা মানে lower_bound!

অর্ধেক করে debugging

একটি ১০,০০০ লাইনের CSV ফাইল দিলে প্রোগ্রাম crash করে, ছোট ফাইলে করে না। কোন লাইনটি দোষী? ফাইলটি অর্ধেক করে দুটি ভাগ আলাদাভাবে দিন। যে ভাগে crash, সেটিকে আবার অর্ধেক। প্রায় ১৪ বারে একটি লাইনে পৌঁছাবেন। একই কৌশল কোডেও চলে: অর্ধেক কোড সাময়িকভাবে বাদ দিয়ে দেখুন সমস্যা থাকে কি না, অথবা মাঝখানে একটি printf বসিয়ে দেখুন সেখানে মান ঠিক আছে কি না। দিশাহীনভাবে খোঁজার চেয়ে এটি অনেক দ্রুত, আর নিশ্চিতভাবে কোথাও পৌঁছায়।

ডেটাবেস index — B-tree-র ভেতরে binary search

bKash বা কোনো ব্যাংকের ডেটাবেসে কোটি কোটি লেনদেন। একটি মোবাইল নম্বরের লেনদেন খুঁজতে পুরো টেবিল পড়লে বহু সেকেন্ড লেগে যেত। তাই ডেটাবেস index তৈরি করে — সাধারণত একটি B-tree (পর্ব ৩৫). এর প্রতিটি node-এ শত শত সাজানো চাবি থাকে; node-এর ভেতরে binary search দিয়ে ঠিক করা হয় কোন শাখায় নামতে হবে। প্রতিটি node অনেক শাখায় ভাগ হয় বলে কোটি সারির টেবিলেও গাছের উচ্চতা মাত্র কয়েক স্তর — অর্থাৎ ডিস্ক থেকে মাত্র কয়েকবার পড়েই উত্তর।

🔎 Search box-এর autocomplete

সাজানো শব্দের তালিকায় “dha” দিয়ে শুরু শব্দগুলো খুঁজতে lower_bound("dha") থেকে lower_bound("dhb") পর্যন্ত — দুটি binary search-এই পুরো পরিসর।

প্রিফিক্স খোঁজা

📈 সময় অনুযায়ী লগ

সার্ভারের লগ সময় অনুযায়ী সাজানো থাকে। “রাত ২টা থেকে ২টা ১০-এর মধ্যে কী ঘটেছে?” — দুটি lower_bound, তারপর শুধু সেই অংশটুকু পড়া।

পরিসর খোঁজা

১০ · AI-এর যুগে binary search যাচাই

AI-কে “binary search লিখে দাও” বললে কয়েক সেকেন্ডে পরিপাটি কোড আসে, আর সাধারণ পরীক্ষায় সেটি ঠিকই চলে। সমস্যা লুকিয়ে থাকে কিনারায় — এক ঘরের array-তে, প্রথম বা শেষ ঘরে, বা একই মান অনেকবার থাকলে। এই অ্যালগরিদম ঠিক সেই জাতের, যেখানে “দেখতে ঠিক” আর “আসলেই ঠিক”-এর মধ্যে দূরত্ব সবচেয়ে বেশি।

AI-এর ভুলকী ঘটেকোন পরীক্ষায় ধরা পড়ে
while (lo <= hi) আর hi = mid মিশিয়ে ফেলাপ্রোগ্রাম আটকে যায়, কিছুই ছাপে নানেই এমন মান খোঁজা, এক ঘরের array
lo = mid (+ 1 ছাড়া)দুই ঘর বাকি থাকলে অসীম লুপদুই ঘরের array, শেষ ঘরের মান
hi = n দিয়ে শুরু, কিন্তু lo <= hi শর্তa[n] পড়া — array-র বাইরেসবার চেয়ে বড় মান খোঁজা, sanitizer
(lo + hi) / 2খুব বড় array-তে overflowকোড পড়ে দেখা (সাধারণ পরীক্ষায় প্রায় কখনো ধরা পড়ে না)
“প্রথম” চাওয়া হয়েছে, কিন্তু মিল পেলেই return midএকই মান অনেকবার থাকলে মাঝের কোনোটি দেয়সব ঘরে একই মান
উত্তরের ওপর binary search-এ ভুল সীমা (lo খুব ছোট)predicate ভুল “হ্যাঁ” বলেএকটি খুব ভারী উপাদান দিয়ে পরীক্ষা

AI-কে শুধু কোড চাইবেন না — চুক্তিটি (কী দিলে কী ফেরত দেবে) আর invariant-টিও চাইবেন। তাহলে আপনি কোডটি যুক্তি দিয়ে যাচাই করতে পারবেন:

C-তে একটি lower_bound ফাংশন লিখে দাও: int lower_bound(const int a[], int n, int x). এটি প্রথম যে index-এ a[i] >= x, সেটি ফেরত দেবে; এমন কিছু না থাকলে n দেবে। a ছোট থেকে বড় ক্রমে সাজানো, একই মান একাধিকবার থাকতে পারে, n শূন্যও হতে পারে। ১. half-open পরিসর [lo, hi) ব্যবহার করো, আর mid হিসাব করো lo + (hi - lo) / 2 দিয়ে। ২. কোডের ওপরে loop invariant-টি এক লাইনে লিখে দাও, আর প্রতিটি lo/hi বদলের পাশে কমেন্টে বলো কেন invariant ভাঙছে না। ৩. ব্যাখ্যা করো কেন লুপটি সবসময় থামবে। ৪. এই ইনপুটগুলোর জন্য প্রত্যাশিত উত্তর একটি টেবিলে দাও: খালি array; এক ঘর; সব ঘরে একই মান; x সবার চেয়ে ছোট; x সবার চেয়ে বড়; x নেই কিন্তু মাঝামাঝি; x প্রথম ও শেষ ঘরে।

AI-এর binary search হাতে পেলে — যাচাইয়ের চেকলিস্ট

পঞ্চম পরীক্ষাটি সবচেয়ে শক্তিশালী। linear search ভুল লেখা প্রায় অসম্ভব, তাই সেটি একটি নির্ভরযোগ্য “বিচারক”। একটি লুপে ছোট ছোট এলোমেলো সাজানো array বানিয়ে দুটি ফাংশনের উত্তর তুলনা করুন; একটিও না মিললে সেই ইনপুটটি ছাপিয়ে থামুন। ধীর কিন্তু নিশ্চিত সমাধান দিয়ে দ্রুত সমাধান যাচাই করা — এটি পেশাদার জগতের একটি সাধারণ কৌশল।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১Binary search-এর ধারণা তো একজন শিশুও বোঝে। তাহলে এটি নির্ভুলভাবে লেখা এত কঠিন কেন?

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

ইতিহাসও এই কথা বলে। Jon Bentley তাঁর Programming Pearls বইয়ে লিখেছেন, Donald Knuth-এর বর্ণনা অনুযায়ী প্রথম binary search প্রকাশিত হয় ১৯৪৬ সালে, কিন্তু সব n-এর জন্য নির্ভুল প্রথম সংস্করণ প্রকাশ পেতে লেগেছিল আরও প্রায় ষোলো বছর। Bentley নিজেও পেশাদার প্রোগ্রামারদের ক্লাসে কাজটি দিয়ে দেখেছিলেন — তাঁর বর্ণনায় বেশিরভাগের কোডেই কোনো না কোনো ভুল পাওয়া গিয়েছিল। আর অংশ ৩-এ দেখেছেন, সেই বইয়ের “প্রমাণিত” কোডেও overflow লুকিয়ে ছিল প্রায় বিশ বছর।

এর তিনটি শিক্ষা আছে। এক, আন্দাজে লেখা +1/-1 নয় — invariant ঠিক করে প্রতিটি লাইন তার সাথে মেলান। দুই, একটি রীতি বেছে নিয়ে সারা জীবন সেটাই লিখুন; প্রতিবার নতুন করে ভাবলে ভুলের সুযোগ বাড়ে। অনেক দল এজন্য নিজেরা binary search না লিখে একটি পরীক্ষিত library ফাংশন ব্যবহারের নিয়ম রাখে। তিন, প্রমাণ শুধু ততটাই নির্ভরযোগ্য, যতটা তার অনুমানগুলো — Bentley-র প্রমাণ ধরে নিয়েছিল সংখ্যা অসীম বড় হতে পারে, কিন্তু int তা পারে না।

মূল উপলব্ধি: ছোট কোড মানেই সহজ কোড নয়। যে কোডে প্রতিটি অক্ষর একটি সিদ্ধান্ত, সেখানে স্মৃতি বা আন্দাজের বদলে একটি লিখিত নিয়ম (invariant) আর কিনারার পরীক্ষাই একমাত্র ভরসা।

প্র ০২দশমিক সংখ্যায় (double) binary search করলে — যেমন √2 বের করতে — কী বদলায়? কখন থামবেন?

√2 খুঁজতে প্রশ্ন করুন: “m × m ≥ 2?” এটি monotonic — m বাড়লে একবার হ্যাঁ হলে হ্যাঁই থাকে। শুরু করুন lo = 1, hi = 2 থেকে, প্রতিবার মাঝখান দেখে অর্ধেক বাদ দিন। কিন্তু এখানে integer-এর মতো “lo == hi হলে থামুন” চলবে না, কারণ বাস্তব সংখ্যার মধ্যে অসীম সংখ্যা আছে — পরিসর কখনো ঠিক শূন্য হয় না।

স্বাভাবিক উত্তর মনে হয় while (hi - lo > 1e-9). কিন্তু এখানে একটি ফাঁদ আছে। Floating-point সংখ্যার precision সীমিত (পর্ব ০৫): double-এ মোটামুটি ১৫–১৬টি অর্থপূর্ণ অঙ্ক। উত্তর যদি বড় হয়, ধরুন ১০০ কোটির কাছাকাছি, তাহলে পাশাপাশি দুটি double-এর ফারাকই 1e-9-এর চেয়ে বড়। তখন mid হিসাব করলে সেটি lo বা hi-এর সমান হয়ে যায়, পরিসর আর ছোট হয় না, আর লুপ কখনো থামে না।

দুটি নিরাপদ পথ আছে। এক, নির্দিষ্ট সংখ্যকবার ঘোরান — যেমন ১০০ বার। প্রতিবার পরিসর অর্ধেক হয়, তাই ১০০ বারে পরিসর শুরুর তুলনায় 2100 ভাগের এক ভাগ — double-এর যেকোনো নির্ভুলতার চেয়ে অনেক সূক্ষ্ম। লুপ থামবেই, কারণ গোনা আছে। দুই, আপেক্ষিক সহনশীলতা ব্যবহার করুন: hi - lo > 1e-9 * hi-এর মতো, যাতে বড় উত্তরে সীমাও বড় হয়।

আরেকটি বিষয়: প্রয়োজনের বেশি নির্ভুলতা চাওয়ার দাম আছে। ট্রাকের ক্ষমতা মিলিমিটার নয়, টনের হিসাবে; ওষুধের মাত্রা মিলিগ্রামে। সমস্যাটি নিজেই বলে দেয় কতটুকু নির্ভুলতা দরকার, আর অনেক সময় দশমিককে পূর্ণসংখ্যায় বদলে (টাকাকে পয়সায়) পুরো ঝামেলাটাই এড়ানো যায়।

মূল উপলব্ধি: অ্যালগরিদম গণিতের জগতে চলে, কিন্তু কোড চলে কম্পিউটারের সীমিত সংখ্যার জগতে। থামার শর্ত লেখার সময় সবসময় জিজ্ঞেস করুন: “এই শর্ত কি কম্পিউটারের সংখ্যায় সত্যিই কখনো সত্য হবে?”

প্র ০৩Divide and conquer অ্যালগরিদম কেন একসাথে অনেক CPU বা অনেক কম্পিউটারে চালানো এত সহজ?

কারণ ভাগ করা টুকরোগুলো একে অপরের ওপর নির্ভর করে না। merge sort-এ বাঁ অর্ধেক সাজানোর সময় ডান অর্ধেক সম্পর্কে কিছু জানার দরকার নেই। তাই একটি core বাঁ অর্ধেক, আরেকটি ডান অর্ধেক সাজাতে পারে একই সময়ে — কারও জন্য কাউকে অপেক্ষা করতে হয় না। ৮ core থাকলে প্রথম তিন স্তরের ভাগ ৮টি টুকরো তৈরি করে, প্রতিটি core একটি করে নেয়। অপেক্ষা শুধু জোড়ার সময়।

এই চিন্তাকে বিশাল আকারে নিয়ে গেছে MapReduce. ২০০৪ সালে Google-এর দুই প্রকৌশলী Jeffrey Dean ও Sanjay Ghemawat একটি গবেষণাপত্রে দেখান কীভাবে হাজার হাজার কম্পিউটারে বিশাল ডেটা প্রক্রিয়া করা যায়: ডেটা টুকরো করে প্রতিটি কম্পিউটারকে একটি করে টুকরো দেওয়া (map), তারপর সবার ফল একত্র করা (reduce) — এই দুই ধাপে। ধরুন সারা দেশের সব দোকানের বিক্রির খাতা থেকে মোট বিক্রি বের করতে হবে — প্রতিটি জেলা নিজের যোগফল করে, তারপর বিভাগ, তারপর দেশ। এটি divide and conquer-এর হুবহু রূপ। এই ধারণা থেকেই পরে Hadoop-এর মতো open-source ব্যবস্থা এসেছে।

তবে এর সীমাও আছে। এক, জোড়ার ধাপ যদি ভারী হয়, সেটিই বাধা হয়ে দাঁড়ায় — merge sort-এর শেষ merge-টি একজনকেই করতে হয়, পুরো n আকারের। দুই, ডেটা এক কম্পিউটার থেকে আরেকটিতে পাঠাতে সময় লাগে; টুকরো খুব ছোট হলে হিসাবের চেয়ে পাঠানোতেই বেশি সময় যায়। তাই বাস্তবে একটি নির্দিষ্ট আকারের নিচে ভাগ বন্ধ করে সরাসরি সমাধান করা হয়। তিন, binary search-এর মতো অ্যালগরিদমে লাভ কম — একবারে একটিই অর্ধেক রাখা হয়, তাই ভাগ করার মতো স্বাধীন কাজই নেই। থ্রেড নিয়ে বিস্তারিত পড়বেন পর্ব ৪৯-এ।

মূল উপলব্ধি: স্বাধীন টুকরোয় ভাগ করা যায় এমন সমস্যা শুধু দ্রুত সমাধানই হয় না — একসাথে অনেক হাতে দেওয়াও যায়। আধুনিক cloud computing-এর বড় অংশ এই সরল কথার ওপর দাঁড়িয়ে।

প্র ০৪একটি তালিকায় অনেকবার খুঁজতে হবে। কখন আগে সাজিয়ে binary search করা লাভজনক, আর কখন প্রতিবার linear search-ই ভালো?

হিসাবটি সরল। ধরুন তালিকায় n = ১০ লাখ উপাদান, আর q বার খুঁজতে হবে।

  • প্রতিবার linear search: মোট প্রায় q × n. q = ১০০০ হলে ১০০ কোটি তুলনা।
  • একবার সাজানো, তারপর binary search: সাজাতে প্রায় n log n ≈ ২ কোটি, তারপর প্রতিটি খোঁজায় ২০ — মোট প্রায় ২ কোটি + q × ২০. q = ১০০০ হলে মোটামুটি ২ কোটি — পঞ্চাশ গুণ কম।
  • q খুব ছোট হলে: q = ১ হলে linear search ১০ লাখ, আর সাজাতেই ২ কোটি। তখন সাজানো লোকসান। সীমারেখা মোটামুটি q ≈ log n-এর কাছাকাছি — অর্থাৎ ২০-৩০ বারের বেশি খুঁজলেই সাজানো লাভজনক।

কিন্তু বাস্তব আরও জটিল। তালিকা যদি বারবার বদলায় — নতুন উপাদান আসে — তাহলে সাজানো array-তে প্রতিটি নতুন উপাদান ঠিক জায়গায় ঢোকাতে বাকিদের সরাতে হয়, n পর্যন্ত কাজ। তখন BST বা balanced tree (পর্ব ৩৫) ভালো, যেখানে ঢোকানো ও খোঁজা দুটোই log n. শুধু “আছে কি না” দরকার হলে hash table (পর্ব ৩৭) গড়ে আরও দ্রুত। কিন্তু hash table ক্রম জানে না — “২৫ থেকে ৪০-এর মধ্যে কতজন?” বা “৩০-এর পরের ছোট মান কোনটি?” প্রশ্নে সাজানো array আর binary search-ই জেতে।

আরেকটি ব্যবহারিক বিষয়: সাজানো array মেমরিতে পাশাপাশি থাকে, তাই CPU cache-এর সাথে খুব ভালো মানায়। ছোট n-এ (কয়েকশো পর্যন্ত) অনেক সময় সরল linear search-ই বাস্তবে সবচেয়ে দ্রুত, কারণ এটি একটানা মেমরি পড়ে আর CPU আগে থেকেই পরের ডেটা এনে রাখতে পারে। O-চিহ্ন বড় n-এর গল্প বলে; ছোট n-এ মাপুন, অনুমান করবেন না।

মূল উপলব্ধি: প্রশ্নের ধরন (কতবার, কী ধরনের, তালিকা বদলায় কি না) ঠিক করে দেয় কোন কাঠামো সেরা। একবারের খরচকে বহুবারের লাভ দিয়ে শোধ করা — এই হিসাব সিস্টেম ডিজাইনের প্রায় সবখানে।

প্র ০৫Linux kernel-এর মতো বিশাল ইতিহাসে git bisect কেন কাজ করে — আর কখন বিভ্রান্ত হয়?

Linux kernel-এর ইতিহাসে দশ লাখের বেশি commit আছে। তবু দুটি release-এর মাঝে সাধারণত কয়েক হাজার থেকে দশ-পনেরো হাজার commit থাকে। ১৬,০০০ commit-এ binary search লাগে প্রায় ১৪টি পরীক্ষা। প্রতিটি পরীক্ষায় kernel build করে চালাতে হয়তো আধা ঘণ্টা লাগে — তবু এক দিনের কাজ, যেখানে একে একে দেখলে লাগত কয়েক বছর। এজন্যই kernel ডেভেলপাররা bug report-এ প্রায়ই অনুরোধ করেন: “দয়া করে bisect করে দেখুন কোন commit-এ শুরু হলো।”

কিন্তু binary search-এর মূল শর্ত — monotonic প্রশ্ন — এখানে সবসময় সত্য নয়। কয়েকটি সমস্যা:

  • মাঝের commit build-ই হয় না: হয়তো অন্য কোনো কারণে সেই commit ভাঙা। তখন git bisect skip দিয়ে সেটি বাদ দিতে হয়, আর git পাশের একটি commit বেছে নেয়।
  • অনিয়মিত bug: bug যদি দশবারে একবার দেখা দেয়, একবার “ঠিক চলল” দেখে “good” বললে ভুল অর্ধেক বাদ পড়ে যায় — পরের সব ধাপ ভুল দিকে যায়। সমাধান হলো প্রতিটি commit বহুবার পরীক্ষা করা, অথবা git bisect run-এর স্ক্রিপ্টে পরীক্ষাটি কয়েকবার চালানো।
  • bug এলো, গেল, আবার এলো: তখন “ঠিক/ভুল”-এর ক্রম একমুখী থাকে না, আর bisect যেকোনো একটি সীমানা খুঁজে দেয় — হয়তো আপনার কাঙ্ক্ষিতটি নয়।
  • শাখা আর merge: git-এর ইতিহাস একটি সরল লাইন নয়, একটি graph (পর্ব ৩৮). git bisect এই graph-এর ওপরেই কাজ করে, তাই ধাপ গোনা সবসময় নিখুঁত log₂ হয় না।

শিক্ষাটি সফটওয়্যার প্রকৌশলের গভীরে যায়: ছোট, আলাদা commit করার অভ্যাস bisect-কে কাজে লাগার মতো করে তোলে। একটি commit-এ দশটি কাজ মিশিয়ে দিলে bisect শুধু বলবে “এই বিশাল commit-এ” — আর সেটি আপনি আগেই জানতেন।

মূল উপলব্ধি: কোনো অ্যালগরিদম লাগানোর আগে তার অনুমানগুলো যাচাই করুন। binary search-এর জাদু আসে monotonic প্রশ্ন থেকে; প্রশ্নটি অনির্ভরযোগ্য হলে জাদুটাই আপনাকে আত্মবিশ্বাসী ভুলের দিকে নিয়ে যায়।

অনুশীলন

বেশিরভাগ কাজ কাগজ-কলমে — হাতে চালিয়ে দেখাই এখানে আসল দক্ষতা।

  1. হাতে চালান: array {4, 9, 15, 15, 22, 31, 40}. (ক) রীতি ক দিয়ে 31 খুঁজুন — প্রতিটি ধাপে lo, hi, mid লিখুন। (খ) lower_bound(15) ও upper_bound(15) কত? (গ) lower_bound(50)?

    (ক) lo = 0, hi = 6 → mid = 3, a[3] = 15 < 31 → lo = 4. তারপর lo = 4, hi = 6 → mid = 5, a[5] = 31 — পাওয়া গেছে, মাত্র দুটি তুলনায়।

    (খ) প্রথম ≥ 15 আছে index 2-এ, প্রথম > 15 আছে index 4-এ (মান 22). তাই 15 আছে 4 − 2 = 2 বার।

    (গ) 50 সবার চেয়ে বড়, তাই উত্তর n = 7 — array-র শেষের পরের ঘর। এই উত্তর দিয়ে a[7] পড়তে গেলে array-র বাইরে চলে যাবেন; তাই ফল ব্যবহারের আগে সবসময় i < n যাচাই করুন।

  2. Bug শিকার: একজন লিখেছেন lo = 0; hi = n - 1; while (lo < hi) { mid = (lo + hi) / 2; if (a[mid] < x) lo = mid + 1; else hi = mid - 1; }, তারপর a[lo] == x দেখে উত্তর দিচ্ছেন। কয়টি সমস্যা খুঁজে পান? কোন ছোট ইনপুটে ভুল দেখাবে?

    তিনটি সমস্যা। এক, রীতি মিশেছে: lo < hi অর্ধ-খোলা রীতির শর্ত, কিন্তু hi = n - 1 ও hi = mid - 1 বন্ধ রীতির। দুই, a[mid] >= x হলে hi = mid - 1 করায় mid নিজেই উত্তর হলে সেটি বাদ পড়ে যায়। উদাহরণ: {1, 2, 3}-এ x = 2. mid = 1, a[1] = 2 ≥ 2 → hi = 0. এখন lo = hi = 0, লুপ শেষ, a[0] = 1 ≠ 2 — বলবে “নেই”, অথচ আছে। তিন, (lo + hi) / 2-এ বড় array-তে overflow. আরও একটি: n = 0 হলে a[lo] = a[0] পড়া হয়, যা array-র বাইরে।

  3. উত্তরের ওপর binary search চিনুন: নিচের কোনগুলোতে চলবে, আর predicate কী হবে? (ক) ১০০টি বই ক্রম না ভেঙে ৪ জন ছাত্রের মধ্যে ভাগ করতে হবে, যাতে সবচেয়ে বেশি পাতা পাওয়া ছাত্রের পাতা যত কম সম্ভব হয়। (খ) একটি এলোমেলো array-র সবচেয়ে বড় মান খোঁজা। (গ) সবচেয়ে ছোট পূর্ণসংখ্যা k, যার জন্য k × k ≥ N.

    (ক) চলবে। predicate: “প্রত্যেককে সর্বোচ্চ P পাতা দিলে কি ৪ জনেই হয়ে যায়?” P বাড়ালে সহজ হয়, তাই monotonic. এটি হুবহু ট্রাকের সমস্যা — চালানের জায়গায় বই, দিনের জায়গায় ছাত্র। সীমা: সবচেয়ে মোটা বই থেকে সব বইয়ের মোট পাতা।

    (খ) চলবে না। কোনো monotonic হ্যাঁ/না প্রশ্ন নেই — মাঝখানের মান দেখে বলা যায় না সবচেয়ে বড়টি কোন দিকে। এলোমেলো তালিকায় সব ঘর দেখতেই হবে, O(n).

    (গ) চলবে। predicate: “k × k ≥ N?” — k বাড়লে একবার সত্য হলে সত্যই থাকে। সীমা 0 থেকে N (N ≥ 1 হলে). সতর্কতা: বড় N-এ k * k নিজেই overflow করতে পারে, তাই k >= N / k-এর মতো ভাগ দিয়ে তুলনা করা নিরাপদ, অথবা long long ব্যবহার করুন।

  4. পরীক্ষার বিচারক বানান (কোড): অংশ ৪-এর lower_bound-এর পাশে একটি slow_lower_bound লিখুন, যা শুরু থেকে একে একে দেখে প্রথম ≥ x-এর index দেয়। তারপর rand() দিয়ে ১০,০০০ বার ছোট (০–৮ আকারের) সাজানো array আর এলোমেলো x বানিয়ে দুটির ফল মিলিয়ে দেখুন। ইচ্ছে করে hi = mid-কে hi = mid - 1 করে দেখুন — বিচারক কত তাড়াতাড়ি ধরে?

    ধীর সংস্করণটি এক লাইনের লুপ: int i = 0; while (i < n && a[i] < x) i++; return i;. সাজানো array বানানোর সহজ উপায়: প্রতিটি ঘরে আগের ঘরের মানের সাথে 0 থেকে 2-এর একটি এলোমেলো সংখ্যা যোগ করুন — এতে একই মান বারবার আসবে, যা duplicate পরীক্ষার জন্য দরকার। ঠিক কোডে ১০,০০০ বারে একটিও অমিল হবে না। hi = mid - 1 করলে সাধারণত প্রথম কয়েকটি পরীক্ষাতেই অমিল ধরা পড়ে, আর মুদ্রিত ইনপুটটি প্রায়ই দুই-তিন ঘরের — ঠিক যেখানে হাতে চালিয়ে ভুলটি বোঝা সহজ। ছোট আকারের array বেছে নেওয়ার কারণও এটাই: কিনারার অবস্থা ছোট ইনপুটেই সবচেয়ে ঘন ঘন আসে।

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

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

ক্রম“সবচেয়ে কম কত হলে চলবে?” ধরনের সমস্যা সমাধানের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. হ্যাঁ/না প্রশ্ন ঠিক করা: “C হলে কি চলবে?”
  2. যাচাই করা প্রশ্নটি monotonic কি না — C বাড়ালে একবার হ্যাঁ হলে হ্যাঁই থাকে
  3. উত্তরের সীমা ঠিক করা: নিশ্চিত “না” থেকে নিশ্চিত “হ্যাঁ”
  4. mid = lo + (hi - lo) / 2 যাচাই করে পরিসর অর্ধেক করা
  5. lo == hi হলে সেটিই সবচেয়ে ছোট “হ্যাঁ”

একটি এলোমেলো (সাজানো নয়) array-তে binary search চালালে সাধারণত কী হয়?

  • Compiler error দেয়
  • কোনো সতর্কতা ছাড়াই ভুল উত্তর দিতে পারে
  • ঠিক উত্তর দেয়, শুধু ধীরে
  • সবসময় অসীম লুপে আটকে যায়

while (lo <= hi) শর্তের সাথে hi = mid লিখলে কী ঝুঁকি?

  • mid হিসাবে overflow
  • Compile হবে না
  • lo == hi অবস্থায় পরিসর আর ছোট হয় না — অসীম লুপ
  • প্রথম ঘরটি কখনো দেখা হয় না

সাজানো array {3, 7, 7, 7, 9}-এ lower_bound(7) আর upper_bound(7) কত?

  • 1 আর 3
  • 2 আর 4
  • 0 আর 4
  • 1 আর 4

“উত্তরের ওপর binary search” লাগানোর জন্য সবচেয়ে জরুরি শর্ত কোনটি?

  • উত্তর বাড়ালে হ্যাঁ/না প্রশ্নটি একবার “হ্যাঁ” হলে আর “না” হয় না (monotonic)
  • ইনপুট array অবশ্যই সাজানো থাকতে হবে
  • সমাধানটি recursion দিয়ে লিখতে হবে
  • উত্তর অবশ্যই পূর্ণসংখ্যা হতে হবে

Merge sort-এর recurrence T(n) = 2T(n/2) + n. Recursion tree দেখে মোট খরচ কত?

  • O(log n)
  • O(n log n)
  • O(n)
  • O(n²)

৫১২টি commit-এর মধ্যে কোনটি bug এনেছে, তা git bisect দিয়ে খুঁজতে মোটামুটি কয়টি পরীক্ষা লাগবে?

  • প্রায় ৫১২টি
  • প্রায় ২৫৬টি
  • প্রায় ৯টি
  • ১টি

শব্দকোষ

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

মনে রাখুন

  1. Binary search চলে শুধু সাজানো ডেটায়, আর প্রতি তুলনায় অর্ধেক বাদ দেয় — O(log n).
  2. একটি invariant ঠিক করুন, একটি রীতি ([lo, hi] বা [lo, hi)) বেছে নিন, আর mid = lo + (hi - lo) / 2 লিখুন।
  3. lower_bound = প্রথম ≥ x, upper_bound = প্রথম > x; বিয়োগফল = x কতবার আছে।
  4. “সবচেয়ে কম কত হলে চলবে?” — হ্যাঁ/না প্রশ্নটি monotonic হলে উত্তরের ওপরেই binary search.
  5. ভাগ → সমাধান → জোড়া; খরচ বুঝতে recursion tree আঁকুন — 2T(n/2) + n মানে n log n.

পরের পর্বে: Divide and conquer-এ টুকরোগুলো আলাদা থাকে। কিন্তু যদি একই ছোট সমস্যা বারবার ফিরে আসে — যেমন Fibonacci-তে? তখন একবার সমাধান করে মনে রাখলেই হাজার গুণ দ্রুত। সেই কৌশলের নাম ডায়নামিক প্রোগ্রামিং.

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

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