Searching, sorting and dynamic programming
The classic algorithms, and the ideas behind them.
Create a free account Try part of it here
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
- Binary search
- Sorting: divide and conquer
- Memoization: remember what you've worked out
- Dynamic programming
- Edit distance, one table cell at a time
- Use the library, understand the idea
Binary search
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
Python's bisect module has this built in. Databases use the same idea in their indexes.
Each pass throws away half of what's left. Watch low and high close in.
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:
- Split the list in half.
- Sort each half the same way (recursion). A list of 0 or 1 items is already sorted, which is the base case.
- 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.
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.
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)
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
jletters takesjinserts. - For any other cell, look at the letters
A[i-1]andB[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.
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])
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).
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
Try a quiz question
What's the Big O of merge sort?