Topic 01

Binary Search

Algorithms

Sorted data answers "is x here?" by discarding half of what is left at every step. Two million sorted record ids need at most 21 comparisons, where a scan needs up to two million, and doubling the data adds one comparison, not a second scan. Few ideas in this book buy so much for so little code.

The price is paid up front and in the details. The data has to be sorted and kept sorted, and the ten-line loop has three places to go wrong, one of which sat inside the Java standard library for nine years before anyone noticed.

Halving as a Counting Argument

Compare the target with the middle element of the range still in play. If the middle is smaller, the target can only be in the upper half; otherwise it can only be in the lower half. One comparison throws away half the remaining range, so k comparisons decide among 2 to the power k items.

Four comparisons for sixteen items
looking for 706 among 16 sorted ids113167233343365478580585657664694706718720740957step 1657 < 706: keep the right half113167233343365478580585657664694706718720740957step 2718 ≥ 706: keep the left half113167233343365478580585657664694706718720740957step 3694 < 706: keep the right half113167233343365478580585657664694706718720740957step 4706 ≥ 706: keep the left halfafter 4 comparisons one position is left: 706 is at index 11. 16 = 2 × 2 × 2 × 2

Sixteen items need four comparisons, because 16 is 2 times 2 times 2 times 2. Two million need 21, because 2 to the 21st is 2,097,152, the first power of two above two million. A billion need 30. Every doubling of the data costs exactly one more comparison, which is what O(log n) means when it is written out: four million ids need 22.

The Invariant That Keeps It Correct

The loop stays correct only if it states what its window means, for example "if the target is present, it lies at or after lo and before hi", and keeps that statement true on every branch. Almost every broken binary search breaks at one of three places.

The first is a bound that is off by one, so the loop stops a step early and never examines the last element. The second is a midpoint that stops moving: when two items remain and the update sets the bound back to the midpoint instead of past it, the window never shrinks and the loop never ends. The third is arithmetic. Computing the midpoint as lo plus hi, divided by two, overflows a 32-bit integer once lo plus hi passes about 2.1 billion, which happens on arrays of over a billion elements. Joshua Bloch reported that bug in the JDK's binarySearch in a Google Research blog post of 2 June 2006, after it had lain in wait for nine years or so. Python's integers grow as needed and cannot overflow, as Chapter 2 explains, but the first two traps apply in every language.

A lower-bound search, with its invariant written down
def lower_bound(items, x):
    # invariant: items[:lo] < x  and  items[hi:] >= x
    lo, hi = 0, len(items)
    while lo < hi:
        mid = lo + (hi - lo) // 2      # never overflows, in any language
        if items[mid] < x:
            lo = mid + 1               # mid is too small: move past it
        else:
            hi = mid                   # mid may be the answer: keep it
    return lo

The function above keeps two promises: everything before lo is smaller than the target, and everything from hi onward is not. Each branch keeps both promises and shrinks the window by at least one, so the loop ends, and when it does lo and hi meet at the first position whose item is not smaller than the target. The midpoint is computed as lo plus half the distance to hi, which gives the same answer and never produces a sum larger than hi.

The Useful Question: Where Would x Go

"Is it present" is the weak form of the question. The strong form returns the first position whose item is not less than x, called the lower bound, and that one number answers far more. The target is present if the item at that position equals it. The number of copies of x is the upper bound, the first position past every x, minus the lower bound. Every id in a range lies between the lower bound of the start and the lower bound of the end. And the lower bound is where x would be inserted to keep the list sorted. Python's standard bisect module ships these two searches, as bisect_left and bisect_right.

Bisecting Anything That Flips Once

Binary search needs sorted answers, not a sorted array. Any yes-or-no question whose answer is "no" up to some point and "yes" after it can be halved the same way: the smallest buffer size that stops a video from dropping frames, the lowest timeout at which a flaky test passes, the first release that fails a test. None of these involves an array. Each needs only a way to ask the question at a chosen point.

Git's bisect command is this idea applied to a history of commits. Given one good commit and one bad one with a thousand commits between them, it tests the middle, keeps the half that contains the change, and finds the commit that broke the build in about 10 test runs, because 2 to the 10th is 1,024.

What Halving Costs on Real Memory

Twenty-one comparisons sounds like nothing, but the probes jump across the array. Two million 8-byte ids take 16 megabytes, larger than a typical L2 cache, so the first probes land far apart in memory: the first jumps 8 megabytes, the next 4, then 2. Each of those early probes is likely a cache miss at around 100 nanoseconds, from the ladder in Chapter 4. Only the last few land within one 64-byte cache line, which holds eight ids.

How far each probe jumps
1 B10 B100 B1 KB10 KB100 KB1 MB10 MB15101521probe number (21 in all)distance from the previous probe, log scalered: a cache line not touched beforegreen: a line already fetcheddashed: one 64-byte cache line

In a real search of the 2 million ids for record 1048576, 18 of the 21 probes touched a cache line the search had not touched before. So one lookup can cost around 18 misses, a couple of microseconds, where a hash lookup (Chapter 5) touches one or two lines. And below a few hundred items, a plain scan often wins outright: it reads contiguous memory at full speed, and its branch goes the same way every time, so the processor predicts it (Chapter 3), while every halving step is a coin flip the predictor cannot learn.

The Price of Keeping It Sorted

The 21 comparisons are bought twice. Once by sorting, which costs n log n (the next topics). And again on every insert, because inserting into a sorted array means shifting everything after the insertion point one place to the right. Measured on Python 3.15, inserting 100,000 random values one at a time into a sorted list took 0.5 seconds; 200,000 took 2 seconds, four times as long for twice the data. Sorting the same 200,000 values once took 0.03 seconds.

So a sorted array is for data that is read far more than it changes, rebuilt in bulk. Data that changes constantly wants the structures that keep binary search's cost while making inserts cheap: a balanced tree in memory or a B-tree on disk, both from Chapter 6.

Binary Search vs a Hash Lookup

Binary search over sorted data costs about log n comparisons, 21 for 2 million, and answers exact, range, nearest and prefix questions, because order survives. Choose it when any query asks "between", "before" or "starts with".

A hash lookup (Chapter 5) costs one or two memory reads on average but answers only exact matches, because hashing scatters keys on purpose. Choose it when every query is "exactly this key".

Misconceptions
  • "Binary search is too simple to get wrong." As Jon Bentley's Programming Pearls records, the first binary search was published in 1946 and the first one correct for every n in 1962, and the midpoint overflow survived in the JDK until 2006. Nearly every bug is at a boundary: an empty array, one element, a target below the first item or above the last, and runs of duplicates.
  • "Binary search always beats a linear scan." On a few dozen or a few hundred items a scan reads contiguous memory with a branch the processor predicts correctly, while halving jumps around with a branch it cannot. The crossover is measured on the hardware, not derived from Big-O.
  • "The midpoint lo plus hi, halved, is safe." In Python it is, because integers grow. The same line ported to Java, C, Go or a NumPy array of 32-bit indexes overflows once lo plus hi passes about 2.1 billion. Adding half the distance to lo is correct in all of them.
  • "Binary search returns the first matching item." The plain version returns whichever equal item the halving lands on. With duplicates, "first occurrence" needs the lower-bound form, and code that assumes otherwise returns a different row after an unrelated insert.
  • "Binary search needs a sorted array." It needs a question whose answer flips once. Bisecting over commits, timeout values or capacity limits involves no array at all.
Why It Matters
  • Keep read-heavy data sorted and answer lookups by halving. Each doubling of the data then costs one more comparison, not twice the work.
  • Write every binary search against a stated invariant and test the five boundary inputs. The bugs live at the edges of the window, not in the middle of it.
  • Ask for the insertion point rather than a yes or no. One lower-bound routine answers presence, counts, ranges and where to insert.
  • Bisect any yes-or-no question that flips exactly once. A regression, a threshold or a capacity limit is found in the logarithm of the number of candidates.
RelatedInterpolation search guesses the position from the key's value; about log log n probes on even keys, far worse on skewed onesGalloping search probes 1, 2, 4, 8 ahead before halving; it returns in Sorting in the Real WorldBalanced trees and B-trees keep binary search's cost while making inserts cheap (Chapter 6)

Knowledge Check

Binary search over 2 million sorted ids takes at most 21 comparisons. How many does it take over 4 million?

  • 22, because doubling the data adds exactly one halving step
  • 42, because twice the data needs twice the comparisons
  • 21, because binary search does not depend on the data size
  • 25, because a larger array spills out of the processor cache

Why does a lower-bound search answer more questions than a plain "is it present" search?

  • It is faster, because it can stop at the first equal item it finds
  • Its position also gives counts, ranges and the place to insert
  • It searches the array from both ends at once, finding two items
  • It works on unsorted data, since it only compares with one item

On an array of 50 small integers, a linear scan beats binary search in a benchmark. What explains it?

  • The benchmark is wrong, because O(log n) always beats O(n)
  • Binary search needs the array sorted again before every lookup
  • The scan reads contiguous memory with a branch the processor predicts
  • Python runs linear scans in compiled code but not binary searches

A program keeps a Python list sorted by inserting 200,000 values one at a time. How does its cost compare with sorting once?

  • About the same, because each insert finds its place by binary search
  • Faster, because the list never has to be sorted all at once
  • Twice the cost of 100,000 inserts, a linear increase in the data
  • Far slower, because every insert shifts the elements after it

A test started failing somewhere in the last 1,000 commits. What does bisecting cost?

  • 1,000 test runs, one per commit, since commits are not sorted
  • 500 test runs on average, like a linear search that stops halfway
  • About 10 test runs, because each run halves the candidates
  • About 32 test runs, the square root of the number of commits

Which midpoint formula survives a port from Python to Java on arrays of over a billion elements?

  • lo plus hi, divided by two, because Java rounds the result down
  • lo plus half of hi minus lo, since no sum exceeds hi
  • hi minus lo, divided by two, because subtraction cannot overflow
  • lo times hi, square-rooted, because the geometric mean stays small

You got correct