S032 · Binary Search — the Pattern Behind 100 Problems
Sorted (or monotonic) + halving = O(log n). One template, three shapes: classic search, lower/upper bound, and binary-search-on-the-answer. The pattern behind git bisect, B-tree lookups, ship-in-D-days, and half the LeetCode medium set.
🎯 Write two bulletproof binary-search templates and recognise the pattern in problems that don't look like binary search.
Why this session exists
Binary search is the smallest possible idea in computer science — halve the search space every step — and the most under-used one. The rookie version ("find a number in a sorted array") is one page. The senior version ("binary-search on the answer of a monotonic predicate") is worth roughly half of the medium-difficulty problem set at any tech interview and shows up inside every database, every version-control system, and every deployment tool. This session teaches both, plus the one template you'll never write from scratch again.
- Write the classic and lower-bound templates from memory, and explain why the loop conditions differ.
- Spot the ‘hidden’ binary search inside problems phrased as ‘minimum X such that predicate(X) is true’.
- Use Python's `bisect` module in real code instead of hand-rolling loops.
- Explain the Josh Bloch overflow bug and why `mid = lo + (hi - lo) // 2` is the safe form.
- Debug the two failure modes: infinite loop (lo never moves) and off-by-one (miss the answer by 1).
Prerequisites
- S023 — Arrays & Lists — the Everyday Container — you need O(1) random access for O(log n) to matter.
- S027 — Recursion — Call Stack, Base Case — a recursive binary-search variant appears in the exercise.
(a) Intuition · 5 min
A friend picks a number between 1 and 1000; you guess; they say higher or lower. Nobody guesses 1, 2, 3 — you guess 500, then 250 or 750, then 125 or 375, halving the range every turn.
Ten guesses is enough because 2¹⁰ = 1024 > 1000. Twenty guesses covers a million. Thirty covers a billion. You could find a single row among the entire US population in the same number of guesses it takes to make a cup of coffee.
Binary search is that game, written down: keep two pointers lo and hi, look at the middle, throw away the half that cannot contain the answer, repeat. Each iteration halves the search space, so total work is ⌈log₂ n⌉.
The one hard prerequisite is monotonicity: the data must be sorted, or, more generally, the answer to the question "is X the answer or too small?" must flip from False to True exactly once as X grows.
- Classic search — find target in a sorted array. Returns index or -1. Loop condition: `lo <= hi`.
- Lower / upper bound — find the first (or last) index where a predicate is True. Returns an insertion point. Loop condition: `lo < hi`.
- Binary search on the answer — the array is imaginary; you search over a range of possible answers `[lo, hi]` and evaluate a monotonic predicate `feasible(x)` at each mid. Same halving, no array.
- 1946John Mauchly · Harvard talkFirst recorded description of binary search, on the ENIAC team's tabulated-function lookup problem.
- 1960First bug-free published versionDerrick Lehmer publishes a correct implementation — the earlier ones had off-by-one bugs. It took 14 years to get right.
- 1971Knuth, TAOCP Volume 3Knuth writes: ‘Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky.’ Still true.
- 2006Josh Bloch · Google Research blogReveals that java.util.Arrays.binarySearch had a 9-year-old integer overflow bug: `mid = (low + high) / 2`.
- todaybisect / std::lower_bound / bsearchEvery standard library ships a correct binary search. Use it. Don't reinvent unless you're solving ‘search on the answer’.
(b) Visual walkthrough · 15 min
Classic search for 23 in a 10-element sorted array
Three comparisons for 10 elements. For a million elements the same loop needs ≤ 20 comparisons. That's the whole point.
The two templates — memorise these, do not improvise
Return index or -1
- Space: `[lo, hi]` — both ends inclusive
- Init: `lo=0, hi=n-1`
- Loop: `while lo <= hi:`
- Miss right: `lo = mid + 1`
- Miss left: `hi = mid - 1`
- Exit: `return -1`
Return first index where predicate True
- Space: `[lo, hi)` — hi is exclusive
- Init: `lo=0, hi=n` (NOT n-1)
- Loop: `while lo < hi:`
- Predicate False at mid: `lo = mid + 1`
- Predicate True at mid: `hi = mid`
- Exit: `return lo` (== hi)
The five-step walk you can execute on any binary-search problem
What is `lo` and `hi`? An array index? A day count? A capacity in kg? A floating-point rate?
A boolean function of one variable that flips from False to True exactly once as the variable grows.
Classic (Template A) for value lookup, lower-bound (Template B) for ‘smallest X such that predicate’.
One line to compute mid (overflow-safe), one branch to shrink the space, no other logic.
Each iteration must strictly shrink `hi - lo`. If not → infinite loop.
Binary search on the answer — the pattern that makes seniors
Problem: ship packages within D=5 days on a boat with capacity C. Weights = [1..10]. Find the smallest C that works.
No sorted array in sight. The search space is [10, 55], the predicate feasible(C) is monotonic (once True, stays True), and Template B halves it in ⌈log₂(45)⌉ = 6 predicate evaluations.
Overflow, and why Python is a special case
Why `mid = (lo + hi) // 2` is a landmine in most languages
"Binary search is easy — I understood it in five minutes. It's the simplest algorithm there is."
The idea is trivial; the implementation is famously not. Off-by-one errors in the boundary update, the wrong loop condition, and integer overflow in (lo+hi)/2 are all classic bugs — the overflow one sat undetected in widely used library code for years. And the version everyone learns (find an exact match) is almost never the version you actually need, which is "find the first element ≥ x".
Because you learned it on the one variant where the invariants are forgiving: distinct values, target present, return on exact match. Every real use is a boundary query on data with duplicates — first occurrence, last occurrence, insertion point — and those variants differ only in which comparison is strict and whether you move lo to mid or mid+1. Get that wrong and you get an infinite loop or a silently off-by-one answer, not an exception.
With duplicates, "found it" is ambiguous — and the standard library already solves the variant you need:
import bisect
xs = [1, 2, 2, 2, 3, 5]
print(bisect.bisect_left(xs, 2)) # 1 - first index where 2 could go
print(bisect.bisect_right(xs, 2)) # 4 - one past the last 2
print(bisect.bisect_right(xs, 2) - bisect.bisect_left(xs, 2)) # 3 - count of 2s
# insertion point for a value that is absent
print(bisect.bisect_left(xs, 4)) # 4 - where 4 would go, no error
# a naive 'return mid on match' search returns SOME index of 2 - which one is undefinedWhy must the array be sorted? The usual answer is "so you know which half to discard" — true but shallow. What is the actual property being exploited?
- 1Binary search discards half the search space after a single comparison against one element.forced by · that is the only way to reach O(log n) — each step must eliminate a constant fraction
- 2Discarding a half is only valid if one comparison lets you conclude something about every element in that half, not just the one you tested.forced by · you never examine those elements; the conclusion must be inferred, not observed
- 3That inference requires a monotonic predicate: some yes/no test that is false for a prefix of the array and true for the whole suffix, with exactly one flip point.forced by · a single flip point means testing any position tells you which side of the flip you are on, and hence the answer for everything beyond it
- 4Sortedness is simply the most common way to manufacture such a predicate — for sorted data, "is
a[i] ≥ x?" is exactly false-then-true.forced by · sorting makes the comparison to a fixed target monotonic in the index
Therefore the real requirement is not sortedness — it is a monotonic predicate over the search space. Sortedness is one instance of it.
And note what this predicts: you can binary search over things that are not arrays at all. Search over an answer space whenever "is answer k feasible?" is monotone — minimum number of machines to finish by a deadline, smallest buffer that avoids spilling, lowest rate limit that keeps latency under target. Same log n, no array involved. That reframing — binary search the answer, not the data — is the single highest-value thing in this session.
Do not think "check the middle, recurse". Think: I maintain a window [lo, hi) with a promise — the answer, if it exists, is inside this window. Every iteration tests one element and shrinks the window while keeping the promise true.
Write the promise down before you write the loop. Every off-by-one bug in binary search is a step that shrinks the window in a way that violates the promise — either it drops the answer, or it fails to shrink at all and you spin forever.
- State the invariant explicitly: "everything left of
lofails the predicate, everything at or right ofhisatisfies it." Then the loop body writes itself. - The window must shrink strictly every iteration. If
lo = midis possible, you can loop forever — that is why it is usuallylo = mid + 1. - Use
lo + (hi - lo) // 2, not(lo + hi) // 2. Python has bignums so it is safe here, but the habit matters everywhere else. - Prefer
bisect_left/bisect_rightover hand-rolling. Hand-roll only when searching an answer space rather than an array.
Fire this model the moment you see: a sorted array or index · "first/last occurrence" · "insertion point" · a range query on a time-ordered dataset · Parquet row-group or file min/max pruning · a database index seek · and any optimisation phrased as "smallest X such that Y holds" where Y is monotone in X.
You have a large collection and need repeated lookups. Sort it and binary search, or hash it?
The honest answer at data-engineering scale is that binary search rarely appears as code you write — it appears as a property you enable. Sorting data at write time is what lets the storage layer skip 99% of files via min/max statistics, and that skipping is binary search operating on file metadata rather than rows.
So the decision is usually made at the schema level, not in a function: choose the sort/cluster key so that your dominant query becomes a range scan. Get that right and the query engine does the binary search for you; get it wrong and no amount of clever code recovers the full scan you just paid for.
(c) Hands-on · 25 min
Save as bsearch_lab.py, run with python3 bsearch_lab.py. Every function is annotated; every print teaches something you should observe.
"""bsearch_lab.py — three shapes of binary search, one file."""
from __future__ import annotations
import bisect
import math
from typing import Callable, List
# ---------- Template A · classic ----------
def binary_search(arr: List[int], target: int) -> int:
"""Return index of target, or -1. Space: [lo, hi]."""
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2 # overflow-safe form
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1 # target is strictly right
else:
hi = mid - 1 # target is strictly left
return -1
# ---------- Template B · lower bound ----------
def lower_bound(arr: List[int], target: int) -> int:
"""First index i where arr[i] >= target. == bisect_left. Space: [lo, hi)."""
lo, hi = 0, len(arr) # note: hi = n, not n-1
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] < target:
lo = mid + 1 # predicate (>= target) still False
else:
hi = mid # predicate True, but earlier index may also be True
return lo
def upper_bound(arr: List[int], target: int) -> int:
"""First index i where arr[i] > target. == bisect_right."""
lo, hi = 0, len(arr)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
# ---------- Binary search on the answer ----------
def min_ship_capacity(weights: List[int], days: int) -> int:
"""Smallest capacity C such that all weights ship in <= days days, in order."""
def feasible(cap: int) -> bool:
d, load = 1, 0
for w in weights:
if load + w > cap:
d += 1
load = 0
load += w
return d <= days
lo, hi = max(weights), sum(weights) # search space
while lo < hi:
mid = lo + (hi - lo) // 2
if feasible(mid):
hi = mid # try smaller
else:
lo = mid + 1 # need bigger
return lo
def bsearch_float(predicate: Callable[[float], bool],
lo: float, hi: float,
iterations: int = 100) -> float:
"""Fixed-iteration float binary search — safer than epsilon comparisons."""
for _ in range(iterations):
mid = (lo + hi) / 2
if predicate(mid):
hi = mid
else:
lo = mid
return lo
def sqrt(n: float) -> float:
"""Compute sqrt via binary search on the answer."""
return bsearch_float(lambda x: x * x >= n, 0.0, max(1.0, n))
# ---------- Demo ----------
if __name__ == "__main__":
arr = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print("classic search(23): ", binary_search(arr, 23)) # 5
print("classic search(24): ", binary_search(arr, 24)) # -1
print("lower_bound(24): ", lower_bound(arr, 24)) # 6
print("upper_bound(23): ", upper_bound(arr, 23)) # 6
print("bisect.bisect_left(24):", bisect.bisect_left(arr, 24))
dupes = [1, 2, 2, 2, 3]
print("count of 2 via bounds: ", upper_bound(dupes, 2) - lower_bound(dupes, 2)) # 3
weights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print("min capacity (5 days): ", min_ship_capacity(weights, 5)) # 15
print("min capacity (3 days): ", min_ship_capacity(weights, 3)) # 20+
print(f"sqrt(2) ≈ {sqrt(2):.10f} vs math.sqrt = {math.sqrt(2):.10f}")Anatomy of the script
Do these three edits, one at a time, and observe:
# Break 1 — Template A with `lo < hi` instead of `lo <= hi`
# Symptom: binary_search(arr, arr[-1]) returns -1 for the last element.
# Break 2 — Template B with `hi = mid - 1` instead of `hi = mid`
# Symptom: `lower_bound([1,2,3,4,5], 3)` returns 3 instead of 2 (off-by-one).
# Break 3 — In min_ship_capacity, replace `lo = mid + 1` with `lo = mid`
# Symptom: infinite loop when `feasible(mid) == False` and `mid == lo`.
# Kill with Ctrl-C after ~1 second.Now you've felt the two failure modes in your fingers. You will never mis-pick a template again — or at least, you'll notice the failure in 5 seconds instead of 20 minutes.
(d) Production reality · 15 min
Josh Bloch's java.util.Arrays.binarySearch, shipped in JDK 1.2 (1998), used int mid = (low + high) / 2. Passed every review, passed every test suite. Worked perfectly — for arrays smaller than Integer.MAX_VALUE / 2.
The moment arrays got big enough (server-side JVM heaps in the 2000s), low + high overflowed to a negative int, mid became negative, and the JVM threw ArrayIndexOutOfBoundsException at random-looking sizes.
The fix is one line: int mid = (low + high) >>> 1 (unsigned shift). Equivalent to low + (high - low) / 2. Every algorithms textbook printed before 2006 taught the buggy version.
A performance regression in the Linux kernel: some workloads slowed by 30% between two releases with ~5,000 commits in between. Reading commits linearly to find the culprit is a week of engineering time.
Linus wrote git bisect: mark a known-good and known-bad commit; git checks out the midpoint; you test; you say git bisect good or bad; repeat. log₂(5000) ≈ 13 checkouts. Add git bisect run <script> and it runs unattended.
Choosing the right bitrate for a per-title, per-scene encode is a monotonic problem: quality goes up with bitrate, cost goes up with bitrate. Netflix wanted the smallest bitrate that hits a target VMAF (perceptual quality) score.
Binary-search on the answer: lo = 100 kbps, hi = 20 Mbps, predicate = encode(bitrate).vmaf >= target. ~7 encodes per title vs a linear sweep of 100+. Saved petabytes of CDN egress per month.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is binary search? (one sentence, no code)
- What is the one prerequisite the input must satisfy? (and give one non-array example)
- Why do senior engineers reach for
bisect_leftinstead of writing the loop? (one sentence)
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.