We have been promising it for three lessons: we previewed it in 01-01 as motivation, we used it in 01-03 to illustrate logarithmic growth, and Module 3 closed by calling it "the jewel of logarithmic efficiency". The time has come to pay that debt. Binary search locates an element in a sorted collection by discarding half of the candidates at every step: where find_stop (02-03) needed up to 40 comparisons to find a stop on a 40-stop line, binary search needs 6. In this lesson we will implement it with surgical care — it is famous for packing subtle bugs into four lines —, trace it over the RutaBus stop catalog, analyze it with the tools from Module 2, and connect it with its parent strategy: divide and conquer (03-01).
Contents
- The non-negotiable requirement: a sorted collection
- Iterative implementation with invariants
- Step-by-step trace: finding a stop by code
- Recursive version
- Analysis: why O(log n)
- Binary search versus linear search
- Useful variants: first occurrence and the
bisectmodule
The non-negotiable requirement: a sorted collection
Binary search makes a bet: it looks at the middle element and, by comparing it with the target, decides which half it cannot be in. That deduction is only valid if the collection is sorted. If the middle element is smaller than the target, everything to its left is too — and we can discard it wholesale without looking at it. Without order there is no deduction to make: discarding a half would be flipping a coin.
RutaBus's central stop catalog assigns each stop an alphabetic code. Kept in order, it is the perfect setting:
codes = ["AVP", "ESN", "HCE", "MVJ", "PDR", "PMA", "POL", "TSU", "UNI"]
# Harbor North Central Old River Main South South Univer-
# Avenue Station Hospital Market Park Square Sports C. Terminal sityAnd if the list is not sorted? Sorting it has its own cost and its own algorithms — exactly the subject of the next three lessons (04-02 to 04-04). Here we take the order as given; the practical rule for when it pays to sort in order to search comes when we compare costs in section 6.
Iterative implementation with invariants
The iterative version is the canonical one. Every line has a precise justification:
def binary_search(items, target):
left = 0 # first candidate index
right = len(items) - 1 # last candidate index
while left <= right: # at least one candidate remains
middle = (left + right) // 2 # central point (integer division)
if items[middle] == target:
return middle # found: return the index
elif items[middle] < target:
left = middle + 1 # the target can only be to the RIGHT
else:
right = middle - 1 # the target can only be to the LEFT
return -1 # empty range: not thereThe tool for reasoning about this loop is its invariant: a statement that is true before every iteration. Here it is:
If
targetis in the list, its index lies in the range[left, right].
Every piece of the code exists to preserve that invariant:
middle = (left + right) // 2: the central index of the current range (integer division//rounds down when the range has even size). Choosing the center is what guarantees that, whatever we decide, we discard half of the candidates — no more, no less.left = middle + 1(and notleft = middle): we have already checked thatitems[middle] != target, somiddlestops being a candidate. Skipping it does not break the invariant (the target cannot be there) and is what guarantees that the range always shrinks — the key to the loop terminating.right = middle - 1: the symmetric argument.while left <= right: the range[left, right]containsright - left + 1elements; whenleft > rightit contains zero. At that point the invariant says: "if it were present, it would be in an empty range" — that is, it is not present. That is why thereturn -1after the loop is correct, not a patch.
Notice how reasoning by invariant turns "I'm pretty sure it works" into a proof: invariant true at the start (the whole range is a candidate), preserved on every iteration, and a strictly shrinking range → the algorithm terminates and its answer is correct.
Step-by-step trace: finding a stop by code
Let's search for the code "PMA" (Main Square) in the list of 9 codes. The indices run from 0 to 8:
| Round | left |
right |
middle |
items[middle] |
Comparison with "PMA" |
Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 4 | "PDR" |
"PDR" < "PMA" |
left = 5 (discards indices 0–4) |
| 2 | 5 | 8 | 6 | "POL" |
"POL" > "PMA" |
right = 5 (discards indices 6–8) |
| 3 | 5 | 5 | 5 | "PMA" |
equal | return 5 |
Three comparisons for 9 elements. Linear search would have needed 6 (it walks through AVP, ESN, HCE, MVJ, PDR, PMA). Watch the discarding mechanics: after the first comparison, 4 candidates remain; after the second, 1. Each round cuts the range in half (sometimes slightly less than half, never more).
And a failed search, "HOS" (a code that does not exist):
| Round | left |
right |
middle |
items[middle] |
Comparison | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 4 | "PDR" |
> "HOS" |
right = 3 |
| 2 | 0 | 3 | 1 | "ESN" |
< "HOS" |
left = 2 |
| 3 | 2 | 3 | 2 | "HCE" |
< "HOS" |
left = 3 |
| 4 | 3 | 3 | 3 | "MVJ" |
> "HOS" |
right = 2 |
| — | 3 | 2 | left > right |
return -1 |
Four comparisons to certify absence among 9 elements. Recall from 02-03 that in linear search verifying absence forces you to look at everything (n comparisons); here log n is enough — absence is certified at the same price as presence.
Recursive version
The structure "discard a half and repeat" is naturally recursive, and in fact it is a degenerate case of divide and conquer: the problem is divided into two halves but only one is conquered (the other is discarded), and there is nothing to combine:
def binary_search_rec(items, target, left, right):
if left > right: # base case: empty range
return -1
middle = (left + right) // 2
if items[middle] == target:
return middle
elif items[middle] < target:
return binary_search_rec(items, target, middle + 1, right)
else:
return binary_search_rec(items, target, left, middle - 1)
binary_search_rec(codes, "PMA", 0, len(codes) - 1) # → 5Same logic, same comparisons. The difference is in space: every pending call occupies a frame on the call stack (02-02), so the recursive version uses O(log n) of auxiliary space against the O(1) of the iterative one. Since Python does not optimize tail recursion either, in practice the iterative version is preferred; the recursive one is worth knowing as a conceptual bridge to the algorithms of 04-03 and 04-04, which genuinely need recursion.
Analysis: why O(log n)
Let's apply the halving-count method — the same level counting as in 03-01. Each failed comparison reduces the number of candidates at least by half:
| Comparison | Remaining candidates (n = 1,000,000) |
|---|---|
| start | 1,000,000 |
| 1 | 500,000 |
| 2 | 250,000 |
| 3 | 125,000 |
| ... | ... |
| 19 | 1 |
| 20 | 0 → not there |
The question "how many times can I divide n by 2 until reaching 1?" has had a name since 01-03: log₂ n. The worst case therefore makes ⌊log₂ n⌋ + 1 comparisons → O(log n). It is the recurrence T(n) = T(n/2) + O(1): a single subproblem of half the size plus constant work — compare it with the T(n) = 2·T(n/2) + O(n) we left pending in 03-01 and will solve in 04-03.
The three cases, using the criteria of 02-03:
- Best case — Θ(1): the target sits exactly at the first
middle. One comparison. - Worst case — Θ(log n): the target sits at a "leaf" of the halving process, or is absent.
- Average case — Θ(log n): most elements need close to log n steps (half of the elements are only reached at the last level), so the average lands within a comparison or two of the worst case.
Space: O(1) auxiliary in the iterative version (three variables), O(log n) in the recursive one because of the stack.
Binary search versus linear search
Let's pit the newcomer against find_stop from 02-03:
find_stop (linear) |
binary_search |
|
|---|---|---|
| Prerequisite | none | sorted list |
| Best case | Θ(1) (first position) | Θ(1) (right at the center) |
| Average case | ≈ n/2 → Θ(n) | Θ(log n) |
| Worst case | Θ(n) (last or absent) | Θ(log n) |
| Element absent | n comparisons | ⌊log₂ n⌋ + 1 comparisons |
| Auxiliary space | O(1) | O(1) |
To get a physical feel for it: with the national catalog of 1,000,000 stops, linear search averages 500,000 comparisons; binary search, 20. It is the difference between hierarchies from 01-03 made flesh.
The fine print is the prerequisite. If the list changes constantly and you will search only once, sorting first (which will cost O(n log n), as we will see) to save on a single search is a bad deal: linear search wins. Binary search shines when the cost of sorting is amortized over many searches — a catalog sorted once at deployment and queried millions of times a day, like RutaBus's.
Useful variants: first occurrence and the bisect module
First occurrence
Our binary_search returns some index where the target is. If there are duplicates — several arrivals with the same time on a board — we often want the first one. The trick: on finding a match, don't stop; note it down and keep searching to the left:
def first_occurrence(items, target):
left, right = 0, len(items) - 1
result = -1
while left <= right:
middle = (left + right) // 2
if items[middle] == target:
result = middle # a candidate... but there may be an earlier one
right = middle - 1 # keep searching to the left
elif items[middle] < target:
left = middle + 1
else:
right = middle - 1
return resultIt is still O(log n): we have added no rounds, only changed what we do on a hit. The invariant is now "result is the leftmost occurrence seen so far, and if there is an earlier one it lies in [left, right]".
bisect: Python's built-in binary search
Python ships binary search out of the box in the bisect module — in fact we already used it without explanation in record_arrival (02-03, exercise 3):
import bisect
board = ["18:05", "18:12", "18:30", "18:47"]
bisect.bisect_left(board, "18:30") # → 2: index of the FIRST "18:30" (or where it would go)
bisect.bisect_right(board, "18:30") # → 3: index AFTER the last "18:30"
bisect.insort(board, "18:20") # finds the slot in O(log n)... and inserts in O(n)Two subtleties that confuse people at first:
bisect_left/bisect_rightdo not tell you whether the element is present: they return the insertion point that keeps the order. To find out whether it is there:i = bisect_left(items, x)and checki < len(items) and items[i] == x— which is exactlyfirst_occurrencefor free.insortsearches in O(log n) but inserts in O(n) because of the shifting done bylist.insert— the conclusion of the 02-03 exercise still stands: binary search speeds up the finding, not the moving.
Common Mistakes and Tips
- Applying it to an unsorted list. The number one mistake, and the most treacherous: it raises no exception, it simply returns wrong results some of the time. If in doubt,
assert items == sorted(items)during development. - Off-by-one on the bounds.
right = len(items)instead oflen(items) - 1, orwhile left < rightinstead of<=: both make the last candidate never get examined (searches that "fail to find" elements sitting at the edges). Pick a convention — here, closed range[left, right]— and stay consistent with it across the three lines that depend on it. - Infinite loop from not shrinking the range. Writing
left = middle(without+ 1) looks innocent, but with a 2-element rangemiddle == leftand the range no longer changes: eternal loop. The golden rule: after each round,middlemust end up outside the new range. - The overflow anecdote. For decades, the reference implementation in Java computed
medio = (izq + der) / 2; in 2006 it was discovered that with arrays of more than 2³⁰ elements the sum of the two indices overflowed the 32-bit integer and produced a negative index. The classic fix ismiddle = left + (right - left) // 2. In Python it cannot happen — integers have arbitrary precision — but you will meet it the moment you touch Java, C or C++, and it is a reminder that even the most analyzed algorithm in history hid a bug for 60 years. - Tip: when you doubt your implementation, systematically test the four border cases: empty list, one element (present and absent), target smaller than everything, target larger than everything. Those five tests catch nearly all off-by-one errors.
Exercises
Exercise 1
Trace by hand (table with left, right, middle, comparison and action) the search for the code "TSU" in codes = ["AVP", "ESN", "HCE", "MVJ", "PDR", "PMA", "POL", "TSU", "UNI"]. How many comparisons does it need? And linear search?
Exercise 2
RutaBus stores the accumulated kilometers of each L1 run in a sorted list. Write count_before(kms, limit) that returns how many runs have strictly fewer than limit km, in O(log n). Hint: don't search for an element; search for a cut point — you can do it with your own binary search or with one call to bisect.
Exercise 3
This version arrives from a RutaBus code review with two bugs. Find them and explain what symptom each one produces (wrong result? infinite loop?):
def search(items, target):
left, right = 0, len(items)
while left <= right:
middle = (left + right) // 2
if items[middle] == target:
return middle
elif items[middle] < target:
left = middle
else:
right = middle - 1
return -1Solutions
Solution 1
| Round | left |
right |
middle |
items[middle] |
Comparison | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 4 | "PDR" |
< "TSU" |
left = 5 |
| 2 | 5 | 8 | 6 | "POL" |
< "TSU" |
left = 7 |
| 3 | 7 | 8 | 7 | "TSU" |
equal | return 7 |
3 comparisons. Linear search would have made 8 (it is the next-to-last element). With just 9 elements the advantage looks modest — remember from 01-03 that the hierarchies pull apart as n grows.
Solution 2
In a sorted list, "how many are smaller than limit" is exactly the index where limit would be inserted from the left:
import bisect
def count_before(kms, limit):
return bisect.bisect_left(kms, limit)
count_before([120, 340, 560, 560, 780], 560) # → 2 (120 and 340)By hand it would be the binary search for the "first index with kms[i] >= limit". This pattern — using binary search to count instead of to find — is among the most profitable in practice. Common mistake: using bisect_right, which would also count the runs with exactly limit km (the statement asked for strictly fewer).
Solution 3
- Bug 1:
right = len(items)combined with the conditionleft <= right. As soon as the range hugs the right edge,middlecan equallen(items)anditems[middle]raisesIndexError(for example, searching for a target larger than every element). Fix:right = len(items) - 1. - Bug 2:
left = middlewithout+ 1. Withleft = 4, right = 5,middle = 4; ifitems[4] < target, the assignment setsleft = 4... and the state does not change: infinite loop. Fix:left = middle + 1, which is moreover correct becausemiddlehas already been ruled out.
The symptom betrays the culprit: an index exception points at the initial bounds; a hang points at a range that fails to shrink.
Conclusion
The debt is paid: binary search discards half of the candidates with every comparison thanks to the invariant "if it is there, it is in [left, right]", and that makes it O(log n) in the worst and average cases — against the Θ(n) of find_stop — with O(1) space in its iterative form. We have seen why every detail matters (middle + 1 to guarantee termination, left <= right so as not to lose the last candidate), the first-occurrence variant and Python's built-in bisect, and we have cataloged its historical traps, from the off-by-one to the overflow that lived for 60 years in the standard libraries. All of its power rests on a condition we have taken as a gift: that the list be sorted. And who sorts it? That is exactly the problem of the next three lessons. We will start in 04-02 with the most human algorithm of them all — the one you use without knowing it when sorting cards or straightening out an arrivals board by hand —: insertion sort.
