Binary Search
Find things in sorted data in O(log n) with one reusable template, lower bound, that avoids the classic off-by-one bugs. Traced, with JS and Python.
On this page
Your app worked last week. Today it crashes, and 1,000 changes were made in between. You could test each change in order until you hit the first broken one. Or you could test change 500. If the app already crashes there, the culprit is in the first half; if not, it is in the second half. One test just ruled out 500 changes. Ten tests in total will pin it down.
That is binary search. It works because the changes have a shape: every change before the culprit is fine, and every change from it onward is broken. Fine, fine, fine, broken, broken. You are looking for the boundary.
The idea: find the first "yes"
A sorted array has the same shape. Take [3, 8, 8, 8, 12, 15, 20, 27] and ask, for each position, "is this value at least 8?" The answers are no, yes, yes, yes, yes, yes, yes, yes. All the "no"s come first, then all the "yes"s, because the array is sorted.
The position of the first "yes" is called the lower bound of 8. It is the most useful single answer binary search can give you:
- If the value there equals 8, it is the first 8 in the array.
- If it does not, 8 is not in the array, and this is where you would insert 8 to keep the array sorted.
- If every answer is "no", the lower bound is
n, one past the end.
To find it, keep two positions, lo and hi. Everything left of lo is known to be "no". Everything from hi onward is known to be "yes". The positions you have not decided yet are [lo, hi): lo is included, hi is not. Check the middle of that range, then throw away the half that cannot hold the first "yes".
Lower bound, traced
Find the lower bound of 8. Watch what happens when the search lands on an 8: it does not stop, because an earlier 8 might exist.
lo = 0, hi = nwhile lo < hi: mid = lo + (hi - lo) // 2 if a[mid] < target: lo = mid + 1 else: hi = midreturn lo- a03lo182838412515620727hi
- target
- 8
- checks
- 0
1The answer could be any position from 0 to 8, where 8 would mean "every value is smaller". So
histarts atn, one past the end. - a03lo182838412mid515620727hi
- target
- 8
- checks
- 1
2mid = 0 + (8 − 0) // 2 = 4. a[4] is 12.
- a03lo182838412hi515620727
- target
- 8
- checks
- 1
312 is at least 8, a "yes". Position 4 might be the first yes, but nothing after it can be. Set
hi = mid, which keeps 4 as the edge of the range. - a03lo1828mid38412hi515620727
- target
- 8
- checks
- 2
4mid = 0 + (4 − 0) // 2 = 2. a[2] is 8.
- a03lo1828hi38412515620727
- target
- 8
- checks
- 2
58 is at least 8: another yes. You found an 8, but maybe not the first one. Do not return. Set
hi = 2and keep looking to the left. - a03lo18mid28hi38412515620727
- target
- 8
- checks
- 3
6mid = 0 + (2 − 0) // 2 = 1. a[1] is 8.
- a03lo18hi2838412515620727
- target
- 8
- checks
- 3
7Yes again.
hi = 1. Only position 0 is still unknown. - a03lo mid18hi2838412515620727
- target
- 8
- checks
- 4
8mid = 0 + (1 − 0) // 2 = 0. a[0] is 3.
- a0318lo hi2838412515620727
- target
- 8
- checks
- 4
93 is smaller than 8, a "no". Position 0 and everything before it is out, so
lo = mid + 1 = 1. - a0318lo hi2838412515620727
- returns
- 1
- checks
- 4
10
loequalshi: the unknown range is empty, and the first yes is at 1. It is the first 8. Four checks for 8 items; a million items would take 20.
The template
Learn this one function well. Almost every binary search on an array is this function, or a small change to the comparison.
// First index i with a[i] >= target, or a.length if there is none.
// a must be sorted from low to high.
function lowerBound(a, target) {
let lo = 0;
let hi = a.length; // half-open range [lo, hi)
while (lo < hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (a[mid] < target) lo = mid + 1; // mid is a "no": answer is right of it
else hi = mid; // mid is a "yes": answer is mid or left
}
return lo; // lo === hi
}
function contains(a, target) {
const i = lowerBound(a, target);
return i < a.length && a[i] === target;
}
lowerBound([3, 8, 8, 8, 12, 15, 20, 27], 8); // 1# First index i with a[i] >= target, or len(a) if there is none.
# a must be sorted from low to high.
def lower_bound(a, target):
lo, hi = 0, len(a) # half-open range [lo, hi)
while lo < hi:
mid = lo + (hi - lo) // 2
if a[mid] < target:
lo = mid + 1 # mid is a "no": answer is right of it
else:
hi = mid # mid is a "yes": answer is mid or left
return lo # lo == hi
def contains(a, target):
i = lower_bound(a, target)
return i < len(a) and a[i] == target
lower_bound([3, 8, 8, 8, 12, 15, 20, 27], 8) # 1Every piece of the template is there for a reason:
hi = n, notn − 1. The answer can ben("everything is smaller"), so the range has to be able to end there.while lo < hi. The loop runs while the unknown range[lo, hi)has something in it. Whenlo == hi, the answer is pinned.midrounds down, somidis always at leastloand always less thanhi. It is a real index inside the range.lo = mid + 1on a "no", becausemiditself is ruled out.hi = midon a "yes", becausemidmight be the answer and must stay at the edge. Either way the range shrinks, so the loop always ends.
One template, many questions
Change the question from "at least target" to "greater than target" and you get the upper bound: the first index after all copies of the target. The only change is < to <=.
// First index i with a[i] > target, or a.length.
function upperBound(a, target) {
let lo = 0, hi = a.length;
while (lo < hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (a[mid] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
}
const a = [3, 8, 8, 8, 12, 15, 20, 27];
upperBound(a, 8) - lowerBound(a, 8); // 3 copies of 8
upperBound(a, 8) - 1; // 3: the last 8
lowerBound(a, 13); // 5: insert 13 here# First index i with a[i] > target, or len(a).
def upper_bound(a, target):
lo, hi = 0, len(a)
while lo < hi:
mid = lo + (hi - lo) // 2
if a[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
a = [3, 8, 8, 8, 12, 15, 20, 27]
upper_bound(a, 8) - lower_bound(a, 8) # 3 copies of 8
upper_bound(a, 8) - 1 # 3: the last 8
lower_bound(a, 13) # 5: insert 13 herePython ships both as bisect.bisect_left (lower bound) and bisect.bisect_right (upper bound). JavaScript has no built-in, so keep the template handy.
Each check halves the unknown range: about log₂ n checks, 20 for a million items.
Common mistakes
Check yourself
What does lowerBound([3, 8, 8, 8, 12, 15, 20, 27], 13) return?
About how many checks does binary search need on a sorted array of 1,000,000 items?
In the template, why is it hi = mid and not hi = mid − 1?
What to practise next
Notice that the template never needed an array. It only needed a yes/no question whose answers go no, no, …, yes, yes. Binary Search on the Answer uses exactly this template to search over possible answers, such as "the smallest speed that is fast enough".