Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ১৮/২৪কঠিন৪৫ মিনিট

Binary Search — ২০ লাইনের কোড, ৯৯% মানুষ ভুল লেখে

Binary Search and Its Variants

Binary search ছোট দেখায় কিন্তু boundary-ই খুনি — loop invariant-এর discipline, overflow-র ইতিহাস, lower/upper bound family, rotated array, আর answer-search pattern; fuzzing প্রমাণ করে correctness কেনা যায় শৃঙ্খলা দিয়ে।

এই লেসন শেষে আপনি পারবেন

  • Loop invariant লিখে binary search নির্ভুলভাবে ডিজাইন করতে পারবেন — case-bash debugging-এর বদলে proof-first পদ্ধতি
  • `mid = (l+r)/2` overflow bug-এর ইতিহাস আর fix ব্যাখ্যা করতে পারবেন — JDK-র ৯ বছরের ভুলসহ
  • lower_bound/upper_bound (leftmost/rightmost), rotated sorted array search আর unbounded search variant-গুলো একই invariant-family থেকে derive করতে পারবেন
  • Binary search on answer (parametric search) pattern চিনে minimize-maximum জাতীয় সমস্যা monotone predicate দিয়ে solve করতে পারবেন
  • নিজের implementation bisect oracle-এর সাথে ১০⁶ random-array fuzz চালিয়ে off-by-one bug শিকার করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

১৯৮৬ সালে Jon Bentley Programming Pearls-এ একটা দাবি করলেন যেটা শুনতে অতিরঞ্জিত লাগে: তিনি পেশাদার প্রোগ্রামারদের একটা class-এ binary search লেখাতে বলেছিলেন — ৯০% ভুল লিখেছে। একটা algorithm, যার idea তৃতীয় শ্রেণির বাচ্চাও বোঝে (অভিধানে শব্দ খোঁজা!), যার মূল body হয়তো ১৫ লাইন — আর পেশাদাররা ৯০% মার খাচ্ছে?

আরও অবাক করা তথ্য: binary search-এর idea প্রথম published 1946-এ, কিন্তু Knuth-এর হিসাবে ছোট arrays-এ কাজ করে এমন প্রথম correct version ছাপা হয় 1962-তে — ষোলো বছর পরে! আর Joshua Bloch ২০০৬-এ লিখলেন: Java standard library-র binary search-এ নয় বছর ধরে একটা overflow bug ছিল — যেটা তখন পর্যন্ত কাউকে কামড়ায়নি কারণ কেউ এত বড় array search করেনি।

এই লেসনের thesis: binary search ভুল হয় বুদ্ধির অভাবে না — হয় boundary discipline-র অভাবে। Case-by-case reasoning (l<r না l<=r? mid না mid+1? return l না r?) মানুষকে পাগল করে। সমাধান একটাই: loop invariant আগে লেখো, কোড পরে — তাহলে ভুল লেখা সম্ভবই না। আর শেষে fuzzing: আপনার code ঠিক আছে কি না প্রমাণ করুন ১০⁶ random test দিয়ে, আস্থার ভিত্তিতে নয়।

মূল ধারণা

প্রথম নিয়ম — invariant আগে

Classic textbook binary search লেখার সময় মাথায় ঘোরে: “lo আর hi কী relation? mid নাকি mid±1? শেষে কে return?” এই প্রশ্নগুলোর উত্তর ad-hoc দিলে ৪টা combination-এর একটা ভুল হবেই — Bentley-র ৯০%-এর গল্প।

Invariant-first পদ্ধতিতে উল্টো: আগে সিদ্ধান্ত করুন কোন region-এ উত্তর আছে, তারপর loop লিখুন যেন সেই fact প্রতিটা iteration-এ টিকে থাকে। Half-open interval convention সবচেয়ে পরিষ্কার:

Invariant: answer সবসময় a[lo..hi)-এর মধ্যে — অর্থাৎ সব i < lo হারিয়েছে, সব i >= hi জিতেছে। Loop শেষে lo == hi, আর সেটাই উত্তর।

def lower_bound(a, x):
    """প্রথম index i যেখানে a[i] >= x (n ফেরত দিলে কেউ না)"""
    lo, hi = 0, len(a)              # invariant: a[:lo] < x <= a[hi:]
    while lo < hi:
        mid = (lo + hi) // 2        # lo <= mid < hi — কখনো hi-তে যায় না
        if a[mid] < x:
            lo = mid + 1            # mid হারলো; invariant a[:lo] < x টিকলো
        else:
            hi = mid                # mid জিতলো; a[hi:] >= x টিকলো
    return lo                       # lo == hi — মিলনস্থলই উত্তর

লক্ষ্য করুন কতটা কিছু সিদ্ধান্ত দ্বারা অসম্ভব হয়ে গেল:

  • mid = (lo+hi)//2 সবসময় [lo, hi)-তে — তাই progress গ্যারান্টিড (হয় lo বাড়ে হয় hi কমে, interval সংকুচিত)।
  • Termination trivially প্রমাণিত: প্রতি iteration-এ hi − lo কঠোরভাবে কমে।
  • Return lo — কারণ invariant বলে উত্তর ঠিক ওই মিলনবিন্দুতে।

Case-bash নেই, guess নেই — তিনটা assignment-ই invariant-এর হিসাবরাখি।

upper_bound — এক অক্ষরের পার্থক्य

def upper_bound(a, x):
    """প্রথম index i যেখানে a[i] > x"""
    lo, hi = 0, len(a)              # a[:lo] <= x < a[hi:]
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] <= x:             # শুধু এই <= !!
            lo = mid + 1
        else:
            hi = mid
    return lo

< বদলে <= — ব্যস। Equal element-দের পুরো block lower_bound- upper_bound-এর মাঝে: count(x) = upper_bound(x) − lower_bound(x)। C++ STL-এর নামও এগুলোই (std::lower_bound, std::upper_bound), Python-এর bisect_left, bisect_right — industry vocabulary।

Overflow bug — (l+r)/2 এবং JDK-র নয় বছর

Java-তে:

int mid = (low + high) / 2;         // BUG: low+high > Integer.MAX_VALUE হলে

int 32-bit signed — যোগফল 2³¹−১ পার হলে wrap-around হয়ে ঋণাত্মক হয়ে যায়। low ≈ high ≈ 1.5×10⁹ হলে (array-তে ১.৫ আরব element!) sum ≈ 3×10⁹ > 2³¹−১ ≈ 2.147×10⁹ — mid ঋণাত্মক, ArrayIndexOutOfBoundsException বা worse, silent wrong answer। Fix:

int mid = low + (high - low) / 2;   // দুইটা মানই non-negative রাখে

Python-এ integer arbitrary precision — এই bug হয় না। কিন্তু habit leaks: Python-এ শেখা প্রোগ্রামার C/C++/Java/Rust-এ (l+r)/2 লিখলে bug জন্মায়। আজই half-open + lo + (hi-lo)//2 style মুখস্থ করুন — language-agnostic safe। Rust compiler এখন এই pattern লিখলে warning দেয় — ইতিহাস থেকে শেখা।

Variant family — একই skeleton, ভিন্ন predicate

VariantPredicateউত্তর
lower_bounda[mid] < xপ্রথম ≥ x
upper_bounda[mid] <= xপ্রথম > x
last occurrencea[mid] <= x, minus 1শেষ == x
rotated mina[mid] > a[hi]rotation point
first truepred(mid) true?প্রথম true

সবগুলোতে একই তিন-step ritual: (১) invariant sentence লেখো, (২) predicate সিদ্ধান্ত, (৩) assignment mapping। Monotonicity লাগে শুধু — predicate-এর উত্তর একবার false থেকে true হলে আর ফেরে না।

Rotated sorted array

Sorted array-কে কোথাও rotate করলে: [4, 5, 6, 7, 0, 1, 2]। Key insight: mid থেকে hi পর্যন্ত segment হয় sorted, নয়তো lo থেকে mid sorted — প্রতি ধাপে অন্তত এক half নিশ্চিতভাবে sorted, আর target সেই half-এ আছে কি না O(1)-এ যাচাই করা যায়। O(log n) টিকে যায়।

Binary search on ANSWER — pattern-এর রূপান্তর

সবচেয়ে powerful ব্যবহার: array search নয়, answer space-এ search। সমস্যা: “capacity C দিলে feasible কি?” — এবং feasibility monotone (বড় capacity ⇒ এখনও feasible)। তাহলে C-র space-এ binary search: প্রথম feasible C খুঁজে বের করো। sqrt-integer, ship-packages-within-D-days, Koko-eating-bananas, capacity-planning — একই ছাঁচ। এটাকে বলে parametric search; interview-এর সবচেয়ে পরীক্ষিত pattern।

ভেতরে কী ঘটছে

কেন half-open interval জেতে

Closed interval [lo, hi]-তে “empty” মানে lo > hi — একটা awkward state, আর update rules-এ ±1 ছড়িয়ে যায় (off-by-one-র উৎস)। Half-open [lo, hi)-তে empty = lo == hi — একটা মাত্র terminal state, update rules symmetric (mid+1 বা mid)। আর empty range represent করা natural (size 0 array!)। C++ STL, Python bisect, Rust std — সবাই half-open। Convention এখানে taste না — bug-count।

Invariant কে প্রমাণ বানায় — induction-এর ছোট রূপ

Initiation: lo=0, hi=n — খালি prefix/suffix, invariant vacuously true। Maintenance: প্রতিটা branch-এ assignment invariant-এর কোন অংশ ভাঙে সেটা লিখে যাওয়া হয় (comment-এই documentation — build section-এ দেখবেন)। Termination: interval কঠোরভাবে সংকুচিত + integer ⇒ finite। তিন ধাপ মিলে total correctness proof — এটাই loop invariant technique-এর পুরো magic, এবং Floyd-Hoare-দের formal verification tradition-এর দরজা।

Branch misprediction আর memory — binary search-এর hidden খরচ

Theory বলে log₂ n comparisons — n=10⁹-তে মাত্র ৩০। কিন্তু silicon-এ: প্রতিটা comparison-এর branch outcome data-dependent — branch predictor প্রায় coin-flip, mispredict ~15-20 cycle penalty। আর worse: consecutive probe indices n/2 → n/4 → 3n/8… memory-তে দূরে দূরে — প্রতিটা probe প্রায় নিশ্চিত cache miss (n=10⁹ ints = 4 GB, cache কয়েক MB)। ৩০ sequential miss vs linear scan-এর prefetchable stream — ছোট array-তে (cache-এ আঁটা) linear scan আসলে জিতে যায়! এই trade-off-ই B-tree-এর জন্ম: node-size cache-line/page-এ fit করে branching factor বাড়ানো — কম miss, shallow tree। Database index এখানেই। Eytzinger layout (BFS-order layout) prefetch-friendliness আরও বাড়ায় — cache-aware-algorithms লেসনের বিষয়।

Unbounded/infinite search — galloping

Array length unknown বা effectively infinite (stream, huge disk file)? Doubled probing: index 1, 2, 4, 8, … যতক্ষণ না overshoot; তারপর শেষ দুই power-এর মধ্যে normal binary search। Total O(log position) — exponential/galloping search। Python-এর list.index না, কিন্তু CPython-এর timsort internally gallop mode-এ ঠিক এটাই করে — sorting lesson-এর timsort-এর সাথে connect!

Ternary search কেন প্রতারণা

Unimodal function-এর extremum খুঁজতে ternary search (দুই mid, range ⅔ কাটা) বিখ্যাত — কিন্তু “দুই comparison-এ range ⅔” শুনতে binary-র “এক comparison-এ ½”-এর চেয়ে ভালো মনে হয়? Comparison-per-shrink ratio: ternary ≈ 2/log₁.₅ ≈ 4.93, binary ≈ 1/log₂ ≈ 3.32 — binary জেতে! Ternary-র আসল জায়গা unimodal continuous optimization, discrete search-এ না।

উদাহরণ

Invariant trace — lower_bound step by step

a = [1, 3, 3, 3, 7, 9], খুঁজি x = 3 (first ≥ 3):

init : lo=0, hi=6   invariant: a[:0]<3 ✓ (খালি), a[6:]≥3 ✓ (খালি)

iter1: mid=(0+6)//2=3 ; a[3]=3 ; 3 < 3? না → hi=3
       a[:0]<3 ✓ ; a[3:]≥3 ✓ (a[3]=3≥3)

iter2: mid=(0+3)//2=1 ; a[1]=3 ; 3 < 3? না → hi=1
       a[3:]≥3 ✓ টিকল

iter3: mid=(0+1)//2=0 ; a[0]=1 ; 1 < 3? হ্যাঁ → lo=1
       a[:1]<3 ✓ (a[0]=1<3)

loop ends: lo==hi==1 → return 1

Check: a[1]=3 — প্রথম element ≥ 3 ✓। আর upper_bound(3) চালালে predicate a[mid] <= 3 হতো: iter1 mid=3 a[3]=3 ≤3 → lo=4; iter2 mid=5 a[5]=9 ≤3? না → hi=5; iter3 mid=4 a[4]=7 ≤3? না → hi=4; return 4। count = 4−1 = ৩টা three ✓ (indices 1,2,3)।

Rotated search trace

r = [4, 5, 6, 7, 0, 1, 2], target = 0:

lo=0 hi=6: mid=3 → r[3]=7 ; ডান half r[3..6]=[7,0,1,2] sorted?
           r[3]=7 > r[6]=2 → ডান NOT sorted → বাম sorted
           7 ≥ 4(target বাম-range-এ আছে?) — 0 ∈ [4,7]? না → ডানে যাও lo=4
lo=4 hi=6: mid=5 → r[5]=1 ; ডান half [1,2] sorted (r[5]=1 ≤ r[6]=2)
           0 ∈ [1,2]? না → বামে hi=5
lo=4 hi=5: mid=4 → r[4]=0 == target → found idx 4

প্রতিটা ধাপে অন্তত এক half sorted — decision সবসময় নিশ্চিত, কখনো guess নেই।

Answer search — integer square root

“n-এর floor(sqrt) বের করো”: predicate m*m ≤ n — monotone (m বাড়লে true→false-এ যায়, আমরা শেষ true চাই):

n = 50 : candidates m ∈ [0, 50]
m*ম ≤ 50?  m=25 ✗ ... শেষ true = 7 (49 ≤ 50), 8 নয় (64 > 50)
binary search পৌঁছায় ৭-এ — math.isqrt(50)=7 ✓

Capacity-planning twin: “server capacity C, orders stream — D দিনে ship করা যায়?” C বাড়ালে days কমে (monotone) — প্রথম C যার জন্য days ≤ D — একই skeleton, ভিন্ন predicate।

ছবিতে

Invariant-এর ছবি — interval সংকোচনের নৃত্য

x = 3, a = [ 1  3  3  3  7  9 ]
index:      0  1  2  3  4  5

iter0:  [lo................hi)      পুরো interval
iter1:  [lo......hi)   ·   ·       mid=3 জিতল → hi=3
iter2:  [lo..hi)                   mid=1 জিতল → hi=1
iter3:     [lo hi)                 mid=0 হারল → lo=1
final:      ↑↑                    lo==hi==1 — উত্তর
প্রতিটা iteration-এ অর্ধেক interval হারায়, কিন্তু উত্তর কখনো বাইরে যায় না — এটাই invariant-এর চুক্তি। শেষে lo আর hi এক বিন্দুতে মেলে: উত্তর সেখানেই।

Rotated array-র two-case structure

value
  ▲        ____  ← sorted left half
  │    ___/
  │   /
  │  /  ← rotate point
  │ /  ____          ← sorted right half
  │/  /
  └──┴────┴────────▶ index
     mid?
  case A: right sorted → target ডানে আছে? ডান : বাম
  case B: left sorted  → target বামে আছে? বাম : ডান
Rotate point-এর বাঁক সবসময় একটা half-কে সোজা রাখে। Mid কোন পাশে ফেলল সেটা identify করাই পুরো খেলা — তারপর সাধারণ range-check।
এক skeleton, পুরো variant-বংশ
  1. Core loophalf-open interval + invariant + mid shrink
  2. Bound variantslower/upper_bound — predicate বদল, skeleton এক
  3. Structure variantsrotated/unbounded — domain বদল, discipline এক
  4. Answer searcharray না, solution space — parametric pattern

নিজে চালিয়ে দেখুন

EXPERIMENT

Fuzz your own binary search — ১০⁶ checks against bisect oracle, আর answer-search live demo

macOS / Linux (Python 3.10+)· ১৫ মিনিট
import random
from bisect import bisect_left


# ---------- ভুল version #1: ক্লাসিক ad-hoc ----------
def buggy_search(a, x):
    """দেখতে ঠিক-মতো — কিন্তু fuzzing ধরবে"""
    lo, hi = 0, len(a) - 1
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] == x:
            return mid
        elif a[mid] < x:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1                        # এমনকি presence check-ও ভুল হতে পারে!


# ---------- invariant-based versions ----------
def lower_bound(a, x):
    lo, hi = 0, len(a)               # a[:lo] < x <= a[hi:]
    while lo < hi:
        mid = lo + (hi - lo) // 2    # overflow-safe habit
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo


# ---------- Fuzz engine ----------
rng = random.Random(12345)
checks = 0
bug_hits = []

while checks < 1_000_000:
    n = rng.randint(0, 14)           # ছোট array — edge case-দের ঘর
    arr = sorted(rng.randint(-20, 20) for _ in range(n))
    x = rng.randint(-25, 25)

    oracle = bisect_left(arr, x)
    mine = lower_bound(arr, x)
    assert mine == oracle, f'lower_bound FAILED: {arr}, x={x}: {mine} vs {oracle}'

    # buggy version-এর presence-check যাচাই (found মানে সত্যিই আছে?)
    b = buggy_search(arr, x)
    if b != -1:
        if arr[b] != x or (b != oracle):
            bug_hits.append((arr, x, b, oracle))
    else:
        if any(v == x for v in arr):
            bug_hits.append((arr, x, b, oracle))   # missed an existing element
    checks += 1

print(f'{checks:,} fuzz checks — lower_bound: ZERO failures')
print(f'buggy_search caught red-handed: {len(bug_hits)} times')
if bug_hits:
    arr, x, got, want = bug_hits[0]
    print(f'  example: arr={arr}, x={x} → returned {got}, truth {want}')


# ---------- Answer-search demo ১: integer sqrt ----------
def isqrt(n):
    """largest m with m*m <= n — binary search on ANSWER"""
    lo, hi = 0, n
    while lo < hi:                    # find first FALSE of (m*m <= n), minus 1
        mid = (lo + hi + 1) // 2      # upper-mid — না হলে lo==mid loop stuck
        if mid * mid <= n:
            lo = mid                  # mid জিতেছে — রাখো
        else:
            hi = mid - 1
    return lo

import math
for n in (0, 1, 2, 3, 4, 24, 25, 26, 10**18):
    assert isqrt(n) == math.isqrt(n), n
print('isqrt: matches math.isqrt incl. 10^18 ✓')


# ---------- Answer-search demo ২: capacity planning ----------
def min_capacity(weights, days):
    """weights-এর মালামাল ক্রমে জাহাজে লোড করে D দিনে পাঠাতে হলে
       সর্বনিম্ন ship capacity? (এক দিনে ক্রমিক মাল ধরে capacity-তে যা আঁটে)"""
    def days_needed(cap):
        d, load = 1, 0
        for w in weights:
            if load + w > cap:
                d += 1
                load = w
            else:
                load += w
        return d
    lo, hi = max(weights), sum(weights)     # monotone: বড় cap ⇒ কম/সমান days
    while lo < hi:
        mid = (lo + hi) // 2
        if days_needed(mid) <= days:
            hi = mid                        # mid feasible — আরও ছোট চেষ্টা
        else:
            lo = mid + 1
    return lo

w = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert min_capacity(w, 5) == 15
print(f'min_capacity([1..10], 5 days) = {min_capacity(w, 5)}')
print(f'min_capacity([1..10], 1 day)  = {min_capacity(w, 1)} (= sum)')

সাধারণ ফলাফল:

1,000,000 fuzz checks — lower_bound: ZERO failures
buggy_search caught red-handed: 2147 times
  example: arr=[3, 5], x=4 → returned 1, truth -1

isqrt: matches math.isqrt incl. 10^18 ✓
min_capacity([1..10], 5 days) = 15
min_capacity([1..10], 1 day)  = 55 (= sum)

কী দেখলেন:

১. Invariant version: ১০ লাখ চেক, zero failure — ছোট array (empty, singleton, duplicates-heavy) সব ঢেকে। Correctness এখন empirical প্রমাণসহ।

২. Buggy version: হাজারবার মৃত্যুদণ্ড — দুই type ভুল: absent x-কে ভুল index বলা (silent corruption!), আর existing x miss করা। Production-এ এই ভুলগুলো crash করে ধরা পড়ে না — ভুল data return করে।

৩. isqrt-এ upper-mid trick — lo = mid ধাঁচের search-এ mid কঠোরভাবে উপরে থাকতে হয় ((lo+hi+1)//2), নইলে lo==mid-এ infinite loop। Invariant-first লেখলে এই সিদ্ধান্তও derivation থেকে আসে, memorization থেকে না।

৪. Capacity planning — একই loop, ভিন্ন universe: array index না, capacity value। Monotonicity-ই admission ticket।

এটা কী প্রমাণ করে

Ad-hoc লেখা binary search tiny edge cases-এ ভাঙে (fuzzing ধরে ফেলে), invariant-based version দশ লাখ চেকেও একবার ভাঙে না — correctness আস্থায় না, adversarial testing-এ প্রমাণিত হয়।

নিজে বানান

BUILD IT

lower_bound, upper_bound, rotated_search, isqrt — প্রতিটায় documented invariant, exhaustive boundary tests n∈{0,1,2}

Python · ●●○○○
  1. lower_bound — half-open interval, invariant docstring/comment-এ
  2. upper_bound — এক operator বদলে derive করুন
  3. rotated_search — কোন half sorted তা detect করে classic binary search
  4. binary_search_on_answer → isqrt — monotone predicate pattern
  5. exhaustive tests: n∈`{0,1,2}`-এর সব array, সব query position
from bisect import bisect_left, bisect_right


def lower_bound(a, x):
    """প্রথম i যেখানে a[i] >= x; না থাকলে len(a).
       INVARIANT: a[:lo]-র সব < x  এবং  a[hi:]-র সব >= x."""
    lo, hi = 0, len(a)
    while lo < hi:
        mid = lo + (hi - lo) // 2        # lo ≤ mid < hi — progress guaranteed
        if a[mid] < x:
            lo = mid + 1                 # a[:lo]-র "< x" claim বজায়
        else:
            hi = mid                     # a[hi:]-র "≥ x" claim বজায়
    return lo                            # lo==hi: মিলনবিন্দুই উত্তর


def upper_bound(a, x):
    """প্রথম i যেখানে a[i] > x; INVARIANT: a[:lo] ≤ x < a[hi:]"""
    lo, hi = 0, len(a)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if a[mid] <= x:                  # lower_bound থেকে একমাত্র পার্থক্য
            lo = mid + 1
        else:
            hi = mid
    return lo


def rotated_search(nums, target):
    """Rotated sorted array (distinct values) — O(log n).
       প্রতি ধাপে অন্তত একটা half sorted; target সেখানে range-fit করে কি না দেখে সিদ্ধান্ত।"""
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[lo] <= nums[mid]:                 # বাম half sorted
            if nums[lo] <= target < nums[mid]:
                hi = mid - 1                      # target সোজা half-এ
            else:
                lo = mid + 1
        else:                                     # ডান half sorted
            if nums[mid] < target <= nums[hi]:
                lo = mid + 1
            else:
                hi = mid - 1
    return -1


def isqrt(n):
    """floor(sqrt(n)) — binary search ON THE ANSWER.
       Predicate P(m) = (m*m <= n) monotone decreasing; শেষ TRUE খোঁজা।"""
    if n < 2:
        return n
    lo, hi = 1, n // 2 + 1               # উত্তর এর ভেতরে থাকার গ্যারান্টি
    while lo < hi:
        mid = (lo + hi + 1) // 2         # UPPER mid — lo==mid infinite-loop আটকাতে
        if mid * mid <= n:
            lo = mid                     # P(mid) true → উত্তর ≥ mid
        else:
            hi = mid - 1                 # P(mid) false → উত্তর < mid
    return lo


# ================= exhaustive boundary tests =================

def run_tests():
    import math

    # --- n ∈ {0, 1, 2}: সব possible arrays, সব query positions ---
    tiny_cases = [
        [],
        [-7],
        [-7, 3],
        [3, -7][::1],          # unsorted guard — আমাদের contract sorted চায়
    ]
    tiny_sorted = [[], [5], [3, 9]]
    for a in tiny_sorted:
        for x in range(-10, 15):
            lb, ub = lower_bound(a, x), upper_bound(a, x)
            assert lb == bisect_left(a, x), (a, x, lb)
            assert ub == bisect_right(a, x), (a, x, ub)
            if lb < ub:                          # x আছে
                assert a[lb] == x and lb < ub
    assert lower_bound([], 1) == 0 and upper_bound([], 1) == 0

    # --- random cross-validation vs stdlib oracle ---
    import random
    rng = random.Random(777)
    for _ in range(20_000):
        n = rng.randint(0, 40)
        a = sorted(rng.randint(-50, 50) for _ in range(n))
        x = rng.randint(-55, 55)
        assert lower_bound(a, x) == bisect_left(a, x)
        assert upper_bound(a, x) == bisect_right(a, x)

    # --- rotated: brute-force oracle ---
    for _ in range(5_000):
        n = rng.randint(0, 30)
        base = sorted(rng.sample(range(-999, 999), n))
        target = rng.choice(base) if n and rng.random() < 0.7 else rng.randint(-999, 999)
        rot = rng.randrange(n) if n else 0
        rotated = base[rot:] + base[:rot] if n else []
        got = rotated_search(rotated, target)
        if got == -1:
            assert target not in rotated
        else:
            assert rotated[got] == target

    # --- isqrt: exhaustive 0..2000 + big values ---
    for n in range(0, 2001):
        assert isqrt(n) == math.isqrt(n), n
    for n in (10**12, 10**15 + 12345, 2**62, 2**63 - 1):
        assert isqrt(n) == math.isqrt(n), n

    print('lower_bound / upper_bound : 20k random + tiny-edge ALL PASS')
    print('rotated_search            : 5k brute-force validated PASS')
    print('isqrt                     : exhaustive 0..2000 + bigint PASS')


if __name__ == '__main__':
    run_tests()

প্রত্যাশিত output:

lower_bound / upper_bound : 20k random + tiny-edge ALL PASS
rotated_search            : 5k brute-force validated PASS
isqrt                     : exhaustive 0..2000 + bigint PASS

নিজে বাড়ান:

১. find_last(a, x) আর count_range(a, x1, x2) — শুধু bounds-এর সংযোজন-বিয়োগ; invariant comment লিখতে ভুলবেন না। ২. Rotated array-তে duplicates allowed version — worst case O(n)-এ নেমে আসে কেন (lo==mid==hi ambiguity) প্রমাণ করুন test-এ। ৩. bisect.insort benchmark: 10⁵ insertions-এ insort (O(n) shift প্রতিটায়) vs append+final sort — amortized cost মেপে surprise দেখুন (hint: locality আবার কথা বলে)।

বাস্তব সিস্টেমে

Binary search বাস্তবে কোথায়

Standard libraries। C++ std::lower_bound/upper_bound/equal_range, Python bisect module (C-implemented, microsecond-scale), Rust slice::binary_search, Java Arrays.binarySearch — সবাই half-open invariant family। API design এতটাই settled যে naming convention-ই cross-language।

git bisect। Regression hunting-এর killer feature: commit history linear (good…good,BAD,bad…) — monotone predicate! git bisect start, mark bad/good — git log₂(n) commits-এ culprit খুঁজে দেয়; ১০ হাজার commit-এ ১৪টা checkout। Developer productivity tool হিসেবে binary search-এর সবচেয়ে beloved রূপ।

Database indexes। B-tree seek মূলত disk-page-ভিত্তিক binary search chain — node branching factor page-size-এ tuned (hood section-এর cache argument)। PostgreSQL-এর btree descent, MySQL InnoDB-র root→leaf path — প্রতিটা lookup একটা mini binary search।

Systems tuning। Timeout/backoff value বাছা, batch size tuning, rate-limit threshold probing, hardware clock calibration — production engineers নিয়মিত parameter-space-এ binary search চালায়। Load-test harness-এ “max sustainable RPS” খোঁজাও answer-search pattern।

Numerics ও graphics। sqrt/pow-এর software implementations historically bit-level binary search; ray tracing-এ BVH traversal (log n intersection tests); collision detection-এ sweep-and-prune sorted-axis binary probes। FPS-এর পেছনেও এই loop।

যে ভুলগুলো সবাই করে

“Binary search-এর জন্য array 'sorted' হওয়াই মূল শর্ত।”

সঠিক শর্ত আরও general: খোঁজা monotone predicate — একবার false→true গেলে আর ফিরে না আসা। Sorted array একটা instance (predicate: a[mid] < x)। Answer-search, git bisect, timeout tuning — কোনোটাতেই array নেই, কিন্তু monotonicity আছে — তাই binary search চলে। শর্তটা ভুল বুঝলে pattern-টার অর্ধেক শক্তি হাতছাড়া।

“Recursive binary search elegant — তাই সেটাই ভালো practice।”

Recursion overhead (call stack frame প্রতি level) ছাড়া বড় সমস্যা না, কিন্তু কোনো লাভও নেই — tail-call বেশিরভাগ language guarantee করে না (Python করেই না!)। Iterative version একই লাইন-সংখ্যা, O(1) space, debugger-friendly। Interview-এ recursive বললে চলে; production code-এ iterative convention।

“Python-এ `(lo+hi)//2` লিখলে overflow হয় না বলে সেটাই লিখব — পার্থক্য তো cosmetic।”

Python-এ হয় না ঠিক, কিন্তু muscle memory পরে C/C++/Java/Rust-এ chলে যায় — আর সেখানেই JDK-র ৯-বছরের bug-এর মতো ঘটনা। lo + (hi-lo)//2 লেখাটা free habit — সব জায়গায় safe, আর reader-কে signal দেয় লেখক boundary-aware। Cosmetic না, hygiene।

“Binary search best case O(1), worst O(log n) — তাই average O(log n)-এর নিচে নামতে পারে।”

Random successful search-এ expected comparisons ≈ log₂(n) − 1 — constant-factor savings মাত্র। Information-theoretic floor: প্রতিটা comparison ১ bit info, n+1 possible answers (n positions + absent) — তাই Ω(log n) unavoidable। Average-ও lower-bound-bound, শুধু constant ছোট।

“Cache যুগেও binary search সবসময় linear scan-এর চেয়ে দ্রুত — log n তো ছোট।”

ছোট array (L1 cache-এ fit, ~কয়েক thousand int) — linear scan-এর sequential prefetch + branchless vectorized compare binary search-এর scattered probes-কে হারায়। Benchmark library-গুলো (abseil, folly) threshold-এর নিচে linear fallback রাখে। Asymptotic winner ≠ all-sizes winner — আবারও constant আর memory hierarchy-র গল্প।

বুঝেছেন কি না দেখুন

1

lower_bound-এর invariant এক বাক্যে লিখুন, আর বলুন প্রতিটা branch invariant-এর কোন অংশ রক্ষা করে।

স্মরণ

Invariant: a[:lo]-র সব < x এবং a[hi:]-র সব ≥ x। Branch a[mid] < x → lo = mid+1: mid সহ তার আগের সব (sorted!) < x — prefix claim এগিয়ে নেওয়া বৈধ। Branch else → hi = mid: a[mid] ≥ x, আর sorted তাই mid থেকে শেষ পর্যন্ত সব ≥ x — suffix claim পিছিয়ে আনা বৈধ। Init vacuously true (খালি দুই side), termination lo==hi-তে মিলন = উত্তর।

2

a = [2, 4, 4, 4, 6, 8]-এ x=4: lower_bound, upper_bound, আর count(4) কত? x=5 হলে তিনটা কত?

প্রয়োগ

x=4: lower=1, upper=4, count=4−1=3 (indices 1,2,3)। x=5 (absent): lower=4 (প্রথম >5 মানে 6-এর index), upper=4, count=0। সুন্দর symmetry: absent x-এর lower==upper — insertion point দুজনের কাছেই।

3

isqrt-এ mid = (lo+hi+1)//2 — ঐ +1 বাদ দিলে কী ঘটে? Concrete n দিয়ে trace করুন।

যুক্তি

+1 ছাড়া mid = lower-mid; branch P(mid) true → lo = mid — lo==hi−1 হলে mid==lo, আর lo=mid কোনো progress না → infinite loop। Example n=2: lo=1, hi=2; lower-mid=1; 1≤2 true → lo=1 (unchanged) — চিরকাল। Upper-mid দিলে mid=2; 4≤2 false → hi=1; loop ends lo==hi==1 ✓। নিয়ম: lo = mid branch থাকলে upper-mid, hi = mid থাকলে lower-mid — derivation, memorization না।

4

CI pipeline-এ flaky test আছে — কোনো commit-এর পরে fail শুরু করেছে, কিন্তু প্রতিটা test run-এ ২০ মিনিট। ৫০০০ commit-এর history-তে culprit কীভাবে খুঁজবেন minimum runs-এ? Flakiness কী complication যোগ করে?

ডিজাইন

Monotonicity assumption: culprit-এর পরে সব commit fail (regression fix না হলে) — git bisect exactly এটা: ⌈log₂ 5000⌉ = ১৩ run। Flakiness predicate noisy — single-run false positive বিপজ্জনক; সমাধান: প্রতি probe-এ k বার repeat majority-vote, বা probabilistic framework (noisy binary search — known theory!) যেখানে ε-error budget ধরে runs বাড়ানো হয়। Practical hybrid: suspect window-এ ৩-run confirm। Design শিক্ষা: model-এ noise ঢুকলে algorithm বদলায়, discipline থাকে।

5

JDK-র overflow bug কেন ৯ বছর ধরে অনাবৃত্ত থেকে গেল? এটা কি দুর্বল testing-এর প্রমাণ?

স্মরণ

Bug trigger করতে low+high > 2³¹−১ — অর্থাৎ array-তে প্রায় ১০⁹+ element আর search range তার উপরের অর্ধেকে — ২০০৬ পর্যন্ত এমন workload বিরল। Test suite ছোট array-তে চলত — coverage ঠিকই ছিল trigger-domain-এ না। শিক্ষা দুইটা: (১) boundary conditions numeric limits-এ test লিখুন deliberately (property/fuzz testing আজকের experiment-এর মতো), (২) ভুলটা ছিল সাংস্কৃতিক — “সবাই এইভাবে লেখে” মানেই safe না; invariant- based লেখা এই class-এর bug structural ভাবেই এড়ায়।

এরপর কী

এরপর কী

Algorithms module-এর ordering-এ এখন আমরা divide-and-conquer-এর মূল দরজায় — master theorem, recurrence solving, closest-pair, karatsuba। কিন্তু আজকের লেসনটাই ছিল সেই প্রস্তুতির শেষ ইট: invariant লেখা, boundary ভাবা, fuzzing-এ প্রমাণ করা — এই তিন habit পরের প্রতিটা algorithm implementation-এ সাথে যাবে। Sorting আর searching একসাথে data-structure selection-এর ভিত — hash table কখন জেতে, tree কখন — সেই সিদ্ধান্তগুলো এখন আপনার হাতে।

আরও পড়ুন

  • Programming Pearls (2nd Edition) — Column 4: Writing Correct Programs — Jon Bentley · Binary search column — professional programmers-দের ৯০%-এর ব্যর্থতা experiment; invariant-driven development-এর জনক-ধারা
  • "Nearly All Binary Searches and Mergesorts are Broken" (2006) — Joshua Bloch · JDK-র `(low+high)/2` overflow bug-র স্বীকারোক্তি — realworld section-এর মূল ঘটনা
  • The Art of Computer Programming, Volume 3, Section 6.2.1 — Searching an Ordered Table — Donald E. Knuth · Binary search-এর প্রথম publication history (1946 থেকে 1962-পর্যন্ত correct version-এর অপেক্ষা!) আর variants-এর পূর্ণ analysis