Binary Search Pattern¶
Difficulty: Easy (classic) / Medium-Hard (on the answer) | Pattern Type: Search space reduction
← Two Pointers | Next: BFS & DFS →
Why This Pattern Exists¶
Problem: Find whether a target exists in a sorted array of n elements.
Brute force: Scan every element. - Time: O(n) - For n=1,000,000: up to a million comparisons
Insight: Because the array is sorted, checking the middle element tells you which half the target must be in (if present at all) — the other half can be discarded entirely without inspecting it. Each comparison halves the remaining search space.
Result: O(log n) instead of O(n). For n=1,000,000, that's ~20 comparisons instead of up to a million.
The deeper, senior-level insight: binary search doesn't require an array at all. It only requires a monotonic predicate — a yes/no question over a range of candidate answers where the answer flips exactly once (all "no" then all "yes", or vice versa). This generalization is "binary search on the answer," and it's the version staff-level interviews probe for, because it shows you can recognize the structure of binary search independent of arrays.
Mental Model¶
Classic: search in a sorted array¶
Array (sorted): [1, 3, 5, 7, 9, 11, 13, 15] target = 11
0 1 2 3 4 5 6 7
left=0, right=7
mid = (0+7)//2 = 3 -> arr[3]=7 < 11 -> search right half
left=4, right=7
mid = (4+7)//2 = 5 -> arr[5]=11 == 11 -> FOUND at index 5
Each step eliminates half the remaining candidates:
[1,3,5,7,9,11,13,15] -> [9,11,13,15] -> [11,13] -> [11] (~log2(8)=3 steps)
Binary search on the answer¶
Question: "What's the minimum capacity to ship all packages within D days?"
Candidate answers form a MONOTONIC predicate over capacity:
capacity too small -> "can we ship in D days?" = NO (takes too many days)
capacity too big -> "can we ship in D days?" = YES (always fits)
capacity: 1 2 3 4 5 6 7 8 9 10
can_ship: N N N N Y Y Y Y Y Y
^
binary search finds this boundary —
the SMALLEST capacity where the answer flips to YES
We're not searching an array of values — we're searching a RANGE of
possible answers [min_possible, max_possible], using a feasibility
check (can_ship(capacity)) as our "compare" step instead of arr[mid].
Two Templates¶
Template 1: Classic search (exact match)¶
def binary_search(arr: list[int], target: int) -> int:
"""
Find index of target in sorted arr, or -1 if absent.
Time: O(log n), Space: O(1)
"""
left, right = 0, len(arr) - 1 # inclusive bounds
while left <= right: # <= because both bounds are valid candidates
mid = left + (right - left) // 2 # avoids overflow, same as (l+r)//2 in Python
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1 # target is to the right, exclude mid
else:
right = mid - 1 # target is to the left, exclude mid
return -1 # not found
Template 2: Binary search on the answer (find boundary)¶
def binary_search_on_answer(lo: int, hi: int, feasible) -> int:
"""
Find the smallest value in [lo, hi] for which feasible(value) is True,
given feasible() is monotonic: False False False ... True True True.
Time: O(log(hi - lo)) * cost of feasible()
"""
while lo < hi: # < because we converge to a single point
mid = lo + (hi - lo) // 2
if feasible(mid):
hi = mid # mid works; try to find something smaller (or equal)
else:
lo = mid + 1 # mid doesn't work; answer must be larger
return lo # lo == hi, the boundary
Off-by-One Pitfalls¶
This is where binary search bugs live. Know these cold:
| Pitfall | Symptom | Fix |
|---|---|---|
while left <= right vs while left < right | Infinite loop or missed last candidate | Use <= when both bounds are valid answers you must still check (exact match search). Use < when converging to a single boundary point (search on answer). |
mid = mid + 1 vs mid = mid on the "feasible" branch | Skips over the actual answer / infinite loop | If mid itself could be the answer, keep it in range: hi = mid (not mid - 1). Only exclude mid (lo = mid + 1 or hi = mid - 1) when you've proven mid is NOT the answer. |
mid = (left + right) // 2 overflow | Integer overflow in languages like Java/C++ (not Python) | Use mid = left + (right - left) // 2. Harmless in Python but good habit / expected in interviews. |
Forgetting the loop can end with left == right + 1 (classic) vs left == right (on-answer) | Off-by-one on final return value | Know which template you're in — classic returns -1 on failure; on-answer returns lo directly since it always converges to a valid value in range. |
| Mixing templates mid-solution | Half-correct code that fails on 2-element arrays | Pick one template for the whole solution and stick to its invariants. |
Debugging trick: trace a 2-element array/range by hand ([a, b]) before trusting any binary search code — most off-by-one bugs show up immediately at that size.
Common Problems¶
1. Classic Binary Search¶
Already shown above as Template 1. O(log n) time, O(1) space.
2. Search in Rotated Sorted Array¶
def search_rotated(nums: list[int], target: int) -> int:
"""
Array was sorted, then rotated at an unknown pivot, e.g. [4,5,6,7,0,1,2].
Insight: at any mid, AT LEAST ONE half is still normally sorted.
Determine which half is sorted, then check if target lies in that
sorted half's range to decide which side to search.
Time: O(log n), Space: O(1)
"""
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
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
else: # right half [mid..right] is sorted
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1
return -1
3. Koko Eating Bananas (Binary Search on the Answer)¶
import math
def min_eating_speed(piles: list[int], h: int) -> int:
"""
Koko eats at speed k bananas/hour; find minimum k so she finishes
all piles within h hours.
Predicate: feasible(k) = "can Koko finish within h hours at speed k?"
This is monotonic: if speed k works, any speed > k also works.
Search space: k in [1, max(piles)].
Time: O(n log(max(piles))), Space: O(1)
"""
def hours_needed(k: int) -> int:
return sum(math.ceil(pile / k) for pile in piles)
lo, hi = 1, max(piles)
while lo < hi:
mid = lo + (hi - lo) // 2
if hours_needed(mid) <= h:
hi = mid # mid works; try smaller speed
else:
lo = mid + 1 # mid too slow; need faster speed
return lo
4. Capacity to Ship Packages Within D Days (Binary Search on the Answer)¶
def ship_within_days(weights: list[int], days: int) -> int:
"""
Find minimum ship capacity so all packages ship (in original order,
without splitting) within `days` days.
Predicate: feasible(capacity) = "can we ship within `days` days?"
Search space: [max(weights), sum(weights)] — capacity can't be less
than the heaviest single package, and never needs to exceed the total.
Time: O(n log(sum(weights))), Space: O(1)
"""
def days_needed(capacity: int) -> int:
days_used, current_load = 1, 0
for w in weights:
if current_load + w > capacity:
days_used += 1
current_load = 0
current_load += w
return days_used
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = lo + (hi - lo) // 2
if days_needed(mid) <= days:
hi = mid
else:
lo = mid + 1
return lo
Pattern Recognition¶
| Clue in problem | Variant |
|---|---|
| "sorted array", "find target/index" | Classic binary search |
| "find first/last occurrence", "find insertion point" | Classic binary search with boundary tweak |
| "rotated sorted array" | Classic binary search with sorted-half detection |
| "minimize the maximum", "maximize the minimum" | Binary search on the answer |
| "smallest X such that condition holds", "find threshold" | Binary search on the answer |
Feasibility check is easy (O(n) or better) but direct answer is hard to compute | Binary search on the answer |
| "n up to 10^9" but you only need to evaluate a monotonic check ~30 times | Binary search on the answer |
Complexity Analysis¶
| Problem | Time | Space | Key Insight |
|---|---|---|---|
| Classic search | O(log n) | O(1) | Halve search space each step |
| Search in rotated array | O(log n) | O(1) | One half is always normally sorted |
| Koko Eating Bananas | O(n log(max(piles))) | O(1) | Binary search on speed, O(n) feasibility check per step |
| Ship Within Days | O(n log(sum(weights))) | O(1) | Binary search on capacity, O(n) feasibility check per step |
Edge Cases¶
# Always check:
# 1. Empty array (len == 0) -> return -1 / not found immediately
# 2. Single element array
# 3. Target smaller than all elements / larger than all elements
# 4. Duplicate elements (which occurrence should you return?)
# 5. For "on the answer": verify lo/hi bounds are both achievable
# (e.g. hi must itself satisfy feasible(hi), or the loop can't converge)
def robust_binary_search(arr, target):
if not arr:
return -1
# ... rest of solution
Interview Follow-ups¶
- "How would you find the first and last position of a target (duplicates allowed)?" — Run two modified binary searches: one biased to keep searching left after a match (find leftmost), one biased right (find rightmost).
- "The feasibility check itself is O(n) — is the overall complexity still good?" — Yes if the answer range is bounded reasonably: O(n log(range)) is usually far better than a brute-force O(n * range) linear scan over every candidate answer.
- "What if the array has duplicates and is rotated — does search-in-rotated still work?" — No, duplicates can make it impossible to tell which half is sorted (e.g.
[1,1,1,0,1]); worst case degrades to O(n) by linearly stepping whennums[left] == nums[mid] == nums[right]. - "Can binary search be applied to a non-array domain, like real numbers?" — Yes — e.g. finding the square root of x via binary search over a floating-point range, stopping when
hi - lo < epsiloninstead oflo < hi.
Key Takeaways¶
Remember
- Binary search needs only monotonicity, not an array — a sorted array is one instance of a monotonic predicate (
arr[i] >= targetflips exactly once). - Classic search:
while left <= right, excludemidon both branches once you've proven it's not the answer. - Search on the answer:
while lo < hi, keepmidin range (hi = mid) when it's feasible, since it might be the best answer. - Signal for "search on the answer": the problem asks to minimize a maximum or maximize a minimum, and you can write an
O(n)-ishfeasible(x)check that's monotonic. - Always trace a 2-element case by hand before trusting the loop bounds — that's where off-by-one bugs hide.