Index Ai Academy
Phase 7 · Software engineering · Data structures & algorithms

Searching, sorting and dynamic programming

The classic algorithms, and the ideas behind them.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A handful of classic algorithms show up everywhere: in databases, search engines, diff tools and spell checkers. Learning them teaches more than the algorithms themselves. You learn the moves behind them: halve the problem, split and combine, and remember answers you've already worked out. Those moves let you invent your own solutions. The last one, dynamic programming, also explains how a model's tokenizer and many text-matching tools work.

What you'll be able to do

  • Write binary search and explain why it needs sorted data
  • Explain how merge sort reaches O(n log n) by splitting and merging
  • Speed up a recursive function with memoization
  • Solve a problem with dynamic programming: edit distance between two strings

What it covers

  1. Binary search
  2. Sorting: divide and conquer
  3. Memoization: remember what you've worked out
  4. Dynamic programming
  5. Edit distance, one table cell at a time
  6. Use the library, understand the idea

Looking for a name in a phone book, you don't start at page one. You open the middle, see whether the name comes before or after, and throw away half the book. Repeat, and a million names take about 20 looks. That's binary search: O(log n).

It only works on sorted data. If the list isn't in order, the middle item tells you nothing about which half to keep.

In plain words: when the data is in order, one look can rule out half of what's left.

def binary_search(items, target):
    lo, hi = 0, len(items) - 1
    looks = 0
    while lo <= hi:
        looks += 1
        mid = (lo + hi) // 2
        if items[mid] == target:
            return mid, looks
        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, looks

prices = list(range(0, 2_000_000, 2))     # a million sorted prices
print(binary_search(prices, 1_234_568))
print(binary_search(prices, 7))           # odd numbers aren't there
It runs right here in your browser, with nothing to install.

Python's bisect module has this built in. Databases use the same idea in their indexes.

Try it now

Each pass throws away half of what's left. Watch low and high close in.

Quick check
Binary search on 1,000 sorted items needs at most about how many looks?
  1. 10
  2. 1,000
  3. 100
  4. 500
Show the answer

10

Halving 1,000 ten times gets you to one item (2 to the 10th is 1,024). It's O(log n).

Sorting: divide and conquer

The simple sorts you'd invent yourself compare items pair by pair, and they're O(n²). There's a better way, and you'd probably find it if you had to sort a big stack of exam papers with friends. You split the stack and hand each friend half. They do the same, until everyone holds one or two papers. Then each person combines two sorted piles by repeatedly taking the lower of the two top papers.

That's merge sort, an example of dividing and conquering:

  1. Split the list in half.
  2. Sort each half the same way (recursion). A list of 0 or 1 items is already sorted, which is the base case.
  3. Merge the two sorted halves: repeatedly take the smaller of the two front items.

Merging two sorted lists is fast, because you only ever compare the two front items:

def merge(a, b):
    out, i, j = [], 0, 0
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            out.append(a[i]); i += 1
        else:
            out.append(b[j]); j += 1
    return out + a[i:] + b[j:]

def merge_sort(items):
    if len(items) <= 1:
        return items
    mid = len(items) // 2
    return merge(merge_sort(items[:mid]), merge_sort(items[mid:]))

print(merge([1, 4, 9], [2, 3, 10]))
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))

Why O(n log n)? Halving a list of n items takes about log n levels to reach single items. At each level, merging touches every item once, which is n work. So it's n work, log n times.

Python's built-in sorted uses Timsort, a tuned mix of merge sort and other tricks. Use it in real code. It's stable, too: items that compare equal keep their original order. That lets you sort by one field, then another.

Quick check
Why does binary search need sorted data, while merge sort doesn't?
  1. They both need sorted data
  2. Binary search decides which half to discard by comparing with the middle; merge sort creates the order itself
  3. Merge sort is slower, so it can afford unsorted data
  4. Binary search only works on numbers
Show the answer

Binary search decides which half to discard by comparing with the middle; merge sort creates the order itself

Throwing away half is only safe if everything in that half is on the wrong side of the target, which requires order.

Memoization: remember what you've worked out

If someone asks you what 37 × 41 is, you work it out once. If they keep asking, you write the answer on a sticky note. Some recursive functions never learn this, and solve the same small problem again and again. The classic case is the Fibonacci numbers, where each number is the sum of the two before it:

calls = 0
def fib(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(25), "after", calls, "calls")

Hundreds of thousands of calls for one number! fib(23) gets worked out again and again. The fix is to save each answer the first time and look it up after. That's memoization, and Python gives you a decorator for it:

from functools import cache

calls = 0
@cache
def fib(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(25), "after", calls, "calls")
print(fib(200))

Each n is now computed once, so it's O(n) instead of growing exponentially.

Quick check
What does this print?
calls = 0
seen = {}
def f(n):
    global calls
    if n in seen: return seen[n]
    calls += 1
    seen[n] = n * n
    return seen[n]
f(3); f(3); f(3)
print(calls)
  1. 1
  2. 3
  3. 9
  4. 0
Show the answer

1

The answer is stored after the first call, so the later two return early and calls stays at 1. The work is done only once.

Dynamic programming

Dynamic programming (DP) is memoization turned into a plan. You break a problem into small subproblems, solve them in an order where each one only needs answers you already have, and store those answers in a table. The name is old and unhelpful. Think of it as "fill in a table of smaller answers", the way a mileage chart lets you read off a long trip from shorter ones.

A famous example is edit distance: the fewest single-letter changes (insert, delete or swap a letter) that turn one word into another. Spell checkers use it to suggest "tent" when you type "tnet". Search engines use it to forgive typos, and diff tools use a close relative.

Build a table where cell [i][j] holds the distance between the first i letters of word A and the first j letters of word B:

  • The first row and column are easy: turning "" into the first j letters takes j inserts.
  • For any other cell, look at the letters A[i-1] and B[j-1]. If they match, the cell copies its diagonal neighbor [i-1][j-1], at no cost. Otherwise take 1 plus the smallest of three neighbors: left (insert), above (delete) and diagonal (swap).

The answer is the bottom-right cell.

In plain words: each cell asks "what's the cheapest way to get this far?", and answers using cells you've already filled in.

Worked example

Edit distance, one table cell at a time

This fills the table for "tent" and "tnet" and prints it, so you can see each cell built from its neighbors.

def edit_table(a, b):
    rows = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(len(a) + 1):
        rows[i][0] = i
    for j in range(len(b) + 1):
        rows[0][j] = j
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                rows[i][j] = rows[i - 1][j - 1]
            else:
                rows[i][j] = 1 + min(rows[i][j - 1], rows[i - 1][j], rows[i - 1][j - 1])
    return rows

table = edit_table("tent", "tnet")
print("    ''  t  n  e  t")
for label, row in zip(["''", "t ", "e ", "n ", "t "], table):
    print(label, row)
print("distance:", table[-1][-1])
In the lesson you can change this and run it.

The bottom-right cell says 2: swap e for n, then n for e. Each cell needed only the three cells above and to its left, so filling rows top to bottom always has the answers ready. The table has (len(a) + 1) × (len(b) + 1) cells, so the work is O(n × m).

Quick check
What does memoization trade to get its speed?
  1. Accuracy: it rounds the answers
  2. Nothing: it's always free
  3. Readability: it needs assembly code
  4. Memory: it stores every answer it has worked out
Show the answer

Memory: it stores every answer it has worked out

It keeps a table of past answers. That costs memory, which is usually a great trade against repeated work.

Use the library, understand the idea

You'll almost never write your own sort for real work. You don't build your own car to learn how engines work either, but knowing helps. Python's sorted is carefully built, well tested, and fast on the partly sorted data that real programs tend to have. Learning merge sort is worthwhile anyway, because it teaches the idea of dividing a problem and combining the answers, which comes up everywhere.

The same goes for binary search. Python has it in the bisect module. But you must know its one rule: the data has to be sorted. On unsorted data, it doesn't fail with an error. It gives wrong answers, and bugs like that are hard to find. A quick check while testing, assert items == sorted(items), is worth the line.

Recursion and dynamic programming, demystified

Recursion has a reputation for being slow, and the plain version can be. Computing Fibonacci numbers recursively redoes the same work billions of times. But once you add memoization, each answer is worked out only once, and it's as fast as a loop. Often the slowness was never about recursion, only about repeated work.

The name "dynamic programming" misleads nearly everyone. It has nothing to do with dynamic code. It's a name from the 1950s for a simple idea: break a problem into smaller ones that overlap, then fill in a table of their answers, starting with the smallest. If you can describe each answer in terms of smaller answers, you can write it down this way. You saw it in edit distance, and it's the method behind tools that compare two documents.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What's the Big O of merge sort?

  1. O(n log n)
  2. O(n²)
  3. O(log n)
  4. O(n)

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson