Binary search is famous for being easy to get almost right. The honest reason: most people memorize a loop instead of stating an invariant — a sentence that stays true about the range at every step. Choose the sentence first, and every disputed detail (< or <=? mid or mid + 1?) stops being a judgment call and becomes a consequence.
The closed-range invariant
The classic form maintains: the target, if it exists, is inside [low, high] — both ends included.
export function binarySearch(sorted: number[], target: number): number {
let low = 0
let high = sorted.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const guess = sorted[mid]
if (guess === target) return mid
if (guess < target) low = mid + 1
else high = mid - 1
}
return -1
}
The loop condition is <= because a one-element window low === high is still a legal range — it has a member you have not examined. Writing < quietly declares an unexamined element "not there", which is why < fails exactly on the boundary cases: first element, last element, a target that lands where the window narrows to one. Every move shrinks the window past the midpoint (mid + 1, mid - 1) because mid itself was just examined and answered — moving onto it re-examines a settled cell and, worse, can make the window stop shrinking, which is the infinite-loop variant of this bug.
fill it in
1 blank · graded here, free
while (low high) {
const mid = Math.floor((low + high) / 2)
if (sorted[mid] === target) return mid
}type into the gaps, then check
The general form: the insertion point
"Where would this go?" is the more powerful question, because "is this there?" is a special case of it. The general form maintains a half-open invariant: everything before low is less than the target, everything from high on is at least the target.
export function insertionIndex(sorted: number[], target: number): number {
let low = 0
let high = sorted.length
while (low < high) {
const mid = Math.floor((low + high) / 2)
if ((sorted[mid] as number) < target) {
low = mid + 1
} else {
high = mid
}
}
return low
}
Notice what changed with the invariant: the loop is now < (an empty range [low, low) has no members, so it ends the search), high starts past the end (the insertion point may be after everything), and the shrink is high = mid — never mid - 1 — because mid failed the "less than" test, so it belongs to the right partition and cannot be discarded past itself. When the loop ends, low === high is the count of elements smaller than the target, which is simultaneously: the insert position, the index of the first equal element, and the answer to "how many came in under this value". One function, three interview questions.
fill it in
1 blank · graded here, free
if ((sorted[mid] as number) < target) {
low = mid + 1
} else {
high =
}type into the gaps, then check
Binary search on an answer, not an index
The pattern generalizes past arrays. If a question has the shape "what is the smallest value of X such that some check passes", and the check is monotone — once true, true forever — then the answer space is a sorted array you cannot see, and binary search walks it. Minimum capacity that ships the cargo in D days, maximum eating speed that finishes the pile in H hours: the check is a greedy simulation, the search is the code above with sorted[mid] replaced by mid itself. Recognizing that a question has this shape is worth more than memorizing any specific instance of it.
How to debug one
A wrong binary search lies quietly — it returns an index that exists. So make the invariant loud: print low, high, mid and the invariant's claim every iteration, and check by hand that the discarded side really is discardable. An infinite loop means the window stopped shrinking — look for a move that lands on mid. A missed boundary means the loop condition disagrees with the invariant — write the invariant out as a sentence, then let the condition read it back to you. In the exercises for this concept, the failing tests are precisely the boundaries: first element, last element, absent-below, absent-above.
Where this bites
- Mixing invariants. A
<=condition withhigh = midmoves can skip the window empty past a live element; a<condition withmid - 1can spin forever. Counter: pick closed or half-open, and derive condition and moves together — they are one decision, not three. high = sorted.lengthin the closed form. The closed range's last legal index islength - 1; usinglengthreads one past the end on the first probe. Counter: the invariant sentence says "both ends included" — an index that is not a member cannot be an end.- Duplicated targets and "found it". The classic form returns some index of a duplicate, and callers that assume the first one build subtle bugs. Counter: when order among equals matters, use the insertion-point form — it is deterministic by construction.
- Searching an unsorted range. The discard argument needs the midpoint to speak for its half. Counter: the first question is always "what property makes one comparison enough to drop half the space?" — sortedness, monotone check, or nothing.