Binary search — including on the answer space
📖 Walk me through it — plain English
Binary search is just the "guess my number" game. I'm thinking of a number from 1 to 100 and I'll only tell you "higher" or "lower." You wouldn't start at 1 and count up — you'd guess 50, then 25 or 75, cutting the choices in half every guess. About 7 guesses covers 100; about 20 covers a million. That halving is the whole trick.
The classic use is finding a value in a sorted list. Look at the middle item. If your target is bigger than it, the target can only be in the right half — so throw the left half away. Smaller? Throw the right away. Each peek halves what's left to look through.
Here's a search for the value 9 in a sorted list, one step at a time:
Why it's fast: every step throws away half the list. Cutting a million down to one takes only ~20 steps — that's what O(log n) means: doubling the input adds just one more step.
The one catch: this only works because the list is sorted. No order, no binary search.
Got that? The interview twist below takes this exact halving idea and points it at a range of possible answers instead of an array — "what's the smallest value that works?"
The vocabulary, defined once
Binary search has a small, fixed cast of characters. Pin down each one and the code stops looking like a puzzle. The plain-English on-ramp is the only idea you truly need: you keep a window of "where the answer could still be," and on every step you look at the middle of that window and throw away the half that can't contain the answer. Half gone, every step. That repeated halving is what makes it fast.
- Binary search — an algorithm that locates a target in a structure with a known order by repeatedly halving the region still under consideration. "Binary" = two halves; each comparison discards one of them.
- Sorted invariant — the standing assumption that the data is in order (ascending here). An invariant is a fact that stays true the whole time the algorithm runs. Binary search is only correct while this holds, because "throw away the left half" relies on every element there being smaller than the target.
lo/hi/mid— three index variables.lois the low end of the current window,hiis the high end, andmidis the midpoint between them that we probe each step. The window[lo, hi]always contains the answer (that's the second invariant); we shrink it until only one cell is left.- Loop bound (
lo <= hivslo < hi) — the condition that keeps the loop going.lo <= hiis used when you might accept the celllo == hias the answer inside the loop (classic "find this exact value, return its index or -1").lo < hiis used when the loop's job is to converge two pointers onto a single surviving cell, and you read offloafter the loop (the "first-true" style below). Mixing the bound up with the wrong update rule is the #1 source of bugs. mid = lo + (hi - lo) // 2— the safe way to compute the midpoint. The naive(lo + hi) // 2is mathematically identical but can overflow in fixed-width-integer languages: ifloandhiare both near the maximum integer, their sum wraps past it and goes negative. Computing the gaphi - lofirst (always smaller thanhi) and adding half of it tolocan never overflow. Python ints are unbounded so it's only style there, but it's the right reflex everywhere.O(log n)— "logarithmic time." The number of steps grows like the logarithm of the input sizen. Concretely: each step halves the window, so the count of steps is "how many times can you halvenbefore reaching 1," which islog₂ n. A million elements → ~20 steps; a billion → ~30. Doublingnadds exactly one step.- Binary search on the answer — the interview twist. Instead of an array, you bisect a range of candidate answers, asking a yes/no question at each midpoint. Covered in depth below.
- Monotonic predicate — a yes/no test
f(k)whose answers, read left to right across the candidates, look likeF F F T T T(orT T T F F F): once it flips, it never flips back. Monotonic means "moves in one direction only." This single flip point is exactly what binary search hunts for. lower_bound/upper_bound— named boundary searches.lower_bound(x)returns the index of the first element>= x(the leftmost spotxcould be inserted while staying sorted).upper_bound(x)returns the first element strictly> x. Both are just first-true bisection with predicatesa[k] >= xanda[k] > x; their difference (upper - lower) is the count of elements equal tox.
A traced search, indices and all
The plain-English walkthrough above showed values; here's the same kind of search written as an interviewer expects — exact lo, mid, hi at every step. We search for 9 in the 8-element array [1, 4, 5, 7, 9, 11, 13, 16] (indices 0–7), using the inclusive bound lo <= hi and returning the index where we find it:
# array: index 0 1 2 3 4 5 6 7
# value 1 4 5 7 9 11 13 16 target = 9
#
# start lo=0 hi=7 mid = 0 + (7-0)//2 = 3 a[3]=7 7 < 9 -> go right: lo = mid+1 = 4
# step 2 lo=4 hi=7 mid = 4 + (7-4)//2 = 5 a[5]=11 11 > 9 -> go left: hi = mid-1 = 4
# step 3 lo=4 hi=4 mid = 4 + (4-4)//2 = 4 a[4]=9 9 == 9 -> FOUND at index 4
Read down the mid column: 3, 5, 4 — three probes to pin one of eight cells, just as log₂ 8 = 3 promises. Notice how the window [lo, hi] shrinks ([0,7] → [4,7] → [4,4]) and how each update steps past the rejected mid (mid+1 or mid-1), never re-examining it. That "step past mid" is what guarantees the window strictly shrinks. Here is the same logic as runnable code, the inclusive-bound form for finding an exact value:
def search(a, target):
lo, hi = 0, len(a) - 1 # window is the whole array, inclusive both ends
while lo <= hi: # <= : a single-cell window lo==hi is still worth checking
mid = lo + (hi - lo) // 2 # overflow-safe midpoint
if a[mid] == target: return mid
if a[mid] < target: lo = mid + 1 # answer is strictly right of mid
else: hi = mid - 1 # answer is strictly left of mid
return -1 # window emptied (lo > hi): target absent
The infinite-loop trap
Every binary-search bug is the same bug wearing a different hat: the window stops shrinking, so the loop spins forever (or, the mirror image, it shrinks one cell too far and skips the answer). The trap appears when the midpoint can equal one of the endpoints and your update rule then fails to move that endpoint. Concrete failure with the lo < hi bound:
# BUGGY — hangs when hi = lo + 1
while lo < hi:
mid = lo + (hi - lo) // 2 # with lo=3, hi=4 -> mid = 3 (floor)
if f(mid): lo = mid # BUG: lo = 3 again -> no progress, loop forever
else: hi = mid - 1
When hi = lo + 1, floor division makes mid == lo. Setting lo = mid writes lo back to itself and nothing changes. The cure is to pair the rounding of mid with the update that always advances. Two clean, memorizable pairings:
Floor mid + lo = mid + 1
mid = lo + (hi - lo) // 2 rounds down. The branch that keeps the left moves lo strictly forward with lo = mid + 1; the other branch sets hi = mid. Safe because the only "no-move" risk (mid == lo) lands in the branch that does +1.
Ceil mid + hi = mid - 1
mid = lo + (hi - lo + 1) // 2 rounds up. The branch that keeps the right moves hi strictly back with hi = mid - 1; the other sets lo = mid. Safe because the only "no-move" risk (mid == hi) lands in the branch that does -1.
Rule of thumb: if an update can leave a pointer on mid, round mid toward the other pointer so the gap is forced to close. The inclusive lo <= hi form sidesteps this entirely, because both of its updates (mid + 1 and mid - 1) always step past mid. The template below uses the half-open lo < hi form correctly: floor mid is paired with lo = mid + 1, so it can never stall.
The harder version isn't searching a sorted array — it's searching the answer. "Smallest k such that f(k) is true" where f is monotonic.
def first_true(lo, hi, f):
# invariant: f(hi) is True; want smallest k with f(k) True
while lo < hi:
mid = (lo + hi) // 2
if f(mid): hi = mid
else: lo = mid + 1
return lo
function firstTrue(lo:number, hi:number, f:(k:number)=>boolean): number {
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (f(mid)) hi = mid;
else lo = mid + 1;
}
return lo;
}
Trace why this template terminates: mid is the floor of the midpoint, so when hi = lo + 1 we get mid = lo. The true branch sets hi = mid = lo, ending the loop; the false branch sets lo = mid + 1 = hi, ending the loop. Either way the gap closes — no stall. This is the floor-mid-with-lo = mid + 1 pairing from above. The returned lo is the first index where f is true: every candidate below it was proven false, every candidate at or above it inherits truth from monotonicity. (The TypeScript >>> 1 is an unsigned right-shift by one bit — a fast, overflow-safe integer halving, the JS idiom for the floor midpoint.)
Searching the answer space
Here is the leap that turns binary search into an interview superpower. There is no sorted array in sight — but there is a range of possible answers and a monotonic yes/no test, and that's all bisection ever needed. The recipe: (1) frame the unknown as "smallest k for which a condition holds," (2) confirm the condition is monotonic (F…F T…T), (3) find a valid [lo, hi] range, (4) run first-true bisection where f(k) is "is k good enough?"
Worked example — ship packages in D days. You have package weights w = [3, 2, 2, 4, 1, 4] loaded onto a conveyor in order, and D = 3 days. Each day a ship carries a contiguous run of packages whose total can't exceed the ship's capacity C. Find the smallest capacity C that still finishes within D days.
- The answer space is the candidate capacities. The smallest workable
Cismax(w) = 4(a day must fit its heaviest single package). The largest you'd ever need issum(w) = 16(ship everything in one day). Solo = 4,hi = 16. - The predicate
f(C)= "can we ship withinDdays using capacityC?" Compute it greedily: walk the weights, keep adding to today's load; when the next package would overflowC, start a new day. Count the days; returndays <= D. - Monotonicity holds: a bigger ship can do anything a smaller one can, so once
f(C)is true it stays true for every largerC. ThatF…F T…Tshape is exactly the flip point first-true bisection finds.
def ship_within_days(w, D):
def f(C): # can capacity C finish in <= D days?
days, load = 1, 0
for x in w:
if load + x > C: # today is full -> open a new day
days += 1
load = 0
load += x
return days <= D
lo, hi = max(w), sum(w) # answer range; f(hi) is guaranteed True
while lo < hi: # first-true bisection over capacities
mid = lo + (hi - lo) // 2
if f(mid): hi = mid # mid works -> maybe smaller works too
else: lo = mid + 1 # mid too small -> need more capacity
return lo # smallest capacity that ships in D days (-> 6 here)
The cost is O(n log(sum)): each predicate call is one linear pass (O(n)), and bisecting the capacity range costs O(log(sum - max)) calls. We never enumerated the answers — we halved a numeric range, asking one greedy question per step. That's "binary search on the answer" in full.
Recognition signals & pitfalls
When to reach for it
- The input is sorted (or rotated-sorted) and you need a value or a boundary.
- The phrasing is "minimize the maximum" or "maximize the minimum" — a dead giveaway for answer-space search.
- You can write a cheap yes/no test
f(k)that's monotonic ink("ifkworks, any larger/smallerkalso works"). - A brute-force loop over a numeric range would be too slow but each candidate is quick to check.
Pitfalls that bite
- Bound/update mismatch — floor mid with
lo = mid(instead ofmid + 1) hangs forever. Pair them as shown. - Wrong loop bound — using
lo < hiwhen you needed to inspect the single celllo == hisilently skips the answer. - Off-by-one in the range — set
[lo, hi]so the answer is guaranteed inside it; for first-true, ensuref(hi)is true. - Non-monotonic predicate — if
fcan flip back, bisection returns garbage. Verify theF…F T…Tshape first. - Overflow — prefer
lo + (hi - lo) // 2over(lo + hi) // 2in fixed-width languages.
Go deeper (optional): the standard libraries already ship boundary searches — Python's bisect.bisect_left / bisect.bisect_right are exactly lower_bound / upper_bound, and C++ has std::lower_bound / std::upper_bound. Reading their docs is a good way to cement the "first index satisfying a predicate" mental model.
Get the bounds exactly right — the classic place to off-by-one or infinite-loop. Write it and the runner will tell you (it kills runaway loops after 2s):