DSA DSA In Depth
0%
lesson binary-searchinvariantsboundary

Closed vs Half-Open Binary Search: Eliminating Off-by-One Infinite Loops

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
Binary Search Boundary Invariants & Monotonic Predicates (Without Off-by-One Bugs)
Errichto Algorithms Binary Search Boundary Invariants & Monotonic Predicates (Without Off-by-One Bugs) Watch on YouTube ↗

Demonstrates why ad-hoc binary search implementations fail on 2-element boundary cases and establishes the monotonic F...FT...T predicate partition invariant.

0:00 / --:--

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:

  1. Signed Midpoint Overflow: Writing (lo + hi) / 2 instead of lo + (hi - lo) / 2 or C++20's overflow-free std::midpoint(lo, hi).
  2. 2-Element Infinite Loops (lo = mid with Floor Rounding): When $hi = lo + 1$, integer floor division sets $mid = \lfloor(lo + hi)/2\rfloor = lo$. If the loop body executes lo = mid without advancing past $mid$, the interval $[lo, hi]$ never shrinks and loops forever!
The Three Canonical Boundary Formulations (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) with mid = 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 set hi = mid (shrinks because $mid < hi$). If $P(mid)$ is False, the first True is $> mid$, so set lo = 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) with mid = lo + (hi - lo) / 2. Because $hi - lo \ge 2$, $lo < mid < hi$ is strictly interior, so BOTH lo = mid and hi = mid strictly shrink $hi - lo$! At termination, $hi = lo + 1$: lo is the last False and hi is the first True.
  • 3. Closed Interval $[lo, hi]$ (lo = 0, hi = n - 1):
    Loop: while (lo <= hi) with lo = mid + 1 and hi = mid - 1. Requires $3$-way branching or tracking an explicit ans variable because hi = mid - 1 discards $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
Monotonic Predicate Partition: Every Binary Search Finds the F → T Cliff idx -1 False A[0] = 2 False A[1] = 5 False A[2] = 8 Last F (lo) A[3] = 12 First T (hi) A[4] = 12 True A[5] = 19 True idx n=6 True • std::lower_bound(x=12): Predicate P(i) := (A[i] >= 12) → First True is index 3 • std::upper_bound(x=12): Predicate P(i) := (A[i] > 12) → First True is index 5 • Count of elements equal to 12 = upper_bound(12) - lower_bound(12) = 5 - 3 = 2 (Range [3, 5))
flowchart LR A["Half-Open Range [lo, hi) with lo < hi"] --> B["mid = lo + (hi - lo) / 2 Guaranteed: lo <= mid < hi"] B --> C{"Predicate P(mid)?"} C -- "True (mid could be First True)" --> D["hi = mid (Shrinks since mid < hi)"] C -- "False (mid cannot be True)" --> E["lo = mid + 1 (Shrinks since mid + 1 > lo)"] D --> F["Loop Ends at lo == hi: Exact First True Index!"] E --> F
Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search
O(log N) · Zero Off-By-One

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.

Step 1 / 11
Custom Input:
Timeline

Invariant & State

Initial Sorted Monotonic Domain: [0, 8) | Threshold P(x): x ≥ 47 (Boundary index = 5)
📊 Live Magnitude & Pointer Stage — Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search Active Pivot Match/Sorted
P=False 10 [0] P=False 18 [1] P=False 24 [2] P=False 31 [3] P=False 39 [4] P=True 47 [5] P=True 55 [6] P=True 68 [7]
[0]10P=False
[1]18P=False
[2]24P=False
[3]31P=False
[4]39P=False
[5]47P=True
[6]55P=True
[7]68P=True
Interval [lo, hi)
[0, 8)
Predicate P(x)
x ≥ 47
Monotonic Split
F×5 then T×3
Max Probes ⌈log₂N⌉
4
[Step 1] Initialized half-open interval [lo=0, hi=8) over sorted array [10, 18, 24, 31, 39, 47, 55, 68] for predicate P(x): x ≥ 47.
Hardware Reality: Branchless Bitwise (Meta) Binary Search & CMOV

In 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);
}
Finding the "Last False" with 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!

Check yourself Question 1 of 4
Concept Check Half-Open Invariant in std::lower_bound recall

Consider the canonical half-open binary search over [lo, hi) = [0, n) for the first index i where P(i) is true (e.g., a[i] >= target).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does a 2-way comparison loop (especially compiled to branchless CMOV) consistently outperform 3-way early-exit binary search on modern out-of-order CPUs?

Key Takeaways

Formulate every binary search as finding the transition point of a monotonic predicate P(i) changing from False to True.
In the half-open interval [lo, hi) with while (lo < hi) and mid = lo + (hi - lo) / 2, mid is strictly less than hi, so hi = mid and lo = mid + 1 both strictly shrink the interval on every iteration.
In Dijkstra's open-sentinel interval (lo, hi) with lo = -1, hi = n, and while (hi - lo > 1), mid is strictly interior (lo < mid < hi), allowing symmetrical lo = mid and hi = mid updates without rounding tricks.
Two-way branchless binary search outperforms three-way early-exit binary search on modern CPUs by replacing unpredictable branches with single-cycle conditional moves (CMOV).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue