Binary Search (Beyond the Sorted Array)
Master the binary search algorithm once and for all — the invariant that kills off-by-one bugs, and the "search on the answer" trick that works on problems that don't look like binary search at all.
The bug you've definitely written
Here's the code most people write from memory:
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo < hi: # bug 1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid # bug 2
return -1
There are two bugs. while lo < hi exits before checking the last candidate, so a single-element array returns -1 even when the element matches. And hi = mid instead of hi = mid - 1 can produce an infinite loop when lo and hi are adjacent — mid computes to lo, the target isn't found, and hi gets set back to lo forever.
These aren't obscure edge cases. They will eat your contest submission or your onsite if you're not careful.
The template you can trust
Pick one formulation and commit to it. Here's the one that works:
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1 # closed interval: both endpoints are valid candidates
while lo <= hi: # loop until the interval is empty
mid = lo + (hi - lo) // 2 # avoids integer overflow (matters in C/Java)
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1 # mid is ruled out; search [mid+1, hi]
else:
hi = mid - 1 # mid is ruled out; search [lo, mid-1]
return -1 # target not present
The invariant: at every point in the loop, if the target exists, it's somewhere in [lo, hi]. When we find arr[mid] < target, the target can't be at mid or anywhere left of it, so lo = mid + 1 preserves the invariant. Same logic for the other branch. When lo > hi, the interval is empty — the target isn't there.
The mid = lo + (hi - lo) // 2 instead of (lo + hi) // 2 matters in languages with fixed-size integers. In Python integers don't overflow so it's style, but write it the safe way anyway — the habit saves you in Java or C++.
{ "type": "array", "title": "closed-interval binary search", "data": [1, 3, 5, 7, 9, 11, 14, 18, 22] }
Step through the visualizer and track lo, mid, and hi at each iteration. Watch how the interval shrinks: 9 elements → 4 → 2 → 1. That's the log base 2 of 9 ≈ 3.2, so 4 comparisons in the worst case. For 1 million elements, you need at most 20.
Find first / find last: same loop, different boundary
Sorted arrays with duplicates: [1, 2, 4, 4, 4, 6, 7]. You want the index of the first 4 (should be 2) or the last 4 (should be 4). The naive idea — find any occurrence, then walk left or right — degrades to O(n) on all-equal arrays.
The binary search version keeps the same skeleton. When you hit a match, instead of returning, you record the candidate and keep narrowing:
def find_first(arr, target):
lo, hi = 0, len(arr) - 1
result = -1
while lo <= hi:
mid = lo + (hi - lo) // 2
if arr[mid] == target:
result = mid # record it, but keep going left
hi = mid - 1
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return result
def find_last(arr, target):
lo, hi = 0, len(arr) - 1
result = -1
while lo <= hi:
mid = lo + (hi - lo) // 2
if arr[mid] == target:
result = mid # record it, but keep going right
lo = mid + 1
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return result
Both are O(log n). The match condition becomes "continue, but remember where you are" rather than "stop." This same idea — don't stop at the first match, keep pushing the boundary in one direction — reappears in every binary search variant.
Rotated sorted array
This one shows up constantly. Take a sorted array like [1, 3, 5, 7, 9, 11, 14] and rotate it at some pivot: [7, 9, 11, 14, 1, 3, 5]. The array isn't globally sorted anymore, but it has a key property: one of the two halves (split at mid) is always sorted.
flowchart LR
A["[7, 9, 11, 14, 1, 3, 5]"] --> B{"arr[mid]=14\narr[lo]=7\narr[hi]=5"}
B -- "arr[lo] ≤ arr[mid]\n→ left half sorted" --> C["Target in [7,14]?\nSearch left\nElse search right"]
B -- "arr[mid] < arr[lo]\n→ right half sorted" --> D["Target in [mid,hi]?\nSearch right\nElse search left"]
style A fill:#7c5cff,color:#0a0a0f
style C fill:#22d3ee,color:#0a0a0f
style D fill:#00ff9d,color:#0a0a0f
The insight: if arr[lo] <= arr[mid], the left half [lo..mid] is cleanly sorted. You can check in O(1) whether the target falls in that range — if yes, search there; if no, search the right half. Otherwise, the right half [mid..hi] is sorted, and you do the same check.
def search_rotated(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2
if arr[mid] == target:
return mid
if arr[lo] <= arr[mid]: # left half is sorted
if arr[lo] <= target < arr[mid]:
hi = mid - 1 # target is in the sorted left half
else:
lo = mid + 1 # target must be in the right half
else: # right half is sorted
if arr[mid] < target <= arr[hi]:
lo = mid + 1 # target is in the sorted right half
else:
hi = mid - 1 # target must be in the left half
return -1
Still O(log n). The half-is-sorted observation is the key — without it you can't safely discard anything.
The real skill: binary search on the answer
This is where binary search goes from "I know the algorithm" to "I can use it to demolish hard problems."
The setup: you have a problem that asks for the minimum (or maximum) value X such that some condition holds. If that condition is monotonic — once it's true for some X, it's true for all larger X (or all smaller) — you can binary search over the range of possible X values.
The pattern:
def solve():
lo, hi = lower_bound, upper_bound # range of possible answers
while lo < hi: # find the first value that satisfies
mid = lo + (hi - lo) // 2
if feasible(mid): # check if mid is a valid answer
hi = mid # mid works, but maybe something smaller does too
else:
lo = mid + 1 # mid doesn't work, need something larger
return lo # lo == hi: the smallest feasible value
Notice while lo < hi here — we want lo == hi at the end (both point to the answer), not an empty interval. And hi = mid (not mid - 1) because mid itself might be the answer.
Worked example: Koko eats bananas
Koko has n piles of bananas, h hours to eat them all. She eats at speed k bananas per hour (one pile at a time, and she waits if she finishes a pile early). What's the minimum k?
The monotonic structure: if speed k works (she finishes in <= h hours), any speed > k also works. We want the smallest k that works.
def min_eating_speed(piles, h):
import math
def feasible(speed):
# how many hours does this speed take?
return sum(math.ceil(p / speed) for p in piles) <= h
lo, hi = 1, max(piles) # speed 1 to max pile size (eating a pile per hour)
while lo < hi:
mid = lo + (hi - lo) // 2
if feasible(mid):
hi = mid # mid works, look for something smaller
else:
lo = mid + 1 # mid is too slow
return lo
The feasibility check is O(n). The binary search runs O(log(max(piles))) times. Total: O(n log m) where m is the max pile size. Without binary search on the answer, brute-forcing every possible speed from 1 to m would be O(n·m) — with piles of 10^9 bananas, that's not happening.
What "monotonic" looks like in the wild
The predicate feasible(mid) doesn't have to be obvious at first glance. Ask yourself: if I increase the candidate value by 1, does it become strictly easier or strictly harder to satisfy the constraint? If yes in one direction, you have a monotonic predicate.
Some examples you'll see in interviews:
| Problem | Candidate X | Predicate |
|---|---|---|
| Ship packages in D days (LeetCode 1011) | Ship capacity | Can we ship all packages in D days at capacity X? |
| Split array into k parts with min max-sum | Subarray max sum | Can we split into k parts where each part's sum ≤ X? |
| Find peak element | — | Different structure; use lo/hi but on a derivative condition |
| Kth smallest in sorted matrix | Matrix value | How many elements ≤ X? Is it ≥ k? |
The template is the same every time. What changes is what lo, hi, and feasible mean.
Complexity
Binary search's power is exactly what it sounds like:
| Variant | Time | Space |
|---|---|---|
| Classic search (sorted array) | O(log n) | O(1) |
| Find first / find last | O(log n) | O(1) |
| Rotated sorted array | O(log n) | O(1) |
| Binary search on answer | O(log(range) × f(n)) | O(1) + whatever f(n) uses |
The O(1) space is often undersold. Most O(log n) solutions you see use recursion — which brings O(log n) stack frames. The iterative version above is flat, using just lo, hi, and mid.
Common mistakes
Forgetting that mid can equal lo. When lo = 4 and hi = 5, mid = 4 = lo. If you then set hi = mid (instead of mid - 1) in a "target is smaller" branch, nothing changes and you loop forever. The closed-interval template avoids this because you always move hi to mid - 1.
Using while lo < hi when you want to check the found element. The lo < hi loop terminates with lo == hi — you still have to check arr[lo] after the loop. The lo <= hi template checks mid inside the loop every time, so you never need a post-loop check.
Binary searching on a non-monotonic predicate. "Find the element closest to the median" is not monotonic in the way that lets you binary search directly. If your predicate can flip from false→true→false as X increases, binary search will give you garbage. Confirm monotonicity before reaching for the pattern.
Integer division in the wrong place. mid = (lo + hi) // 2 always rounds down. That's usually fine, but in the "search for last occurrence" or "find upper bound" variants, you sometimes want mid = lo + (hi - lo + 1) // 2 (round up) to avoid the same infinite-loop issue where mid == lo when the interval has two elements and you set lo = mid. If you ever write lo = mid (not lo = mid + 1), round up.
Putting it all together
Binary search is O(log n) not because it's magic, but because it has one job: shrink the search space by half at each step. Everything else — the closed interval, the mid ± 1, the monotonic predicate — is in service of guaranteeing that the halving is always valid.
When you see a problem, the tell for binary search isn't "is there a sorted array?" — it's "can I define a space of candidates and a check that tells me, in one shot, whether to go left or right?" If yes, you have an O(log n) solution hiding in what might look like a brute-force problem.
For more on how search fits into the broader picture, see the searching crash course. The sorted array structure that makes all of this work is covered in arrays. And if you want the tree-based alternative that also gives you O(log n) search (with dynamic insertions), see binary search trees.
Frequently asked questions
▸Why does binary search require a sorted array?
Binary search works by comparing the middle element to your target and then discarding half the remaining elements. That only works if you can guarantee all smaller values are to the left and all larger values are to the right — which sorted order gives you. On an unsorted array you have no way to know which half to discard, so you cannot safely eliminate anything.
▸How do you avoid off-by-one errors in binary search?
Pick an invariant and stick to it. The most reliable approach: keep the search space as a closed interval [lo, hi] where both endpoints are valid candidates. Loop while lo <= hi. Compute mid = lo + (hi - lo) // 2. After comparing, move lo to mid + 1 or hi to mid - 1 — never to mid itself, which risks an infinite loop. This template eliminates the classic boundary bugs.
▸What is "binary search on the answer"?
Instead of searching for a value in an array, you binary search over the range of possible answers to find the smallest (or largest) value that satisfies some condition. The condition must be monotonic — once it becomes true, it stays true as you increase the candidate value. Classic examples: minimum speed to eat all bananas, minimum days to ship all packages, minimum capacity for a split into k subarrays.
▸How do you find the first or last occurrence of a value with binary search?
When you find the target at mid, do not stop — keep narrowing the search toward the left (for first occurrence) or right (for last occurrence). For first: set hi = mid - 1 and record mid as a candidate. For last: set lo = mid + 1 and record mid. The loop still runs in O(log n) because you always move a boundary.
▸Can binary search work on an infinite or very large search space?
Yes. Binary search on the answer often operates over a huge numeric range — 1 to 10^9 or 1 to 10^18 — rather than over an array. You just need a monotonic feasibility function and upper/lower bounds on the possible answer. Because each iteration halves the range, even a range of 10^18 takes only about 60 iterations.
You may also like
Prefix Sums
Precompute running totals so any range sum becomes one subtraction. The prefix sum technique turns O(n) per query into O(1) — and the hashmap combo unlocks a whole class of subarray counting problems.
Monotonic Stacks
The monotonic stack is the O(n) trick for finding the next greater or smaller element — a sorted stack you maintain on the fly, with one deceptively simple pop-while loop at its core. Used in Daily Temperatures, Largest Rectangle in Histogram, and Trapping Rain Water.
Greedy Algorithms
Make the locally optimal choice and never look back — when greedy is correct it beats dynamic programming cold. The hard part isn't the algorithm, it's knowing when greed works and when it quietly hands you the wrong answer.