Unit 7: Remember the Answer
Dynamic programming, the way contests use it.
Unit 7 of 25 in Competitive programming for kids. Its 5 lessons are State, Move, Start, Counting the Ways, The Longest Climb, The Knapsack and The Salesman Comes Back — 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.
🧩 State, Move, Start
The three questions
Every dynamic programming solution answers the same three questions.
What is the state? The smallest description of a part-finished problem. What are the moves? How a state is reached from smaller ones. Where does it start? The state whose answer needs no working out.
Get those three right and the code writes itself.
Back to the coins that beat greedy
Unit 4 paid 8 pence with coins of 1, 4 and 5, and greedy used four coins when two would do. Here is the fix.
State: an amount. Moves: pay one coin, leaving a smaller amount. Start: 0 pence needs 0 coins.
Python
coins = [1, 4, 5]
target = 8
big = 999
best = [0] + [big] * target
for amount in range(1, target + 1):
for c in coins:
if c <= amount:
best[amount] = min(best[amount], best[amount - c] + 1)
print(best)
print(best[target])
It prints
[0, 1, 2, 3, 1, 1, 2, 3, 2] 2
Every amount is worked out once, and never again
That is the whole idea. The plain recursion from unit 4 asks "how do I pay 4 pence?" over and over; the table asks once and writes it down.
The cost is the number of states times the number of moves: amount * coins. A million pence and ten coins is ten million steps, which fits in a second.
The same thing, upside down
Filling a table from the bottom is one way. The other is the recursion you already had, plus a notebook of answers already found — memoisation.
Identical work, and the notebook shows it: eight amounts were ever asked about.
Python
coins = [1, 4, 5]
memo = {}
def fewest(amount):
if amount == 0:
return 0
if amount in memo:
return memo[amount]
best = 999
for c in coins:
if c <= amount:
best = min(best, fewest(amount - c) + 1)
memo[amount] = best
return best
print(fewest(8), len(memo))
It prints
2 8
Try it yourself
A DP has 100000 states and 10 moves from each. Roughly how many steps?
- 100 thousand
- A million
- A million million
- 10
What does this print?
Python
coins = [1, 3]
target = 5
big = 999
best = [0] + [big] * target
for amount in range(1, target + 1):
for c in coins:
if c <= amount:
best[amount] = min(best[amount], best[amount - c] + 1)
print(best[target])
Answer them in the app
🔢 Counting the Ways
Swap the min for a plus
"How few coins?" becomes "how many ways?" by changing one operator. Instead of keeping the best move, add up what every move offers.
The start changes too: there is exactly one way to pay nothing.
Python
coins = [1, 3, 4]
target = 5
ways = [1] + [0] * target
for c in coins:
for amount in range(c, target + 1):
ways[amount] += ways[amount - c]
print(ways)
It prints
[1, 1, 1, 2, 3, 3]
Now swap the two loops round
Same numbers, same +=, loops the other way about — and the answer is 6 instead of 3.
Neither is a bug. With the coins outside, 1+4 and 4+1 are the same handful of coins. With the amounts outside, they are two different ways to pay.
Python
coins = [1, 3, 4]
target = 5
ways = [1] + [0] * target
for amount in range(1, target + 1):
for c in coins:
if c <= amount:
ways[amount] += ways[amount - c]
print(ways)
It prints
[1, 1, 1, 2, 4, 6]
Read the question, then choose the loop
If the problem says "in how many ways can you pay" and the coins are a handful, you want the first one. If it says "how many sequences of steps", you want the second.
Getting this backwards is one of the most common wrong answers in contest history, and it costs nothing to check: try it on 5 with coins 1, 3 and 4 and see whether you expected 3 or 6.
The same shape on a grid
Paths from the top left to the bottom right, moving only right or down. The ways into a square are the ways into the square above plus the ways into the square to its left — and a wall has no ways at all.
Python
grid = [
"....",
".#..",
"....",
]
rows = 3
cols = 4
ways = [[0] * cols for r in range(rows)]
ways[0][0] = 1
for r in range(rows):
for c in range(cols):
if grid[r][c] == "#":
ways[r][c] = 0
continue
if r > 0:
ways[r][c] += ways[r - 1][c]
if c > 0:
ways[r][c] += ways[r][c - 1]
for row in ways:
print(row)
It prints
[1, 1, 1, 1] [1, 0, 1, 2] [1, 1, 2, 4]
Try it yourself
Which loop order counts 1+4 and 4+1 as the same way of paying 5?
- Coins on the outside, amounts on the inside
- Amounts on the outside, coins on the inside
- Either — they always agree
- Neither — you need a set
What does this print?
Python
coins = [1, 2]
target = 4
ways = [1] + [0] * target
for c in coins:
for amount in range(c, target + 1):
ways[amount] += ways[amount - c]
print(ways[target])
Answer them in the app
📈 The Longest Climb
Pick numbers going up, without reordering them
From 7, 3, 5, 3, 6, 2, 9 pick as many as you can, in the order they appear, each bigger than the last. The best is 3, 5, 6, 9 — four of them.
This is the longest increasing subsequence, and it turns up in contests constantly, usually in disguise.
The state is "climbs ending here"
length[i] is the longest climb that finishes at position i. To fill it, look back at every earlier position holding a smaller number and take the best of those, plus one.
Two loops, so O(n * n): fine to 5000 numbers, and the answer is the biggest entry rather than the last one.
Python
numbers = [7, 3, 5, 3, 6, 2, 9]
n = len(numbers)
length = [1] * n
for i in range(n):
for j in range(i):
if numbers[j] < numbers[i]:
length[i] = max(length[i], length[j] + 1)
print(length)
print(max(length))
It prints
[1, 1, 2, 1, 3, 1, 4] 4
A million numbers needs something better
Keep a list of ends: tails[k] is the smallest number that any climb of length k + 1 can end on. Small ends are worth more, because more numbers can follow them.
For each new number, find where it belongs with a binary search. Off the end — it extends the longest climb. Inside — it becomes a better end for that length.
Python
import bisect
numbers = [7, 3, 5, 3, 6, 2, 9]
tails = []
for x in numbers:
pos = bisect.bisect_left(tails, x)
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
print(x, tails)
print(len(tails))
It prints
7 [7] 3 [3] 5 [3, 5] 3 [3, 5] 6 [3, 5, 6] 2 [2, 5, 6] 9 [2, 5, 6, 9] 4
Careful: that list is not the answer
tails ends up as [2, 5, 6, 9], and that is not a climb in the original list — the 2 comes after the 6.
Only its length is right. If a problem asks for the climb itself you have to record where each number came from, which is the usual reason people lose marks on this one.
Try it yourself
Why does the fast version keep the smallest possible end for each length?
- To save memory
- Because a smaller end leaves more numbers that can extend the climb
- Because the list has to stay sorted
- To make the binary search faster
What does this print?
Python
numbers = [4, 4, 4, 4]
n = len(numbers)
length = [1] * n
for i in range(n):
for j in range(i):
if numbers[j] < numbers[i]:
length[i] = max(length[i], length[j] + 1)
print(max(length))
Answer them in the app
🎒 The Knapsack
A bag that holds 6 kilos
Three things to take: 3kg worth 4, 4kg worth 5, 2kg worth 3. The bag holds 6 kilos, and each thing can be taken once or not at all.
Greedy by value picks the 5 and then the 3 — 6 kilos, worth 8. Here greedy happens to be right, and on the next data it will not be.
One row per item
best[c] is the most value that fits in c kilos using the items seen so far. Take each item in turn and let it improve every capacity it fits in.
Watch the row change as each item is offered.
Python
items = [(3, 4), (4, 5), (2, 3)]
capacity = 6
best = [0] * (capacity + 1)
for weight, value in items:
for c in range(capacity, weight - 1, -1):
best[c] = max(best[c], best[c - weight] + value)
print(best)
print(best[capacity])
It prints
[0, 0, 0, 4, 4, 4, 4] [0, 0, 0, 4, 5, 5, 5] [0, 0, 3, 4, 5, 7, 8] 8
The capacity loop runs backwards for a reason
Forwards, best[c - weight] may already include this item, so it gets taken twice. Backwards, the value it reads is always from before this item existed.
Here is the same item offered forwards: one 3kg item worth 4 somehow fills a 6kg bag with 8.
Python
capacity = 6
weight = 3
value = 4
best = [0] * (capacity + 1)
for c in range(weight, capacity + 1):
best[c] = max(best[c], best[c - weight] + value)
print(best)
It prints
[0, 0, 0, 4, 4, 4, 8]
So the direction is the difference between two problems
Backwards is the 0/1 knapsack — each item once. Forwards is the unbounded knapsack — as many of each as you like, which is exactly what the coin problems in lessons 1 and 2 were.
One word in the problem statement decides it, and one word in your loop answers it.
Try it yourself
Which loop direction lets each item be taken only once?
- Forwards
- Backwards
- Either
- It depends on the values
What does this print?
Python
items = [(2, 3), (2, 3)]
capacity = 4
best = [0] * (capacity + 1)
for weight, value in items:
for c in range(capacity, weight - 1, -1):
best[c] = max(best[c], best[c - weight] + value)
print(best[capacity])
Answer them in the app
🏆 The Salesman Comes Back
Unit 2 tried every order. Now count what that cost
Twenty towns, every order: about two million million million tours. There is no computer on earth that finishes that.
But think about what actually matters half way round a tour: which towns you have already visited, and where you are standing. Not the order you did them in.
Python
import math
for n in [10, 15, 20]:
print(n, math.factorial(n - 1), (1 << n) * n * n)
It prints
10 362880 102400 15 87178291200 7372800 20 121645100408832000 419430400
The state is a bitmask and a town
So the state is (visited, here) — and unit 3 already showed that a set of visited towns is just a number.
There are 2 n masks times n towns of them, and from each you may move to any of n towns. That is the 2 n * n * n in the table, and for 20 towns it is 400 million: big, but finite.
Filling it in
Start in town 0 with only town 0 visited, at cost 0. From every reachable state, step to a town that is not in the mask yet and write down the cheaper of what is there and what you have.
At the end, every town has been visited: add the road home and take the best.
Python
d = [
[0, 5, 9, 4],
[5, 0, 3, 8],
[9, 3, 0, 6],
[4, 8, 6, 0],
]
n = 4
big = 10 ** 9
dp = [[big] * n for mask in range(1 << n)]
dp[1][0] = 0
for mask in range(1 << n):
for here in range(n):
if dp[mask][here] == big:
continue
for nxt in range(n):
if (mask >> nxt) & 1:
continue
step = dp[mask][here] + d[here][nxt]
if step < dp[mask | (1 << nxt)][nxt]:
dp[mask | (1 << nxt)][nxt] = step
full = (1 << n) - 1
print(min(dp[full][i] + d[i][0] for i in range(1, n)))
It prints
18
The same 18 as unit 2, and that is the point
Complete search got 18 by looking at six whole tours. This gets 18 by filling in 64 little boxes, and it would still be working at twenty towns where complete search stopped being possible.
Nothing was approximated. The answer is exactly the same one — found by asking a better question about what a half-finished tour needs to remember.
Try it yourself
Why is (visited, here) enough — why not remember the whole route?
- The route is too long to store
- Nothing ahead depends on the order you visited them in, only on which ones are left and where you are
- The route is always the same
- It is not enough — this only gives an estimate
What does this print?
Python
n = 4
print((1 << n) * n, 1 << n)
Answer them in the app