← Data structures & algorithms
Foundations Reuse overlapping work

Dynamic programming

Reuse the smaller answer.

The classic diff lines up two files by filling a table of smaller answers. A spell checker ranks “did you mean” words by an edit distance computed the same way, and TeX breaks paragraphs into lines with it. Each time, the obvious one-step-at-a-time choice can be wrong, and the fix is to solve every smaller question once and keep the answer. Let’s watch a postage desk where taking the biggest stamp first costs an extra stamp.

TypeScriptGoOne postage desk, two implementations.

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.

Dynamic programming

Reuse the answer for each smaller state.

Rulelargest first
Target6 cents
Left6 cents

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.

01/ 07
Largest stamp first for 6 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.

TypeScriptReading
stamps.ts
// 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 };
}
GoAlongside
stamps.go
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.

The 6-cent parcel reaches the same remaining amount through different stamp choices. Which change removes the repeated work while keeping the recurrence?

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.

Exact postage: time and extra space
OperationTimeExtra spaceWhat it assumes
Largest stamp firstO(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 tO(kᵗ)O(t)k is the number of stamp choices; the call stack follows the longest chain of smaller targets.
Memoized recursive minimumO(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 tabulationO(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 stampsO(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.

Take the postage desk into your editor. Add a 5-cent stamp, find a target where the largest stamp first still loses, and count the calls each strategy makes for 30 cents.

Back to data structures & algorithms →