// STUHUB · C++ DATA STRUCTURES

Binary Search: Invariants, Off-By-One Bugs, and the Exact Loop That Never Fails

Why binary search is easy to state and hard to write: the loop invariant, the three classic off-by-one patterns, and a version of the algorithm you can reproduce under exam pressure.

Introduction

Binary search finds a target in a sorted array in O(log n) by halving the search space each step. The idea takes one line to state; the implementation is famously bug-prone — Jon Bentley's classic exercise found that most professional programmers could not write it correctly. This note pins down the invariant that makes the loop provably correct, shows the three off-by-one patterns that break it, and ends with drills you can do before an exam.

The invariant that makes the loop correct

Every correct binary search maintains one invariant: if the target exists, it lies within [lo, hi] — the current, shrinking candidate range. The loop continues while the range is non-empty (lo <= hi); each iteration either finds the target or discards a half in a way that preserves the invariant; and when the range empties, the target is provably absent.

The subtle requirement is on the midpoint update. If arr[mid] < target, the target — if present — must be to the right of mid, so the new range starts at mid + 1. If arr[mid] > target, it must be to the left including nothing at mid, so the range ends at mid - 1. Getting +1/-1 wrong does not crash; it silently discards the answer or loops forever.

cpp
int binary_search(const int arr[], int n, int target) {
    int lo = 0, hi = n - 1;          // candidate range [lo, hi], inclusive
    while (lo <= hi) {               // range non-empty?
        int mid = lo + (hi - lo) / 2;  // overflow-safe midpoint
        if (arr[mid] == target) return mid;
        if (arr[mid] < target) lo = mid + 1;   // discard [lo, mid]
        else                   hi = mid - 1;   // discard [mid, hi]
    }
    return -1;                        // range empty: not present
}

The three classic bugs (and how each smells)

Bug 1 — missing +1/-1: lo = mid or hi = mid with the inclusive range. With a two-element range where mid rounds to lo, the range never shrinks and the loop spins forever. Smell: infinite loop on tiny inputs; test [1, 2] searching for 2.

Bug 2 — wrong loop condition: while (lo < hi) with lo <= hi-style updates. This exits before examining the last candidate. Smell: target at an endpoint is reported missing; test [5] searching for 5.

Bug 3 — overflow midpoint: (lo + hi) / 2 can overflow when both indices are large ints. Smell: works in tests, undefined behavior on huge arrays. The fix is lo + (hi - lo) / 2, or in C++20, std::midpoint(lo, hi).

The two variants exams actually ask for

Exact-match search (above) is the warm-up. The two variants that matter in exams and interviews:

Lower bound — the first index whose value is >= target. The invariant changes to "everything left of lo is < target":

while (lo < hi) {
    int mid = lo + (hi - lo) / 2;
    if (arr[mid] < target) lo = mid + 1;  // arr[mid] can't be the answer
    else                   hi = mid;      // arr[mid] might be the answer
}
return lo;  // first index with arr[lo] >= target

Note the asymmetric update (hi = mid, no -1) matching the half-open convention — mixing the two conventions in one loop is bug pattern #1 in disguise. In production code, std::lower_bound implements exactly this.

Search in a rotated sorted array — decide which half is sorted by comparing endpoints, then check whether the target lies in the sorted half. Same loop skeleton, one extra branch.

cpp
// lower_bound: first index with arr[index] >= target
int lower_bound_idx(const int arr[], int n, int target) {
    int lo = 0, hi = n;              // half-open [lo, hi)
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] < target) lo = mid + 1;
        else                   hi = mid;
    }
    return lo;                        // may be n: all values < target
}

Why O(log n) — the counting argument

Each iteration halves the candidate range: n → n/2 → n/4 → ... → 1. The number of halvings until the range has one element is ⌈log₂ n⌉, so at most that many iterations, each O(1). For n = 1,000,000 that is ~20 comparisons; for n = 10⁹, ~30. This is also the exam favorite: "how many comparisons for n = 2²⁰?" — answer: at most 21 (20 halvings plus the final comparison).

The precondition is easy to state and easy to forget: the array must be sorted for the discard step to be valid. Running binary search on unsorted data returns garbage without any error — it is the algorithm's quietest failure mode.

Exercises with solutions

Work through each question before opening the solution below it.

Exercise 1

Trace binary_search on arr = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91] with target = 23. Write down lo, hi, mid at each step.

Solution

Step 1: lo=0, hi=9, mid=4 → arr[4]=16 < 23 → lo=5. Step 2: lo=5, hi=9, mid=7 → arr[7]=56 > 23 → hi=6. Step 3: lo=5, hi=6, mid=5 → arr[5]=23 == target → return 5. Three comparisons; the invariant held throughout (23 always inside [lo, hi]).

Exercise 2

A classmate's binary search infinite-loops on arr = [1, 3], target = 3. Their update is lo = mid. Diagnose and fix.

Solution

Trace: lo=0, hi=1, mid=0 → arr[0]=1 < 3 → lo = mid = 0. The range never changes: infinite loop. The bug: with lo <= hi (inclusive range), discarding the left half must skip mid, because arr[mid] was already examined and is not the target. Fix: lo = mid + 1. General rule: in an inclusive-range loop, both updates move strictly past mid.

Exercise 3

Write count_occurrences(arr, n, target) for a sorted array with duplicates, in O(log n), using lower/upper bounds.

Solution

Count = (index of first element > target) − (index of first element >= target).

int count_occ(const int arr[], int n, int target) {
    int lo = 0, hi = n;              // lower bound: first >= target
    while (lo < hi) {
        int m = lo + (hi - lo) / 2;
        if (arr[m] < target) lo = m + 1; else hi = m;
    }
    int first_ge = lo;
    hi = n;                          // upper bound: first > target
    while (lo < hi) {
        int m = lo + (hi - lo) / 2;
        if (arr[m] <= target) lo = m + 1; else hi = m;
    }
    return lo - first_ge;            // occurrences of target
}

Both loops share the same midpoint idiom lo + (hi - lo) / 2 — writing them symmetrically is itself a bug guard. (In real code, prefer std::equal_range.)

Published and updated 2026-08-09 · DUOCODE TECHNOLOGY