Why Binary Search Bugs Persisted for 60 Years
Jon Bentley famously reported in Programming Pearls that over $90\%$ of professional programmers failed to write a bug-free binary search in an hour, and Joshua Bloch revealed in 2006 that Java's Arrays.binarySearch had contained a signed integer overflow bug (int mid = (low + high) / 2; overflowing when low + high >= 2^31) for nearly a decade. Every binary search bug stems from one of two root causes:
- Signed Midpoint Overflow: Writing
(lo + hi) / 2instead oflo + (hi - lo) / 2or C++20's overflow-freestd::midpoint(lo, hi). - 2-Element Infinite Loops (
lo = midwith Floor Rounding): When $hi = lo + 1$, integer floor division sets $mid = \lfloor(lo + hi)/2\rfloor = lo$. If the loop body executeslo = midwithout advancing past $mid$, the interval $[lo, hi]$ never shrinks and loops forever!
lower_bound / First True)Let $P : \{0, \dots, n-1\} \to \{\text{False}, \text{True}\}$ be a monotonic predicate (if $P(i)$ is True, then $P(j)$ is True for all $j \ge i$). Extending conventionally with virtual sentinels $P(-1) = \text{False}$ and $P(n) = \text{True}$, we seek the first index $i \in [0, n]$ where $P(i)$ is True ( returning $n$ if all elements are False):
- 1. C++ STL Half-Open Interval $[lo, hi)$ (
lo = 0, hi = n):
Invariant: All indices $< lo$ are False; all indices $\ge hi$ are True.
Loop:while (lo < hi)withmid = lo + (hi - lo) / 2. Since $lo < hi$, $mid \in [lo, hi - 1]$ strictly!
Update: If $P(mid)$ is True, the first True is $\le mid$, so sethi = mid(shrinks because $mid < hi$). If $P(mid)$ is False, the first True is $> mid$, so setlo = mid + 1(shrinks because $mid + 1 > lo$). Terminates at $lo == hi$ in $\lceil \log_2(n+1) \rceil$ steps. - 2. Dijkstra / TopCoder Open-Interval Dual Sentinel $(lo, hi)$ (
lo = -1, hi = n):
Invariant: $P(lo)$ is always False; $P(hi)$ is always True.
Loop:while (hi - lo > 1)withmid = lo + (hi - lo) / 2. Because $hi - lo \ge 2$, $lo < mid < hi$ is strictly interior, so BOTHlo = midandhi = midstrictly shrink $hi - lo$! At termination, $hi = lo + 1$:lois the last False andhiis the first True. - 3. Closed Interval $[lo, hi]$ (
lo = 0, hi = n - 1):
Loop:while (lo <= hi)withlo = mid + 1andhi = mid - 1. Requires $3$-way branching or tracking an explicitansvariable becausehi = mid - 1discards $mid$ from the interval.
| Formulation | Initial Bounds | Loop Condition | Updates ($P(mid)$ True / False) | Post-Loop State |
|---|---|---|---|---|
Half-Open $[lo, hi)$ (STL lower_bound) |
lo = 0, hi = n |
lo < hi |
hi = mid / lo = mid + 1 |
lo == hi (First True in $[0, n]$) |
| Open Sentinels $(lo, hi)$ (Dijkstra) | lo = -1, hi = n |
hi - lo > 1 |
hi = mid / lo = mid |
lo = Last False, hi = First True |
| Closed $[lo, hi]$ (Classic 3-Way) | lo = 0, hi = n - 1 |
lo <= hi |
hi = mid - 1 / lo = mid + 1 |
lo == hi + 1 (Insertion point lo) |
| Meta / Bitwise Jump Binary Search | pos = 0, bit = 1 << lg(n) |
bit > 0 |
if (!P(pos + bit)) pos += bit |
pos = Last False, pos + 1 = First True |
Evaluate predicate P(x) over a monotonic boolean space [F, F, ..., F, T, T, ..., T]. Maintain the loop invariant P(lo−1) == False and P(hi) == True until lo == hi.
Invariant & State
CMOVIn a standard while (lo < hi) loop, the condition if (a[mid] < target) is data-dependent and unpredictable (50/50 outcome), incurring ~15 cycles per branch misprediction. By instead writing the search as a fixed $\lfloor \log_2 n \rfloor + 1$ iteration loop (or while (len > 1) { size_t half = len / 2; base += (a[base + half] < target) * half; len -= half; }), Clang and GCC emit a single branchless conditional move (CMOV) instruction! Even though a 2-way comparison always checks $\lceil \log_2 n \rceil$ elements instead of stopping early when a[mid] == target, avoiding early-exit branches is faster because early hits occur on fewer than $2/n$ of queries while the extra equality branch slows down $99.9\%$ of queries.
Production C++20 Implementation: Half-Open lower_bound, upper_bound & Branchless Search
#include <bit>
#include <concepts>
#include <cstddef>
#include <numeric>
// 1. Canonical Half-Open [lo, hi) lower_bound: First index i in [0, n] with !(a[i] < target)
template <std::totally_ordered T>
constexpr std::size_t lower_bound_half_open(const T* a, std::size_t n, const T& target) noexcept {
std::size_t lo = 0;
std::size_t hi = n; // Half-open [0, n): n is the "not found / past-the-end" sentinel
while (lo < hi) {
const std::size_t mid = std::midpoint(lo, hi); // Overflow-safe lo + (hi - lo) / 2
if (a[mid] < target) {
lo = mid + 1; // mid is strictly False; advance past mid
} else {
hi = mid; // mid is True (a[mid] >= target); keep mid in candidate range
}
}
return lo;
}
// 2. Canonical Half-Open [lo, hi) upper_bound: First index i in [0, n] with target < a[i]
template <std::totally_ordered T>
constexpr std::size_t upper_bound_half_open(const T* a, std::size_t n, const T& target) noexcept {
std::size_t lo = 0;
std::size_t hi = n;
while (lo < hi) {
const std::size_t mid = std::midpoint(lo, hi);
if (!(target < a[mid])) { // a[mid] <= target (False)
lo = mid + 1;
} else { // a[mid] > target (True)
hi = mid;
}
}
return lo;
}
// 3. Branchless CMOV Lower Bound (Paul-Virak Khuong & Pat Morin style)
template <std::totally_ordered T>
constexpr std::size_t lower_bound_branchless(const T* a, std::size_t n, const T& target) noexcept {
if (n == 0) return 0;
const T* base = a;
std::size_t len = n;
while (len > 1) {
const std::size_t half = len >> 1;
// Compiles to a single CMP + CMOV with zero branch mispredictions!
base += (base[half - 1] < target) ? half : 0;
len -= half;
}
return static_cast<std::size_t>(base - a) + ((*base < target) ? 1U : 0U);
}
lo = mid Requires Upper-Midpoint Rounding!Suppose you use a closed interval $[lo, hi]$ to find the last False index, updating lo = mid when $P(mid)$ is False and hi = mid - 1 when $P(mid)$ is True. If you compute mid = lo + (hi - lo) / 2 (floor division), then when $hi = lo + 1$ and $P(lo)$ is False, $mid = lo$ and lo = mid loops forever! Whenever a binary search branch assigns lo = mid, you must round the midpoint up via mid = lo + (hi - lo + 1) / 2—or better yet, use the open sentinel loop while (hi - lo > 1) where $lo < mid < hi$ is guaranteed automatically!