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."""
# Closed range [left, right]. 0 is the first index. len - 1 is the last.
left, right = 0, len(nums) - 1
# Invariant: if target is in nums, its index is inside [left, right].
# <= because a range with left == right still holds one candidate.
while left <= right:
# // 2: integer midpoint, rounds down. This form cannot overflow.
mid = left + (right - left) // 2
if nums[mid] == target:
return mid # exact hit, done
if nums[mid] < target:
left = mid + 1 # + 1: mid is too small, so drop it too
else:
right = mid - 1 # - 1: mid is too large, so drop it too
return -1 # -1: the "not found" signal, never a real index
mid throws away the half that cannot hold the target, plus mid itself.Reading the figure. Blue cells are still in the range. Amber is mid. Red cells are discarded. Green is the answer. The purple letters mark left, mid, and right. Notice that mid is dropped with + 1 or - 1, since it was already checked.
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.
"""
# lo, hi: the smallest and largest candidate x.
# Lower bound example: lo = 0, hi = len(nums) - 1.
# predicate(x): a yes/no test that is False, False, ..., then True forever.
# Lower bound example: predicate(i) is nums[i] >= target.
# Peak example: predicate(i) is arr[i] > arr[i + 1].
# + 1: right is one past the last candidate, so "none true" returns hi + 1.
left, right = lo, hi + 1
# Invariant: every x below left is False. Every x at or above right is True
# (or past the end). The answer is always inside [left, right].
# < not <=: stop when left == right, the range is down to one spot.
while left < right:
mid = left + (right - left) // 2 # // 2 rounds down, so mid < right
if predicate(mid):
right = mid # mid might be the answer, so keep it
else:
left = mid + 1 # + 1: mid is definitely not, so discard it
return left # left == right: the first True, or hi + 1
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.
left and right together until they meet on the first True.Reading the figure. Each cell shows predicate(i), which is nums[i] >= 4. The extra cell at index 5 is the “none true” slot, hi + 1. Blue is still in range, amber is mid, red is discarded, green is the answer. A T at mid keeps mid with right = mid. An F drops it with left = mid + 1.
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."""
# lo, hi: the range of possible answers, not array indexes.
# Koko example: lo = 1 (slowest speed), hi = max(piles) (one pile per hour).
# is_feasible(x): True if answer x works. Once True, larger x stays True.
# Koko example: can she eat every pile within h hours at speed x?
# No + 1 on hi: hi is assumed to work, so the answer always exists.
left, right = lo, hi
# Invariant: the smallest feasible value is inside [left, right].
while left < right:
mid = left + (right - left) // 2 # // 2 rounds down, so mid < right
if is_feasible(mid):
right = mid # mid works, but something smaller might too
else:
left = mid + 1 # + 1: mid fails, and so does everything below
return left # left == right: the smallest value that works
| 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? |
nums[mid].Reading the figure. The top row lists every possible speed and the hours it would take for piles = [3, 6, 7, 11]. A speed is feasible (T) when the hours are at most 8. Amber is the speed tested, red is ruled out, green is the answer. The search calls the check only four times, not eleven.
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
"""
# Closed range. 0 is the first index. len(nums) - 1 is the last index.
left, right = 0, len(nums) - 1
# Invariant: if target is in nums, its index is inside [left, right].
# <= because left == right still leaves one candidate to check.
while left <= right:
# Overflow-safe form. Identical in Python, required in C++ or Java.
# // 2: integer midpoint, rounds down.
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
left = mid + 1 # + 1: mid and everything left of it is too small
else:
right = mid - 1 # - 1: mid and everything right of it is too large
return -1 # range is empty: -1 means "not present"
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 |
Reading the figure. Blue cells are still in the range. Amber is mid. Red cells are discarded. Green is the answer. There is no green cell here. After the third step left passes right, the range is empty, and the loop exits with -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
"""
# Closed range. 0 is the first index. len(nums) - 1 is the last index.
left, right = 0, len(nums) - 1
# Invariant: if target is in nums, its index is inside [left, right].
while left <= right:
mid = left + (right - left) // 2 # // 2: integer midpoint, rounds down
if nums[mid] == target:
return mid
# <= not <: when two elements remain, mid == left and they are equal.
if nums[left] <= nums[mid]:
# Left half [left, mid] is sorted, so its range is known exactly.
# mid is excluded with < because we already know it is not target.
if nums[left] <= target < nums[mid]:
right = mid - 1 # - 1: target is in the left half, drop mid
else:
left = mid + 1 # + 1: target is not there, drop mid too
else:
# Then the right half [mid, right] must be the sorted one.
if nums[mid] < target <= nums[right]:
left = mid + 1 # + 1: target is in the right half, drop mid
else:
right = mid - 1 # - 1: target is not there, drop mid too
return -1 # range is empty: -1 means "not present"
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 |
Reading the figure. Blue cells are in range, amber is mid, red is discarded, green is the answer. The purple bracket marks the half that is sorted. In step 1, 0 is outside 4 to 7, so the whole left half goes. In step 2, 0 is inside 0 to 1, so the search keeps the left part.
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.
"""
# Half-open [left, right). 0 is the first index. right = len(nums) is one
# past the end, so "every value is smaller" can return len(nums).
left, right = 0, len(nums)
# Invariant: nums[:left] are all < target. nums[right:] are all >= target.
while left < right:
mid = left + (right - left) // 2 # // 2 rounds down, so mid < right
if nums[mid] < target:
left = mid + 1 # + 1: mid fails, discard it
else:
right = mid # mid might be the answer, keep it
return left # left == right: the first index >= target
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".
# The length check comes first so nums[first] never reads past the end.
if first == len(nums) or nums[first] != target:
return [-1, -1] # -1, -1: the "absent" answer the problem asks for
# The last target sits just before the first value greater than target.
# + 1: integers, so target + 1 is the next value up.
# - 1: step back from that first larger value onto the last 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.
Reading the figure. Each row is one call to lower_bound. A cell shows T when nums[i] reaches the target, and green marks the first T. The run of 8s (amber) starts at the first boundary and ends one cell before the second.
import bisect
def search_range_bisect(nums: list[int], target: int) -> list[int]:
"""What you would ship."""
# bisect_left: first index with nums[i] >= target. Same as lower_bound.
first = bisect.bisect_left(nums, target)
# Past the end, or a different value there: target is absent.
if first == len(nums) or nums[first] != target:
return [-1, -1] # -1, -1: the "absent" answer
# bisect_right: first index with nums[i] > target.
# - 1: step back onto the last copy of target.
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
"""
# 0 is the first index. len(arr) - 1 is the last. The peak is in between.
left, right = 0, len(arr) - 1
# Invariant: the peak is somewhere in [left, right].
# < not <=: stop when one index is left, that index is the peak.
while left < right:
mid = left + (right - left) // 2 # // 2 rounds down, so mid < right
# mid + 1 is safe: mid < right, so mid + 1 is at most right.
# + 1 compares mid with its right neighbour to see the slope.
if arr[mid] < arr[mid + 1]:
left = mid + 1 # + 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 |
arr[mid] with its right neighbour tells you which slope you are on. That is enough to halve the range.Reading the figure. The bars show the mountain. The F/T row is arr[i] > arr[i + 1], false while climbing and true going down. Blue is in range, amber is mid, red is discarded, green is the peak. On a climb the code drops mid. On a descent it keeps mid, since it might be the peak.
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.