Binary Search
Binary search repeatedly discards half of an ordered search interval. Its most reusable form finds a boundary rather than stopping at an arbitrary equal value.
Try target 2 first: finding a matching value does not finish a lower-bound search. Then try 5 to see why the answer can be a position beyond the last element.
Advance one comparison at a time. The value row stays fixed; only the search boundaries move.
Top: index. Blue underline: unknown interval. Dashed outline: midpoint. Green outline: answer. ∅ marks position 4, beyond the array.
Lower bound
This implementation returns the first position whose value is at least the target, using the half-open interval :
def lower_bound(values: list[int], target: int) -> int:
lo, hi = 0, len(values)
while lo < hi:
mid = lo + (hi - lo) // 2
if values[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
Trace, sentinel, and predicate direction
For values = [1, 2, 2, 4] and target 2:
The first comparison finds a 2 at index 2, but an earlier 2 may exist. hi = mid removes index 2 from the unknown interval while retaining it as a possible answer at the right boundary. The next comparison does the same at index 1. Only index 0 is proved too small, so lo = mid + 1 skips it. The bounds meet at 1, the first duplicate.
The return value can be len(values), an insertion sentinel, not an index
to dereference. Empty input returns 0, a target below all keys returns 0,
and one above all keys returns n. This matches the partition contract of
Python bisect_left.
Exact membership then becomes:
position = lower_bound(values, target)
found = position < len(values) and values[position] == target
Invariant
All positions before lo are known to be too small; all positions at or after
hi are known to satisfy the boundary condition. The unknown region is
[lo, hi). Each iteration shrinks it, and termination at lo == hi identifies
the boundary.
Cost and requirements
- Time: comparisons.
- Iterative auxiliary space: .
- Requires a monotone condition and efficient access to the midpoint.
A sorted linked list does not provide constant-time midpoint access, so binary search is usually inappropriate even though the values are ordered.
The nonnegative integer
hi - lo strictly decreases and after a step is at most half its previous
value. Hence at most comparisons for .
When ordered keys change frequently, a balanced search tree also supports logarithmic updates, predecessors, and ranges.
Generalization
Replace values[mid] < target with the negation of a false-to-true feasibility predicate to search
for the first feasible integer, minimum capacity, or transition point. State the
interval and postcondition before writing the loop; most binary-search bugs are
boundary-contract bugs.
For a predicate feasible(i) that is false then true, the correct replacement
is if not feasible(mid): lo = mid + 1, otherwise hi = mid. Using
if feasible(mid) with the original branches reverses the meaning. Return
hi_initial if no position is feasible. Nonmonotone predicates, unsorted data,
or mutation during the search invalidate the invariant; sorting or validating
first costs extra and is not part of the logarithmic search.
Run these boundary checks after the definitions above:
assert lower_bound([], 2) == 0
assert lower_bound([2], 2) == 0
assert lower_bound([1, 2, 2, 4], 2) == 1
assert lower_bound([1, 2, 2, 4], 3) == 3
assert lower_bound([1, 2], 0) == 0
assert lower_bound([1, 2], 3) == 2