The most transferable binary-search move in the whole interview canon: you are not searching an array — you are bisecting the answer space. Define a monotone predicate feasible(x) that, once true, stays true for all larger (or all smaller) x, then binary-search the value where it flips. The art is (1) spotting the monotone predicate and (2) writing feasible().
Stop thinking “where is the value in this array?” and start thinking “what is the smallest x that works?” If working is a monotone property — once an x works, every larger one does too — the line of candidate answers reads F F F | T T T, and you can bisect straight to the seam in O(log(range)) feasibility checks.
Plain binary search searches a concrete sorted array. This technique searches a line you invent: the range of possible answers. For Koko it's the speeds [1 .. max(pile)]; for integer sqrt it's [0 .. n]; for “split array into k subarrays” it's [max(arr) .. sum(arr)]. None of these lines physically exist — you conjure them from the constraints.
The “comparison” that drives the bisection isn't arr[mid] vs target; it's a yes/no function feasible(mid)you write yourself. Each probe answers a whole-problem question (“can we do it at this speed / capacity / budget?”) and, because the answer is monotone, deletes an entire half of the answer line.
Watch the speeds line up and the feasibility flip exactly once. We binary-search the F | T seam, calling feasible(mid) as the comparison:
Koko: smallest eating speed k that clears the piles in h hours.
feasible(k) = "can Koko finish at speed k within h hours?"
speed k : 1 2 3 4 5 6 7 8 9 10 11
feas(k) : F F F T T T T T T T T
↑
└── first TRUE ← THIS is the answer (k = 4)
There is NO array. We INVENT the line [1 .. max(pile)] and binary-search
it for the F|T seam, calling feasible(mid) as the "comparison".The Visualize tab animates exactly this: lo, mid, hi over the speed line, with feasible(mid) shown true/false and the discarded half greyed out each step.
When a problem says “minimize the maximum”, “maximize the minimum”, or “smallest/largest xsuch that you can …”, climb these rungs before brute-forcing every x:
feasible(x) = can we achieve it with x?”x works, does every larger x work too?” If yes, feasiblegoes false→true once. If you can't prove it, this technique does not apply.lo = smallest plausible answer, hi = a value you know works. These are values, not indices.feasible().The technique rests entirely on monotonicity: once true, always true. If feasible(x) reads F T F T across the range, deleting a half is no longer safe and the bisect silently returns garbage.
feasible() MUST be monotone for the bisect to be valid.
good (ship capacity c clears cargo in D days):
c : 5 8 10 12 15 20
feas(c): F F F T T T one clean flip → bisect away
trap ("is exactly k bytes used?" / any non-threshold question):
x : 1 2 3 4 5 6
feas(x): F T F T F T no single seam → bisect lies
Test: "if x works, does every LARGER x work too?" If you can't say yes,
you do NOT have a binary-search-on-answer problem.feasible(x), and binary-search the value where it flips. The Visualize tab bisects Koko's speed line and shows feasible(mid) true/false each step.Many optimization problems have no sorted array to search — but the answer itself lives in an orderable range, and a yes/no test on candidate answers is monotone. Instead of trying every candidate (an O(range) scan), you binary-search the range [lo, hi] for the boundary where feasible flips.
The loop invariant is identical to ordinary binary search: at every step the answer (if it exists) lies in [lo, hi]. Every boundary update must preserve it. What changes is the “comparison” — it's a whole-problem feasibility check, not an array lookup.
The technique is valid iff feasible(x) is monotone: there is exactly one value where it flips. Formally, for a min-feasible search: feasible(x) ⇒ feasible(x + 1) for all x in range.
hours ≤ h is monotone in speed.x*x ≤ n is true for small x, false for large — a clean true→false flip.Once you can articulate the monotonicity, the rest is the standard first-true (or last-true) bisection.
R = hi − lo candidates, each costing f.log₂(R) feasibility checks, each costing f.Total cost is O(log(hi − lo) × cost-of-feasible). For Koko, feasible is O(n) over the piles, so the whole thing is O(n · log(max(piles))) — far below the O(max(piles) · n) brute force.
Two mirror images, and getting the wrong one is the #1 source of off-by-one bugs:
F…F | T…T, find first true): mid rounds down; feasible(mid) true → hi = mid, else lo = mid + 1; return lo.T…T | F…F, find last true): mid rounds up (+1 in the shift) to avoid an infinite loop; feasible(mid) true → lo = mid, else hi = mid - 1; return lo.[3, 6, 7, 11] and h = 8 hours. There is no array to search — we invent the answer line of speeds [1 .. 11] and bisect it.1function minEatingSpeed(piles: number[], h: number): number {2▶ let lo = 1; // slowest conceivable speed3▶ let hi = Math.max(...piles); // fastest useful speed4 while (lo < hi) { // invariant: answer in [lo, hi]5 const mid = lo + ((hi - lo) >> 1); // probe a candidate speed6 if (feasible(piles, h, mid)) { // can finish in time at speed mid?7 hi = mid; // yes -> mid might be the answer, keep it8 } else {9 lo = mid + 1; // no -> too slow, must go faster10 }11 }12 return lo; // smallest speed where feasible flips true13}1415function feasible(piles: number[], h: number, speed: number): boolean {16 let hours = 0;17 for (const p of piles) hours += Math.ceil(p / speed);18 return hours <= h; // monotone: faster speed never needs more hours19}
Reach for binary-search-on-the-answer when the problem asks for an extremal value (a min or a max), brute-forcing every candidate would be too slow, and you can write a yes/no feasibility test that is monotone in that value.
| "minimize the maximum" / "maximize the minimum" | binary search the answer value; feasible() checks whether the cap/floor is achievable |
| "smallest k such that you can finish / fit / ship in time" | min-feasible bisection on k with a greedy feasible() check |
| "can it be done with budget / capacity / speed x?" with x monotone | the question IS the predicate — bisect x for the boundary |
| answer is a number in a known range, and trying every value is O(range) | bisect the range to O(log range) feasibility checks |
| "largest x such that x·x ≤ n" / integer sqrt / value threshold | max-feasible bisection (round mid up; compare via x ≤ n/x to dodge overflow) |
x” can be true, then false, then true again across the range, there is no single boundary — this technique silently returns the wrong answer. Prove the one flip first.lo/hi as positions, not values.O(n) and beats an O(n log n) bisect — see Minimum Size Subarray Sum in the Problems tab.When → You want the smallest value satisfying a monotone condition. Fill in feasible() — that stub is where the actual problem lives.
// Generic "smallest x with feasible(x) true" bisection.
// The predicate must be MONOTONE: false…false…TRUE…TRUE (one flip, never re-toggles).
function minFeasible(lo: number, hi: number): number {
// lo = smallest candidate answer, hi = a value you KNOW is feasible.
while (lo < hi) { // invariant: the answer lives in [lo, hi]
const mid = lo + ((hi - lo) >> 1); // midpoint, can't overflow, rounds down
if (feasible(mid)) {
hi = mid; // mid works → it might BE the answer, keep it
} else {
lo = mid + 1; // mid fails → answer is strictly larger
}
}
return lo; // lo === hi: the first x where feasible flips true
}
// Replace with the problem-specific check. This is where 90% of the work lives.
function feasible(mid: number): boolean {
return true;
}
// For "largest x with feasible(x) true" (feasible is true…true…FALSE…false), flip it:
// while (lo < hi) { const mid = lo + ((hi - lo + 1) >> 1); // round UP
// if (feasible(mid)) lo = mid; else hi = mid - 1; }
// return lo;lo is the smallest value that could possibly be the answer; hi is any value you can prove is feasible (so the answer is guaranteed to be ≤ hi). Loose bounds still work — they just cost a couple extra iterations.When → The answer is a rate (speed, capacity, throughput) and feasible(rate)simulates a greedy pass: “at this rate, does the job finish within the limit?”
// Koko Eating Bananas (LC 875) — binary-search the RATE.
// "Smallest eating speed k that finishes all piles within h hours."
function minEatingSpeed(piles: number[], h: number): number {
let lo = 1; // slowest conceivable speed
let hi = Math.max(...piles); // no point eating faster than the biggest pile
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (hoursAt(piles, mid) <= h) { // feasible: can finish in time at this speed
hi = mid; // yes → try slower
} else {
lo = mid + 1; // no → must go faster
}
}
return lo;
}
// hours needed at a given speed — Σ ceil(pile / speed). Monotone DECREASING in speed,
// so "hours <= h" is monotone INCREASING (false for slow speeds, true for fast ones).
function hoursAt(piles: number[], speed: number): number {
let hours = 0;
for (const p of piles) hours += Math.ceil(p / speed);
return hours;
}hi = max(piles) → at a speed equal to the biggest pile, every pile takes exactly one hour, so the job needs n ≤ h hours — already feasible. No point ever searching faster than that.When → A pure numeric search with no array at all: find the largest x with x·x ≤ n (or similar true→false predicate). This is the max-feasible mirror.
// Integer sqrt (LC 69) — value bisection with a non-array predicate.
// "Largest x such that x*x <= n." feasible(x) = (x*x <= n) is true…true…FALSE.
function mySqrt(n: number): number {
if (n < 2) return n;
let lo = 1, hi = n; // answer ∈ [1, n]
while (lo < hi) {
const mid = lo + ((hi - lo + 1) >> 1); // round UP for the max-feasible variant
if (mid <= n / mid) { // == mid*mid <= n, but NEVER overflows
lo = mid; // mid is feasible → it might be the largest, keep it
} else {
hi = mid - 1; // mid too big → shrink
}
}
return lo; // largest x with x*x <= n
}mid ≤ n / mid instead of mid * mid ≤ n. In fixed-width-integer languages mid * mid can overflow; the division form never does. Note the + 1 in the shift — rounding up is mandatory for the max-feasible loop or it spins forever at a two-element window.The defining mistake of this technique: initializing lo/hi to 0 and arr.length - 1 when they should be values — e.g. 1 and max(piles), or max(arr) and sum(arr). Always ask “what unitare my bounds in?” You are bisecting the answer space, not array positions.
For min-feasible (first true): when feasible(mid) is true, mid might be the answer, so write hi = mid — never hi = mid - 1, which would throw away the answer. When false, mid is ruled out, so move past it: lo = mid + 1.
For max-feasible (last true), mirror it: true → lo = mid, false → hi = mid - 1, and round mid up (lo + ((hi - lo + 1) >> 1)) or the loop never terminates at a two-element window.
Binary search on the answer is only correct when feasible()is genuinely monotone over the searched range. Convince yourself with one sentence (“more capacity is never worse”). If the predicate can flip back, the bisect deletes a half that may contain the true answer and returns nonsense — with no error to warn you.
The midpoint is safe (lo + ((hi - lo) >> 1)), but the predicate can overflow: mid * mid for integer sqrt, or a running sum that exceeds MAX_INT. Prefer division forms (mid ≤ n / mid) or 64-bit accumulators. In TS/JS numbers are 64-bit floats so overflow is rare, but adopt the habit for the languages where it bites.