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 কেনা যায় শৃঙ্খলা দিয়ে।
আগে এটা বুঝি
১৯৮৬ সালে 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
| Variant | Predicate | উত্তর |
|---|---|---|
| lower_bound | a[mid] < x | প্রথম ≥ x |
| upper_bound | a[mid] <= x | প্রথম > x |
| last occurrence | a[mid] <= x, minus 1 | শেষ == x |
| rotated min | a[mid] > a[hi] | rotation point |
| first true | pred(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 1Check: 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 — উত্তর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 বামে আছে? বাম : ডান- Core loophalf-open interval + invariant + mid shrink
- Bound variantslower/upper_bound — predicate বদল, skeleton এক
- Structure variantsrotated/unbounded — domain বদল, discipline এক
- Answer searcharray না, solution space — parametric pattern
নিজে চালিয়ে দেখুন
Fuzz your own binary search — ১০⁶ checks against bisect oracle, আর answer-search live demo
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-এ প্রমাণিত হয়।
নিজে বানান
lower_bound, upper_bound, rotated_search, isqrt — প্রতিটায় documented invariant, exhaustive boundary tests n∈{0,1,2}
- lower_bound — half-open interval, invariant docstring/comment-এ
- upper_bound — এক operator বদলে derive করুন
- rotated_search — কোন half sorted তা detect করে classic binary search
- binary_search_on_answer → isqrt — monotone predicate pattern
- 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-র গল্প।
বুঝেছেন কি না দেখুন
1lower_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-তে মিলন
= উত্তর।
2a = [2, 4, 4, 4, 6, 8]-এ x=4: lower_bound, upper_bound, আর count(4)
কত? x=5 হলে তিনটা কত?
প্রয়োগ
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 দুজনের কাছেই।
3isqrt-এ mid = (lo+hi+1)//2 — ঐ +1 বাদ দিলে কী ঘটে? Concrete n দিয়ে
trace করুন।
যুক্তি
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 না।
4CI 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 থাকে।
5JDK-র 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