Break a hard problem into overlapping sub-problems, solve each once, and reuse the answer. The art is choosing a state precise enough to write a correct recurrence — everything else is bookkeeping.
A DP problem is a question that keeps asking smaller versions of itself. The instant you can write the answer for input n in terms of the answer for a smallerinput, you already have a DP — everything after that is just refusing to re-answer a question you've answered before.
Imagine solving the problem by brute-force recursion, but with a notebook beside you. Every time you finish a sub-question, you write the answer down next to the question. Before solving anything, you glance at the notebook — if it's already there, you copy it instead of redoing the work.
That's the entire idea. DP is brute force plus a memory. The slow version re-derives the same facts millions of times; the fast version derives each fact exactly onceand reuses it. The recursion didn't get smarter — it just stopped being forgetful.
Here is naive fib(5). Notice the same nodes growing over and over — that repetition is the wasted work:
fib(5) ├─ fib(4) │ ├─ fib(3) ◄ computed here ... │ │ ├─ fib(2) │ │ │ ├─ fib(1) = 1 │ │ │ └─ fib(0) = 0 │ │ └─ fib(1) = 1 │ └─ fib(2) ◄ ... and again │ ├─ fib(1) = 1 │ └─ fib(0) = 0 └─ fib(3) ◄ ... and AGAIN (whole subtree re-grown) ├─ fib(2) ◄ ... and again │ ├─ fib(1) = 1 │ └─ fib(0) = 0 └─ fib(1) = 1 fib(3) is solved 2x, fib(2) 3x, fib(1) 5x. At fib(50): ~10^10 calls. The work is exponential ONLY because we keep re-answering the same questions.
Now write each answer down the first time. The whole tree flattens into a single tape you fill left to right:
index : 0 1 2 3 4 5
dp : [ 0 ][ 1 ][ 1 ][ 2 ][ 3 ][ 5 ]
▲ ▲ ▲
each cell = the two cells to its left, added once.
6 additions total. The exponential tree collapsed to a line.When a problem might be DP, don't reach for a table. Climb these five rungs in order — the code falls out at the bottom:
max, min, +, or ||.The single highest-leverage habit in DP: before coding, say your state definition as a complete English sentence. A good state is self-contained — given only the state, you can answer the sub-problem without peeking back at the original input.
dp[i] = the most money I can rob from houses 0..i.”dp[a] = the fewest coins that sum to amount a.”dp[i][j] = the fewest edits to turn the first i chars of s into the first j chars of t.”Strip away the specific problem and every top-down DP is the same four moves: base case → check memo → try every last choice → write it down. Read it as a sentence, not as code:
solve(state): # state = the smallest description
# of "where am I" that fixes the answer
if state is trivially small: # 1. BASE CASE — answer is obvious, no recursion
return obvious answer
if state in memo: # 2. ALREADY SOLVED — never redo work
return memo[state]
best = empty # 3. TRY EVERY LAST CHOICE
for each choice available now:
sub = the smaller state this choice leaves behind
best = combine(best, solve(sub)) # recurse on something SMALLER
memo[state] = best # 4. WRITE IT DOWN before returning
return bestThe onlythings that change between problems are: what the state is, what the base case returns, and how you combine the sub-answers. The shape never changes — which is exactly why DP becomes second nature once you've internalized this skeleton.
People treat memoization and tabulation as different techniques. They're not — they traverse the same dependency graph, just from opposite ends:
dp[0] ── dp[1] ── dp[2] ── dp[3] ── dp[4] ── dp[5]
(base) (answer)
TOP-DOWN : start at dp[5], recurse LEFT toward the base, cache on the way back.
"I need dp[5]. That needs dp[4] and dp[3]. Those need ..." (lazy)
BOTTOM-UP : start at dp[0], march RIGHT, each cell ready when you reach it.
"dp[0], dp[1], now I can do dp[2], now dp[3], ..." (eager)
Same arrows. Same answers. Only the order of visiting differs.The most common “my DP is wrong and I can't see why” bug: the state doesn't capture everything that affects the answer. The tell is that the same state can lead to differentcorrect answers depending on history you didn't record.
i” isn't enough — it depends on whether you currently hold a share. Add that dimension: dp[i][holding].i items” depends on remaining capacity. Add it: dp[i][w].Every DP solution follows the same checklist — work through these steps before writing a single line of code:
dp[i] as a function of strictly smaller states — dp[i-1], dp[i-1][j-1], etc.dp[0] (or dp[0][0])? These are the sub-problems so small the answer is obvious without recursion.DP applies only when the problem has both of these properties:
Recognizing which family a problem belongs to immediately suggests the right state shape:
dp[i] depends on a constant number of previous entries. Often space-optimized to two rolling variables.dp[i] = max(dp[i-1] + a[i], a[i]).dp[w] over capacity; iterate capacity descending. Subset-sum and partition problems are 0/1 knapsack variants.dp[i][j] over two sequences or a 2-D grid (LCS, edit distance, unique paths, interleaving string).dp[i][j] represents the optimal answer over a contiguous sub-range [i, j]. Fill by increasing length (Burst Balloons).The total work equals number of distinct states × work per state. For a 1-D problem with n positions that is O(n); for a 2-D string problem it is O(m × n). Space follows the same count unless you apply a rolling-row optimization to reduce a 2-D table to a single row.
dp[i][j] only reads from row i-1, replace the full table with two 1-D arrays (or one, updating in-place with the correct direction) to hit O(n) space.dp[r][c] = paths to that cell. Each cell = the cells it leans on (top + left).Reach for DP when the problem asks for an optimum or count over choices and a greedy argument does not cleanly apply. The clearest signal is a naive recursion that revisits the same arguments repeatedly.
| "count the number of ways to reach / decode / tile" | define dp[i] = number of ways; sum over valid last choices |
| "minimum / maximum cost, path length, or jumps to reach the end" | dp[i] = min/max cost; take the best of all transitions into i |
| "can you make / partition to exactly sum S?" | 0/1 knapsack boolean dp[w]; or subset-sum reframed as knapsack |
| "longest / shortest subsequence (not subarray)" | dp[i] = answer for subsequences ending at i; try all j < i |
| "choices with downstream consequences (cooldown, transaction limit)" | state-machine DP — add a dimension for the current state (hold/sold/rest) |
| "overlapping sub-problems visible in naive recursion tree" | memoize the recursive function; the cache key = the function arguments |
| "best answer over a contiguous interval / range of indices" | interval DP dp[i][j]; fill by increasing gap length (j - i) |
When → The recurrence is easiest to express recursively, or the DAG of sub-problems is sparse (many states are never reached). Write the recursion first, then add a Map or array cache keyed on the arguments.
// dp[state] = answer for this sub-problem; compute once, reuse forever.
function solve(input: number[]): number {
const memo = new Map<string, number>();
// Define dp(i) = <your English state definition here>
function dp(i: number): number {
if (i < 0) return 0; // base case — smallest valid sub-problem
if (i === 0) return 1; // base case
const key = String(i);
if (memo.has(key)) return memo.get(key)!;
// Recurrence: combine dp of strictly smaller sub-problems
const result = dp(i - 1) + dp(i - 2); // example: Fibonacci shape
memo.set(key, result);
return result;
}
return dp(input.length - 1);
}i and j, the key is `${i},${j}` or a flat index `${i * n + j}`. Prefer a flat array over a Map when bounds are known — it is faster and avoids string allocation.When → The state is a single index and each cell depends only on the previous one or two. The rolling-variable form (no array at all) reduces space from O(n) to O(1).
// dp[i] = best answer considering only the first i elements.
// Space-optimised: keep only the previous two values (rolling variables).
function rob(nums: number[]): number {
// prev2 = dp[i-2], prev1 = dp[i-1]
let prev2 = 0, prev1 = 0;
for (const n of nums) {
// dp[i] = max(skip this house, rob this house + dp[i-2])
const cur = Math.max(prev1, n + prev2);
prev2 = prev1;
prev1 = cur;
}
return prev1; // dp[n]
}prev2 / prev1 / cur maps directly onto dp[i-2] / dp[i-1] / dp[i].When → The problem involves two strings or sequences (LCS, edit distance, interleaving, distinct subsequences). Use 1-indexed rows and columns so that index 0 represents the empty prefix — base cases fill themselves with zeros.
// dp[i][j] = answer for s[0..i-1] and t[0..j-1] (1-indexed rows/cols → easy base cases).
function lcs(s: string, t: string): number {
const m = s.length, n = t.length;
// dp[0][*] = 0, dp[*][0] = 0 (empty prefix → LCS length 0)
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s[i - 1] === t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1; // characters match → extend
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // skip one char
}
}
}
return dp[m][n];
}dp[i][j] reads from dp[i-1][j-1], dp[i-1][j], and dp[i][j-1], so outer loop over i, inner over j (left-to-right, top-to-bottom) is always correct.When → The problem asks for an optimal value or count over items with a capacity/budget constraint. The only difference between 0/1 and unbounded is the direction you iterate the capacity loop.
// dp[w] = best value achievable with capacity w.
// ---- 0/1 knapsack: each item used AT MOST ONCE ----
// Iterate capacity DESCENDING so each item is considered at most once.
function knapsack01(weights: number[], values: number[], W: number): number {
const dp = new Array(W + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
for (let w = W; w >= weights[i]; w--) { // <-- descending!
// dp[w] = max(skip item i, take item i)
dp[w] = Math.max(dp[w], values[i] + dp[w - weights[i]]);
}
}
return dp[W];
}
// ---- Unbounded knapsack: each item used ANY number of times ----
// Iterate capacity ASCENDING so the same item can be reused.
function knapsackUnbounded(coins: number[], amount: number): number {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (const coin of coins) {
for (let w = coin; w <= amount; w++) { // <-- ascending!
// dp[w] = min coins to make amount w
dp[w] = Math.min(dp[w], 1 + dp[w - coin]);
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}The most common DP bug: your recurrence seems right but produces wrong answers because the state doesn't capture everything relevant. Classic example — stock problems with a transaction limit or cooldown. The fix is adding another dimension (e.g. dp[i][k][holding]) so the state is unambiguous. Always write the English definition first and ask: “Given only this state, can I answer the sub-problem without re-reading the input?”
Off-by-one in base cases ripples through the entire table. A reliable convention: use 1-indexed DP arrays (length n+1) so that index 0 represents the empty prefix or zero capacity — it naturally initializes to 0 or Infinity depending on the problem, and you never need a special guard for i-1 < 0.
For coin-change style minimums, initialize dp[0] = 0 (zero coins to make amount 0) and all other entries to Infinity so the Math.min update is correct from the start.
Bottom-up DP is only correct when every cell is computed after all the cells it depends on. For 2-D problems this usually means outer loop over rows, inner over columns. For interval DP, loop by increasing length of the interval, not by left endpoint — otherwise dp[i][j]reads an entry for a longer interval that hasn't been filled yet.
Iterating capacity ascending in a 0/1 knapsack allows an item to be picked multiple times (because dp[w - weight] was already updated in this pass). Iterating descendingin an unbounded knapsack prevents reuse. This is the single most common knapsack implementation error; fix it with the mnemonic: “descend to restrict, ascend to repeat.”
When using 1-indexed DP arrays for strings (dp length n+1), dp[i] corresponds to s[i-1]. Forgetting the -1 offset when reading the character (e.g. s[i] === t[j] instead of s[i-1] === t[j-1]) is silent and produces subtly wrong results that only manifest on certain inputs.