Skip to main content

Updated Sep 11, 2026

Binary Search

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.

A sorted array of seventeen values with arrows showing a search narrowing from the middle element 14 to 6, then to 8, then arriving at 7
Searching for 7. Each probe eliminates half of what remains: 17 candidates, then 8, then 3, then 1 — four comparisons instead of seventeen. Wikimedia Commons, CC BY-SA 4.0

Core Concepts​

PropertyValue
Best caseO(1)O(1) — the target is the first midpoint
Average / worstO(log⁡n)O(\log n)
SpaceO(1)O(1) iterative, O(log⁡n)O(\log n) recursive
RequiresSorted data and O(1)O(1) random access
Comparisons⌊log₂ n⌋ + 1 in the worst case

Mechanism​

Searching [1, 3, 5, 8] for a value that is present, then one that is absent, shows both outcomes of the exact-match loop below — tracking lo, mid, and hi at the start of each iteration:

a = [1, 3, 5, 8] (indices 0, 1, 2, 3)

search for 5:
lo=0 hi=3 mid=1 a[1]=3 < 5 -> lo = mid+1 = 2
lo=2 hi=3 mid=2 a[2]=5 == 5 -> return 2

search for 4:
lo=0 hi=3 mid=1 a[1]=3 < 4 -> lo = mid+1 = 2
lo=2 hi=3 mid=2 a[2]=5 > 4 -> hi = mid-1 = 1
lo=2 hi=1 lo > hi, loop ends -> return -1

Finding 5 takes two probes because the second one lands exactly on the target. Looking for 4 — a value that would sit between indices 1 and 2 — narrows the range until lo crosses hi, which is exactly the termination condition proving no such index exists.

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:

DetailWhy it matters
lo + (hi - lo) // 2(lo + hi) // 2 overflows for large arrays in fixed-width integer languages — see Integers & Two's Complement for why lo + hi can exceed the type's range while neither lo nor hi does. This was the JDK bug.
while lo <= hiWith inclusive bounds, lo == hi still holds one unchecked element. < skips it.
mid + 1 / mid - 1Assigning lo = mid when lo == mid loops forever. The ±1 guarantees the range shrinks.
Pick one convention and never mix them

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. There are exactly four boundary variants anyone ever needs, and they are all one template:

lo, hi = 0, len(a) # half-open: hi is one past the end
while lo < hi:
mid = lo + (hi - lo) // 2
if <predicate>(a[mid]):
hi = mid # a[mid] already satisfies it — it or something left of it is the answer
else:
lo = mid + 1
return lo # first index where <predicate> holds, or len(a) if none does

The predicate and what you do with the returned lo are the only things that change:

VariantPredicate <predicate>(a[mid])Answer
First index >= target (lower_bound)a[mid] >= targetlo
First index > target (upper_bound)a[mid] > targetlo
Last index < targeta[mid] >= targetlo - 1
Last index <= targeta[mid] > targetlo - 1

The "last" variants are not separate loops — they are the "first" loop's answer, one position to the left, because "the last index where the predicate is false" is one less than "the first index where it is true." This is what makes the half-open template worth memorising over the inclusive-bounds one: every boundary question reduces to picking a predicate and, optionally, subtracting one.

def first_true(a, predicate):
"""Index of the first element for which predicate(a[i]) is True. 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 predicate(a[mid]):
hi = mid # a[mid] already satisfies it
else:
lo = mid + 1
return lo


def lower_bound(a, target):
return first_true(a, lambda v: v >= target)


def upper_bound(a, target):
return first_true(a, lambda v: v > target)


def last_less_than(a, target):
return lower_bound(a, target) - 1


def last_less_or_equal(a, target):
return upper_bound(a, target) - 1

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 same half-open template applies to any monotonic predicate — any question whose answer, once true, stays true — even when the "array" is a range of candidate answers that is never materialised, such as "smallest capacity that ships all packages within days." See Binary Search on the Answer for the full pattern: rate problems, the floating-point variant, and its stopping rule.

# all four boundary variants, checked on the traced input
assert binary_search([1, 3, 5, 8], 5) == 2 # the traced hit
assert binary_search([1, 3, 5, 8], 4) == -1 # the traced miss
assert lower_bound([1, 3, 5, 8], 5) == 2
assert upper_bound([1, 3, 5, 8], 5) == 3
assert last_less_than([1, 3, 5, 8], 5) == 1 # index of 3
assert last_less_or_equal([1, 3, 5, 8], 5) == 2 # index of 5 itself

Practical Usage​

# doc:no-run
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, Rust slice::binary_search (returning Result<usize, usize> — the cleanest of these designs).

Edge Cases & Pitfalls​

Binary search on unsorted data does not fail — it returns garbage

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) // 2 overflow — real in C, C++ 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_bound when it matters.
  • Binary search on a linked list is pointless — reaching the midpoint is O(n)O(n), making the whole search O(nlog⁡n)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 searchLinear searchHash table
Per lookupO(log⁡n)O(\log n)O(n)O(n)O(1)O(1) expected
Sorted input neededYesNoNo
Random access neededYesNoNo
Range / nearest-match queriesYesNoNo
Insertion into the structureO(n)O(n)O(1)O(1) at the endO(1)O(1)

Recall​

References​

Books & Videos​