Unit 31: Dynamic Programming
Never solve the same thing twice.
Unit 31 of 31 in Python for kids. Its 6 lessons are Remembering Answers, Building From the Bottom, Counting the Ways, What to Take, Comparing Two Words and DP Master — below is everything each one explains, and a question or two from it to try.
Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.
This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.
🧠 Remembering Answers
Write it down the first time
The fix for all that repeated work is embarrassingly simple: keep a dictionary of answers you have already worked out, and look there first. This is memoisation.
Python
def fib(n, memo):
if n in memo:
return memo[n]
if n < 2:
return n
memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
return memo[n]
print(fib(20, {}))
print(fib(50, {}))
It prints
6765 12586269025
21,891 calls becomes 39
Same answer, same recursion — but nothing is worked out twice. And fib(50), which would have taken longer than a school term, returns instantly.
Python
calls = 0
def fib(n, memo):
global calls
calls = calls + 1
if n in memo:
return memo[n]
if n < 2:
return n
memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
return memo[n]
calls = 0
fib(20, {})
print(calls)
It prints
39
Python will do it for you
@lru_cache is a decorator that adds memoisation to any function. This is what decorators are *for* — the same function, quietly given a memory.
Python
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(50))
print(fib(90))
It prints
12586269025 2880067194370816120
Try it yourself
When does memoisation help?
- Always
- When the same smaller problems come up more than once
- Only for Fibonacci
- When the function is slow
What does memoisation cost you?
- Nothing at all
- Memory — every remembered answer has to be kept somewhere
- Accuracy
- It makes code longer to run
Answer them in the app
🧱 Building From the Bottom
Start small and work upwards
Instead of asking for the big answer and recursing down, fill in a table starting from the smallest case. This is tabulation, and it needs no recursion at all.
Python
def fib(n):
table = [0] * (n + 1)
table[1] = 1
for i in range(2, n + 1):
table[i] = table[i - 1] + table[i - 2]
return table[n]
print(fib(20))
print(fib(50))
It prints
6765 12586269025
You rarely need the whole table
Fibonacci only ever looks two steps back, so two variables will do. This is the same idea using almost no memory.
Python
def fib(n):
a = 0
b = 1
for _ in range(n):
a, b = b, a + b
return a
print(fib(10))
print(fib(50))
It prints
55 12586269025
Climbing stairs
You can go up one or two steps at a time. The ways to reach step 5 are the ways to reach step 4 plus the ways to reach step 3 — because you arrive from one or the other.
Python
def ways(n):
table = [0] * (n + 1)
table[0] = 1
for i in range(1, n + 1):
table[i] = table[i - 1]
if i > 1:
table[i] = table[i] + table[i - 2]
return table[n]
for steps in [1, 2, 3, 4, 5]:
print(steps, ways(steps))
It prints
1 1 2 2 3 3 4 5 5 8
Try it yourself
Those stair numbers — 1, 2, 3, 5, 8 — look familiar. Why?
- Coincidence
- It is Fibonacci: each answer is built from the two before it, exactly the same way
- Because stairs come in pairs
- They are the square numbers
What is the main advantage of tabulation over memoised recursion?
- It gives different answers
- No recursion, so no risk of running out of stack on a big problem
- It needs no table
- It is always shorter
Answer them in the app
🔢 Counting the Ways
Paths across a grid
Moving only right or down, the ways to reach a square are the ways to reach the one above plus the one to its left. Fill the grid in and the answer is in the corner.
Python
def paths(rows, cols):
grid = [[1] * cols for _ in range(rows)]
for r in range(1, rows):
for c in range(1, cols):
grid[r][c] = grid[r - 1][c] + grid[r][c - 1]
return grid[rows - 1][cols - 1]
print(paths(2, 3))
print(paths(3, 3))
print(paths(4, 4))
It prints
3 6 20
Fewest coins to make an amount
For each amount, try every coin and keep the best. total + 1 stands in for "impossible", since no answer could ever really need that many coins.
Python
def fewest(coins, total):
impossible = total + 1
table = [0] + [impossible] * total
for amount in range(1, total + 1):
for coin in coins:
if coin <= amount:
table[amount] = min(table[amount], table[amount - coin] + 1)
if table[total] == impossible:
return -1
return table[total]
print(fewest([1, 2, 5], 11))
print(fewest([1, 3, 4], 6))
print(fewest([5], 3))
It prints
3 2 -1
Counting ways rather than fewest
A small change asks a different question. Looping coins on the outside counts each *combination* once, so 1+2+2 and 2+1+2 are not counted twice.
Python
def ways(coins, total):
table = [1] + [0] * total
for coin in coins:
for amount in range(coin, total + 1):
table[amount] = table[amount] + table[amount - coin]
return table[total]
print(ways([1, 2, 5], 5))
It prints
4
Try it yourself
Why does taking the biggest coin first not always work?
- It always works
- With coins 1, 3 and 4, greedily taking 4 then 1 then 1 gives three coins — but 3 and 3 is only two
- Big coins are worth less
- It works but is slow
What does this print?
Python
grid = [[1] * 3 for _ in range(2)]
for r in range(1, 2):
for c in range(1, 3):
grid[r][c] = grid[r - 1][c] + grid[r][c - 1]
print(grid)
Answer them in the app
🎒 What to Take
A bag that will not hold everything
Each item has a weight and a value, and the bag has a limit. The knapsack problem asks for the most valuable load that fits — and greedily taking the most valuable thing first does not work.
Python
weights = [2, 3, 4]
values = [3, 4, 5]
print("bag holds 5")
print("greedy takes item 3 (value 5, weight 4), then nothing fits ->", 5)
print("best is items 1 and 2 (weight 5) ->", 3 + 4)
It prints
bag holds 5 greedy takes item 3 (value 5, weight 4), then nothing fits -> 5 best is items 1 and 2 (weight 5) -> 7
Two questions per item
For every item and every possible bag size, ask: better to leave it, or to take it and use the rest of the space? The table remembers every answer.
Python
def knapsack(weights, values, capacity):
n = len(weights)
table = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
table[i][w] = table[i - 1][w]
if weights[i - 1] <= w:
taking = table[i - 1][w - weights[i - 1]] + values[i - 1]
table[i][w] = max(table[i][w], taking)
return table[n][capacity]
print(knapsack([2, 3, 4], [3, 4, 5], 5))
It prints
7
A bigger bag never makes things worse
Reading along a row shows the best value climbing as the bag grows and then levelling off once everything worth taking already fits.
Python
def best_for_each_size(weights, values, capacity):
n = len(weights)
table = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
table[i][w] = table[i - 1][w]
if weights[i - 1] <= w:
table[i][w] = max(table[i][w], table[i - 1][w - weights[i - 1]] + values[i - 1])
return table[n]
print(best_for_each_size([2, 3, 4], [3, 4, 5], 9))
It prints
[0, 0, 3, 4, 5, 7, 8, 9, 9, 12]
Try it yourself
What does table[i][w] mean?
- The weight of item i
- The best value using the first i items with a bag of size w
- How many items fit
- The value of item i
Answer it in the app
🔤 Comparing Two Words
The longest thing two words share
The longest common subsequence is the longest run of letters appearing in both, in the same order but not necessarily together. If the two letters match, the answer grows by one; if not, take the better of dropping one letter from either.
Python
def lcs(a, b):
table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
if a[i - 1] == b[j - 1]:
table[i][j] = table[i - 1][j - 1] + 1
else:
table[i][j] = max(table[i - 1][j], table[i][j - 1])
return table[len(a)][len(b)]
print(lcs("cat", "cart"))
print(lcs("ABCBDAB", "BDCABA"))
It prints
3 4
How many edits to turn one word into another
Edit distance counts the changes needed — insert, delete or swap a letter. It is how spellcheckers guess what you meant.
Python
def edit(a, b):
table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(len(a) + 1):
table[i][0] = i
for j in range(len(b) + 1):
table[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]:
table[i][j] = table[i - 1][j - 1]
else:
table[i][j] = 1 + min(table[i - 1][j], table[i][j - 1], table[i - 1][j - 1])
return table[len(a)][len(b)]
print(edit("cat", "cut"))
print(edit("kitten", "sitting"))
It prints
1 3
Why this is the same idea as the knapsack
Both fill a grid where each square depends only on squares already filled in. That is what dynamic programming *is* — not a trick, a shape.
Python
print("knapsack : table[i][w] from table[i-1][...]")
print("edit : table[i][j] from table[i-1][...] and table[i][j-1]")
print("both : each square built from squares already done")
It prints
knapsack : table[i][w] from table[i-1][...] edit : table[i][j] from table[i-1][...] and table[i][j-1] both : each square built from squares already done
Try it yourself
Why is the first row of the edit table 0, 1, 2, 3…?
- It is just a counter
- Turning an empty word into a word of length 3 takes 3 insertions
- To make the table square
- It could be anything
Answer it in the app
🏆 DP Master
Try it yourself
What does this print?
Python
table = [0] * 6
table[0] = 1
for i in range(1, 6):
table[i] = table[i - 1]
if i > 1:
table[i] = table[i] + table[i - 2]
print(table)
Answer it in the app