Dynamic Programming I
The question
A courier's bag holds 10 kg. Five parcels are waiting, and each pays a fee on delivery:
| parcel | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| weight (kg) | 4 | 3 | 5 | 2 | 6 |
| pay | 10 | 7 | 12 | 3 | 13 |
Which parcels should go in the bag to earn the most? This is 0/1 knapsack: items with integer weights and values , a capacity , and a subset of total weight at most whose value is as large as possible. Each item is taken once or not at all.
This module is about a method more than a problem. Knapsack, independent sets on trees and longest increasing subsequences look unrelated, and each becomes a table once seven decisions are made. Prerequisites: Python, big-O notation and proof by induction; module 07's doubling array appears once, in the recap.
Trying everything, and trying greedily
With five parcels you can try all subsets. The best is parcels 0 and 4: weight 10, pay 23, and no other subset reaches it. The obvious shortcut, taking parcels in order of pay per kilogram while they fit, takes parcel 0 (2.5 per kg) and parcel 2 (2.4 per kg), then nothing else fits: 22.
from itertools import combinations
from functools import lru_cache
WTS, PAY, CAP = [4, 3, 5, 2, 6], [10, 7, 12, 3, 13], 10
def brute_force(wts, pay, cap):
best = (0, ())
for r in range(len(wts) + 1):
for s in combinations(range(len(wts)), r):
if sum(wts[i] for i in s) <= cap and sum(pay[i] for i in s) > best[0]:
best = (sum(pay[i] for i in s), s)
return best
def greedy_by_ratio(wts, pay, cap):
load = total = 0
for i in sorted(range(len(wts)), key=lambda i: -pay[i] / wts[i]):
if load + wts[i] <= cap:
load, total = load + wts[i], total + pay[i]
return total
assert brute_force(WTS, PAY, CAP) == (23, (0, 4)) and greedy_by_ratio(WTS, PAY, CAP) == 22
Greedy is wrong because a good ratio can leave an awkward gap: after parcels 0 and 2 the bag has 1 kg left, and 1 kg earns nothing. Brute force is right but takes subsets, about at .
Where does brute force waste its work? Write it as a recursion: the best pay from parcels with kg of room either skips parcel or takes it. On twenty parcels and a 50 kg bag, that recursion makes 710,746 calls, but it only ever asks 777 different questions. It answers the same question, "best from the first parcels with kg left", hundreds of times.
import random
rng = random.Random(9)
w20 = [rng.randint(1, 10) for _ in range(20)]
v20 = [rng.randint(1, 30) for _ in range(20)]
calls, asked = 0, set()
def plain(i, c):
global calls
calls += 1
asked.add((i, c))
if i == 0:
return 0
best = plain(i - 1, c)
if w20[i - 1] <= c:
best = max(best, plain(i - 1, c - w20[i - 1]) + v20[i - 1])
return best
assert plain(20, 50) == 232 and calls == 710_746 and len(asked) == 777
Dynamic programming is the decision to answer each question once and write the answer down.
Seven decisions before any code
Every dynamic program in this course is designed by making the same seven decisions, in words, before writing a line:
| Decision | The question it answers |
|---|---|
| State | What does one table entry mean, in one sentence, with its indices? |
| Recurrence | How is an entry computed from smaller entries, and why does its case split cover every solution? |
| Base case | Which entries are known without the recurrence? |
| Evaluation order | In what order can the table be filled so that every entry's inputs are ready? |
| Answer location | Which entry, or which max over entries, is the answer? |
| Reconstruction | How do you recover the object (the subset, the nodes, the subsequence), not just its value? |
| Space | Which entries can be forgotten, and what does forgetting them cost? |
The state is the decision that everything else hangs on. If you cannot say what one cell means, you cannot check the recurrence, and "what does this cell mean?" is the first question to ask of any DP you debug.
Invariant (every table in this module)
When an entry is written, it equals the optimum of exactly the subproblem its state names.
The proof is always the same induction in evaluation order. The recurrence reads only entries written earlier, which are correct by induction. Its cases are exhaustive: every feasible solution of the subproblem falls into one case. And within a case, the best solution is that case's choice plus an optimal solution of a smaller state. If the rest were not optimal, swapping in a better rest would improve the whole (an exchange argument). That shows the optimum is at most what the recurrence computes. Conversely, every candidate the recurrence compares is a real solution (a case's choice added to a feasible solution of a smaller state), so the recurrence never exceeds the optimum. The two bounds meet.
Knapsack, decided
- State: is the best pay using only parcels with room , for and .
- Recurrence: , the second option only when . An optimal subset for either leaves parcel out, and then the rest is optimal for , or contains it, and then the rest is optimal for . Those two cases cover every subset. Conversely, both options are achieved by real subsets: by one that fits in , and the second option by adding parcel to one that fits in .
- Base case: for every : no parcels, no pay.
- Evaluation order: increasing. Row reads only row , so the order within a row does not matter.
- Answer location: .
- Reconstruction: walk back from . If , parcel was needed: record it and subtract its weight from . Then move to row .
- Space: one row of cells is enough for the value (next section), but this walk back needs every row.
The seven decisions translate almost word for word into a recursion that remembers its answers. This version asks only the questions it needs; the lab fills the whole table bottom-up instead, in the evaluation order above.
@lru_cache(maxsize=None)
def best(i, c):
"""T[i][c]: the best pay from parcels 0..i-1 with room c."""
if i == 0:
return 0
skip = best(i - 1, c)
if WTS[i - 1] > c:
return skip
return max(skip, best(i - 1, c - WTS[i - 1]) + PAY[i - 1])
table = [[best(i, c) for c in range(CAP + 1)] for i in range(len(WTS) + 1)]
assert table[3] == [0, 0, 0, 7, 10, 12, 12, 17, 19, 22, 22]
assert table[5] == [0, 0, 3, 7, 10, 12, 13, 17, 19, 22, 23]
The table, filled and walked back
Here is the whole table for the courier. Row has considered parcels , and column is the room left.
The answer 23 sits in the bottom-right corner. It came from , which beats , so parcel 4 was taken and the walk moves to room . In rows 4, 3 and 2 the value at room 4 does not change (10, 10, 10), so parcels 3, 2 and 1 were skipped. At row 1 the value drops from 10 to 0, so parcel 0 was taken. The subset is .
assert table[4][4] + PAY[4] == 23 > table[4][10] == 22 and CAP - WTS[4] == 4
assert [table[i][4] for i in (4, 3, 2, 1, 0)] == [10, 10, 10, 10, 0]
assert brute_force(WTS, PAY, CAP)[1] == (0, 4)
Predict: why not read the chosen parcels off the last row?
The last row holds values only: 23 at room 10 says nothing about which parcels made it. The walk back compares each row with the one above to learn which choice produced the value, so this walk back needs every row.
Space. The value alone needs one row, updated in place. Scan the room downwards, from to , so that still holds the previous row's value when it is read. An upward scan reads a cell that this parcel has already updated, so the parcel can be taken twice. With one parcel of weight 2 and pay 3 and a 4 kg bag, the upward scan answers 6 and the right answer is 3. The plain one-row version gives up the walk back above, because the rows it compares have been overwritten. Reconstruction does not need every value, though. Store one take-or-skip bit per cell, bits instead of words, and walk back along the bits. Or split the parcels in half, run one row forwards over the first half and one row backwards over the second, pick the room split that maximises the sum, and recurse on each half: words of space for about twice the time.
Why is not polynomial
The table has cells and each costs a constant number of word operations, so the time is in the worst case on a word-RAM. That looks polynomial, but it isn't polynomial in the right thing. The input writes in about bits, and itself is exponential in that. Multiply every weight and the capacity by 10: the answer is still 23, each number grows by about 3.3 bits, and the table grows tenfold.
def input_bits(scale):
return sum((w * scale).bit_length() for w in WTS) + (CAP * scale).bit_length() \
+ sum(v.bit_length() for v in PAY)
@lru_cache(maxsize=None)
def scaled(i, c, s):
if i == 0:
return 0
skip = scaled(i - 1, c, s)
return skip if WTS[i - 1] * s > c else max(skip, scaled(i - 1, c - WTS[i - 1] * s, s) + PAY[i - 1])
rows = [(s, input_bits(s), 5 * (CAP * s + 1), scaled(5, CAP * s, s)) for s in (1, 10, 100, 1000)]
assert rows == [(1, 34, 55, 23), (10, 52, 505, 23), (100, 72, 5005, 23), (1000, 92, 50005, 23)]
| scale | input bits | cells filled (rows 1–5) | answer |
|---|---|---|---|
| 1 | 34 | 55 | 23 |
| 10 | 52 | 505 | 23 |
| 100 | 72 | 5,005 | 23 |
| 1000 | 92 | 50,005 | 23 |
The input grows by about 20 bits per step, and the work grows by a factor of 10 per step: exponential in the input's length. An algorithm that is polynomial in the numeric values but not in their bit length is called pseudo-polynomial. It is excellent when is a few thousand kilograms and useless when is a 64-bit byte count. Nobody expects to do much better: 0/1 knapsack is NP-hard, so a truly polynomial algorithm would give one for every problem in NP.
Two states per node: independent sets on a tree
A charity runs its volunteers as a tree: node 0 is the coordinator, and every other volunteer has exactly one supervisor. It wants a team with no volunteer next to their own supervisor, and each volunteer has a weight (hours they can give). This is maximum-weight independent set on a tree: pick nodes, no node together with its parent, with total weight as large as possible.
Try one number per node, "the best total in 's subtree". The parent can't use it, because it doesn't say whether itself was picked, and that is exactly what the parent's rule needs to know. On a coordinator of weight 5 with one volunteer of weight 4, adding "best below" to the coordinator's weight gives , a team that breaks the rule. So the state carries that bit of information:
- State: two numbers per node. is the best weight in 's subtree with picked, and is the best with not picked.
- Recurrence: and over 's children . Picking rules out its children and nothing else. Different children's subtrees share no edge, so once 's status is fixed they are independent.
- Base case: a leaf has and (empty sums).
- Evaluation order: children before parents (post-order).
- Answer location: at the root .
- Reconstruction: top-down, carrying "may this node be picked?". Pick if it may be and . The children of a picked node may not be picked.
- Space: two numbers per node, time and space. The value alone can fold each child's pair into its parent's running sums as soon as the child is done, so only the sums along the current path stay live; reconstruction reads every node's pair, so it keeps all .
The root's pair is , so the answer is 22. Reconstruction starts at the root: , so the coordinator is not picked. Node 1 has and is picked. Node 2 has and is picked. Node 3 has and is not, so its children 7 and 8 may be, and both are. The team weighs 22. Picking every other level gives 16 or 18. Picking the heaviest volunteer who still fits, over and over, takes 2 (weight 8), then 3 (weight 7), then 1, and gets 18.
The numbers above are checked here by brute force over every team inside each subtree, a deliberate stand-in: writing the linear-time version is the lab's job.
KIDS = {0: [1, 2, 3], 1: [4, 5], 2: [6], 3: [7, 8]}
HOURS = [1, 3, 8, 7, 1, 1, 2, 6, 5]
PARENT = {c: p for p, cs in KIDS.items() for c in cs}
def subtree(u):
return [u] + [x for c in KIDS.get(u, []) for x in subtree(c)]
def best_team(nodes, must=None, never=None):
top = 0
for r in range(len(nodes) + 1):
for s in combinations(nodes, r):
if any(PARENT.get(x) in s for x in s) or (must is not None and must not in s) \
or (never is not None and never in s):
continue
top = max(top, sum(HOURS[x] for x in s))
return top
pairs = [(best_team(subtree(u), must=u), best_team(subtree(u), never=u)) for u in range(9)]
assert pairs == [(16, 22), (3, 2), (8, 2), (7, 11), (1, 0), (1, 0), (2, 0), (6, 0), (5, 0)]
assert sum(HOURS[x] for x in (1, 2, 7, 8)) == 22
assert sum(HOURS[x] for x in (0, 4, 5, 6, 7, 8)) == 16 and sum(HOURS[x] for x in (1, 2, 3)) == 18
def heaviest_first(nodes):
team = []
for u in sorted(nodes, key=lambda u: -HOURS[u]):
if PARENT.get(u) not in team and not any(PARENT.get(x) == u for x in team):
team.append(u)
return team, sum(HOURS[x] for x in team)
assert heaviest_first(range(9)) == ([2, 3, 1], 18)
assert heaviest_first(range(8, -1, -1))[1] == 18 # the same whichever way ties are broken
Predict: should reconstruction pick a node when inc equals exc?
Either choice is optimal: both lead to a team of the same weight. The rule above breaks ties by not picking, which leaves the children free. What matters is that the top-down pass uses the same numbers the bottom-up pass computed, so every step stays optimal.
Longest increasing subsequence in
A sequence of twelve readings:
A subsequence keeps the order of the positions and may skip any of them. It is not a contiguous block: skips 58 and 61. We want the longest one that is strictly increasing.
- State: is the length of the longest increasing subsequence ending at index .
- Recurrence: . A subsequence ending at is either alone or some subsequence ending at an earlier, smaller with appended.
- Base case: when no earlier is smaller, the max is empty and counts as 0, so : alone.
- Evaluation order: increasing.
- Answer location: , not . On , but the answer is 2.
- Reconstruction: store the that gave the max, and follow those links back.
- Space: , and none of it can be dropped: a later may read any earlier , and the answer is a max over all of .
A = [42, 17, 58, 23, 61, 35, 19, 70, 44, 52, 27, 66]
def lis_quadratic(a):
L, link = [1] * len(a), [None] * len(a)
for i in range(len(a)):
for j in range(i):
if a[j] < a[i] and L[j] + 1 > L[i]:
L[i], link[i] = L[j] + 1, j
i = max(range(len(a)), key=L.__getitem__) if a else None
out = []
while i is not None:
out.append(a[i])
i = link[i]
return L, out[::-1]
L, sub = lis_quadratic(A)
assert L == [1, 1, 2, 2, 3, 3, 2, 4, 4, 5, 3, 6] and sub == [17, 23, 35, 44, 52, 66]
assert lis_quadratic([3, 4, 1])[0][-1] == 1
The inner loop runs whatever the data, so this makes exactly comparisons : 66 here, and about for .
Faster: the tails array
The quadratic DP asks, for each new element, "which earlier subsequences can I extend?" and looks at all of them. Most of that is wasted. Among subsequences of the same length, only the one with the smallest last value matters, because it is the easiest to extend. So keep one number per length:
Invariant (tails)
After reading a prefix of the array, tails[k] is the smallest last value of any increasing
subsequence of length in that prefix, and tails is strictly increasing.
To read the next element , find the first slot with tails[k] by binary search
(it is sorted), and put there, or append if there is no such slot. The answer is the
length of tails.
Why the invariant survives. Suppose it holds before . For slots , tails[j] , so
a subsequence ending in can't lower them. At slot , tails[k-1] , so extends a
subsequence of length , and tails[k], so is the new smallest. For ,
ending at with length would need a subsequence of length ending below , but every
such subsequence ends at a value tails[j-1] tails[k] . And lands between
tails[k-1] and the old tails[k], so tails stays sorted.
The step not to skip: the final tails array is not an answer. It mixes the ends of
different candidates, and here 27 (position 10) comes after 44 (position 8). To recover a real
subsequence, remember a link whenever lands in slot : the position of the element in slot
at that moment. Following links back from the element in the last slot gives a longest
increasing subsequence.
The check below computes tails straight from the invariant's definition, using from the
quadratic DP: tails[k] is the smallest in the prefix with , because a
subsequence of length ending at contains one of every shorter length ending there.
It is a stand-in that confirms the figure. The binary-search version is the lab's.
def tails_by_definition(a, upto):
L = lis_quadratic(a[:upto])[0]
return [min(a[i] for i in range(upto) if L[i] >= k + 1) for k in range(max(L))]
assert tails_by_definition(A, 6) == [17, 23, 35] and tails_by_definition(A, 7) == [17, 19, 35]
assert tails_by_definition(A, 11) == [17, 19, 27, 44, 52]
assert tails_by_definition(A, 12) == [17, 19, 27, 44, 52, 66]
assert A.index(27) > A.index(44) # so the final tails is not a subsequence of A
Cost. With elements in tails, a binary search makes at most
comparisons, so the total is at most
: in the worst case, in the comparison model. That matches a known lower bound: in the comparison model,
the longest increasing subsequence needs comparisons in the worst case (Fredman,
1975; stated here, not proved).
Measure the claim
Counting comparisons on seeded random arrays (the course's verification script,
tools/verify/unit3.py, computes these):
| quadratic DP, | tails method | tails | |
|---|---|---|---|
| 1,000 | 499,500 | 5,251 | 0.527 |
| 10,000 | 49,995,000 | 69,181 | 0.521 |
| 100,000 | 4,999,950,000 | 858,880 | 0.517 |
The ratio in the last column is flat, as an bound predicts, and well under the
worst-case constant 1 because tails stays short on random input (its length grows like
). These are measurements on these inputs, not a theorem about all inputs. The
quadratic column is exact for every input.
import math
assert [n * (n - 1) // 2 for n in (1000, 10_000, 100_000)] == [499_500, 49_995_000, 4_999_950_000]
assert [round(c / (n * math.log2(n)), 3)
for n, c in ((1000, 5251), (10_000, 69_181), (100_000, 858_880))] == [0.527, 0.521, 0.517]
A problem that looks different
A post office sells stamps worth 1, 7 and 10 cents. What is the fewest stamps that make exactly 14 cents? Taking the largest stamp that fits gives , five stamps. Two are enough. What would one table entry mean, and where is the answer? The lab's last problem is a different one.
Practise
In the lab you fill a knapsack table cell by cell, with each cell's dependencies drawn as arrows and the recurrence checked in every frame, then walk it back. You predict and then watch the tails array on a new sequence, write the two-state tree DP with reconstruction for a new tree, measure the pseudo-polynomial table and the two subsequence methods on the lab's own seeds, and finish with one problem that does not say what it is.
Recap
- You can now: name the seven DP decisions for a new problem; fill, walk back and space-optimize a knapsack table; solve independent set on a tree with two states per node; and find a longest increasing subsequence in , recovering the subsequence with links.
- Invariant: each entry equals the optimum of exactly its state's subproblem, because the
recurrence's case split is exhaustive and reads only entries written earlier. For
tails: slot holds the smallest end of an increasing subsequence of length . - Complexity achieved: knapsack time, pseudo-polynomial (against subsets); the tree in (against teams); the longest increasing subsequence in at most comparisons (against ), all worst case.
- Failure mode: the one-row knapsack scanned upwards quietly lets every parcel be taken
again and again; returning the final
tailsas the subsequence. - In real software: Python's
bisect.bisect_leftis the binary search the tails method uses. Git's--patiencediff (xdiff/xpatience.c) matches lines that are unique in both files and keeps a longest increasing subsequence of the matches, found with a binary search over sorted pile ends. - Retrieval: (module 07)
tailsonly ever grows by appending. If it were an array that doubles its capacity when full, what would appends cost in copying?
Check yourself
- Why is the knapsack recurrence correct, and why must the table be walked back instead of read off the last row?
- Each parcel may now be taken any number of times. What changes in the recurrence and in the one-row loop, and on which instance do the two answers differ?
- For random numbers, compare the quadratic DP with the tails method, and say whether either computes the longest increasing contiguous block.
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.