Binary Search Template and Variants
Binary search is a five-line algorithm that developers still get wrong under interview pressure — off-by-one errors and infinite loops are the two most common bugs in all of interview coding. Learn one correct template, understand why each line is there, and you’ll never second-guess it again.
What you’ll learn
Section titled “What you’ll learn”- The canonical, bug-free binary search template —
lo <= hivslo < hi, and when to use each. - The overflow-free
midhabit:mid = lo + (hi - lo) // 2. lower_bound/upper_bound, hand-rolled and via Python’sbisectmodule.- Binary search on the answer — searching a range of possible answers instead of an array.
- Why binary search is , and the classic ways it breaks.
The cue
Section titled “The cue”When it is the wrong tool. An unsorted array with no monotonic predicate: a hash set is for
membership and a linear scan is for anything else — sorting first to enable binary search
costs and is usually a loss for a single query. For many queries on static data,
sort once and binary search repeatedly. And in Python, reach for bisect before hand-rolling: it is
C-speed and correct, and the two functions are exactly lower_bound and upper_bound.
The canonical template: does the target exist?
Section titled “The canonical template: does the target exist?”The most common shape: search a sorted array for an exact value, return its
index or -1. Search space is inclusive on both ends, so the loop keeps
going while lo <= hi — the space is empty exactly when lo crosses
past hi.
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2 # overflow-free habit -- see note below
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1 # target is to the right -- discard mid and everything left
else:
hi = mid - 1 # target is to the left -- discard mid and everything right
return -1
arr = [1, 3, 4, 7, 9, 11, 13, 18, 21, 25]
print(binary_search(arr, 13)) # expect 6
print(binary_search(arr, 6)) # expect -1 -- not presentWatch lo, mid, hi narrow the search space
Section titled “Watch lo, mid, hi narrow the search space”lo <= hi vs lo < hi: two templates, two purposes
Section titled “lo <= hi vs lo < hi: two templates, two purposes”There isn’t one “correct” binary search loop condition — there are two common templates, each suited to a different question:
| Template | Bounds | Question it answers |
|---|---|---|
while lo <= hi | lo, hi = 0, len(arr) - 1 (inclusive both ends) | “Does the target exist? Give me its index.” |
while lo < hi | lo, hi = 0, len(arr) (hi is exclusive) | “Give me the boundary — first index satisfying some condition.” |
The lo < hi template converges until lo == hi, landing exactly on the
boundary you’re looking for — which is exactly what lower_bound and
upper_bound need.
lower_bound / upper_bound, hand-rolled
Section titled “lower_bound / upper_bound, hand-rolled”lower_bound(arr, target) finds the first index where arr[i] >= target.
upper_bound(arr, target) finds the first index where arr[i] > target.
Together they bracket every occurrence of target in a sorted array.
def lower_bound(arr, target):
lo, hi = 0, len(arr) # hi is EXCLUSIVE here -- "one past the end"
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
arr = [1, 3, 3, 3, 5, 7, 9]
print("lower_bound(3):", lower_bound(arr, 3)) # first 3 -> index 1
print("upper_bound(3):", upper_bound(arr, 3)) # first index after all 3s -> index 4
print("count of 3s: ", upper_bound(arr, 3) - lower_bound(arr, 3))lower_bound / upper_bound via bisect
Section titled “lower_bound / upper_bound via bisect”The standard library already implements exactly this, in C, via the
bisect module — no need to hand-roll it in production code.
import bisect
arr = [1, 3, 3, 3, 5, 7, 9]
print("bisect_left(3): ", bisect.bisect_left(arr, 3)) # same as hand-rolled lower_bound
print("bisect_right(3):", bisect.bisect_right(arr, 3)) # same as hand-rolled upper_bound
# insort keeps a list sorted after inserting -- finds the spot in O(log n),
# but the actual insertion still shifts elements, so it's O(n) overall
bisect.insort(arr, 4)
print("after insort(4):", arr)Binary search on the answer
Section titled “Binary search on the answer”The pattern that turns binary search from “a way to search arrays” into “a way to solve optimization problems”: instead of searching an array, search the range of possible answers, using a feasibility check to decide which half to keep.
Koko Eating Bananas: Koko has piles of bananas and h hours. Each
hour she picks one pile and eats up to speed bananas from it (finishing a
pile early doesn’t help — the rest of that hour is wasted). Find the
minimum integer speed that lets her finish every pile within h
hours.
The key insight: as speed increases, the hours needed only ever
decreases (or stays the same) — that monotonic relationship is exactly
what binary search needs.
import math
def min_eating_speed(piles, h):
def hours_needed(speed):
return sum(math.ceil(pile / speed) for pile in piles)
lo, hi = 1, max(piles) # answer is somewhere in [1, max(piles)]
while lo < hi:
mid = lo + (hi - lo) // 2
if hours_needed(mid) <= h:
hi = mid # speed=mid WORKS -- try to go slower (smaller)
else:
lo = mid + 1 # speed=mid too slow -- need to go faster (bigger)
return lo
print(min_eating_speed([3, 6, 7, 11], 8)) # expect 4
print(min_eating_speed([30, 11, 23, 4, 20], 5)) # expect 30Every “minimize the maximum” or “maximize the minimum” phrasing —
ship capacity over n days, minimum time to complete tasks, splitting an
array to minimize the largest subarray sum — reduces to this same shape:
binary search over the answer, with an O(f(n)) feasibility check inside.
graph TD
A["Candidate speeds: 1 .. 11 (max pile)"] --> B["mid = 6 -> hours_needed(6) = 5 <= 8 -> feasible, try smaller"]
B --> C["mid = 3 -> hours_needed(3) = 10 > 8 -> too slow, need bigger"]
C --> D["mid = 4 -> hours_needed(4) = 8 <= 8 -> feasible, try smaller"]
D --> E["lo == hi == 4 -> answer is 4"]
Dry run
Section titled “Dry run”The three templates on [1, 2, 2, 2, 5, 8]
Section titled “The three templates on [1, 2, 2, 2, 5, 8]”The array deliberately has a run of duplicates, because that is where the templates diverge.
| Target | bs_exact | lower_bound | upper_bound | Count = upper - lower |
|---|---|---|---|---|
| 2 | 2 | 1 | 4 | 3 |
| 3 | -1 | 4 | 4 | 0 |
| 0 | -1 | 0 | 0 | 0 |
| 9 | -1 | 6 | 6 | 0 |
Verified against bisect.bisect_left and bisect.bisect_right — identical at every row.
Four things this settles:
bs_exactreturns an index, not the first. For target 2 it returns 2, which is neither the first (1) nor the last (3) occurrence — just wherever the halving happened to land. If a problem asks for the first or last occurrence, the exact-search template is the wrong one and no amount of tweaking the comparisons fixes it.upper_bound - lower_boundis the count, here 3 for the three 2s. That is the standard way to count occurrences in , and it is why both variants exist.- A missing value still returns a meaningful position. Target 3 gives 4 from both bounds — the
index where 3 would be inserted. So
lower_bounddoubles as “insertion point”, which is LC 35 in its entirety. - Out-of-range targets return the ends, not
-1. Target 0 gives 0; target 9 gives 6, which islen(a)— one past the end, and a legal insertion point rather than an error. Notehistarts atlen(a), notlen(a) - 1, in the bound templates precisely so that 6 is reachable.
The two loop shapes, side by side
Section titled “The two loop shapes, side by side”| Exact search | Boundary search | |
|---|---|---|
| Init | lo, hi = 0, len(a) - 1 | lo, hi = 0, len(a) |
| Loop | while lo <= hi | while lo < hi |
| Move | lo = mid + 1 / hi = mid - 1 | lo = mid + 1 / hi = mid |
| Return | inside the loop, else -1 | lo, after the loop |
hi = mid versus hi = mid - 1 is the whole distinction. In an exact search you have already
proved a[mid] != target before moving, so mid can be discarded. In a boundary search mid is a
live candidate for the answer until something better is found, so it must stay in the window.
The two mismatches are the source of nearly every binary-search bug:
while lo < hiwithhi = mid - 1skips the answer — it discards a candidate it never disproved.while lo <= hiwithhi = midhangs forever — whenlo == hi,mid == lo, andhi = midchanges nothing.
Termination for the boundary shape: lo < hi guarantees mid < hi under floor division, so
hi = mid strictly shrinks the window and lo = mid + 1 strictly grows lo. Every iteration makes
progress, so it cannot loop.
Time and space complexity
Section titled “Time and space complexity”| Operation | Complexity |
|---|---|
| Binary search on a sorted array | |
lower_bound / upper_bound (hand-rolled or bisect) | |
| Binary search on the answer (feasibility check costs ) | |
| Space |
The variant map
Section titled “The variant map”| Variant | The template | Canonical problem |
|---|---|---|
| Does the target exist? | Exact search, lo <= hi, return inside | 704 |
| Insertion point | lower_bound — bisect_left | 35 Search Insert Position |
| First and last occurrence | lower_bound and upper_bound - 1 | 34 Find First and Last Position |
| Count occurrences | upper_bound - lower_bound | 34 |
| First index satisfying a predicate | Boundary search on the predicate, not the value | 278 First Bad Version |
| Rotated sorted array | Identify which half is sorted, then test the target against it | 33 · 81 · details |
| Peak in an unsorted array | Boundary search on the local slope — no sortedness needed | 162 · 852 |
| Search a 2D matrix | Binary search the flattened index with divmod | 74 |
| Minimise the maximum / maximise the minimum | Binary search the answer, with a feasibility predicate | 1011 · 410 · 875 |
kth smallest in a sorted matrix | Binary search the value, counting entries | 378 |
| Real-valued answer | Fixed ~100 iterations, or until hi - lo < eps | 644 |
| Median of two sorted arrays | Binary search the split point of the shorter array | 4 |
LeetCode problem set
Section titled “LeetCode problem set”Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.
Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
- 35Search Insert PositioneasyExactly `lower_bound`: the first index where the target could be inserted to keep the array sorted
- 704Binary SearcheasyThe canonical exact-match template, direct application
- 33Search in Rotated Sorted ArraymediumThe exact-match template, with one extra check per step to figure out which half is still sorted
- 875Koko Eating BananasmediumBinary search on the answer, as above
- 34Find First and Last Position of Element in Sorted Arraymedium`lower_bound` and `upper_bound` back to back
- 981Time Based Key-Value Storemedium
Practice — real LeetCode problems
Section titled “Practice — real LeetCode problems”Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.
LC 704 — Binary Search · Easy
Section titled “LC 704 — Binary Search · Easy”Problem. Given a sorted array of distinct integers and a target, return its
index, or -1 if absent. Must run in .
Constraints. 1 <= len(nums) <= 10^4, sorted ascending, all values distinct.
Examples. [-1,0,3,5,9,12], target = 9 gives 4 · target = 2 gives -1
Editorial
The canonical exact search. Each comparison halves the range.
Time . Space .
Getting the loop shape right is the whole content, and it is worth naming the two forms explicitly because mixing them is the most common binary-search bug:
while lo <= hiwithlo = mid + 1/hi = mid - 1— for finding an exact value.midis fully excluded once tested, and the loop must consider the caselo == hi, hence<=.while lo < hiwithhi = mid— for finding a boundary, wheremidmay itself be the answer and must stay in range.
([5], 5) and ([5], -5) are the single-element tests: with lo < hi the loop body
would never run and the first would wrongly return -1.
(lo + hi) // 2 cannot overflow in Python. In C++ or Java you would write
lo + (hi - lo) // 2, which is worth mentioning since it is the reason that idiom
exists.
Follow-ups: “Where would it be inserted if absent (LC 35)?” — next problem, and it
needs the boundary shape. “First and last occurrence with duplicates (LC 34)?” — two
boundary searches. “Rotated array (LC 33)?” — see
Binary Search on Rotated Arrays.
“Use bisect?” — bisect_left does this; mention it, then write the loop.
LC 35 — Search Insert Position · Easy
Section titled “LC 35 — Search Insert Position · Easy”Problem. Given a sorted array of distinct integers and a target, return its
index if found, otherwise the index where it would be inserted to keep the array
sorted. Must be .
Constraints. 1 <= len(nums) <= 10^4, sorted ascending, distinct.
Examples. [1,3,5,6], target = 5 gives 2 · target = 2 gives 1 ·
target = 7 gives 4 · target = 0 gives 0
Editorial
This is lower bound: the first index whose value is >= target. That single
definition answers both cases — if the target is present, its own index is the first
such position; if absent, that position is exactly where it belongs.
Time . Space .
Two changes from LC 704, both necessary:
hi = len(nums), notlen(nums) - 1.target = 7must return4, which is one past the last index. Startinghiat3makes that answer unreachable.hi = mid, nevermid - 1.midis a live candidate for the boundary.
The loop terminates with lo == hi, and that common value is the answer — which is
why nothing is returned from inside the loop.
This is precisely bisect.bisect_left(nums, target). Knowing that the standard
library already has both bisect_left (lower bound) and bisect_right (upper bound)
is worth stating — and knowing which is which is what LC 34 tests.
Follow-ups: “First and last occurrence (LC 34)?” — next problem: bisect_left
and bisect_right. “With duplicates, which index does this give?” — the leftmost.
“How do you remember the two shapes?” — exact search excludes mid and needs <=;
boundary search keeps mid and needs <.
LC 34 — Find First and Last Position of Element in Sorted Array · Medium
Section titled “LC 34 — Find First and Last Position of Element in Sorted Array · Medium”Problem. Given a sorted array, return the starting and ending index of a given
target, or [-1, -1] if it is absent. Must be .
Constraints. 0 <= len(nums) <= 10^5, sorted ascending, values may repeat.
Examples. [5,7,7,8,8,10], target = 8 gives [3,4] · target = 6 gives
[-1,-1] · [], target = 0 gives [-1,-1]
Editorial
Two boundary searches, differing by one comparison:
- Lower bound — first index with
nums[i] >= target. - Upper bound — first index with
nums[i] > target. The last occurrence is one before it.
Time — two logarithmic passes. Space .
The presence check is essential and easy to omit. The lower bound returns an
insertion point whether or not the target exists, so you must verify both that it is
in range (first == len(nums) guards the empty array and past-the-end cases) and
that the value there is the target. ([], 0) and ([5,7,7,8,8,10], 6) both
exercise it.
Once you recognise these as bisect_left and bisect_right, the whole problem is
two library calls:
from bisect import bisect_left, bisect_right
lo = bisect_left(nums, target)
if lo == len(nums) or nums[lo] != target:
return [-1, -1]
return [lo, bisect_right(nums, target) - 1]Worth showing — it demonstrates you know the standard library — but interviewers usually want the hand-written version, since the point is the boundary logic.
([2,2], 2) giving [0,1] confirms the range spans all duplicates.
Follow-ups: “Count occurrences?” — upper - lower, no extra work. “Only the
first occurrence?” — one search. “Why two searches and not one plus a scan?” — a
linear scan over duplicates would be , which breaks the requirement.
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “Find the first occurrence, not any occurrence” | Whether you know the templates differ | The exact-search template cannot do it — on [1,2,2,2,5,8] it returns index 2 for target 2, neither first nor last. Switch to lower_bound: while lo < hi with hi = mid |
“Count how many times x appears, in ” | Composing the two bounds | upper_bound(x) - lower_bound(x). Verified: 3 for the three 2s |
“Why hi = mid and not hi = mid - 1?” | The core distinction | In an exact search you have proved a[mid] != target, so mid is discardable. In a boundary search mid is still a candidate, so discarding it skips the answer. Pairing lo <= hi with hi = mid instead hangs forever |
| “Prove your loop terminates” | Rigour | For lo < hi with floor division, mid < hi always — so hi = mid strictly shrinks the window and lo = mid + 1 strictly advances lo. Every iteration makes progress |
“(lo + hi) // 2 can overflow” | Language awareness | Not in Python — integers are arbitrary precision. In C++ or Java use lo + (hi - lo) // 2. Knowing why the idiom exists beats copying it |
| “The array is not sorted. Can you still binary search?” | The real precondition | Sometimes — what is required is a monotonic predicate, not sortedness. Peak finding works on arbitrary input because “the surviving range contains a peak” is preserved at every step |
| “What if the target is out of range?” | Edge cases | lower_bound returns 0 or len(a), both legal insertion points — which is why hi starts at len(a), not len(a) - 1, in that template. Verified: 0 for target 0, and 6 for target 9 |
“Would you use bisect?” | Practical judgement | Yes, in real code — bisect_left is lower_bound and bisect_right is upper_bound, both in C, and since 3.10 both take key=. Then offer the hand-rolled version if the mechanics are the point |
| “Now the answer is a value, not an index” | Recognising the bigger pattern | Binary search on the answer: pick bounds that provably bracket it, write a monotonic feasible(mid), narrow with the boundary template. over the value range, not |
| “How many iterations for ?” | Sanity with numbers | About 30, since . Useful for arguing that a solution fits when a linear scan over the value range would not |
Self-check
Section titled “Self-check”-
On [1,2,2,2,5,8], the exact-search template returns 2 for target 2. What is wrong with using it to find the first occurrence?
The exact search stops at the first match it happens to hit, which depends entirely on the midpoint arithmetic. No adjustment of the comparisons fixes it -- you need the boundary template, which keeps narrowing after a match instead of returning. That is the whole reason lower_bound and upper_bound exist alongside the exact search.
pch.quizShowAnswer
B — Index 2 is neither the first (1) nor the last (3) -- it is just where the halving landed. Finding the first requires the lower_bound template — The exact search stops at the first match it happens to hit, which depends entirely on the midpoint arithmetic. No adjustment of the comparisons fixes it -- you need the boundary template, which keeps narrowing after a match instead of returning. That is the whole reason lower_bound and upper_bound exist alongside the exact search.
-
How do you count occurrences of x in a sorted array in O(log n)?
Verified: for target 2 in [1,2,2,2,5,8] that is 4 - 1 = 3. Scanning outward from a hit is O(k) in the number of duplicates, so an array of n identical values makes it O(n) -- correct but not logarithmic, and that is exactly the case an interviewer will reach for.
pch.quizShowAnswer
B — upper_bound(x) - lower_bound(x) — Verified: for target 2 in [1,2,2,2,5,8] that is 4 - 1 = 3. Scanning outward from a hit is O(k) in the number of duplicates, so an array of n identical values makes it O(n) -- correct but not logarithmic, and that is exactly the case an interviewer will reach for.
-
Why does the boundary template use `hi = mid` while the exact search uses `hi = mid - 1`?
The two mismatches are the source of nearly every binary-search bug. `while lo < hi` with `hi = mid - 1` discards a candidate it never disproved, so it silently skips the answer. `while lo <= hi` with `hi = mid` never shrinks the window when lo == hi, so it hangs forever. Keep the pairs together.
pch.quizShowAnswer
B — In an exact search you have proved a[mid] != target so mid is discardable; in a boundary search mid is still a candidate for the answer — The two mismatches are the source of nearly every binary-search bug. `while lo < hi` with `hi = mid - 1` discards a candidate it never disproved, so it silently skips the answer. `while lo <= hi` with `hi = mid` never shrinks the window when lo == hi, so it hangs forever. Keep the pairs together.
-
In the lower_bound template, why does `hi` start at `len(a)` rather than `len(a) - 1`?
Verified: target 9 on a 6-element array returns 6, and target 0 returns 0. Both are legal insertion points, and 6 is unreachable if hi starts at 5. This is why lower_bound doubles as "where would x go?" -- which is LC 35 in its entirety -- and why it returns a position rather than -1 for a missing value.
pch.quizShowAnswer
B — So that len(a) is a reachable answer -- a target larger than everything has its insertion point one past the end — Verified: target 9 on a 6-element array returns 6, and target 0 returns 0. Both are legal insertion points, and 6 is unreachable if hi starts at 5. This is why lower_bound doubles as "where would x go?" -- which is LC 35 in its entirety -- and why it returns a position rather than -1 for a missing value.
-
Can binary search work on an array that is not sorted?
The requirement is the ability to discard half *with certainty*. Sortedness is one way to earn that, not the definition. Peak finding (LC 162) is the cleanest counterexample -- nothing about the input is ordered, yet each comparison of a[mid] against a[mid+1] rules out one side for good.
pch.quizShowAnswer
B — Yes, when a monotonic predicate exists: peak finding works on arbitrary input because "the surviving range contains a peak" is preserved every step — The requirement is the ability to discard half *with certainty*. Sortedness is one way to earn that, not the definition. Peak finding (LC 162) is the cleanest counterexample -- nothing about the input is ordered, yet each comparison of a[mid] against a[mid+1] rules out one side for good.
-
`(lo + hi) // 2` is often written `lo + (hi - lo) // 2`. Does it matter in Python?
The two expressions are mathematically identical and Python cannot overflow, so either is fine here. Being able to say *why* the defensive form exists -- rather than copying it as a ritual -- is what the question is testing, and it matters the moment the interviewer's language is not Python.
pch.quizShowAnswer
B — No -- Python integers are arbitrary precision. The idiom exists for C++/Java, where lo + hi can overflow a 32-bit int — The two expressions are mathematically identical and Python cannot overflow, so either is fine here. Being able to say *why* the defensive form exists -- rather than copying it as a ritual -- is what the question is testing, and it matters the moment the interviewer's language is not Python.
-
Roughly how many iterations does a binary search take on n = 10^9?
log2(10^9) is just under 30. This is the arithmetic that makes "binary search on the answer" viable over enormous value ranges -- a range of 10^18 is only about 60 probes. Being able to produce that number quickly is what lets you argue an approach fits inside the time limit.
pch.quizShowAnswer
B — About 30, since 2^30 is roughly 10^9 — log2(10^9) is just under 30. This is the arithmetic that makes "binary search on the answer" viable over enormous value ranges -- a range of 10^18 is only about 60 probes. Being able to produce that number quickly is what lets you argue an approach fits inside the time limit.
Recall card
Section titled “Recall card”- Two templates, never mixed: exact value ->
lo <= hi,hi = mid - 1, return inside the loop. Boundary ->lo < hi,hi = mid, returnloafter. hi = midbecausemidmay be the answer.lo < hiwithhi = mid - 1skips it;lo <= hiwithhi = midhangs.- Exact search returns an index, not the first. On
[1,2,2,2,5,8]target 2 gives index 2 — neither end. lower_bound=bisect_left,upper_bound=bisect_right. Verified identical. Count ofxisupper - lower(3 for three 2s).- In the bound templates
histarts atlen(a), so a too-large target returnslen(a)— a legal insertion point. That makeslower_boundthe answer to LC 35. - A missing value still returns its insertion position, not
-1. - The precondition is a monotonic predicate, not sortedness. Peak finding needs no order at all.
- Termination: under
lo < hiand floor division,mid < hi, sohi = midalways shrinks. lo + (hi - lo) // 2matters in C++/Java, never in Python.- ~30 iterations at , ~60 at — the arithmetic behind binary search on the answer.
- One template, two flavors:
lo <= hiwith inclusive bounds for exact matches;lo < hiwith an exclusivehifor boundary-finding (lower_bound/upper_bound). mid = lo + (hi - lo) // 2is an overflow-free habit worth keeping even in Python, where integers never actually overflow.bisect.bisect_left/bisect.bisect_rightarelower_bound/upper_bound, already written and tested in the standard library.- Binary search on the answer: when a feasibility check’s result is monotonic in some parameter, binary search that parameter’s range instead of an array — turns many “minimize the maximum” problems into .
- Off-by-one bugs and infinite loops almost always trace back to mixing
bounds conventions — pick inclusive or exclusive
hiand stay consistent.
You’ve now covered sorting and searching, the two building blocks behind nearly every “optimize this over sorted or monotonic data” interview question.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading