Dynamic Programming

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.

Topic guide24 problems
The unlock

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.

MENTAL MODEL A notebook of answers you never recompute

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.

The reframe → Don't think “what loop do I write?” Think “what question keeps coming back, and what's the smallest label I can file its answer under?” That label is your state.

SEE IT Watch the exponential tree collapse

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.
The smell test → draw the recursion tree for a tiny input. If you spot the same node twice, memoizing turns the exponential tree into a linear (or polynomial) walk. No repeats? It's plain divide-and-conquer, not DP.

HOW TO THINK The cold-start ladder — run this on any new problem

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:

  1. Find the last decision. Picture an optimal solution already built. Ask: “what was the final choice that produced it?”(the last house robbed, the last coin added, the last character matched). You usually don't know whichchoice it was — so you'll try them all.
  2. Name the state in one English sentence. Write “dp[i] = the answer if the input were only the first i things”. If you can't finish that sentence, you don't have the state yet — keep rung 1 going.
  3. Write the recurrence by enumerating that last choice. Each possible last choice peels the problem down to a smaller state you already defined. Combine their answers with max, min, +, or ||.
  4. Pin the base case.What's the answer when the input is empty or size 1? That's the floor the recursion lands on.
  5. Pick a direction. Recurse + cache (top-down) or fill a table in dependency order (bottom-up). Same recurrence, your choice.
The one trick that unlocks state → almost every DP yields to “what is the last thing I decide, and what's left to decide after it?” Get that, and the recurrence writes itself.

SAY IT If you can’t say the state out loud, you don’t have it yet

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.

  • House Robber:dp[i] = the most money I can rob from houses 0..i.”
  • Coin Change:dp[a] = the fewest coins that sum to amount a.”
  • Edit Distance:dp[i][j] = the fewest edits to turn the first i chars of s into the first j chars of t.”
Failure mode → if two situations share the same state value but truly have different answers, your sentence is missing a clause. That missing clause is a missing dimension (see below).

RECURSION SHAPE Every DP is this skeleton in disguise

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 best

The 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.

TWO DIRECTIONS Top-down and bottom-up are one DAG, walked two ways

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.
  • Top-down is the literal translation of your recurrence: write the recursion, slap a cache on it. Best when the state space is sparse (many states never get visited) or the transitions are awkward to order by hand.
  • Bottom-up replaces the call stack with a loop in dependency order. Best when every state is needed and you want to drop the recursion overhead or shrink memory to a rolling row.
Start top-down →it mirrors your thinking exactly, so it's the fastest path to a correct answer. Convert to bottom-up only if you need the speed or the space win.

WHEN IT BREAKS A missing dimension is a missing clause in your sentence

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.

  • Cooldown stock problem: “best profit by day i” isn't enough — it depends on whether you currently hold a share. Add that dimension: dp[i][holding].
  • Knapsack: “best value using the first i items” depends on remaining capacity. Add it: dp[i][w].
The fix is always the same →ask “what do I need to know about my past choices to make the next one correctly?” Whatever that is becomes a new axis of the state. Then re-run the SAY IT test on the expanded sentence.

MNEMONIC Each cell = the cells it leans on.

Each cell = the cells it leans on. Define a table where every entry is built from a few smaller, already-solved entries — then fill in an order that guarantees those are ready. Solve each subproblem once, reuse forever. The Visualize tab lights the dependency cells as each one fills.

RECIPE The four-step DP recipe

Every DP solution follows the same checklist — work through these steps before writing a single line of code:

  1. Define the state. Write a one-sentence English definition: “dp[i] = …”. The state must encode everything you need to answer the question for that sub-problem without re-examining the original input.
  2. Write the recurrence. Express dp[i] as a function of strictly smaller states — dp[i-1], dp[i-1][j-1], etc.
  3. Identify base cases. What is dp[0] (or dp[0][0])? These are the sub-problems so small the answer is obvious without recursion.
  4. Choose top-down or bottom-up. Top-down (memoization): write the recurrence as recursion + a cache. Bottom-up (tabulation): fill a table in dependency order. Both have the same asymptotic cost; choose whichever makes the order clearest.

PRECONDITIONS Overlapping sub-problems + optimal substructure

DP applies only when the problem has both of these properties:

  • Overlapping sub-problems. The same smaller question recurs many times in a naive recursion tree. Without overlap, divide-and-conquer is enough (and memoization adds no benefit).
  • Optimal substructure. The optimal answer to the full problem can be built from optimal answers to sub-problems. If the best solution to the whole depends on a non-optimal sub-solution (e.g. because sub-choices interact), DP cannot be applied directly.
Quick smell-test → Draw the recursion tree for a small input. Do you see the same node computed more than once? If yes, memoize it.

TAXONOMY The six DP sub-families

Recognizing which family a problem belongs to immediately suggests the right state shape:

  • 1-D linear (Fibonacci / House Robber). dp[i] depends on a constant number of previous entries. Often space-optimized to two rolling variables.
  • Kadane (running best). Track the best value ending at i; used for maximum subarray / subproduct. The recurrence is dp[i] = max(dp[i-1] + a[i], a[i]).
  • 0/1 knapsack. Each item is used at most once. dp[w] over capacity; iterate capacity descending. Subset-sum and partition problems are 0/1 knapsack variants.
  • Unbounded knapsack. Items may be reused freely (Coin Change, Coin Change II). Iterate capacity ascending.
  • 2-D string / grid DP. dp[i][j] over two sequences or a 2-D grid (LCS, edit distance, unique paths, interleaving string).
  • Interval DP. dp[i][j] represents the optimal answer over a contiguous sub-range [i, j]. Fill by increasing length (Burst Balloons).
  • State-machine DP. Multiple explicit states per position (hold / sold / rest in Stock problems). The recurrence transitions between states at each step.

COST Why memoization transforms the complexity

Naive recursion
O(2ⁿ)
Recomputes the same sub-problems exponentially.
With memoization
O(states)
Each unique sub-problem solved exactly once.

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.

Space optimization → When 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.

RUN IT Each cell = the cells it leans on

step 0 / 13
STARTCount paths from top-left to bottom-right moving only right/down. Build a table where dp[r][c] = paths to that cell. Each cell = the cells it leans on (top + left).
0
1
2
3
0
·
·
·
·
1
·
·
·
·
2
·
·
·
·
cell being filledcells it depends on
slowfast

TRIGGERS When you see ___ → reach for ___

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)

RED FLAGSWhen it's NOT this pattern

  • A greedy proof exists. If you can show that always taking the locally optimal choice never forecloses a globally better one (exchange argument), greedy is simpler and faster. Activity selection, interval scheduling, and Jump Game I are greedy — not DP.
  • All choices are independent.If sub-problems do not share any computation (e.g. the sub-problems partition the input without overlap), divide-and-conquer suffices. DP's cache only pays off when the same arguments recur.
  • You need the actual items / path, not just the optimum value. DP computes the optimum naturally, but reconstructing which choices produced it requires either storing parent pointers or back-tracking through the table — easy to forget under interview pressure.
  • Constraints are tiny (n ≤ 20). Exponential backtracking or bitmask DP may be the intended approach. Very small inputs are a cue that full enumeration is acceptable, and a clean recursive solution may score better than a complex DP table.

TEMPLATE Top-down memoization

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.

top-down-memoization.ts
// 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);
}
Cache key = function arguments → if your recursive function takes two integers 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.

TEMPLATE Bottom-up 1-D tabulation (rolling variables)

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).

bottom-up-1-d-tabulation-rolling-variables-.ts
// 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]
}
Define dp[i] before the loop → write the English meaning as a comment (“dp[i] = max money robbing houses 0..i”) so the recurrence is obviously correct. The rolling rename prev2 / prev1 / cur maps directly onto dp[i-2] / dp[i-1] / dp[i].

TEMPLATE Bottom-up 2-D table (two sequences)

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.

bottom-up-2-d-table-two-sequences-.ts
// 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];
}
Fill order matters → cell 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.

TEMPLATE 0/1 vs unbounded knapsack

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.

0-1-vs-unbounded-knapsack.ts
// 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];
}
Mnemonic → descending= “used at most once” (0/1 knapsack, partition-equal-subset-sum, target-sum); ascending= “reuse freely” (coin-change, coin-change-ii). Getting this backwards produces wrong answers that are hard to debug.

PITFALL Under-specified state (missing a dimension)

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?”

PITFALL Wrong base cases or dp[0] boundary errors

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.

PITFALL Wrong iteration order (filling a cell before its dependencies)

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.

PITFALL 0/1 vs unbounded knapsack: wrong inner-loop direction

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.”

PITFALL Off-by-one between string index and dp index

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.

PROBLEMS

#70Climbing Stairs1-D Fibonacci-style: dp[i] = dp[i-1] + dp[i-2]; the canonical entry-point for the pattern.#322Coin ChangeUnbounded knapsack minimization: dp[amt] = 1 + min over valid coins of dp[amt - coin]; ascending capacity loop.#53Maximum SubarrayKadane's algorithm: dp[i] = max(nums[i], dp[i-1] + nums[i]) — the best subarray ending at i; track the global max.#152Maximum Product SubarrayKadane variant tracking both a running max and running min (negative × negative can become the new max).#300Longest Increasing Subsequencedp[i] = length of LIS ending at index i; O(n²) with inner j < i scan, or O(n log n) with patience-sort binary search.#1143Longest Common Subsequence2-D DP: dp[i][j] = LCS length of s[0..i-1] and t[0..j-1]; match extends dp[i-1][j-1], mismatch takes max of dp[i-1][j] and dp[i][j-1].#139Word Break1-D boolean DP: dp[i] = true if s[0..i-1] can be segmented; for each i try all dictionary words ending at i.#198House Robber1-D pick/skip recurrence: dp[i] = max(dp[i-1], nums[i] + dp[i-2]); space-optimized to two rolling variables.#213House Robber IICircular array: run the linear House Robber recurrence twice — once excluding the first house and once excluding the last — and take the max.#91Decode Ways1-D DP over the string: each position can extend via a valid 1-digit or 2-digit transition; dp[0] = 1 (empty prefix) is the key base case.#62Unique Paths2-D grid path count: dp[i][j] = dp[i-1][j] + dp[i][j-1]; equivalent to C(m+n-2, m-1) combinatorially.#55Jump GameGreedy/DP reachability: track the farthest index reachable so far; if i ever exceeds it the end is unreachable.#72Edit Distance2-D string alignment DP: dp[i][j] = min operations to convert s[0..i-1] to t[0..j-1]; transitions cover insert, delete, and replace.#746Min Cost Climbing Stairs1-D min-cost recurrence: dp[i] = cost[i] + min(dp[i-1], dp[i-2]); identical shape to climbing-stairs but optimizing cost.#416Partition Equal Subset Sum0/1 knapsack boolean subset-sum: can we pick elements summing to total/2? Descending capacity iteration ensures each element is used at most once.#309Best Time to Buy and Sell Stock with CooldownState-machine DP with three states per day: hold (own a stock), sold (just sold, in cooldown), and rest (idle); transitions enforce the one-day cooldown.#518Coin Change IIUnbounded knapsack counting combinations: iterate coins in the outer loop, amounts ascending, to count distinct combinations (not permutations).#494Target Sum0/1 knapsack reframed: assign + or − to each number; equivalent to finding a subset of size (total + target) / 2, solvable with a counting knapsack.#97Interleaving String2-D DP: dp[i][j] = true if s1[0..i-1] and s2[0..j-1] interleave to form s3[0..i+j-1]; two transitions from dp[i-1][j] and dp[i][j-1].#329Longest Increasing Path in a MatrixDFS with memoization on a grid treated as a DAG: dp[r][c] = longest increasing path starting at (r,c); no explicit topological sort needed because values are strictly increasing.#115Distinct Subsequences2-D counting DP: dp[i][j] = number of ways s[0..i-1] contains t[0..j-1] as a subsequence; match adds dp[i-1][j-1] to the skip case dp[i-1][j].#312Burst BalloonsInterval DP: dp[i][j] = max coins in sub-range (i,j) choosing the LAST balloon to burst; fill by increasing interval length.#10Regular Expression Matching2-D DP with special transitions for '.' (any char) and '*' (zero or more of preceding); the '*' case reads dp[i][j-2] (use zero) or dp[i-1][j] (use one more).#44Wildcard MatchingGiven a string s and a pattern p with ? (any single char) and * (any sequence, including empty), decide if p matches all of s. A 2D DP table over prefixes of both strings resolves it in O(m*n).