Binary search is not about sorted arrays. It is about any yes/no question whose answer flips exactly once along a line. Find the flip, and you have halved the world.
Everyone can write the textbook version. Interviews test the modified versions: rotated arrays, first and last occurrence, peaks, and the big one, binary search on the answer, where there is no array to search at all. All of them are the same loop with a different question in the middle.
F F F F T T T, with a single flip, binary search applies. Sortedness is the most common way to get monotonicity, but it is not the only one.| Signal | What it looks like |
|---|---|
| Sorted input | “sorted array”, “rotated sorted array”, “sorted matrix”. |
| Boundary language | “first index where…”, “last occurrence”, “insertion position”, “the smallest x such that…”. |
| A log requirement | “must run in O(log n)”. That is not a hint, it is an instruction. |
| Minimise a maximum | “minimum capacity to ship in D days”, “smallest eating speed”, “split the array to minimise the largest sum”. This is binary search on the answer. |
| Huge numeric range | An answer somewhere in 1..10⁹ with a cheap feasibility check. |
n is exhausted in about log₂n steps. Every difficulty in this pattern comes from one place: proving the discard is safe.True”. Then there is one loop to remember instead of five.Binary search is notorious for off-by-one errors. The cure is to pick one convention and never deviate. Two conventions are worth knowing.
Closed range [left, right] | Half-open range [left, right) | |
|---|---|---|
| Initial | left = 0, right = n - 1 | left = 0, right = n |
| Loop while | left <= right | left < right |
| Discard left | left = mid + 1 | left = mid + 1 |
| Discard right | right = mid - 1 | right = mid |
| Best for | Finding an exact match | Finding a boundary |
| Result on exit | left > right, nothing found | left == right, the boundary |
mid = (left + right) // 2 the midpoint rounds down, so mid can equal left but never equals right when left < right. That means right = mid is safe and left = mid is not: it can leave the range unchanged forever. If you ever need left = mid, round the midpoint up instead, with mid = (left + right + 1) // 2.Write mid = left + (right - left) // 2 rather than (left + right) // 2. In Python they are identical, since integers never overflow. In C++ or Java the second can overflow, and this is a famous bug that sat in the JDK’s own binary search for nine years. Interviewers notice the safe form.
def find(nums: list[int], target: int) -> int:
"""Index of target in a sorted list, or -1."""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
def first_true(lo: int, hi: int, predicate) -> int:
"""Smallest x in [lo, hi] with predicate(x) true, or hi + 1 if none.
Requires the predicate to be monotone: once true, true forever.
"""
left, right = lo, hi + 1 # right is one past the last candidate
while left < right:
mid = left + (right - left) // 2
if predicate(mid):
right = mid # mid might be the answer, so keep it
else:
left = mid + 1 # mid is definitely not, so discard it
return left
This one template covers lower bound, upper bound, insertion position, first occurrence, last occurrence, and every “binary search on the answer” problem. If you memorise one, memorise this one.
bisect.bisect_left(nums, x) is the first index where nums[i] >= x, and bisect_right is the first index where nums[i] > x. In production, use them. In an interview, write the loop, then mention them.This is the version that separates candidates, and it deserves its own heading because there is no array in it at all.
x such that something is achievable. Checking “is x achievable” is easy and linear. And achievability is monotone: if x works then anything larger works too. Then binary search over the range of possible answers, not over any input array, calling the feasibility check at each step.def minimum_feasible(lo: int, hi: int, is_feasible) -> int:
"""Smallest value in [lo, hi] that works, assuming feasibility is monotone."""
left, right = lo, hi
while left < right:
mid = left + (right - left) // 2
if is_feasible(mid):
right = mid
else:
left = mid + 1
return left
| Problem | Search over | Feasibility check |
|---|---|---|
| Koko Eating Bananas | speed, 1 to max(piles) | Can she finish within h hours at this speed? |
| Capacity to Ship in D Days | capacity, max(w) to sum(w) | Does a greedy pack fit in d days? |
| Split Array Largest Sum | the largest allowed sum | Can the array be cut into at most k pieces under that cap? |
| Minimum Days to Make Bouquets | day number | Are there m runs of k bloomed flowers by that day? |
Total cost is O(log(range) × cost of one check). When you see “minimise the maximum” or “maximise the minimum”, say “binary search on the answer” immediately. It is the single most reliable pattern-recognition win in the whole set.
left = mid. With a rounding-down midpoint, that can leave the range unchanged. Use mid + 1, or round the midpoint up.right = n with while left <= right reads past the end. Pick one row of the table and stay in it.mid from a boundary search. Boundary searches return left, after the loop.bisect_left gives an insertion point, which may be len(nums) or may point at a different value. Check before you dereference.(left + right) // 2 out of habit. Harmless in Python, wrong in a language with fixed-width integers. Write the safe form and say why.Given a sorted array of distinct integers and a target, return its index, or -1 if it is absent. Must be O(log n).
left > right means the range is empty and the target is not there.def binary_search(nums: list[int], target: int) -> int:
"""Index of target in a sorted list of distinct integers, or -1.
Args:
nums: Ascending, distinct integers.
target: The value to find.
Returns:
The index of target, or -1 if it is not present.
Example:
>>> binary_search([-1, 0, 3, 5, 9, 12], 9)
4
"""
left, right = 0, len(nums) - 1
# Invariant: if target is in nums, its index is inside [left, right].
while left <= right:
# Overflow-safe form. Identical in Python, required in C++ or Java.
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
left = mid + 1 # everything at or left of mid is too small
else:
right = mid - 1 # everything at or right of mid is too large
return -1
nums = [-1, 0, 3, 5, 9, 12], target = 2:
| left | right | mid | nums[mid] | action |
|---|---|---|---|---|
| 0 | 5 | 2 | 3 | too big, right = 1 |
| 0 | 1 | 0 | -1 | too small, left = 1 |
| 1 | 1 | 1 | 0 | too small, left = 2 |
| 2 | 1 | — | — | left > right, return -1 |
The range starts at size n and at least halves each iteration, so after k iterations it is at most n / 2ᵏ. The loop stops when the size drops below 1, which needs k > log₂n. For a billion elements that is 30 iterations.
right = -1, the loop never runs, returns -1.[left, right]. Every branch discards only values that cannot be the target, so the invariant survives.”A sorted array of distinct values has been rotated at some unknown pivot, for example [4,5,6,7,0,1,2]. Find the index of a target, or -1. Must be O(log n).
nums[left] with nums[mid] to find which half is the clean one, then check whether the target falls inside that half’s known range. If it does, search there. If it does not, it must be in the messy half.The reasoning is always about the sorted half, because that is the only half whose contents you can reason about from its two endpoints.
def search_rotated(nums: list[int], target: int) -> int:
"""Index of target in a rotated sorted array of distinct values, or -1.
Args:
nums: A sorted array rotated at an unknown pivot. Values are distinct.
target: The value to find.
Returns:
Its index, or -1.
Example:
>>> search_rotated([4, 5, 6, 7, 0, 1, 2], 0)
4
"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
if nums[left] <= nums[mid]:
# Left half [left, mid] is sorted, so its range is known exactly.
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
else:
# Then the right half [mid, right] must be the sorted one.
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1
return -1
nums[left] <= nums[mid] and not <mid equals left, so the two values are the same and a strict < would wrongly classify the left half as unsorted. With distinct values, <= is correct and safe. If duplicates are allowed, this test breaks down entirely: for [1, 1, 1, 0, 1] you cannot tell which half is sorted, and the standard fix is to shrink left by one when nums[left] == nums[mid] == nums[right], which makes the worst case O(n). Raise this; it is the standard follow-up.nums = [4,5,6,7,0,1,2], target = 0:
| left | right | mid | nums[mid] | sorted half | action |
|---|---|---|---|---|---|
| 0 | 6 | 3 | 7 | left, 4 to 7 | 0 not in [4, 7), go right |
| 4 | 6 | 5 | 1 | left, 0 to 1 | 0 in [0, 1), go left |
| 4 | 4 | 4 | 0 | — | match, return 4 |
Find the rotation point with a separate binary search (the smallest element), then run a normal binary search on the correct segment. Two clean passes, still O(log n), and easier to get right under pressure. The one-pass version above is what most interviewers expect, but naming the two-pass option shows you have thought about the trade-off between cleverness and clarity.
Given a sorted array that may contain duplicates, return the first and last index of a target as [first, last], or [-1, -1] if it is absent. Must be O(log n).
target is one before the first index of target + 1. So a single lower_bound helper, called twice, answers both halves. For non-integer values you would write a matching upper_bound instead.def lower_bound(nums: list[int], target: int) -> int:
"""First index where nums[index] >= target, or len(nums) if there is none.
This is the boundary template: the predicate "nums[i] >= target" is
false then true, with exactly one flip, because nums is sorted.
"""
left, right = 0, len(nums) # half-open: right is one past the end
while left < right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1 # mid fails, discard it
else:
right = mid # mid might be the answer, keep it
return left
def search_range(nums: list[int], target: int) -> list[int]:
"""First and last index of target in a sorted array, or [-1, -1].
Args:
nums: Ascending integers, duplicates allowed.
target: The value to locate.
Returns:
[first_index, last_index], or [-1, -1] if target is absent.
Example:
>>> search_range([5, 7, 7, 8, 8, 10], 8)
[3, 4]
"""
first = lower_bound(nums, target)
# lower_bound always returns a valid insertion point, which may be past
# the end or may point at a larger value. Both mean "not present".
if first == len(nums) or nums[first] != target:
return [-1, -1]
# The last target sits just before the first value greater than target.
last = lower_bound(nums, target + 1) - 1
return [first, last]
nums = [5, 7, 7, 8, 8, 10], target = 8. lower_bound(8) returns 3, the first index with a value at least 8. lower_bound(9) returns 5, the first index with a value at least 9, so the last 8 is at index 4. Answer [3, 4].
Now target = 6. lower_bound(6) returns 1, but nums[1] is 7, not 6, so the guard fires and the answer is [-1, -1]. That validation step is essential: a boundary search never tells you the value is present, only where it would go.
import bisect
def search_range_bisect(nums: list[int], target: int) -> list[int]:
"""What you would ship."""
first = bisect.bisect_left(nums, target)
if first == len(nums) or nums[first] != target:
return [-1, -1]
return [first, bisect.bisect_right(nums, target) - 1]
bisect_left is lower_bound and bisect_right is upper_bound. Knowing that these map onto the two boundary searches, and which is which, is worth stating.
first == len(nums), caught by the length check. That check must come first, or the index lookup raises.[0, n - 1].lower_bound returns 0, which equals len(nums), so [-1, -1].target + 1: not a concern in Python; in a fixed-width language write a separate upper_bound.An array is a mountain: it strictly increases to a single peak, then strictly decreases. Return the index of the peak, in O(log n).
arr[i] > arr[i + 1]?” is monotone: False all the way up the mountain, then True all the way down, flipping exactly once at the peak. That is the F F F T T T shape from Figure 11.1, so Template B applies directly. This problem is the best demonstration that binary search is about monotone predicates, not about sortedness.def peak_index_in_mountain_array(arr: list[int]) -> int:
"""Index of the single peak of a mountain array.
Args:
arr: Strictly increasing then strictly decreasing, length >= 3.
Returns:
The index i where arr[i] is the maximum.
Example:
>>> peak_index_in_mountain_array([0, 2, 5, 8, 4, 1])
3
"""
left, right = 0, len(arr) - 1
# Invariant: the peak is somewhere in [left, right].
while left < right:
mid = left + (right - left) // 2
if arr[mid] < arr[mid + 1]:
left = mid + 1 # still climbing, so mid is not the peak
else:
right = mid # descending or at the peak, so mid may be it
return left # left == right, and that is the peak
right = mid and not mid - 1When arr[mid] > arr[mid + 1], the array is already descending at mid, so mid itself might be the peak. Discarding it with mid - 1 would lose the answer. In the other branch, arr[mid] < arr[mid + 1] proves mid is not the peak, so mid + 1 is safe. Discard only what you have proved cannot be the answer. That sentence is the whole discipline of this pattern.
Note also that mid + 1 is always a valid index inside the loop: the guard is left < right, so mid < right ≤ n - 1. No bounds check is needed, and saying so shows you checked.
arr = [0, 2, 5, 8, 4, 1]:
| left | right | mid | arr[mid] vs arr[mid+1] | action |
|---|---|---|---|---|
| 0 | 5 | 2 | 5 < 8, climbing | left = 3 |
| 3 | 5 | 4 | 4 > 1, descending | right = 4 |
| 3 | 4 | 3 | 8 > 4, descending | right = 3 |
| 3 | 3 | — | — | loop ends, return 3 |
nums[-1] and nums[n] treated as negative infinity. Remarkably, the identical code works. If arr[mid] < arr[mid + 1] then the rising slope to the right must eventually turn over or hit the boundary, so a peak exists in [mid + 1, right]. The same argument runs the other way. The invariant is not “the peak” but “a peak”, and it survives. Being able to explain why is a strong finish.[0, 1, 0]: returns 1.[0, 5, 4, 3] or [1, 2, 3, 0].left < right, not <=. With <= and right = mid it never terminates.True. One template covers lower bound, upper bound, first, last, and insertion point.[left, right] or half-open [left, right), and never mix them.mid and mid ± 1 every time.left = mid an infinite loop. Use mid + 1, or round up.Pattern 12, Dynamic Programming, is the last and the largest. Where binary search discards half the possibilities, DP keeps all of them but makes sure no subproblem is ever solved twice.