PYTH-201 · Lesson 7 of 12 · 24 min

Searching

▶ Video coming soon A video version of this lesson is on the way.

Searching is the most common thing programs do with data. The shop looks up a coffee by SKU, Anita finds the sales in a date range, Mei finds a loyalty member by phone number, and the website shows every coffee between 18 and 21 dollars. Lesson 5 showed that a dictionary answers exact lookups in O(1) on average. But many searches are not exact: they ask for the first item above a value, every item in a range, or the nearest match. For those, keeping data sorted and searching it cleverly is the key.

This lesson compares linear search with binary search, the algorithm that finds an item in a sorted list by repeatedly halving the part still to be searched. You will write binary search yourself, learn why it is easy to get subtly wrong, use the standard library's bisect module to do it correctly, answer range questions, and apply the same halving idea to questions that are not about lists at all.

Linear search checks each item in turn until it finds a match or runs out of items. It is simple, works on any list, sorted or not, and is exactly what x in items and items.index(x) do. Its cost is O(n) in the worst case, when the item is last or missing. For short lists, or for a search done only once, linear search is perfectly sensible: sorting the data first would cost more than the search saves.

Linear search works on unsorted data at O(n) cost
def linear_search(items, target):
    """Return (index, comparisons); index is -1 if target is missing."""
    for i, item in enumerate(items):
        if item == target:
            return i, i + 1
    return -1, len(items)

skus = ["RC-104", "RC-101", "RC-106", "RC-102", "RC-105", "RC-103"]
print(linear_search(skus, "RC-102"))
print(linear_search(skus, "RC-999"))
Output
(3, 4)
(-1, 6)

Linear search also answers questions that no index can, such as the first order over 100 dollars placed by a new customer, because it can test any condition. When a condition is checked once over a list, a generator expression with next is the idiomatic Python form: next((o for o in orders if o.total > 100), None) stops at the first match.

If the list is sorted, there is a far better way. Look at the middle item. If it is the target, you are done. If the target is smaller, it can only be in the left half; if larger, only in the right half. Either way, one comparison has discarded half of the remaining items. Repeat on the remaining half until the target is found or nothing remains. Because the search space halves each time, a sorted list of n items needs at most about log₂ n + 1 comparisons: 20 for a million items, 30 for a billion.

The algorithm keeps two indexes, lo and hi, marking the part of the list still to be searched. Each pass computes mid, the middle of that range, compares items[mid] with the target and moves lo or hi past mid. When lo passes hi, the range is empty and the target is not in the list.

Binary search finds any of 500,000 sorted items in about 19 comparisons
def binary_search(items, target):
    """items must be sorted. Return (index, comparisons); index is -1 if missing."""
    lo, hi, comparisons = 0, len(items) - 1, 0
    while lo <= hi:
        mid = (lo + hi) // 2
        comparisons += 1
        if items[mid] == target:
            return mid, comparisons
        if items[mid] < target:
            lo = mid + 1                   # target is in the right half
        else:
            hi = mid - 1                   # target is in the left half
    return -1, comparisons

members = list(range(100_000, 1_100_000, 2))       # 500,000 sorted member numbers
for target in [100_000, 734_562, 1_099_998, 734_563]:
    print(target, binary_search(members, target))
Output
100000 (0, 18)
734562 (317281, 16)
1099998 (499999, 19)
734563 (-1, 19)
A sorted row of eleven prices from 9.50 to 24.00. Three rows below show the search for 19.75. In step one, lo is at index 0, hi at 10 and mid at 5, which holds 18.50, smaller than the target, so the left half is greyed out. In step two, lo is 6, hi is 10, mid is 8, holding 21.50, larger, so the right part is greyed out. In step three, lo is 6, hi is 7, mid is 6, which holds 19.75: found after three comparisons.
Each comparison discards half of the remaining range

Binary search has a famous reputation for being easy to describe and hard to write correctly. The classic mistakes are off-by-one errors: using lo < hi instead of lo <= hi and missing the last remaining item, or setting lo = mid instead of lo = mid + 1, which can loop forever when lo and hi are next to each other. Testing at the edges, the first item, the last item, a missing item smaller than everything and one larger than everything, catches nearly all of them.

The bisect module

Python's standard library already contains a carefully tested binary search in the bisect module. bisect_left(items, x) returns the position where x would be inserted to keep the list sorted, before any equal items; bisect_right returns the position after any equal items. To test whether x is present, check that the position is inside the list and holds x. insort inserts an item in the right place, though the insertion itself still shifts items, so insort is O(n) overall even though finding the position is O(log n).

bisect finds insertion points; insort keeps a list sorted
from bisect import bisect_left, bisect_right, insort

prices = [9.50, 14.00, 18.50, 18.50, 19.75, 21.50, 22.00]
print(bisect_left(prices, 18.50), bisect_right(prices, 18.50))

def contains(sorted_items, x):
    i = bisect_left(sorted_items, x)
    return i < len(sorted_items) and sorted_items[i] == x

print(contains(prices, 19.75), contains(prices, 20.00))
insort(prices, 20.25)
print(prices)
Output
2 4
True False
[9.5, 14.0, 18.5, 18.5, 19.75, 20.25, 21.5, 22.0]

bisect also accepts a key function, so you can search a list of records sorted by one field, such as Coffee objects sorted by price, without building a separate list of prices. The list must already be sorted by that same key, or the results are meaningless: binary search trusts the order completely and does not check it.

Searching records by a key with bisect
from bisect import bisect_left
from dataclasses import dataclass

@dataclass
class Coffee:
    sku: str
    price: float

menu = sorted([Coffee("RC-104", 20.25), Coffee("RC-101", 18.50), Coffee("RC-102", 22.00),
               Coffee("RC-105", 19.00), Coffee("RC-106", 21.50)], key=lambda c: c.price)
i = bisect_left(menu, 20.00, key=lambda c: c.price)
print("first coffee from 20.00 up:", menu[i].sku, menu[i].price)
Output
first coffee from 20.00 up: RC-104 20.25

Range queries

Binary search really pays off for range questions. To find every coffee priced from 18 to 21 dollars, find the first position with a price of at least 18 (bisect_left) and the first position with a price above 21 (bisect_right). Everything between those positions is in range. Both searches are O(log n), and slicing out the k results costs O(k), so the query is O(log n + k), however many items lie outside the range.

Every item in a range with two binary searches
from bisect import bisect_left, bisect_right

def in_range(sorted_items, low, high):
    return sorted_items[bisect_left(sorted_items, low):bisect_right(sorted_items, high)]

prices = [9.50, 14.00, 18.00, 18.50, 19.00, 19.75, 20.25, 21.00, 21.50, 22.00]
print(in_range(prices, 18.00, 21.00))
print(len(in_range(prices, 23.00, 30.00)))

days = ["2026-11-01", "2026-11-03", "2026-11-04", "2026-11-08", "2026-11-09", "2026-11-12"]
print(in_range(days, "2026-11-03", "2026-11-09"))       # ISO dates sort as text
Output
[18.0, 18.5, 19.0, 19.75, 20.25, 21.0]
0
['2026-11-03', '2026-11-04', '2026-11-08', '2026-11-09']

The date example works because ISO dates, written year-month-day with leading zeros, sort correctly as plain text. Anita's sales file is already in time order, so finding a week's sales is two binary searches rather than a pass over the whole year.

Try it yourself

Count coffees in a price range

Write count_in_range(prices, low, high) that returns how many prices in the sorted list prices satisfy low <= price <= high.

Use bisect_left and bisect_right so the function is O(log n); do not loop over the list.

def count_in_range(prices, low, high):
    return sum(1 for p in prices if low < p < high)

print(count_in_range([9.5, 18.0, 19.0, 21.0, 22.0], 18.0, 21.0))

Binary search on the answer

The halving idea works on any yes-or-no question whose answer switches once, from no to yes, as a number increases. Such a question is called monotonic. Rosa asks: what is the smallest number of 30-bag roast batches Kai needs this week to fill all the wholesale orders? Checking a candidate number is easy (multiply and compare), and if some number of batches is enough, every larger number is enough too. Binary search over the possible answers finds the smallest that works in a handful of checks.

Searching for the smallest number of batches that is enough
def enough(batches, orders, bags_per_batch=30):
    return batches * bags_per_batch >= sum(orders)

def fewest_batches(orders, most=1000):
    lo, hi = 0, most                       # the answer lies in [lo, hi]
    while lo < hi:
        mid = (lo + hi) // 2
        if enough(mid, orders):
            hi = mid                       # mid works: the answer is mid or smaller
        else:
            lo = mid + 1                   # mid is too few
    return lo

week = [42, 120, 75, 18, 64]               # bags ordered by five wholesale customers
print(fewest_batches(week), "batches for", sum(week), "bags")
Output
11 batches for 319 bags

Here the arithmetic answer is simply sum divided by 30, rounded up, so binary search is not needed. Its value shows when the check is harder, such as testing whether a delivery schedule fits into a number of driver hours, where no formula exists but checking one candidate is straightforward. This version uses lo < hi with hi = mid, a different but equally correct pattern for finding the first yes; being consistent within one function matters more than which pattern you choose.

Try it yourself

First day below the reorder point

stock is a list of the bags left of one coffee at the end of each day; it never increases. Write first_low_day(stock, point) that returns the index of the first day on which stock was below point, or -1 if it never was.

Use binary search, since the list is monotonic (non-increasing). For [42, 35, 30, 22, 14, 9, 5] and point 10, the answer is 5.

def first_low_day(stock, point):
    for i, bags in enumerate(stock):
        if bags <= point:
            return i
    return -1

print(first_low_day([42, 35, 30, 22, 14, 9, 5], 10))

Choosing a search strategy

Which approach is best depends on how often you search, how often the data changes and what kind of question you ask. The table summarizes the options covered so far, plus the trees of lesson 9, which keep data sorted while allowing fast insertion.

Search strategies and their costs
ApproachSearch costSet-up or update costBest for
Linear searchO(n)NoneShort or unsorted lists, one-off searches, any condition
Binary search (bisect)O(log n)Sort once O(n log n); insert O(n)Sorted data searched often, range queries
dict or setO(1) averageBuild O(n); insert O(1)Exact-match lookups
Balanced search treeO(log n)Insert O(log n)Sorted data that changes often (lesson 9)

Real systems combine these ideas. A database index, which PYTH-215 explores, is a tree designed so that each step of the search reads one block from disk, and it supports both exact lookups and range queries. A spreadsheet's lookup function with an approximate-match option is a binary search, which is why it requires the lookup column to be sorted and gives nonsense when it is not. Search engines first narrow millions of documents down with hash-based indexes of words, then rank the few thousand that match. Even git uses binary search: its bisect command, mentioned in PYTH-110, finds the commit that introduced a bug by testing the middle commit of a range and halving the range each time. Whenever you face a slow search, ask whether the data could be kept sorted or indexed by the thing you search for.

Worked problem

Is sorting first worth it?

Mei needs to check 2,000 phone numbers from a promotion signup sheet against 50,000 loyalty members to find who is already a member. Compare doing a linear search for each number, sorting the members once and binary searching, and building a set.

Show solution
  1. Linear search for each of 2,000 numbers among 50,000 members: about 2,000 × 50,000 = 100 million comparisons in the worst case.
  2. Sorting 50,000 members costs about 50,000 × log₂ 50,000, roughly 800,000 steps, and then 2,000 binary searches cost about 2,000 × 16 = 32,000 comparisons: under a million steps in total.
  3. Building a set of members is about 50,000 steps, and 2,000 lookups are about 2,000 more: the fastest option for exact matches.
  4. If Mei also needed ranges, such as every number in an area code, the sorted list would be the better structure.

Answer: Use a set for exact matching (about 52,000 steps); sorted data with binary search is also fast and supports ranges; repeated linear search is about 100 times slower.

Common mistakes

  • MistakeRunning binary search on data that is not sorted.

    InsteadSort first, by the same key you search with, or use a dict or linear search.

  • MistakeUsing while lo < hi with hi = mid - 1, which skips the last remaining item.

    InsteadPair lo <= hi with mid ± 1, or lo < hi with hi = mid, and test the edges.

  • MistakeSetting lo = mid instead of lo = mid + 1.

    InsteadMove past mid, or the search can loop forever on two items.

  • MistakeSorting a list before every search.

    InsteadSort once and keep it sorted, or use a set for exact matches.

  • MistakeAssuming insort is O(log n).

    InsteadFinding the position is O(log n), but inserting shifts items, so insort is O(n).

  • MistakeUsing bisect_left when equal items should be counted on the right.

    Insteadbisect_left gives the position before equal items, bisect_right the position after.

Summary and key terms

  • Linear search works on any data and any condition, at O(n) cost.
  • Binary search finds an item in a sorted list in O(log n) comparisons by halving the range each time.
  • Binary search is easy to get wrong; test the first item, last item and missing items at both ends.
  • The bisect module provides tested binary search: bisect_left, bisect_right and insort.
  • Range queries need two binary searches and cost O(log n + k) for k results.
  • Binary search also finds the smallest answer to any monotonic yes-or-no question.
  • Choose linear search, binary search, a dict or a tree based on how often you search and update.
linear search
Checking items one by one until a match is found.
binary search
Searching a sorted sequence by repeatedly halving the range that could hold the target.
search space
The part of the data that could still contain the target.
off-by-one error
A bug where a loop or index is wrong by exactly one.
bisect_left
The position to insert x before any equal items, keeping a list sorted.
bisect_right
The position to insert x after any equal items.
insort
Insert an item into a sorted list at the correct position.
range query
A search for every item between two values.
monotonic
Never changing direction: a yes-or-no answer that switches only once as a number grows.
key function
A function that extracts the value used for sorting or searching.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. At most how many comparisons does binary search need for 1,000 sorted items?

    Show answer

    About 10 (2 to the 10th power is 1,024).

  2. Trace binary search for 22.0 in [9.5, 14.0, 18.5, 19.75, 22.0]. Which indexes are checked?

    Show answer

    mid = 2 (18.5), then mid = 3 (19.75), then mid = 4 (22.0): found.

  3. What do bisect_left and bisect_right return for 18.5 in [9.5, 18.5, 18.5, 22.0]?

    Show answer

    1 and 3.

  4. Why is insort O(n) even though bisect is O(log n)?

    Show answer

    Inserting into a list shifts the later items.

  5. What is the cost of finding all k prices between two values in a sorted list?

    Show answer

    O(log n + k).

  6. Give an example of a monotonic question from Ridgeline's business.

    Show answer

    For example: is this many batches enough to fill the week's orders?

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz