Binary Search
Overviewโ
Binary search compares the target against the middle element of a sorted array and discards half the remaining range at every step. Twenty steps suffice for a million elements; thirty for a billion.
It is also famously difficult to write correctly. Jon Bentley reported that 90% of professional programmers failed to produce a correct version given several hours, and the implementation in the JDK carried an overflow bug from 1997 until 2006. The idea is simple; the boundary conditions are not.

Core Conceptsโ
| Property | Value |
|---|---|
| Best case | O(1) โ the target is the first midpoint |
| Average / worst | O(log n) |
| Space | O(1) iterative, O(log n) recursive |
| Requires | Sorted data and O(1) random access |
| Comparisons | โlogโ nโ + 1 in the worst case |
Architecture / Mechanismโ
def binary_search(a, target):
lo, hi = 0, len(a) - 1 # inclusive bounds
while lo <= hi: # <= because lo == hi is a valid range of one
mid = lo + (hi - lo) // 2 # overflow-safe midpoint
if a[mid] == target:
return mid
if a[mid] < target:
lo = mid + 1 # +1: mid is excluded, guaranteeing progress
else:
hi = mid - 1
return -1
Every line above is where implementations go wrong:
| Detail | Why it matters |
|---|---|
lo + (hi - lo) // 2 | (lo + hi) // 2 overflows for large arrays in fixed-width integer languages. This was the JDK bug. |
while lo <= hi | With inclusive bounds, lo == hi still holds one unchecked element. < skips it. |
mid + 1 / mid - 1 | Assigning lo = mid when lo == mid loops forever. The ยฑ1 guarantees the range shrinks. |
Inclusive bounds (hi = len - 1, while lo <= hi, hi = mid - 1) and half-open bounds
(hi = len, while lo < hi, hi = mid) are both correct. Bugs come from combining halves of the
two. The half-open form generalises better to the boundary-finding variants below, which is why
bisect and lower_bound use it.
The variant that matters more: finding a boundaryโ
Exact-match search is the least useful form. Far more often you want the insertion point โ the first position where a predicate becomes true. This version never terminates early, always converges on a boundary, and handles duplicates and absent values uniformly:
def lower_bound(a, target):
"""Index of the first element >= target. Returns len(a) if none."""
lo, hi = 0, len(a) # half-open: hi is one past the end
while lo < hi:
mid = lo + (hi - lo) // 2
if a[mid] < target:
lo = mid + 1
else:
hi = mid # no -1: mid may itself be the answer
return lo
From lower_bound everything else follows: a[i] == target tests membership, upper_bound - lower_bound
counts occurrences, and the returned index is exactly where an insert would preserve order.
Binary searching an answer, not an arrayโ
The technique applies to any monotonic predicate โ any question whose answer, once true, stays true. The "array" can be a range of candidate answers that is never materialised:
# Smallest capacity that ships all packages within `days`
def min_capacity(weights, days):
def feasible(cap): # monotonic: if cap works, cap+1 works
needed, load = 1, 0
for w in weights:
if load + w > cap:
needed, load = needed + 1, 0
load += w
return needed <= days
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = lo + (hi - lo) // 2
if feasible(mid):
hi = mid
else:
lo = mid + 1
return lo
This "binary search on the answer" pattern turns an optimisation problem into O(log range) feasibility checks, and it is one of the highest-value techniques in competitive programming and in real capacity planning alike.
Practical Usageโ
import bisect
i = bisect.bisect_left(a, x) # lower_bound โ first index where a[i] >= x
j = bisect.bisect_right(a, x) # upper_bound โ first index where a[i] > x
count = j - i # occurrences of x
found = i < len(a) and a[i] == x # membership test
bisect.insort(a, x) # insert, keeping the list sorted (O(n) for the shift)
Equivalents elsewhere: C++ std::lower_bound/upper_bound/equal_range, Java
Arrays.binarySearch (which returns -(insertion point) - 1 when absent), Rust
slice::binary_search (returning Result<usize, usize> โ the cleanest of these designs).
Edge Cases & Pitfallsโ
There is no check and no error. It silently returns a wrong index or "not found" for a value that is present, and the bug survives testing because it is data-dependent. If a sort was supposed to happen earlier and did not, this is where it surfaces โ as a wrong answer, far from the cause.
(lo + hi) // 2overflow โ real in C, C++, Java and Rust. Python's unbounded integers make it safe there, which is why the habit does not transfer.- Which duplicate you get is unspecified for exact-match search. Use
lower_bound/upper_boundwhen it matters. - Binary search on a linked list is pointless โ reaching the midpoint is O(n), making the whole search O(n log n), worse than a plain scan.
- Below ~50 elements a linear scan is usually faster on real hardware, for cache and branch-prediction reasons.
- Floating-point ranges never converge with
lo < hi. Iterate a fixed number of times (100 is ample) or compare against an epsilon.
Comparisonsโ
| Binary search | Linear search | Hash table | |
|---|---|---|---|
| Per lookup | O(log n) | O(n) | O(1) expected |
| Sorted input needed | Yes | No | No |
| Random access needed | Yes | No | No |
| Range / nearest-match queries | Yes | No | No |
| Insertion into the structure | O(n) | O(1) at the end | O(1) |
Referencesโ
- Bentley, J., Programming Pearls, Ch. 4 โ the correctness argument, and the source of the "90% get it wrong" figure.
- Bloch, J. (2006), "Extra, Extra โ Read All About It: Nearly All Binary Searches and Mergesorts are Broken" โ the JDK overflow bug.
- Knuth, The Art of Computer Programming, Vol. 3, ยง6.2.1 โ binary search and its variants in full.
Books & Videosโ
- Python
bisectdocumentation โ includes recipes for the boundary variants above.
Related Pagesโ
- Linear Search โ the no-preconditions alternative.
- Sorting Algorithms โ the prerequisite step.
- Balanced Trees โ binary search made incrementally updatable.