Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Coding · Binary search

Three templates and their invariants

Lesson · 12 min read · Binary search · Introduction to binary search

An exact match, the first true and the last true. Each has its own meaning for lo and hi, and the updates follow from that meaning.

Decide what lo and hi mean

Off-by-one errors in binary search come from writing the updates before deciding what the range means. Fix the meaning first, as an invariant, a statement true before every iteration, and the loop condition, the midpoint and both updates follow from it. Three meanings cover every problem in this chapter.

Exact matchFirst trueLast true
Invariantif the target exists, it is in [lo, hi]the first true is in [lo, hi]the last true is in [lo, hi]
Loop whilelo ≤ hilo < hilo < hi
Midpointeither roundingrounded downrounded up
Test passesreturn midhi = midlo = mid
Test failslo = mid + 1 or hi = mid − 1lo = mid + 1hi = mid − 1
After the loopnot foundlo is the answerlo is the answer

"First true" is written here with a closed range whose top candidate is known to pass. When it may not (nothing in the array is large enough), add one candidate past the end, as the next template does.

Exact match

Exact match in a sorted array

search(a, target)

  1. lo = 0, hi = n − 1. If target is anywhere, it is in a[lo..hi].
  2. While lo ≤ hi: mid = lo + (hi − lo) / 2.
  3. If a[mid] = target, return mid. If a[mid] < target, lo = mid + 1. Otherwise hi = mid − 1.
  4. The range is empty: return −1.

Both updates move past mid, because mid has been checked and is not the target. The loop runs while the range has at least one element, which is lo ≤ hi for a closed range.

def search(nums, target):
    lo, hi = 0, len(nums) - 1          # if target is anywhere, it is in nums[lo..hi]
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            lo = mid + 1               # nums[lo..mid] are all too small
        else:
            hi = mid - 1               # nums[mid..hi] are all too large
    return -1

The same code is the reference solution to Find a value in a sorted array, where it is compiled and run against the tests in all six languages.

First true

First index whose element passes

firstTrue(a, test)

  1. lo = 0, hi = n. The answer is in [lo, hi]; hi = n stands for "nothing passes".
  2. While lo < hi: mid = lo + (hi − lo) / 2, rounded down, so mid < hi.
  3. If test(a[mid]), hi = mid: mid may be the first true, so keep it. Otherwise lo = mid + 1.
  4. Return lo.

This is the template to know best. It never checks for equality and never exits early, so there is one exit and one meaning for it. The lower bound, the upper bound, the insertion point, the peak of a mountain and every "smallest value that works" are this loop with a different test.

def search_insert(nums, target):
    lo, hi = 0, len(nums)              # the answer is in [lo, hi); hi = n means "after everything"
    while lo < hi:
        mid = (lo + hi) // 2
        if nums[mid] >= target:
            hi = mid                   # mid passes: the first pass is mid or earlier
        else:
            lo = mid + 1               # mid fails, and so does everything before it
    return lo

The same code is the reference solution to Where a value belongs in a sorted array, where it is compiled and run against the tests in all six languages.

Last true, and why the midpoint rounds up

Maximising problems want the last candidate that passes: the largest x with x² ≤ n, the widest spacing that fits. Now a passing mid must stay in the range, lo = mid, and a failing one goes, hi = mid − 1. With the midpoint rounded down, that pair of updates can stop moving.

The last x with x² ≤ 10, from the range [3, 4]: first with the midpoint rounded down, then rounded up.

  1. Step 1 / 6
    0011223344556677lohi
    lo3hi4

    Searching for the last x with x² ≤ 10. Two candidates are left: 3 and 4. The answer is 3.

  2. Step 2 / 6
    0011223344556677lo / midhi
    lo3mid3hi4

    mid = 3 + (4 − 3) / 2 = 3, rounded down. 3² = 9 ≤ 10, so mid passes and lo = mid = 3. Nothing has changed.

  3. Step 3 / 6
    0011223344556677lo / midhi
    lo3mid3hi4

    Again mid = 3, it passes, lo = 3. The range is still [3, 4].

  4. Step 4 / 6
    0011223344556677lo / midhi
    lo3mid3hi4

    Again mid = 3, it passes, lo = 3. The range is still [3, 4], and it always will be: the loop never ends.

  5. Step 5 / 6
    0011223344556677lomid / hi
    lo3mid4hi4

    Rounded up instead, mid = 4. 4² = 16 > 10, so hi = mid − 1 = 3. The range has shrunk to [3, 3] and the loop stops at 3.

  6. Step 6 / 6
    0011223344556677answer
    answer3

    With lo = mid on a pass, mid must round up; with hi = mid on a pass, it must round down. Each pairing guarantees the range shrinks.

Last true in code

The integer square root is the smallest complete example: the last r with r² ≤ x, midpoint rounded up as lo + (hi − lo + 1) / 2.

def my_sqrt(x):
    lo, hi = 0, x                      # the answer is in [lo, hi]
    while lo < hi:
        mid = (lo + hi + 1) // 2       # round up, or lo = mid never moves
        if mid * mid <= x:
            lo = mid                   # mid works; the answer is mid or larger
        else:
            hi = mid - 1
    return lo

The same code is the reference solution to Integer square root without a library, where it is compiled and run against the tests in all six languages.

A last-true search can always be rewritten as a first-true search: the last true is one before the first false. Some people keep a single template and convert. Either habit is fine; mixing them inside one loop is not.