AlgoAlgo/03 · Binary Search in Depth
CHAPTER 03 · Binary Search+

Binary search+

Binary search is usually introduced as "find a value in a sorted array". That is only one case. What the method really needs is a yes/no test that flips from false to true exactly once and never flips back. This chapter starts from one template and one interval convention, then works through boundaries, rotated arrays, and peaks, and ends at binary search on the answer: turning a hard optimization problem into a short series of yes/no questions.

§01

One template: name the interval, then derive everything else

The idea is easy. The boundaries are not. Fix a convention that cannot loop forever.

Binary search works on a sorted sequence. Compare the element in the middle with the target. That single comparison tells you whether the answer is in the left half or the right half, so half of the candidates disappear at once. 100 numbers need at most 7 questions (⌈log₂100⌉), and a billion numbers need 30. Try it yourself first:

Guessing lab — think of a number from 1 to 100, keep it to yourself, and let the machine find it
50
Candidate interval [1, 100], 100 numbers left · 0 asked / at most 7
The machine guesses 50, the middle of the interval. Is your number higher or lower? Each answer removes half of the remaining candidates.

The idea is easy to state and hard to write. Most people produce an infinite loop or an off-by-one error on the first attempt. This is well documented: Jon Bentley reported in Programming Pearls that when he gave professional programmers a couple of hours to write a binary search, fewer than 10 percent produced a correct one. So do not write it from intuition. Write it from a convention.

Start by naming the interval. This chapter uses the closed interval [lo, hi]: both ends are still candidates. The invariant is that if target is in the array, its index is inside [lo, hi]. Everything already discarded has been proved not to be the answer. The loop condition and both updates follow from that one sentence:

  • [lo, hi] is non-empty exactly when lo <= hi, so that is the loop condition. When lo == hi there is still one element left to check.
  • If nums[mid] < target, then mid and every index to its left hold values smaller than target. The answer can only be in [mid+1, hi], so lo = mid + 1.
  • If nums[mid] > target, the answer can only be in [lo, mid−1], so hi = mid - 1.

Why it terminates: mid always lies inside [lo, hi], and both updates step past mid, so each iteration removes at least the element at mid. hi − lo therefore strictly decreases and the interval reaches empty in a finite number of steps. Each iteration also removes about half of what is left, so the loop runs about log₂n times. Time O(log n). The iterative version keeps only two numbers, so extra space is O(1); written recursively, the call stack is O(log n) deep.

There is a second convention: the half-open interval [lo, hi), where hi is one position past the last candidate. Derive it the same way. [lo, hi) is non-empty when lo < hi, so the loop is while (lo < hi). Discarding the right part is hi = mid, not mid - 1, because hi is already outside the interval. Discarding the left part is still lo = mid + 1. Both conventions are correct. Mixing them is the classic bug while (lo <= hi) together with hi = mid never ends. Pick one convention and derive the rest from it. Everything below uses the closed interval.

Here is the plain exact search (LC 704) written that way:

lc704_binary_search.py
1class Solution:
2 def search(self, nums: list[int], target: int) -> int:
3 lo, hi = 0, len(nums) - 1 # closed interval [lo, hi]
4 while lo <= hi:
5 mid = lo + (hi - lo) // 2 # // rounds down
6 if nums[mid] == target:
7 return mid
8 elif nums[mid] < target:
9 lo = mid + 1 # answer is on the right
10 else:
11 hi = mid - 1 # answer is on the left
12 return -1
Python integers have arbitrary precision, so lo + hi cannot overflow and (lo + hi) // 2 is safe here. Keeping the lo + (hi - lo) // 2 habit still pays off, because the same code translated to Java or C++ stays correct.
Failure 01
Interval and loop condition disagree

With the closed interval [lo, hi] the loop must be lo <= hi, because lo == hi still leaves one element to check. Using lo < hi skips it. The half-open form has its own matching set. Pick one and stay with it.

Failure 02
mid overflows

In a language with fixed-width integers, such as Java or C++, (lo + hi) / 2 can overflow. Write lo + (hi - lo) / 2: same value, no overflow. Python integers cannot overflow, and JavaScript numbers are exact up to 2⁵³ — but >> truncates to 32 bits there, so do not use it.

Failure 03
The interval stops shrinking

mid rounds down, so mid == lo whenever hi == lo or hi == lo+1. With while (lo <= hi), hi = mid rewrites hi with the value it already had once lo == hi: infinite loop. With while (lo < hi), lo = mid does the same once hi == lo+1. Every branch must move past mid.

The bug that hid for nine years

In 2006 Joshua Bloch published Extra, Extra — Read All About It: Nearly All Binary Searches and Mergesorts Are Broken. He pointed out that java.util.Arrays.binarySearch computed (low + high) / 2, which overflows into a negative number once the array is large enough. The code had been shipping for about nine years, and the same line had been copied into many textbooks. The fix was one line: low + (high - low) / 2. In binary search, the hard part is always the boundaries.

§02

Boundaries: lower_bound and upper_bound

MEDIUM

Worked example A · LC 34 — with duplicates, where do the first and last copies sit?

The exact search has a weakness. When the array contains duplicates, it returns whichever matching index it happened to land on. Many problems need the first or the last one instead. Two precise tools cover almost every such question:

  • lower_bound(t) is the index of the first element ≥ t. It equals the number of elements smaller than t. If every element is smaller than t, it returns n, the array length — one position past the end.
  • upper_bound(t) is the index of the first element > t. It equals the number of elements ≤ t. If every element is ≤ t, it also returns n.

Everything else follows. The number of copies of t is upper_bound(t) − lower_bound(t); if that is 0, t is absent. The first occurrence is lower_bound(t), but only after checking that this index is below n and that the value there really is t. If it is, the last occurrence is upper_bound(t) − 1.

Both are written with one template: closed interval plus a candidate variable. When the test passes, record the current mid in ans, then keep shrinking toward the side you want. The invariant is: ans is the best index found so far, and any better one is still inside [lo, hi]. Initialize ans to n so that "nothing matched" comes out correctly.

LC 34: in a sorted array, return the first and last index of target, or [−1, −1] if it is absent. Brute force: find any match, then walk left and right — that is O(n) when the array is all target, and it wastes the sorted order. Solution: the left end is lower_bound(target); the right end is the first index greater than target, minus one. Step through it:

LC 34 · both ends of 8: lower_bound first, then upper_bound
lo
hi
50
71
72
83
84
85
106
The target 8 appears several times. Find the leftmost one first, with the "first index whose value is ≥ 8" template: when the test passes, record the index as a candidate and keep searching to the left.
1 / 10

Note the shortcut in the animation. "First index > 8" is the same position as "first index ≥ 9", so upper_bound(t) = lower_bound(t + 1). That rewrite is only valid because the values are integers, where t + 1 is the next possible value; on floating-point data you would need a real upper_bound. With integers, one lower_bound function produces both ends:

lc34_search_range.py
1class Solution:
2 def searchRange(self, nums: list[int], target: int) -> list[int]:
3 def lower(t: int) -> int: # first index with nums[i] >= t
4 lo, hi, ans = 0, len(nums) - 1, len(nums)
5 while lo <= hi:
6 mid = lo + (hi - lo) // 2
7 if nums[mid] >= t:
8 ans, hi = mid, mid - 1 # record, then look further left
9 else:
10 lo = mid + 1
11 return ans
12
13 left = lower(target)
14 if left == len(nums) or nums[left] != target:
15 return [-1, -1]
16 return [left, lower(target + 1) - 1] # upper_bound = lower_bound(t + 1)
The standard library already has both: bisect.bisect_left is lower_bound and bisect.bisect_right is upper_bound. You may call them in an interview, but write the loop by hand at least once — that is where the boundaries become clear.

Complexity and the usual follow-up questions

Two binary searches: O(log n) time, O(1) space. Common follow-ups: (1) "Can one function give both ends?" — yes, lower_bound(t) and lower_bound(t+1)-1. (2) "How many times does target occur?" — right − left + 1, or equivalently upper_bound − lower_bound. (3) "What about LC 35, search insert position?" — the answer is lower_bound(target) directly, with no existence check at all. Same template, three uses.

§03

What binary search actually requires

MEDIUM

Worked example B · LC 33 — the array is scrambled, so why can you still halve it?

Time to correct a common belief. Binary search does not require the array to be sorted. What it requires is that one O(1) test tells you which half can be discarded. Put differently: the range must split into a part where the test is false and a part where it is true, with a single flip between them. Sorted order is the most common way to get such a split, but it is not the only way.

LC 33: a sorted array was rotated — for example [0,1,2,4,5,6,7] became [4,5,6,7,0,1,2] — and you must find target and return its index. The array is no longer sorted as a whole, so the plain search fails. The key observation: a rotation creates exactly one drop, so wherever you cut, at least one of the two halves is fully sorted. Decide which half that is, then decide whether target lies inside it, and one half can be discarded safely. LC 33 guarantees that all values are distinct, which keeps the first decision unambiguous:

LC 33 · finding 0 in a rotated array (each step asks: which half is sorted?)
lo
hi
40
51
62
73
04
15
26
[4,5,6,7,0,1,2] is a sorted array that was rotated: 7 is followed by 0, so there is exactly one drop. The array is not sorted as a whole, but wherever you cut it, at most one half can contain that drop. Target: 0.
1 / 5

The test is short: nums[lo] <= nums[mid] means [lo, mid] is sorted; otherwise [mid, hi] is. Then compare target against the two ends of that sorted half. Inside a sorted half a range comparison is reliable; the other half may contain the drop, so it is left for the next iteration.

lc33_search_rotated.py
1class Solution:
2 def search(self, nums: list[int], target: int) -> int:
3 lo, hi = 0, len(nums) - 1
4 while lo <= hi:
5 mid = lo + (hi - lo) // 2
6 if nums[mid] == target:
7 return mid
8 if nums[lo] <= nums[mid]: # left half is sorted
9 if nums[lo] <= target < nums[mid]:
10 hi = mid - 1
11 else:
12 lo = mid + 1
13 else: # right half is sorted
14 if nums[mid] < target <= nums[hi]:
15 lo = mid + 1
16 else:
17 hi = mid - 1
18 return -1
Python allows chained comparison, so nums[lo] <= target < nums[mid] reads exactly like the mathematical interval. It is easier to read and harder to get backwards.
Variant 01 · LC 153
Minimum of a rotated array

Compare nums[mid] with the right end nums[hi]: greater means the minimum is strictly to the right (lo = mid+1), otherwise hi = mid. Converge to a single index. Comparing with nums[lo] misjudges an array that was not actually rotated.

Variant 02 · LC 81
⚠️ Duplicates get in the way

When a[lo] == a[mid] == a[hi], as in [1,1,1,0,1], neither half can be shown to be sorted. The split is gone, so the only safe move is lo++, hi--, and the worst case becomes O(n).

Variant 03 · LC 154
⚠️ Minimum with duplicates

The duplicate version of LC 153. When a[mid] == a[hi] there is nothing to decide on, so shrink conservatively with hi--; the minimum stays in range because a copy of a[hi] sits at mid. Worst case O(n).

Why 81 and 154 degrade while 153 does not

Without duplicates, a[lo] and a[mid] always compare to a definite answer, so the split holds on every step and the time stays O(log n). Once duplicates are allowed and a[lo] == a[mid] == a[hi], the same three values are consistent with "the left half is flat and sorted" and with "the drop is hidden among equal values". Nothing distinguishes them, so that step gives up halving and removes one element from each end instead. Duplicates are what break binary search, and saying that clearly is what an interviewer is listening for when they ask "what if there are duplicates?"

§04

Peaks and matrices: two more sources of the split

MEDIUM

Halving without sorted order: from the direction of the slope, and from row and column order.

LC 162, find a peak: return the index of any element larger than both of its neighbors. Neighboring values are never equal, and positions outside the array count as −∞. The array has no order at all, yet the direction of the slope gives the split. If nums[mid] < nums[mid+1], the values are rising at mid, and a peak must exist in [mid+1, hi]: either they keep rising all the way to hi, which is then a peak because its right neighbor is −∞, or they stop rising at some index, which is then a peak. Otherwise a peak exists in [lo, mid]. So you can halve the range by always walking uphill:

lc162_find_peak.py
1class Solution:
2 def findPeakElement(self, nums: list[int]) -> int:
3 lo, hi = 0, len(nums) - 1 # converge to one index
4 while lo < hi:
5 mid = lo + (hi - lo) // 2
6 if nums[mid] < nums[mid + 1]:
7 lo = mid + 1 # rising: a peak is on the right
8 else:
9 hi = mid # otherwise a peak is at mid or left
10 return lo
Why nums[mid + 1] is always a valid read: while lo < hi guarantees mid < hi, and hi is the last index, so mid + 1 is at most hi. The index never goes past the end of the array.

Now two dimensions. Matrix search comes in two genuinely different forms, and the difference is how far the ordering reaches:

LC 74 · ordered everywhere
Flatten it, then search

Each row increases, and the first value of a row is greater than the last value of the row above. Reading m×n row by row gives one strictly increasing array. Search [0, mn−1] and map back with mid/n and mid%n. O(log mn).

LC 240 · ordered locally
Walk in from the top-right

Only each row and each column is sorted; rows do not connect. Flattening gives no order, so a single search would miss values. Stand at the top-right corner: too large means move left (drop a column), too small means move down (drop a row). O(m + n).

lc74_search_matrix.py
1class Solution:
2 # LC 74: ordered everywhere - the whole matrix is one sorted array
3 def searchMatrix(self, matrix: list[list[int]], target: int) -> bool:
4 m, n = len(matrix), len(matrix[0])
5 lo, hi = 0, m * n - 1
6 while lo <= hi:
7 mid = lo + (hi - lo) // 2
8 val = matrix[mid // n][mid % n] # flat index back to row, column
9 if val == target:
10 return True
11 elif val < target:
12 lo = mid + 1
13 else:
14 hi = mid - 1
15 return False
Compare with LC 240: if the matrix only guarantees order within each row and each column, this code misses values, because the flattened sequence is not sorted. Identify which kind of matrix you have before choosing a template.
lc240_search_matrix_ii.py
1class Solution:
2 # LC 240: ordered locally - eliminate from the top-right corner
3 def searchMatrix(self, matrix: list[list[int]], target: int) -> bool:
4 r, c = 0, len(matrix[0]) - 1 # top-right corner
5 while r < len(matrix) and c >= 0:
6 val = matrix[r][c]
7 if val == target:
8 return True
9 elif val > target:
10 c -= 1 # drop a whole column
11 else:
12 r += 1 # drop a whole row
13 return False
One way to picture it: the matrix behaves like a binary search tree rooted at the top-right corner. Moving left goes to smaller values, moving down goes to larger ones, and each step follows one edge — hence O(m + n).
§05

Binary search on the answer

MEDIUM

Worked example C · LC 875 Koko — when computing the answer is hard, ask a series of yes/no questions instead.

So far the search ran over array indices. Now it runs over the answer itself. The rule is one sentence: when computing the best answer is hard but checking a candidate answer is easy, search the answer. The condition is that the check must be monotonic: there is a point where the yes/no test flips from false to true and it never flips back. Drawn out, it is a line of F…F followed by T…T. Note what is not required: the input array does not have to be sorted. Only the predicate has to be monotonic.

What you are looking for is the highlighted flip point, the first candidate that passes. That is exactly the lower_bound from §02, with one substitution: instead of comparing with target, you call a predicate you wrote yourself, usually named judge or check.

LC 875: Koko has several piles of bananas, piles, and the guards come back in h hours. Each hour she picks one pile and eats k bananas from it; if the pile runs out she waits for the next hour rather than starting another pile. Find the smallest speed k that finishes within h hours. Brute force: try k = 1, 2, 3, … and stop at the first one that works — O(max × n), and max can be 10⁹, so it is too slow. Why binary search applies: a higher speed never needs more hours, so judge(k) = Σ⌈pile/k⌉ ≤ h is monotonic in k. Search the value range [1, max(piles)] for the first k that passes. The F/T line drawn above is this exact problem, with piles = [3,6,7,11] and h = 8:

LC 875 · searching the speed range [1, 11] (piles = [3,6,7,11], h = 8) (unit: bananas per hour)
1
2
3
4
5
6
7
8
9
10
11
The candidate speeds are 1 to 11 bananas per hour. The upper bound is 11 because 11 is the largest pile: Koko eats from only one pile per hour, so any speed above 11 finishes each pile in the same one hour. A faster speed never needs more time, so the test "can she finish within h hours" is monotonic in k. That is what makes binary search valid here.
1 / 5
lc875_koko_eating_bananas.py
1class Solution:
2 def minEatingSpeed(self, piles: list[int], h: int) -> int:
3 def can(k: int) -> bool: # can speed k finish within h?
4 return sum((p + k - 1) // k for p in piles) <= h
5
6 lo, hi, ans = 1, max(piles), max(piles)
7 while lo <= hi:
8 mid = lo + (hi - lo) // 2
9 if can(mid):
10 ans, hi = mid, mid - 1 # it works -> try slower
11 else:
12 lo = mid + 1 # too slow -> go faster
13 return ans
-(-p // k) and math.ceil(p / k) also round up, but (p + k - 1) // k stays in integers and is the safest of the three. Python integers are arbitrary precision, so the sum cannot overflow.

In production: answer search shows up in capacity planning

The pattern of "guess a value, then verify it" is common in real systems. Load testing uses it to find the highest request rate a service still survives. Adaptive video players use it to pick the highest bitrate that does not stall. Query planners and compilers use it to find the smallest degree of parallelism that meets a latency budget. In every case computing the optimum directly is hard, while checking one configuration is easy — so the search runs over the space of answers.

Three questions to ask out loud in an interview

When a problem says "maximize the minimum", "minimize the maximum", or "find the extreme value that satisfies a condition", say these three things: (1) "What is the range of the answer?" — that fixes lo and hi. (2) "Given a candidate x, can I check it in O(n)?" — that is the judge function. (3) "Is the check monotonic in x?" — that is the F…F T…T line. Three yes answers mean binary search on the answer, at O(n · log(range)).

§06

More answer search: one method, three appearances

Minimize the maximum (LC 1011, LC 410) and find the largest feasible value (LC 69, LC 367).

Once LC 875 is clear, the problems below are the same method in different words. Start with the standard "minimize the maximum" problem, LC 1011: packages on a conveyor belt must be loaded in their given order, and you need the smallest ship capacity that gets them all delivered within D days. judge(cap) loads packages one by one and starts a new day whenever the next one does not fit, then compares the day count with D. The lower end of the range is the heaviest package — below that, that package can never be loaded at all — and the upper end is the sum of all weights, which ships everything in one day.

lc1011_ship_within_days.py
1class Solution:
2 def shipWithinDays(self, weights: list[int], days: int) -> int:
3 def can(cap: int) -> bool:
4 need, cur = 1, 0
5 for w in weights:
6 if cur + w > cap:
7 need, cur = need + 1, 0 # start a new day
8 cur += w
9 return need <= days
10
11 lo, hi = max(weights), sum(weights) # range: heaviest package .. total
12 ans = hi
13 while lo <= hi:
14 mid = lo + (hi - lo) // 2
15 if can(mid):
16 ans, hi = mid, mid - 1
17 else:
18 lo = mid + 1
19 return ans
Compare with LC 875: the structure is identical. Only the judge changed, from "hours needed at this speed" to "days needed at this capacity". Recognizing that sameness turns the effort spent on one problem into ten.

The last shape is the square root (LC 69): compute ⌊√x⌋, the largest integer k with k×k ≤ x. That predicate is monotonic too, but it runs the other way: it is true for small k and false for large k, so you want the last value that passes. Record the candidate, then push right with lo = mid + 1 — the mirror image of LC 875. Keep the two directions apart:

lc69_sqrt.py
1class Solution:
2 def mySqrt(self, x: int) -> int:
3 lo, hi, ans = 0, x, 0
4 while lo <= hi:
5 mid = lo + (hi - lo) // 2
6 if mid * mid <= x: # passes -> record, then try larger
7 ans, lo = mid, mid + 1
8 else:
9 hi = mid - 1 # too large -> shrink downward
10 return ans
No overflow to worry about: Python integers are arbitrary precision, so mid * mid is always exact. LC 367 is the same search with a different exit: return true on mid * mid == num, and false once the interval is empty.
TemplateIntervalLoopWhen the test passesReturnsProblems
Exact search[lo, hi]lo <= hiequal -> return midindex / −1704 · 74
Boundary (first feasible)[lo, hi]lo <= hians=mid; hi=mid−1ans34 · 35 · 875 · 1011
Boundary (last feasible)[lo, hi]lo <= hians=mid; lo=mid+1ans69 · 367
Converging[lo, hi]lo < himid+1 on one side, mid on the otherlo153 · 162 · 852

Ordinary binary search vs binary search on the answer

Ordinary binary search looks for a position in data that is already sorted; the range it searches is the set of array indices. Binary search on the answer guesses inside the range of possible answers, from the smallest to the largest one that could be correct, and the input array may not be sorted at all. The test also changes: instead of comparing with target, you call a judge function you wrote. That is what takes binary search from "look something up" to "solve an optimization problem".

§07

Problem set: 18 binary search problems

Core set

Grouped as template, boundaries, monotonic split, and answer search, from easier to harder. Think for 30 seconds before opening the hint.

§08

Chapter quiz

✎ Quiz

Answer all 9 correctly to mark this chapter as complete.

QUESTION 01 / 9

Why is mid = lo + (hi − lo) / 2 preferred over (lo + hi) / 2?

QUESTION 02 / 9

A search uses the closed interval [lo, hi] and mid = lo + (hi − lo) / 2. Which pair of loop condition and update runs forever?

QUESTION 03 / 9

A binary search returns the first index whose value is >= target (lower_bound). What does it return when target is not in the array?

QUESTION 04 / 9

In the sorted array [5,7,7,8,8,8,10], how many times does 8 occur? (Work it out as upper_bound(8) − lower_bound(8).)

QUESTION 05 / 9

A rotated sorted array such as [4,5,6,7,0,1,2] is not sorted as a whole. Why can binary search still work on it?

QUESTION 06 / 9

What is the worst-case time of LC 81 (rotated array search with duplicates), and why?

QUESTION 07 / 9

Which of these are binary search on the answer — guess a candidate answer, then verify it with a monotonic yes/no test? (Select all that apply)

QUESTION 08 / 9

In LC 875 the test is "can Koko finish within h hours at speed k?". Which statement about its monotonicity is correct?

QUESTION 09 / 9

LC 74 (rows increasing, and the first value of each row larger than the last value of the row above) can be solved with one binary search, but LC 240 (rows and columns each increasing, no relation between rows) cannot. Why?

What to take away from this chapter
  • Binary search does not require a sorted array. It requires one O(1) test that says which half to discard — a range that splits into a false part and a true part with a single flip between them. Sorted order is the most common source of that split, not the only one.
  • Name the interval first, then derive the rest. Closed interval [lo, hi] goes with lo <= hi, hi = mid - 1, and lo = mid + 1. Half-open [lo, hi) goes with lo < hi and hi = mid. Mixing the two is the classic bug.
  • The invariant is: if the answer exists, its index is inside [lo, hi]. Every claim about correctness traces back to it, and every branch must move past mid so the interval strictly shrinks and the loop ends.
  • Overflow is language-specific. In Java or C++, (lo + hi) / 2 can overflow, so write lo + (hi - lo) / 2. Python integers are arbitrary precision. JavaScript numbers are exact to 2⁵³, so plain division is fine, but (lo + hi) >> 1 truncates to 32 bits and is wrong for large ranges.
  • Boundary search = record a candidate, then keep pushing to one side. lower_bound(t) is the first index ≥ t, upper_bound(t) the first index > t; both return n when nothing qualifies, and for integers upper_bound(t) = lower_bound(t+1).
  • Rotated arrays: one of the two halves is always sorted, so ask which one and whether target is inside it. Duplicates (LC 81, LC 154) destroy that test and the worst case becomes O(n).
  • Binary search on the answer: when solving is hard, check instead. If the yes/no test on a candidate answer is monotonic, search the range of answers and call judge. LC 875, LC 1011, and LC 410 are the same method. Two mirrored directions: first feasible (LC 875, hi = mid−1) and last feasible (LC 69, lo = mid+1).
  • Cost: O(log n) time, O(1) extra space when written iteratively, O(log n) stack when written recursively. For an answer search it is O(cost of judge × log(range)).