01 / The idea
The biggest stamp first is one stamp too many.
A post office desk sells 1-, 3-, and 4-cent stamps, and a customer wants the fewest stamps that make exactly 6 cents. The shortcut everyone reaches for is to take the largest stamp that fits, again and again: 4, then 1, then 1. Three stamps. Two 3-cent stamps do it in two.
So the desk has to compare. Try a final 1-cent stamp: now solve 5 cents. Try a 3: solve 3 cents. Try a 4: solve 2 cents. Each smaller question makes more choices of its own, and different paths keep arriving at the same amounts.
Dynamic programming solves each of those smaller questions once and keeps the answer. It applies when the best answer for a remaining amount depends only on that amount, not on the path that reached it, and when many paths ask for the same amounts.
02 / Name the rule
Make the remaining question small enough to store.
Let best(amount) mean “the fewest stamps that add up to exactly this amount.”
The base case is best(0) = 0. For every stamp that fits, remove it and ask for
the smaller answer:
best(amount) = 1 + min(best(amount - stamp)) The state is just the remaining amount because the stamps on sale never change. If a real problem also depended on the day, a weight limit, or the stamp used last, that would belong in the state too. A state that leaves out a relevant input can return a plausible but wrong answer.
There are two ways to reuse the answers. A memo keeps the recursion and remembers each amount the first time it is solved. A table fills every amount from 0 upward, so each one’s smaller answers are already there.
What makes the recurrence safeProgress, a base case, and an explicit impossible result
Each legal stamp makes the amount smaller, so the calls move toward zero. A target that
cannot be made returns null in TypeScript, and 0 with Reachable false
in Go, rather than pretending an incomplete plan is valid.
03 / Follow one operation
See the shortcut fail, then the same amounts return.
Before you watch, predict how many times plain recursion asks for 2 cents while solving 6. The animation starts with the largest-first rule on a 6-cent parcel, then expands the recurrence and shows the repeated amounts. A memo turns a repeated call into a lookup; the table fills amounts from 0 upward. Try it lets you pick a target and a strategy.
Reuse the answer for each smaller state.
stamps on the parcel
The largest stamp that fits
[1, 3, 4]Nothing on the 6 cents parcel yet. The largest stamp that fits is 4 cents.
Nothing on the 6 cents parcel yet. The largest stamp that fits is 4 cents.
Take the largest stamp that still fits.
Nothing on the 6 cents parcel yet. The largest stamp that fits is 4 cents.
Reduced motion: choose a scene to see its completed state.
Read this scene
Nothing on the 6 cents parcel yet. The largest stamp that fits is 4 cents.
Nothing on the 6 cents parcel yet. The largest stamp that fits is 4 cents.
Largest stamp first for 6 cents.
Watch restarts when you return. Step through keeps your selected state. Try it measures a fresh target using the same postage rule.
04 / Read the shape
Top-down and bottom-up share the state, not the control flow.
Basic form is evaluate with its three modes, and largestFirst, the shortcut it replaces. Plain recursion follows only the amounts the target reaches but revisits them. Memoized recursion keeps that shape and stores each result. Tabulation fills every amount up
to the target, including ones no plan can reach, and needs no recursive frames. Each mode
rebuilds its plan from the choices it made. In the wild wraps them in small functions
that answer for a parcel.
evaluate holds the recurrence in three modes: the best plan for an amount is one stamp plus the best plan for what remains. largestFirst is the shortcut it replaces.
// The state is the remaining postage amount. Dynamic programming works when that state is
// sufficient to describe the smaller problem and many paths ask for the same state again.
export function evaluate(problem: StampProblem, mode: Mode, trace = false): Evaluation {
const { target, denominations } = validate(problem);
const steps: Step[] = [];
let calls = 0;
let computed = 0;
let transitions = 0;
let peakFrames = 0;
let minimum: number | null;
let table: readonly (number | null)[] = [];
if (mode === 'tabulated') {
const result = tabulate(target, denominations);
const traceTable = Array<number | null>(target + 1).fill(null);
traceTable[0] = 0;
for (let amount = 1; amount <= target; amount++) {
const value = result.table[amount];
traceTable[amount] = value;
const stamp = result.choice[amount];
transitions += denominations.filter((denomination) => denomination <= amount).length;
if (trace) steps.push({ kind: 'fill', amount, value, stamp });
}
computed = target;
table = traceTable;
minimum = table[target] ?? null;
return {
mode,
target,
denominations,
minimum,
plan: planFromTable(target, result.choice, minimum),
calls,
computed,
transitions,
peakFrames,
table,
steps
};
}
// The stamp that produced each amount's best answer, so the plan comes from this run.
const choice = Array<number | null>(target + 1).fill(null);
const memo = new Map<number, number>();
if (mode === 'memoized') memo.set(0, 0);
function solve(amount: number, depth: number): number {
calls++;
peakFrames = Math.max(peakFrames, depth + 1);
const cached = mode === 'memoized' && memo.has(amount);
if (trace) steps.push({ kind: 'call', amount, depth, cached });
if (cached) {
const value = memo.get(amount);
if (value === undefined) throw new Error('missing memoized value');
if (trace) steps.push({ kind: 'return', amount, depth, value: asResult(value) });
return value;
}
computed++;
if (amount === 0) {
if (mode === 'memoized') memo.set(amount, 0);
if (trace) steps.push({ kind: 'return', amount, depth, value: 0 });
return 0;
}
if (amount < 0) {
if (trace) steps.push({ kind: 'return', amount, depth, value: null });
return IMPOSSIBLE;
}
let best = IMPOSSIBLE;
for (const denomination of denominations) {
if (denomination > amount) break;
transitions++;
const candidate = solve(amount - denomination, depth + 1) + 1;
if (candidate < best) {
best = candidate;
choice[amount] = denomination;
}
}
if (mode === 'memoized') memo.set(amount, best);
if (trace) steps.push({ kind: 'return', amount, depth, value: asResult(best) });
return best;
}
const rawMinimum = solve(target, 0);
minimum = asResult(rawMinimum);
if (mode === 'memoized') {
const known = Array<number | null>(target + 1).fill(null);
for (const [amount, value] of memo) {
if (amount >= 0) known[amount] = asResult(value);
}
table = known;
}
return {
mode,
target,
denominations,
minimum,
plan: planFromTable(target, choice, minimum),
calls,
computed,
transitions,
peakFrames,
table,
steps
};
}
// The shortcut dynamic programming replaces: always take the largest stamp that still fits.
// For 6 cents with 1, 3, and 4 it takes 4 + 1 + 1, one stamp more than 3 + 3.
export function largestFirst(problem: StampProblem): { plan: number[]; count: number | null } {
const { target, denominations } = validate(problem);
const plan: number[] = [];
let remaining = target;
for (const stamp of [...denominations].reverse()) {
while (stamp <= remaining) {
plan.push(stamp);
remaining -= stamp;
}
}
return remaining === 0 ? { plan, count: plan.length } : { plan: [], count: null };
} func Evaluate(problem Problem, mode Mode, trace bool) (Evaluation, error) {
target, denominations, err := validate(problem)
if err != nil {
return Evaluation{}, err
}
if mode != Recursive && mode != Memoized && mode != Tabulated {
return Evaluation{}, fmt.Errorf("unknown mode %q", mode)
}
evaluation := Evaluation{Mode: mode, Target: target, Denominations: denominations}
if mode == Tabulated {
table, reachable, choice, hasChoice := tabulate(target, denominations)
evaluation.Table = table
evaluation.TableReachable = reachable
evaluation.Computed = target
for amount := 1; amount <= target; amount++ {
for _, stamp := range denominations {
if stamp > amount {
break
}
evaluation.Transitions++
}
if trace {
step := Step{Kind: "fill", Amount: amount, Value: table[amount], Reachable: reachable[amount]}
if hasChoice[amount] {
step.Stamp = choice[amount]
step.HasStamp = true
}
evaluation.Steps = append(evaluation.Steps, step)
}
}
evaluation.Minimum = table[target]
evaluation.Reachable = reachable[target]
evaluation.Plan = planFromTable(target, choice, hasChoice, evaluation.Minimum, evaluation.Reachable)
return evaluation, nil
}
const impossible = int(^uint(0) >> 1)
// The stamp that produced each amount's best answer, so the plan comes from this run.
choice := make([]int, target+1)
hasChoice := make([]bool, target+1)
memo := map[int]int{}
if mode == Memoized {
memo[0] = 0
}
var solve func(int, int) int
solve = func(amount, depth int) int {
evaluation.Calls++
if depth+1 > evaluation.PeakFrames {
evaluation.PeakFrames = depth + 1
}
_, cached := memo[amount]
if mode != Memoized {
cached = false
}
if trace {
evaluation.Steps = append(evaluation.Steps, Step{Kind: "call", Amount: amount, Depth: depth, Cached: cached})
}
if cached {
value := memo[amount]
if trace {
evaluation.Steps = append(evaluation.Steps, Step{Kind: "return", Amount: amount, Depth: depth, Value: value, Reachable: value != impossible})
}
return value
}
evaluation.Computed++
if amount == 0 {
if mode == Memoized {
memo[amount] = 0
}
if trace {
evaluation.Steps = append(evaluation.Steps, Step{Kind: "return", Amount: amount, Depth: depth, Value: 0, Reachable: true})
}
return 0
}
if amount < 0 {
if trace {
evaluation.Steps = append(evaluation.Steps, Step{Kind: "return", Amount: amount, Depth: depth, Value: impossible})
}
return impossible
}
best := impossible
for _, denomination := range denominations {
if denomination > amount {
break
}
evaluation.Transitions++
candidate := solve(amount-denomination, depth+1)
if candidate != impossible && candidate+1 < best {
best = candidate + 1
choice[amount] = denomination
hasChoice[amount] = true
}
}
if mode == Memoized {
memo[amount] = best
}
if trace {
evaluation.Steps = append(evaluation.Steps, Step{Kind: "return", Amount: amount, Depth: depth, Value: best, Reachable: best != impossible})
}
return best
}
minimum := solve(target, 0)
evaluation.Reachable = minimum != impossible
if !evaluation.Reachable {
// No exact plan: report zero with Reachable false, as tabulation does,
// so the sentinel never reaches the public result.
evaluation.Minimum = 0
evaluation.Plan = []int{}
} else {
evaluation.Minimum = minimum
evaluation.Plan = planFromTable(target, choice, hasChoice, minimum, true)
}
return evaluation, nil
}
// LargestFirst is the shortcut dynamic programming replaces: always take the largest stamp
// that still fits. For 6 cents with 1, 3, and 4 it takes 4 + 1 + 1, one stamp more than 3 + 3.
func LargestFirst(problem Problem) ([]int, bool, error) {
target, denominations, err := validate(problem)
if err != nil {
return nil, false, err
}
plan := []int{}
remaining := target
for i := len(denominations) - 1; i >= 0; i-- {
for denominations[i] <= remaining {
plan = append(plan, denominations[i])
remaining -= denominations[i]
}
}
if remaining != 0 {
return []int{}, false, nil
}
return plan, true, nil
} Reading the TypeScriptThe recurrence plus a mode
evaluate keeps the input immutable, counts calls and transitions, and records
a trace for the lesson. Every mode stores the stamp that produced each amount’s best answer,
which makes the plan explicit.
Reading the GoThe same contract with explicit reachability
Go reports an impossible amount with a separate reachability flag. That keeps zero stamps for zero cents distinct from “no exact plan,” without a sentinel in the public result.
What would I normally use in application code?A memo helper, or the library that already solved it
For a one-off recurrence, a Map in TypeScript or a map in Go
keyed by the state. For the famous ones, a library: a diff package for sequences, an
edit-distance function for spelling. The Memoization lesson covers caching a pure function
safely; this one is about when a recurrence has repeated states worth storing.
05 / Try a decision
Spot the reuse before reaching for the table.
Several changes sound like they would speed up the search. Decide which one removes the repeated work before the feedback tells you.
06 / Follow the cost
Pay once per state, then choose where the answers live.
Here is every operation at a glance, with target t and k kinds of stamp. The rest of this section is about why reuse turns exponential work into a table’s worth.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Largest stamp first | O(k + t) | O(t) | Fast and often wrong: for 6 cents with 1, 3, and 4 it returns three stamps where two will do. |
| Plain recursive minimum for target t | O(kᵗ) | O(t) | k is the number of stamp choices; the call stack follows the longest chain of smaller targets. |
| Memoized recursive minimum | O(k · t) | O(t) | At most one answer is computed for each amount; the memo and the recursive frames are both linear in t. |
| Bottom-up tabulation | O(k · t) | O(t) | Fill t amounts and inspect up to k prior choices per amount; no recursive call stack is needed. |
| Reconstruct the chosen stamps | O(t) | O(t) | Follow one stored choice from the target back to zero; the returned plan can contain t stamps. |
Plain recursion branches on every stamp at every amount, so its work grows exponentially: 168 calls for 10 cents. With a memo, each amount is solved once and later calls read it: 26 calls. The table does the same state-space work bottom-up: 25 transitions, and no recursive frames at all.
The table is not free memory. It is the price of never solving an amount twice, and it grows with the target, not with the number of paths.
07 / Give it a real job
Keep the table where the prices stop changing.
At a real desk, the stamp values change a few times a year and parcels arrive all day. So the desk fills one table up to its largest likely target when the prices change, and every parcel after that is a lookup and a walk back through the stored choices.
What the example leaves out is a decision too. Real postage has stamps that run out, a limit on how many fit on a parcel, and customers who prefer fewer kinds of stamp. Each is a new part of the state, and each makes the table bigger.
In frontend code, dynamic programming usually arrives inside a library: the diff behind a code-review view, the fuzzy matcher in a command palette, or the line breaking in a text layout engine.
08 / Make the call
Choose a memo or a table for a reason.
Reach for dynamic programming when a larger answer is built from correct smaller answers and the same smaller questions come up more than once. Use a memo when only part of the state space is reachable or the recurrence reads naturally top-down. Use a table when the order is clear, the work should be predictable, or the call stack’s depth is not yours to risk.
Look elsewhere when the question changes. A greedy rule with a proof, such as coins where the largest always works, needs no table. No repeated states: plain recursion, or backtracking. A cache around a pure API call is memoization without a recurrence.
09 / Take the idea with you
Explain it without saying “dynamic programming.”
“For every amount up to the target, I work out the fewest stamps once and write it down. To solve an amount, I try each stamp and look up the answer for what is left. The biggest stamp first can be wrong, so I compare instead of guessing.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why 4 + 1 + 1 loses to 3 + 3, why plain recursion keeps asking for the same amounts, and why the table needs no call stack. Then find a recursive function in your own code that calls itself with the same arguments twice, and decide whether it should remember the answer.
Connections to follow nextRelated lessons
- Recursion expresses the smaller problem and its base case before you decide how to reuse it.
- Memoization makes cache keys, purity, and invalidation a deliberate contract.
- Levenshtein distance is a two-dimensional table where each edit reuses its neighbors’ answers.