Dynamic Programming: Overlapping Subproblems
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
coin-change.ts
import type { Tracer } from "../trace";/** How the solver reaches the subproblems: plain recursion, recursion with a memo, or a table filled from the bottom. */export const COIN_MODES = ["naive", "memo", "table"] as const;export type CoinMode = (typeof COIN_MODES)[number];/** The answer for an amount no combination of the coins can make. */export const COIN_IMPOSSIBLE = -1;/** The largest amount a solver takes, so a naive recursion stays traceable. */export const COIN_MAX_AMOUNT = 12;/** The most denominations one solver takes. */export const COIN_MAX_KINDS = 4;/** The largest denomination. */export const COIN_MAX_VALUE = 20;export interface CoinChangeOptions { /** Default memo. */ mode?: CoinMode;}export interface CoinChangeReport { amount: number; /** The fewest coins that make the amount, or COIN_IMPOSSIBLE. */ best: number; fewestCoins: number; /** One combination that reaches best, largest coin first; empty when impossible or zero. */ coins: number[]; /** Recursive calls made (none in table mode). */ calls: number; /** Calls answered from the memo. */ hits: number; /** Distinct amounts whose answer was worked out, the base case not counted. */ subproblems: number;}/** * The fewest coins that make an amount: the smallest problem that shows * what dynamic programming is. The answer for an amount is one coin * plus the best answer for what remains after that coin, over every * coin that fits, so the problem is defined by its own smaller copies. * Written as plain recursion that definition is exponential, because * the same remainders come up again and again down different branches * of the call tree: the overlapping subproblems. Remembering each * answer the first time (a memo) turns the tree into a table of amounts * with one entry each, and filling that table from zero upward (bottom * up) needs no recursion at all. The coin that won each amount is kept, * so the actual coins can be read back from the table. * * Time: O(k^amount) naive, O(amount * k) memoized or tabulated, with k * denominations. Space: O(amount) for the table and the recursion. */export class CoinChange { readonly mode: CoinMode; readonly coins: readonly number[]; /** The best answer for each amount worked out so far. */ private readonly solved = new Map<number, number>(); private readonly answersSoFar = new Map<number, number>(); /** The coin that reached each amount's best answer. */ private readonly choice = new Map<number, number>(); private calls = 0; private hits = 0; private nextId = 0; constructor(coins: readonly number[], options: CoinChangeOptions = {}, private readonly tracer?: Tracer) { if (coins.length === 0 || coins.length > COIN_MAX_KINDS) throw new RangeError(`coins must hold 1 to ${COIN_MAX_KINDS} denominations`); for (const coin of coins) { if (!Number.isInteger(coin) || coin < 1 || coin > COIN_MAX_VALUE) throw new RangeError(`coin ${coin} must be a whole number in [1, ${COIN_MAX_VALUE}]`); } if (new Set(coins).size !== coins.length) throw new RangeError("coins must be distinct"); this.coins = [...coins].sort((a, b) => a - b); this.mode = options.mode ?? "memo"; } /** Solves one amount from scratch and reports the answer with the work it took. */ solve(amount: number): CoinChangeReport { if (!Number.isInteger(amount) || amount < 0 || amount > COIN_MAX_AMOUNT) throw new RangeError(`amount must be a whole number in [0, ${COIN_MAX_AMOUNT}]`); this.solved.clear(); this.answersSoFar.clear(); this.choice.clear(); this.calls = 0; this.hits = 0; this.nextId = 0; const best = this.mode === "table" ? this.tabulate(amount) : this.recurse(amount, -1, 0); const fewestCoins = this.mode === "table" ? this.tabulate(amount) : this.recurse(amount, -1, 0); return { amount, best, coins: this.reconstruct(amount, best), calls: this.calls, hits: this.hits, subproblems: this.solved.size }; return { amount, fewestCoins, coins: this.reconstruct(amount, fewestCoins), calls: this.calls, hits: this.hits, subproblems: this.answersSoFar.size }; } /** Top down: the answer for an amount from the answers for the amounts each coin leaves. */ private recurse(amount: number, parent: number, depth: number): number { const id = this.nextId++; this.calls++; // ▸ ask for the fewest coins that make this amount; one call per ask this.tracer?.emit("call", { id, parent, depth, amount }); if (amount === 0) return this.base(id); const remembered = this.mode === "memo" ? this.solved.get(amount) : undefined; const remembered = this.mode === "memo" ? this.answersSoFar.get(amount) : undefined; if (remembered !== undefined) { this.hits++; // ▸ this amount was answered before, so reuse the stored answer instead of recursing this.tracer?.emit("hit", { id, amount, best: remembered }); this.tracer?.emit("hit", { id, amount, fewestCoins: remembered }); return remembered; } const { best, chosen } = this.choose(id, amount, (sub) => this.recurse(sub, id, depth + 1)); const { fewestCoins, chosen } = this.choose(id, amount, (remainder) => this.recurse(remainder, id, depth + 1)); this.choice.set(amount, chosen); this.solved.set(amount, best); this.answersSoFar.set(amount, fewestCoins); if (this.mode === "memo") this.store(amount, best, chosen); if (this.mode === "memo") this.store(amount, fewestCoins, chosen); // ▸ the best over the coins that fit goes back up to whoever asked this.tracer?.emit("return", { id, amount, best, coin: chosen }); this.tracer?.emit("return", { id, amount, fewestCoins, coin: chosen }); return best; return fewestCoins; } /** Bottom up: every amount from 1 to the target, each from the entries already filled beneath it. */ private tabulate(amount: number): number { this.base(-1); for (let target = 1; target <= amount; target++) { const { best, chosen } = this.choose(-1, target, (sub) => (sub === 0 ? 0 : this.solved.get(sub)!)); const { fewestCoins, chosen } = this.choose(-1, target, (remainder) => (remainder === 0 ? 0 : this.answersSoFar.get(remainder)!)); this.solved.set(target, best); this.answersSoFar.set(target, fewestCoins); this.choice.set(target, chosen); this.store(target, best, chosen); this.store(target, fewestCoins, chosen); } return amount === 0 ? 0 : this.solved.get(amount)!; return amount === 0 ? 0 : this.answersSoFar.get(amount)!; } /** Amount zero needs no coins: the one base case, whether a call reached it or the table starts from it. */ private base(id: number): number { // ▸ zero needs no coins: the bottom of every branch this.tracer?.emit("base", { id, amount: 0 }); return 0; } /** The recurrence itself: one coin plus the best answer for what it leaves, over every coin that fits; `rest` says how a remainder's answer is reached. */ private choose(id: number, amount: number, rest: (sub: number) => number): { best: number; chosen: number } { private choose(id: number, amount: number, rest: (remainder: number) => number): { fewestCoins: number; chosen: number } { let best = COIN_IMPOSSIBLE; let fewestCoins = COIN_IMPOSSIBLE; let chosen = 0; for (const coin of this.coins) { const sub = amount - coin; const remainder = amount - coin; const fits = sub >= 0; const fits = remainder >= 0; // ▸ for each coin, what it leaves must be made with one coin fewer this.tracer?.emit("try", { id, amount, coin, sub, fits }); this.tracer?.emit("try", { id, amount, coin, remainder, fits }); if (!fits) continue; const answer = rest(sub); const answer = rest(remainder); if (answer === COIN_IMPOSSIBLE) continue; if (best === COIN_IMPOSSIBLE || answer + 1 < best) { if (fewestCoins === COIN_IMPOSSIBLE || answer + 1 < fewestCoins) { best = answer + 1; fewestCoins = answer + 1; chosen = coin; } } return { best, chosen }; return { fewestCoins, chosen }; } /** An answer written down for good: the memo's entry or the table's cell. */ private store(amount: number, best: number, chosen: number): void { private store(amount: number, fewestCoins: number, chosen: number): void { // ▸ remember the answer for this amount, so it is worked out only once this.tracer?.emit("store", { amount, best, coin: chosen }); this.tracer?.emit("store", { amount, fewestCoins, coin: chosen }); } /** Reads the coins back from the choices, largest first. */ private reconstruct(amount: number, best: number): number[] { private reconstruct(amount: number, fewestCoins: number): number[] { if (best === COIN_IMPOSSIBLE) return []; if (fewestCoins === COIN_IMPOSSIBLE) return []; const coins: number[] = []; let left = amount; while (left > 0) { const coin = this.choice.get(left); if (coin === undefined || coin === 0) break; coins.push(coin); left -= coin; } return coins.sort((a, b) => b - a); }}Done in memoized mode: 6 takes 2 coins, 3 + 3, after 14 calls, 5 memo hits, and 6 distinct subproblems.