We closed the graphs module by saying that the next muscle to train was a different one: searching and sorting within large volumes of data. Rutalia accumulates millions of rows — the delivery history with its timestamps, the product catalog, the weight-tier rates — and on sorted data there is a tool that turns searches of millions of steps into a few dozen: binary search. You have already met it in passing: in 01-02 we analyzed its recurrence T(n) = T(n/2) + c ("search in sorted addresses") and concluded it was O(log n). This lesson works it in depth, because binary search has a well-earned reputation as the sharpest and the most treacherous of the basic techniques: it is trivial to state, yet a classic study found bugs in the majority of implementations written by professional programmers. You will learn to write it with an invariant that makes it bulletproof, its lower_bound/upper_bound variants, Python's bisect module, and the most powerful pattern of all: binary search on the answer.

Contents

  1. Classic binary search and why it is treacherous
  2. The interval invariant: the right way to reason
  3. lower_bound and upper_bound: first and last occurrence
  4. Python's bisect module in practice
  5. Binary search on the answer: the "is feasible(x)?" pattern
  6. Monotonic functions and a mention of ternary search

Classic binary search and why it is treacherous

The idea is the familiar one: if the array is sorted, compare against the middle element and discard the half that cannot contain what you are looking for. Each comparison halves the problem, hence the recurrence T(n) = T(n/2) + c that we solved in 01-02: O(log n). Over a history of 10 million Rutalia deliveries, that is about 24 comparisons versus the 10,000,000 of a linear scan.

def binary_search(data, target):
    """Returns an index i such that data[i] == target, or -1 if absent.

    Requires `data` to be sorted in ascending order.
    """
    lo, hi = 0, len(data) - 1           # CLOSED interval [lo, hi]
    while lo <= hi:
        mid = (lo + hi) // 2
        if data[mid] == target:
            return mid
        elif data[mid] < target:
            lo = mid + 1                # the target, if present, is to the right
        else:
            hi = mid - 1                # the target, if present, is to the left
    return -1

Let's break down why each line is the way it is, because this is where people get cut:

  • lo, hi = 0, len(data) - 1: we choose to represent the closed interval [lo, hi] — both endpoints are valid candidates. This decision shapes everything else.
  • while lo <= hi: with a closed interval, the interval is empty when lo > hi. Writing lo < hi here would be a bug: it would leave the single-candidate case unexamined.
  • lo = mid + 1 and hi = mid - 1: we always exclude mid from the new interval, because we have already examined it. Writing lo = mid or hi = mid in this version produces the classic infinite loop when the interval shrinks to one or two elements and mid stops advancing.

The three classic bugs

Bug Symptom Cause
Misinitialized bounds (hi = len(data) with a closed interval) IndexError or wrong result Mixing the closed convention [lo, hi] with the semi-open [lo, hi)
lo = mid or hi = mid without adjusting the condition Infinite loop with 1–2 elements mid can coincide with lo because // rounds down
Condition lo < hi with a closed interval Fails to find elements that are present The last candidate is never examined

The historic overflow in (lo + hi) // 2

In Python, integers have arbitrary precision and this line is safe. But it is worth knowing: in Java, C or C++, (lo + hi) / 2 with 32-bit integers overflows when lo + hi exceeds 2³¹ − 1, even though both values are valid indices on their own. This bug sat in the binary search of Java's standard library (java.util.Arrays.binarySearch) for nearly ten years until it was detected in 2006. The robust form in those languages is:

int mid = lo + (hi - lo) / 2;   // mathematically equivalent, no overflow

If one day you port your Rutalia code from Python to a Java or Go service, remember this line. It is the perfect example of why binary search is treacherous: the algorithm is correct on the whiteboard and fails on the machine.

The interval invariant: the right way to reason

In 03-03 we reasoned about Dijkstra with an invariant ("distances extracted from the heap are final"). Binary search is mastered the same way: instead of memorizing where each +1 goes, you declare an invariant and maintain it.

Let's use the semi-open convention [lo, hi), which is Python's native one (slicing, range, bisect). The invariant we choose, with the most useful variant already in mind:

Invariant: every element with index < lo is strictly less than the target; every element with index >= hi is greater than or equal to the target.

Graphically, the array is split into three zones that the loop keeps narrowing:

indices:   0 ......... lo ......... hi ......... n
zone:      [  < target  |  unknown?  |  >= target  ]
def lower_bound(data, target):
    """First index i such that data[i] >= target (n if none exists)."""
    lo, hi = 0, len(data)               # semi-open interval [lo, hi)
    while lo < hi:
        mid = (lo + hi) // 2
        if data[mid] < target:
            lo = mid + 1                # data[mid] < target: joins the left zone
        else:
            hi = mid                    # data[mid] >= target: joins the right zone
    return lo                            # lo == hi: the exact boundary

Notice the details and compare them with the closed version:

  • Here hi = mid without -1 is correct, because hi is exclusive: assigning hi = mid means "I know data[mid] >= target", exactly what the invariant demands.
  • There is no infinite loop: since lo < hi inside the loop, we always have mid < hi, so hi = mid strictly shrinks the interval, and so does lo = mid + 1.
  • On exit, lo == hi and the two zones touch: lo is the boundary between "less than the target" and "greater than or equal". That boundary is a much richer result than a mere "present / absent".

This way of thinking — choose the invariant, write each branch to maintain it, and the result falls out on its own — is the one you must internalize. Every variant that follows is the same skeleton with a different invariant.

lower_bound and upper_bound: first and last occurrence

Real data has duplicates. In Rutalia's history, thousands of deliveries share the same day; in the rate table, several products share a weight tier. The useful question is almost never "is value X present?" but "where does the block of X start and where does it end?". Two boundaries exist for that:

Function Returns Boundary invariant
lower_bound(a, x) First index i with a[i] >= x left: < x — right: >= x
upper_bound(a, x) First index i with a[i] > x left: <= x — right: > x

With both boundaries you can answer everything:

  • First occurrence of x: i = lower_bound(a, x); it exists if i < len(a) and a[i] == x.
  • Last occurrence of x: upper_bound(a, x) - 1 (if x exists).
  • Number of occurrences: upper_bound(a, x) - lower_bound(a, x) — zero if x is absent.
  • Range of an interval of values [x, y]: a[lower_bound(a, x) : upper_bound(a, y)].

upper_bound is lower_bound with one comparison changed (<= instead of < when picking the branch). In Python you don't need to write them: they come ready-made.

Python's bisect module in practice

The standard library ships both boundaries under their own names: bisect_left is lower_bound and bisect_right (alias bisect) is upper_bound. In addition, insort_left/insort_right insert while keeping the list sorted (careful: insertion into a list is O(n) because elements shift; only locating the position is logarithmic).

Let's see it on Rutalia's delivery history, sorted by timestamp. As always, generic fictional data:

import bisect

# History sorted by timestamp: (timestamp_iso, delivery_id, zone)
history = [
    ("2026-07-01T08:12:00", "E-10231", "ALM"),
    ("2026-07-01T08:47:00", "E-10232", "MER"),
    ("2026-07-01T09:03:00", "E-10233", "CEN"),
    ("2026-07-01T09:03:00", "E-10234", "CEN"),   # same minute: a real duplicate
    ("2026-07-01T10:30:00", "E-10235", "UNI"),
    ("2026-07-01T11:15:00", "E-10236", "RIO"),
    ("2026-07-01T13:40:00", "E-10237", "HOS"),
]

# Which deliveries happened between 09:00 and 11:00 on day 1?
# Tuples compare lexicographically: searching by the first field is enough.
start = bisect.bisect_left(history, ("2026-07-01T09:00:00",))
end   = bisect.bisect_right(history, ("2026-07-01T11:00:00", "￿"))

for delivery in history[start:end]:
    print(delivery)
# ('2026-07-01T09:03:00', 'E-10233', 'CEN')
# ('2026-07-01T09:03:00', 'E-10234', 'CEN')
# ('2026-07-01T10:30:00', 'E-10235', 'UNI')

Two important tricks in the example:

  • Searching with partial tuples: ("2026-07-01T09:00:00",) is a one-element tuple; when compared against three-element tuples, lexicographic comparison decides on the first field, which is exactly what we want. For the upper limit we append a "very large" sentinel ("￿") as the second field, so that every delivery at that exact instant is included.
  • bisect_left for the start, bisect_right for the end: the universal pattern for extracting ranges with duplicates.

Since Python 3.10, bisect accepts key=, which avoids the sentinels:

# Search directly on the timestamp field with key= (Python >= 3.10)
start = bisect.bisect_left(history, "2026-07-01T09:00:00", key=lambda e: e[0])
end   = bisect.bisect_right(history, "2026-07-01T11:00:00", key=lambda e: e[0])

Weight-tier rates: the star use of bisect

Rutalia charges shipping according to package weight, by tiers. This problem — "given a value, which tier does it fall into?" — is exactly one call to bisect:

import bisect

# Upper bounds of each weight tier (kg) and its rate (EUR)
bounds = [1, 2, 5, 10, 20]                      # up to 1 kg, up to 2 kg, ...
rates  = [2.90, 3.80, 5.50, 8.20, 12.00, 19.90]  # the last one: over 20 kg

def rate(weight_kg):
    # bisect_left: a package of exactly 2.0 kg falls into the "up to 2 kg" tier
    tier = bisect.bisect_left(bounds, weight_kg)
    return rates[tier]

for weight in [0.4, 2.0, 2.1, 25.0]:
    print(f"{weight:>5} kg -> {rate(weight):.2f} EUR")
#   0.4 kg -> 2.90 EUR
#   2.0 kg -> 3.80 EUR
#   2.1 kg -> 5.50 EUR
#  25.0 kg -> 19.90 EUR

The choice between bisect_left and bisect_right here is not cosmetic: it decides whether the exact bound (2.0 kg) falls into the cheap tier or the expensive one. With bisect_right, 2.0 kg would pay 5.50 EUR. It is the kind of boundary detail that in production translates into incorrect billing — another display of the treacherous character of this family of algorithms.

Binary search on the answer: the "is feasible(x)?" pattern

So far we were searching in an array. The big conceptual leap of this lesson is that you can search in the space of possible answers, even when no array exists. Only one property is needed:

If there is a function feasible(x) that answers yes/no and is monotonic — if x works, every x' > x also works (or the other way around) — then the optimal answer can be found by binary search on x.

The answer space looks like this: NO NO NO NO YES YES YES YES. Finding the boundary between the last NO and the first YES is exactly a lower_bound over a "virtual array" that we never materialize.

Rutalia example: minimum van capacity for k trips

The van for zone CEN must deliver a sequence of packages in the given order (that is how they come palletized from the warehouse), in at most k trips. What is the minimum capacity (kg) the van needs?

  • Is it monotonic? Yes: if capacity C can do it in k trips, so can C+1 (the big van can imitate the small one).
  • feasible(C): checked with a trivial greedy — keep filling the current trip and open a new one when the package doesn't fit. Sound familiar? It is a cousin of the first-fit from the bin packing of 02-02, but here the order is fixed and the greedy actually is exact.
def trips_needed(weights, capacity):
    """Number of trips if we load in order with the given capacity (exact greedy)."""
    trips, load = 1, 0
    for w in weights:
        if load + w <= capacity:
            load += w
        else:
            trips += 1
            load = w
    return trips

def min_capacity(weights, k):
    """Minimum capacity to deliver `weights` (in order) in <= k trips."""
    lo = max(weights)        # lower bound: the heaviest package must fit
    hi = sum(weights)        # upper bound: everything in one trip surely suffices
    while lo < hi:                                  # same skeleton as lower_bound
        mid = (lo + hi) // 2
        if trips_needed(weights, mid) <= k:         # feasible(mid)?
            hi = mid         # mid works: the answer is <= mid
        else:
            lo = mid + 1     # mid doesn't work: the answer is > mid
    return lo

weights = [8, 3, 12, 5, 7, 9, 4, 6, 10, 2]   # kg, in palletizing order
print(min_capacity(weights, k=3))            # 23
print(trips_needed(weights, 23))             # 3  -> trips: 8+3+12 | 5+7+9 | 4+6+10+2
print(trips_needed(weights, 22))             # 4  (23 really is the minimum)

Analysis: each feasible costs O(n) and we make O(log R) calls, with R = sum − max; total O(n · log R). The naive alternative of trying capacities one by one is O(n · R): with weights in grams and ranges in tons, the gap is enormous.

Second example: minimum time t to complete the deliveries

Same pattern in a different disguise: Rutalia has m couriers and courier i takes t_i minutes per delivery (motorbike, bicycle, van...). What is the minimum time T to complete n deliveries with everyone working in parallel?

  • feasible(T): in T minutes, courier i completes T // t_i deliveries. Do they add up to at least n? An O(m) computation.
  • Monotonicity: more time, more deliveries. NO→YES boundary.
def min_time(times_per_delivery, n):
    lo, hi = 1, min(times_per_delivery) * n      # safe bounds
    while lo < hi:
        mid = (lo + hi) // 2
        if sum(mid // t for t in times_per_delivery) >= n:
            hi = mid
        else:
            lo = mid + 1
    return lo

print(min_time([4, 7, 10], n=12))   # 28
# Check: with 27 min -> 6+3+2 = 11 < 12; with 28 min -> 7+4+2 = 13 >= 12. Correct.

The mental pattern to take away: whenever the problem statement asks for "the minimum X such that..." or "the maximum X such that...", ask yourself whether you can write a monotonic, cheap feasible(x). If the answer is yes, the optimization problem turns into O(log R) decision problems. In 02-03 we searched for optima by pruning trees with bounds; here we search by narrowing an interval. Two different philosophies for the same verb: optimize.

Monotonic functions and ternary search (brief mention)

Binary search does not need an array: it needs monotonicity. It works just as well for solving f(x) = target with f increasing and continuous (numerical bisection, with while hi - lo > 1e-9 instead of indices), for instance to calibrate the average speed the fleet must drive at to meet a delivery window.

If the function is not monotonic but is unimodal (it goes down and then up, like total cost as a function of the number of vans: too few = overtime, too many = underused fleet), the sibling tool is ternary search: evaluate two interior points m1 < m2 and discard the third that cannot contain the minimum. It is also O(log n). We won't develop it further: keep in mind that it exists, along with the keyword unimodal to recognize when to apply it.

Common Mistakes and Tips

  • Mixing interval conventions. 90% of the bugs come from using hi = len(a) with while lo <= hi, or hi = len(a) - 1 with hi = mid. Pick one convention (we recommend the semi-open [lo, hi), Python's native one) and note it in a comment on the first line.
  • Not checking the result of bisect_left. bisect_left(a, x) returns an insertion position; it does not guarantee that x is present: check i < len(a) and a[i] == x before declaring the element found.
  • Searching in unsorted data. Binary search on an unsorted array does not fail loudly: it returns garbage with total confidence. In development, an assert all(a[i] <= a[i+1] for i in range(len(a)-1)) will save you (remove it in production: it is O(n) and defeats the whole point).
  • Infinite loop in the "maximize" variant. If you search for the maximum feasible x over integers and move lo = mid, the downward rounding of (lo + hi) // 2 makes no progress when hi == lo + 1. Solution: round up with mid = (lo + hi + 1) // 2 in that variant.
  • Wrong initial bounds in search on the answer. If lo is not a valid lower bound (e.g. forgetting max(weights) in the van example), the checker may run with capacities where not even a single package fits and return nonsense. Spend a minute justifying both bounds.
  • Tip: when in doubt about an implementation, test it against brute force with small random arrays, including the empty one and the single-element one. Binary search almost always fails at n ∈ {0, 1, 2}.

Exercises

Exercise 1 — One day's range in the history. With the history sorted by timestamp from the bisect section, write deliveries_on_day(history, day) returning the list of deliveries whose timestamp starts with day (format "2026-07-01"), using bisect_left/bisect_right (do not scan the whole list). The cost must be O(log n) + O(k), where k is the size of the result.

Exercise 2 — Last delivery before an instant. Write last_delivery_before(history, ts) returning the last delivery whose timestamp is strictly less than ts, or None if there is none. Hint: which boundary gives you that position directly, bisect_left or bisect_right?

Exercise 3 — Maximum admissible package weight. Let's flip the search-on-the-answer pattern: Rutalia wants to advertise the maximum per-package weight it can promise, knowing that its van of fixed capacity C must still complete the delivery of weights (in order) in k trips, and assuming that any package exceeding the advertised weight gets capped at that weight. Write max_allowed_weight(weights, k, C) with binary search on the answer. Watch out for the "maximize" variant and its infinite loop.

Solutions

Solution 1:

import bisect

def deliveries_on_day(history, day):
    start = bisect.bisect_left(history, (day,))            # "2026-07-01" < "2026-07-01T..."
    end   = bisect.bisect_right(history, (day + "￿",))  # sentinel just above the day
    return history[start:end]

day = deliveries_on_day(history, "2026-07-01")
print(len(day), "deliveries")   # 7 deliveries (all of them, in this sample history)

The trick is the lesson's: the prefix "2026-07-01" is smaller than any timestamp of the day (because any extra character makes the longer string greater when the prefix matches), and the sentinel "￿" exceeds all of them. Cost: two O(log n) searches plus the O(k) slice.

Solution 2:

def last_delivery_before(history, ts):
    i = bisect.bisect_left(history, (ts,))   # first index with timestamp >= ts
    return history[i - 1] if i > 0 else None

print(last_delivery_before(history, "2026-07-01T09:03:00"))
# ('2026-07-01T08:47:00', 'E-10232', 'MER')  — the 09:03 ones do NOT count (strictly less)

bisect_left gives the first element >= ts; the one before it is, by definition of the invariant, the last one < ts. With bisect_right we would have obtained "the last one with timestamp <= ts", the other common semantics — choosing the right boundary is the exercise.

Solution 3:

def max_allowed_weight(weights, k, C):
    lo, hi = 1, C    # advertising more than C changes nothing: no trip admits more than C
    # We seek the MAXIMUM feasible x: the boundary is YES...YES NO...NO
    while lo < hi:
        mid = (lo + hi + 1) // 2         # round UP: avoids the infinite loop
        capped = [min(w, mid) for w in weights]
        if trips_needed(capped, C) <= k:
            lo = mid                      # mid is feasible: the answer is >= mid
        else:
            hi = mid - 1                  # mid is not feasible: the answer is < mid
    return lo

weights = [8, 3, 12, 5, 7, 9, 4, 6, 10, 2]
print(max_allowed_weight(weights, k=3, C=22))
# 11: capping the 12 kg package at 11, the trips become 8+3+11 | 5+7+9 | 4+6+10+2
# (all <= 22). Without capping (x=12), 4 trips would be needed: 12 is not feasible.

The monotonicity runs the other way this time: the lower the advertised weight, the lower the total load and the fewer the trips — if x is feasible, so is x−1. That is why the boundary is YES→NO, we move lo = mid on success, and that forces the upward rounding (lo + hi + 1) // 2 to guarantee progress.

Conclusion

Binary search is pure O(log n): the recurrence T(n) = T(n/2) + c from module 1 turned into a tool. The essence of this lesson is not the algorithm — it fits in ten lines — but the discipline: (1) choose an interval convention and an invariant, and let them write the branches for you; (2) think in boundaries (lower_bound/upper_bound) rather than in "present or absent", because boundaries answer ranges, counts and rate tiers; (3) recognize the search on the answer pattern: any monotonic "minimum/maximum x such that feasible(x)" is solved with O(log R) calls to a cheap checker — that is how we computed the minimum capacity of Rutalia's fleet without trying capacities one by one.

All of this rested on a silent premise: that the data was already sorted. The history by timestamp, the weight-tier rates, the catalog... someone had to sort them, and over millions of rows that is not done just any old way. In the next lesson (04-02) we open the sorting box: why O(n²) algorithms die at scale, how mergesort and quicksort achieve O(n log n), why that bound cannot be beaten by comparing... and how, with the right cards (Rutalia's postal codes, for example), it can indeed be beaten.

© Copyright 2026. All rights reserved