Part 4 · Algorithms · Module 30

Searching & Array Techniques

Binary search is ten lines long and famously easy to get wrong. This module teaches it properly, with invariants that make off-by-one errors impossible, and then adds the handful of array techniques (two pointers, sliding windows, prefix sums) that turn many O(n²) brute-force solutions into O(n) ones.

⏱ ~2.5 hoursPrerequisites: M29, M19, M23

The bisect module

The standard library ships tested, C-accelerated binary search. You should use it instead of hand-writing the loop in production code.

FunctionReturnsEquivalent to
bisect_left(a, x)first index i with a[i] >= xlower bound
bisect_right(a, x) / bisect(a, x)first index i with a[i] > xupper bound
insort_left(a, x) / insort(a, x)inserts x, keeping a sortedbisect + list.insert

The key= argument (Python 3.10+)

All bisect functions accept key=. It is applied to the list items, not to the value you search for, so pass the already-extracted key as x. For insort, the key is applied to the new item too, so pass the whole record.

Remember: bisect_left is lower bound, bisect_right is upper bound; key= applies to list items only (and to the inserted item in insort).

Binary search on the answer

Binary search works on any monotone yes/no question over a range of numbers, not just on arrays. If "can we do it with capacity c?" is false for small c and true for every c past some threshold, the smallest feasible c is a boundary, and you can find it in O(log range) feasibility checks.

Minimum ship capacity

Packages with weights [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] must ship in order within 5 days. Each day the ship carries consecutive packages up to its capacity. What is the smallest capacity that works? A capacity below the heaviest package can never work; the total weight always works. In between, more capacity never hurts: monotone.

lo = max(weights); hi = sum(weights)      # answer is in [lo, hi]
while lo < hi:
    mid = (lo + hi) // 2
    if feasible(mid): hi = mid           # mid works; maybe smaller works too
    else:             lo = mid + 1       # mid fails; need more
return lo

Integer square root

The predicate "m * m <= n" is true then false as m grows, so the largest m where it holds is ⌊√n⌋. This works for integers far too big for floating point, where int(n ** 0.5) is wrong.

Complexity: O(log(hi − lo)) checks times the cost of one check. For shipping that is O(n log(sum)). When to use: "find the minimum X such that ..." or "maximum X such that ..." where checking a given X is easy and feasibility is monotone in X.

Two pointers

Intuition: two people walk toward each other from opposite ends of a sorted bookshelf, looking for two books whose prices add up to a gift card's value. If the pair is too expensive, the person on the right steps left (cheaper); too cheap, the person on the left steps right. Each step rules out a whole row of pairs, so the walk takes O(n) instead of checking all O(n²) pairs.

Invariant: no valid pair uses an index outside [i, j]. The comments explain why each move preserves it. Cost: O(n) time, O(1) space (after an O(n log n) sort, if needed).

Same-direction pointers: dedupe in place

A slow pointer marks where the next kept item goes; a fast pointer scans. This is how you compact a sorted array without extra memory.

Three-sum

Find all unique triples summing to zero. Sort, fix the first element, then run the two-pointer pair search on the rest. O(n²) instead of O(n³), with duplicates skipped by stepping past equal values.

def three_sum(nums: list[int]) -> list[tuple[int, int, int]]:
    nums = sorted(nums)
    out = []
    for k in range(len(nums) - 2):
        if k and nums[k] == nums[k - 1]:
            continue                       # same first element: same triples
        i, j = k + 1, len(nums) - 1
        while i < j:
            s = nums[k] + nums[i] + nums[j]
            if s < 0:
                i += 1
            elif s > 0:
                j -= 1
            else:
                out.append((nums[k], nums[i], nums[j]))
                i += 1
                while i < j and nums[i] == nums[i - 1]:
                    i += 1
    return out

Remember: opposite-end pointers on sorted data exploit order to discard many pairs per step; slow/fast pointers compact or partition in place.

Sliding windows

Intuition: a train window shows a fixed stretch of countryside; as the train moves, one tree enters on the right and one leaves on the left. You do not re-look at the whole view, you just update for what entered and what left. A sliding window keeps a running summary of a contiguous subarray and updates it in O(1) per step.

Fixed-size window

Maximum sum of any k consecutive items: compute the first window's sum, then slide: add the entering item, subtract the leaving one. O(n) instead of O(n·k).

Variable-size window

For "longest/shortest subarray such that ...", grow the window by moving right; whenever the window breaks the rule, shrink it by moving left until it is valid again. Each index enters once and leaves once, so the total work is O(n) even though there is a loop inside a loop.

Invariant: s[left:right + 1] has no repeated characters, and no valid window ending at right starts before left. When to use: contiguous subarrays or substrings where a window that is valid stays valid when shrunk (or when grown). It does not work for sums with negative numbers, where shrinking can make a sum bigger; use prefix sums there.

Remember: fixed windows add one and drop one; variable windows expand right and shrink left; each index moves forward at most once per pointer.

Prefix sums and difference arrays

Intuition: a car's odometer. To know how far you drove between Tuesday and Friday, you do not add up every trip; you subtract Tuesday's odometer reading from Friday's. A prefix sum array stores the running total, P[i] = a[0] + ... + a[i-1] with P[0] = 0, and then any range sum is one subtraction: sum(a[i:j]) == P[j] - P[i].

Difference arrays: the reverse trick

Suppose you must apply many range updates ("add 5 to every seat from row 10 to 40") and read the final array once. A difference array D records only the changes at the boundaries: add v at D[i] and subtract v at D[j]. Taking the prefix sum of D at the end reconstructs every value. Each update is O(1); the final pass is O(n).

Complexity: prefix sums O(n) build, O(1) per query, O(n) space. Difference arrays O(1) per update, O(n) to materialize. If you need updates and queries interleaved, a Fenwick tree (M27) does both in O(log n). Two-dimensional prefix sums work the same way for rectangle sums in images and grids.

Remember: prefix sums turn range queries into subtraction; difference arrays turn range updates into two point updates; they are inverses of each other.

Hashing versus sorting

Many search problems can be solved two ways. "Does any pair sum to t?" can sort then use two pointers (O(n log n), O(1) extra space), or scan once with a set of seen values (O(n) expected, O(n) space).

def has_pair_hash(nums: list[int], t: int) -> bool:
    seen: set[int] = set()
    for x in nums:
        if t - x in seen:
            return True
        seen.add(x)
    return False
def has_pair_sort(nums: list[int], t: int) -> bool:
    a = sorted(nums)
    i, j = 0, len(a) - 1
    while i < j:
        s = a[i] + a[j]
        if s == t:
            return True
        i, j = (i + 1, j) if s < t else (i, j - 1)
    return False
NeedPreferWhy
Exact membership, many lookupsset / dictO(1) expected per lookup
Range queries ("all between a and b"), nearest value, k-th smallestsorted list + bisecthashing destroys order
Low memory, data already sortedtwo pointers / binary searchO(1) extra space
Unhashable or only comparable keyssortingneeds only <
Grouping or countingdict / Counterone pass
Deterministic worst casesortinghash tables have adversarial worst cases

Remember: hash for exact lookups, sort for order-dependent questions; when both work, measure.

Practice

Write lower_bound(a, x) returning the first index i such that a[i] >= x in the sorted list a, or len(a) if there is none. Use a binary search loop; do not import bisect.

Hint

Invariant: a[:lo] < x <= a[hi:]. If a[mid] < x, set lo = mid + 1; otherwise hi = mid.

Show solution
def lower_bound(a, x):
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo

Write find_range(a, x) returning a tuple (first, last) of the first and last indexes where x occurs in the sorted list a, or (-1, -1) if it does not occur. Use bisect_left and bisect_right so it runs in O(log n).

Hint

lo = bisect_left(a, x); if lo == len(a) or a[lo] != x, it is absent. Otherwise the last index is bisect_right(a, x) - 1.

Show solution
from bisect import bisect_left, bisect_right

def find_range(a, x):
    lo = bisect_left(a, x)
    if lo == len(a) or a[lo] != x:
        return (-1, -1)
    return (lo, bisect_right(a, x) - 1)

Koko has piles of bananas and h hours. Each hour she picks one pile and eats up to k bananas from it (if the pile has fewer, she finishes it and waits for the next hour). Write min_speed(piles, h) returning the smallest integer k that lets her finish within h hours. Assume len(piles) <= h. It must handle piles up to 10⁹ quickly.

Hint

Hours needed at speed k is sum(ceil(p / k)), which is non-increasing in k. Search k in [1, max(piles)] for the first speed where hours ≤ h. Integer ceiling: (p + k - 1) // k.

Show solution
def min_speed(piles, h):
    lo, hi = 1, max(piles)
    while lo < hi:
        mid = (lo + hi) // 2
        needed = sum((p + mid - 1) // mid for p in piles)
        if needed <= h:
            hi = mid
        else:
            lo = mid + 1
    return lo

Write three_sum(nums) returning a list of all unique triples (a, b, c) with a <= b <= c and a + b + c == 0, sorted ascending. Aim for O(n²): sort, then two pointers for each first element.

Hint

After sorting, skip a first element equal to the previous one. When you find a triple, advance i past all equal values to avoid duplicates.

Show solution
def three_sum(nums):
    nums = sorted(nums)
    out = []
    for k in range(len(nums) - 2):
        if k and nums[k] == nums[k - 1]:
            continue
        i, j = k + 1, len(nums) - 1
        while i < j:
            s = nums[k] + nums[i] + nums[j]
            if s < 0:
                i += 1
            elif s > 0:
                j -= 1
            else:
                out.append((nums[k], nums[i], nums[j]))
                i += 1
                while i < j and nums[i] == nums[i - 1]:
                    i += 1
    return out

Write min_window_len(nums, target) for a list of positive integers, returning the length of the shortest contiguous subarray whose sum is >= target, or 0 if none exists. Use a variable-size sliding window for O(n).

Hint

Add nums[right] to a running sum. While the sum is at least the target, record the window length and subtract nums[left], moving left forward.

Show solution
def min_window_len(nums, target):
    best = 0
    left = 0
    total = 0
    for right, x in enumerate(nums):
        total += x
        while total >= target:
            length = right - left + 1
            if best == 0 or length < best:
                best = length
            total -= nums[left]
            left += 1
    return best

Write two functions. apply_updates(n, updates) starts from n zeros and, for each (start, stop, value), adds value to every index in range(start, stop), returning the final list; use a difference array so each update is O(1). prefix_sums(a) returns P with P[0] == 0 and P[i] == sum(a[:i]) (length len(a) + 1).

Hint

Use a list diff of length n + 1: diff[start] += value, diff[stop] -= value. The answer is the running total of diff[:n] (itertools.accumulate helps).

Show solution
from itertools import accumulate

def apply_updates(n, updates):
    diff = [0] * (n + 1)
    for start, stop, value in updates:
        diff[start] += value
        diff[stop] -= value
    return list(accumulate(diff[:n]))

def prefix_sums(a):
    return [0, *accumulate(a)]

Mini-project: "find my range" log search

Mini-projectlogrange: slice a log by time in O(log n)

Production logs are append-only, so their lines are already sorted by timestamp. When an incident happened "between 14:02 and 14:07," scanning a 2 GB log line by line is slow. Build a tool that binary searches the timestamps to print exactly the lines in a time range.

pixi init logrange && cd logrange
pixi add pytest
pixi run python make_log.py app.log --lines 500000
pixi run python logrange.py app.log --start 2026-10-14T14:02:00 --end 2026-10-14T14:07:00
from bisect import bisect_left
from datetime import datetime

def parse_ts(line: str) -> datetime:
    return datetime.fromisoformat(line.split(" ", 1)[0])

def find_range(lines: list[str], start: datetime, end: datetime) -> list[str]:
    """Return lines with start <= timestamp < end."""
    lo = bisect_left(lines, start, key=parse_ts)
    hi = bisect_left(lines, end, key=parse_ts)
    return lines[lo:hi]
  1. Write make_log.py that generates sorted ISO-8601 timestamped lines with random levels and messages.
  2. Implement find_range with bisect and key=, half-open [start, end). Test boundaries: start before the first line, end after the last, empty ranges, and many lines sharing one timestamp.
  3. Add --level ERROR filtering applied only within the found range, and a --count flag that prints only how many lines matched.
  4. Compare with a linear scan using timeit and report the speedup.
  5. Stretch: search the file without loading it, by seeking to byte offsets with f.seek(), skipping to the next newline, and parsing the timestamp there (binary search over byte positions).
  • The generator produces a sorted log of at least 100,000 lines
  • find_range uses bisect with key= and a half-open interval
  • Boundary tests pass: before start, after end, empty range, duplicate timestamps
  • Level filtering and --count work
  • A timing comparison shows the binary search beating a linear scan
  • Tests pass under pixi run pytest

Stretch goals: the byte-offset file search above; support multiple log files merged with heapq.merge; build a sparse index (every 1,000th line's offset and timestamp) saved alongside the log.

Quiz

Recap

Going further