~/searching/binary-search
Binary search
Find a value in a sorted array by halving the range on every look. A million elements take at most 20 looks.
Keep a range that must contain the answer. Look at its middle and throw away the half that can't.
The data is sorted, or a yes/no condition flips from false to true exactly once.
O(log n)
O(1)
You’ll recognise it when
- The input is sorted, and you need to find a value or the spot where it would go.
- The question says first or last position where something becomes true.
- A linear scan would work, but
nis 10⁵ or more and there are many queries. - You want the smallest speed, capacity or time that still works, and bigger always works too. That’s binary search on the answer (see Variations).
The idea
Someone picks a number from 1 to 100 and answers each guess with “higher” or “lower”. You wouldn’t guess 1, 2, 3… You’d guess 50: whatever the reply, half the numbers are gone. Seven guesses always suffice, because 2⁷ = 128 is more than 100.
Binary search is that game on a sorted array. Look at the middle element. If it’s smaller than the target, the target can only be in the right half, so drop the left half. Otherwise drop the right half. Every look halves what’s left.
How it works
We’ll write the version that’s most useful in practice, lower bound: return the first index whose value is >= target. One loop answers both “where is it?” and “where would it go?”.
Scroll through the steps and the graphic follows along. You can also press play, step with the arrow keys, or edit the input: try duplicates, a missing value, or a target bigger than everything.
- Keep a half-open range
[lo, hi)that must contain the answer. Start with everything:lo = 0,hi = n. - While the range isn’t empty (
lo < hi), look at the middle,mid = (lo + hi) // 2. - If
a[mid] < target, the answer is right ofmid: setlo = mid + 1. - Otherwise
a[mid]could be the answer, so keep it: sethi = mid. - When
lo == hithe range is empty andlois the answer. To check membership, testlo < n and a[lo] == target.
Why it’s correct: everything left of lo is always < target, and everything from hi on is always >= target. Each step keeps both facts true and shrinks the gap between them, so when the gap closes, lo sits exactly on the boundary.
def lower_bound(a, target):
"""First index i with a[i] >= target, or len(a) if there is none."""
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def search(a, target):
"""Index of target in a, or -1."""
i = lower_bound(a, target)
return i if i < len(a) and a[i] == target else -1#include <vector>
using namespace std;
// First index i with a[i] >= target, or a.size() if there is none.
int lowerBound(const vector<int>& a, int target) {
int lo = 0, hi = a.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
// Index of target in a, or -1.
int search(const vector<int>& a, int target) {
int i = lowerBound(a, target);
return (i < (int)a.size() && a[i] == target) ? i : -1;
}class BinarySearch {
// First index i with a[i] >= target, or a.length if there is none.
static int lowerBound(int[] a, int target) {
int lo = 0, hi = a.length;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
// Index of target in a, or -1.
static int search(int[] a, int target) {
int i = lowerBound(a, target);
return (i < a.length && a[i] == target) ? i : -1;
}
}Why it’s O(log n)
Each pass through the loop at least halves hi - lo. After k passes at most n / 2ᵏ elements remain, so the loop runs at most about log₂ n + 1 times.
| n | looks, worst case |
|---|---|
| 1,000 | 10 |
| 1,000,000 | 20 |
| 1,000,000,000 | 30 |
Space is O(1): three integers, no recursion.
Common mistakes
Mixing two range conventions
hi = n - 1 belongs to closed ranges [lo, hi] with while lo <= hi and hi = mid - 1. hi = n belongs to half-open ranges with while lo < hi and hi = mid. Mixing lines from each skips elements or loops forever. Pick one style, and learn all four lines of it together.
Writing lo = mid
When the range has two elements, mid equals lo. If the step is lo = mid, nothing changes and the loop never ends.
lo = mid # ✗ stuck when hi == lo + 1
lo = mid + 1 # ✓ a[mid] < target, so mid itself is ruled out
Overflow in C++ and Java
(lo + hi) / 2 overflows once lo + hi passes 2³¹ − 1, which happens on big arrays or when searching over large answer ranges. Python’s integers never overflow.
int mid = (lo + hi) / 2; // ✗ can overflow
int mid = lo + (hi - lo) / 2; // ✓ same value, no overflow
Reading a[lo] without checking lo
If the target is bigger than every element, the loop ends with lo == n, one past the end. Check lo < n before reading a[lo].
Variations
- Upper bound. The first index with
a[i] > target: change<to<=. The number of copies oftargetisupper − lower, and the last copy is atupper − 1. - Binary search on the answer. The “array” doesn’t exist; you have a check
ok(x)that is false, false, …, then true, true, … Searchxfor the first true. Classic example: the smallest ship capacity that moves every package withinDdays. - Rotated sorted array. One half around
midis always sorted. Compare with that half’s ends to decide whether the target can be there. - Real numbers. For a continuous answer, loop a fixed number of times (say 100) instead of
while lo < hi, and setlo = midorhi = mid. - Library versions. Python
bisect.bisect_left, C++std::lower_bound, JavaArrays.binarySearch. Java returns-(insertion point) - 1when the value is missing, not-1.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
Versions 1..n: every version from some point on is “bad” and all earlier ones are “good”. Checking one version is expensive. What’s the approach?
The predicate flips from False to True exactly once, which is the core binary search condition even without a sorted array. That’s O(log n) calls to the expensive check; the scan is O(n).
-
2
What does this print?
def lower_bound(a, x):lo, hi = 0, len(a)while lo < hi:mid = (lo + hi) // 2if a[mid] < x:lo = mid + 1else:hi = midreturn loprint(lower_bound([1, 3, 5], 6), lower_bound([], 1), lower_bound([1, 3, 5], 0))With the half-open range [lo, hi) starting at [0, n), the answer can be n, meaning “x is bigger than everything”. An empty list returns 0 without touching
a. Callers must checki < len(a)before readinga[i]. -
3
This should find the last index with
a[i] <= x, anda[0] <= xis guaranteed. On some inputs it never terminates. Why?lo, hi = 0, len(a) - 1while lo < hi:mid = (lo + hi) // 2if a[mid] <= x:lo = midelse:hi = mid - 1Floor division rounds
middown toloon a two-element range, and if thelo = midbranch runs, nothing changes. Whenever the update islo = mid, round up so the range always shrinks. Changing tolo <= himakes it worse: it loops even on one-element ranges. -
4
What does this print?
from bisect import bisect_left, bisect_righta = [5, 7, 7, 8, 8, 10]def first_last(x):i = bisect_left(a, x)if i == len(a) or a[i] != x:return [-1, -1]return [i, bisect_right(a, x) - 1]print(first_last(8), first_last(6))bisect_leftgives the first 8 at index 3 andbisect_rightgives 5, one past the last 8, hence the- 1. For a missing valuebisect_leftreturns its insertion point, so you must checka[i] == x(andi < len(a)) before trusting it. -
5
Why do Java or C++ solutions write
mid = lo + (hi - lo) / 2, and does Python need it?With indexes near 2^31,
lo + hioverflows and becomes negative in fixed-width integers. Python’s integers grow as needed. The form is still worth mentioning in an interview to show you know the issue.