Pattern 21 of 24 · Search & optimisation · 40 min

Dynamic Programming II: Grids, Strings and Knapsack

dp[i][j] over two strings, a grid, or items × capacity: the same recipe with one more dimension.

Spot it:two stringsgrid pathspick items under a budgetedit / match / subsequenceJavaScript you'll use: Arrays: create, read, update Gotchas and debugging Recursion, the call stack and memoization
Say it in 30 seconds
  • O(m × n)Time for a grid or two strings
  • O(n × W)Knapsack time: n items, capacity W
  • O(min(m, n))Space for two strings with one row
Show what to say

“When one index cannot describe a subproblem, I use two: a grid cell, a prefix of each of two strings, or the first i items with capacity c. It is the 1D recipe with one more index: define the cell, seed row 0 and column 0, compare the last decision, fill in dependency order. Time is O(m × n), and one row often gives O(n) space.”

Trade-offs

  • Full table vs one row: one row cuts space to O(n) but loses the history, so you cannot walk back to rebuild the answer.
  • Loop direction in one-row knapsack: capacity high to low is 0/1 (each item once); low to high is unbounded (items repeat).
  • Coins outside vs amounts outside: coins outer counts each combination once (Coin Change II); amounts outer counts 1 + 2 and 2 + 1 separately.

Bring it up before they ask

Build rows with Array.from so they are not shared, size the table (m + 1) × (n + 1), and remember row i's letter is a[i - 1], not a[i].

0 of 0 sections ticked
30-second answer

When one index can't describe a subproblem, use two: a grid cell, a prefix of each of two strings, or "the first i items with capacity c". Same recipe as 1D DP, one more dimension.

When to reach for it

Spot it in the problem0 of 4

Tap the signals you can see in the problem.

RememberTwo stringsA gridItems under a budget

Pick your variant
  • Grid paths

    dp[i][j] meansanswer for reaching (i, j)

    Unique Paths
  • Two strings

    dp[i][j] meansanswer for the first i and j letters

    Edit Distance
  • Knapsack

    dp[i][j] meansbest from the first i items, capacity j

    Partition Equal Subset Sum
  • Intervals

    dp[i][j] meansanswer for the slice a[i..j]

    Burst Balloons

The core idea: a cell, its neighbours, and a fill order

Think of a spreadsheet. Each cell holds an answer, is computed from cells you already filled, and you fill them in an order that has them ready.

Read more · 49 words

Example: how many right-and-down paths reach a cell? The last move came from above or from the left, so add those two cells:

Fill row by row, left to right and both neighbours are always done. A 3 × 3 grid:

111
123
136
paths[r][c] = paths[r - 1][c] + paths[r][c - 1];   // the first row and first column are all 1

Two strings: prefixes and the empty row

The state is a pair of prefixes: dp[i][j] covers the first i letters of a and the first j of b.

Read more · 32 words

Row 0 and column 0 are the empty prefix.

Compare the last letters. For the longest common subsequence, equal letters use both (diagonal + 1). Otherwise keep the larger of above and left:

Two strings: fill the LCS table, then walk back

dp: rows = prefixes of "CLOUD", columns = prefixes of "COLD" (6×5)
∅COLD
∅00000
C0
L0
O0
U0
D0
m
5
n
4

dp[i][j] = the length of the longest common subsequence of the first i letters of "CLOUD" and the first j letters of "COLD". Row 0 and column 0 (∅) are the empty prefix: nothing in common, so 0.

function lcs(a, b) {
  const m = a.length, n = b.length;
  const dp = 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 (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
      else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
    }
  }
  let i = m, j = n, out = "";
  while (i > 0 && j > 0) {
    if (a[i - 1] === b[j - 1]) { out = a[i - 1] + out; i--; j--; }
    else if (dp[i - 1][j] >= dp[i][j - 1]) i--;
    else j--;
  }
  return out;
}
All steps as text
  1. dp[i][j] = the length of the longest common subsequence of the first i letters of "CLOUD" and the first j letters of "COLD". Row 0 and column 0 (∅) are the empty prefix: nothing in common, so 0.
  2. C = C: both last letters can be used together, so take the best without them (diagonal, 0) plus 1. dp[1][1] = 1.
  3. C ≠ O: one of these last letters is not in the answer. Drop C (above, 0) or drop O (left, 1), keep the larger: 1.
  4. C ≠ L: one of these last letters is not in the answer. Drop C (above, 0) or drop L (left, 1), keep the larger: 1.
  5. C ≠ D: one of these last letters is not in the answer. Drop C (above, 0) or drop D (left, 1), keep the larger: 1.
  6. L ≠ C: one of these last letters is not in the answer. Drop L (above, 1) or drop C (left, 0), keep the larger: 1.
  7. L ≠ O: one of these last letters is not in the answer. Drop L (above, 1) or drop O (left, 1), keep the larger: 1.
  8. L = L: both last letters can be used together, so take the best without them (diagonal, 1) plus 1. dp[2][3] = 2.
  9. L ≠ D: one of these last letters is not in the answer. Drop L (above, 1) or drop D (left, 2), keep the larger: 2.
  10. O ≠ C: one of these last letters is not in the answer. Drop O (above, 1) or drop C (left, 0), keep the larger: 1.
  11. O = O: both last letters can be used together, so take the best without them (diagonal, 1) plus 1. dp[3][2] = 2.
  12. O ≠ L: one of these last letters is not in the answer. Drop O (above, 2) or drop L (left, 2), keep the larger: 2.
  13. O ≠ D: one of these last letters is not in the answer. Drop O (above, 2) or drop D (left, 2), keep the larger: 2.
  14. U ≠ C: one of these last letters is not in the answer. Drop U (above, 1) or drop C (left, 0), keep the larger: 1.
  15. U ≠ O: one of these last letters is not in the answer. Drop U (above, 2) or drop O (left, 1), keep the larger: 2.
  16. U ≠ L: one of these last letters is not in the answer. Drop U (above, 2) or drop L (left, 2), keep the larger: 2.
  17. U ≠ D: one of these last letters is not in the answer. Drop U (above, 2) or drop D (left, 2), keep the larger: 2.
  18. D ≠ C: one of these last letters is not in the answer. Drop D (above, 1) or drop C (left, 0), keep the larger: 1.
  19. D ≠ O: one of these last letters is not in the answer. Drop D (above, 2) or drop O (left, 1), keep the larger: 2.
  20. D ≠ L: one of these last letters is not in the answer. Drop D (above, 2) or drop L (left, 2), keep the larger: 2.
  21. D = D: both last letters can be used together, so take the best without them (diagonal, 2) plus 1. dp[5][4] = 3.
  22. The table is full and the answer cell dp[5][4] = 3. To recover the letters themselves, walk back from that corner, retracing which neighbour each cell came from.
  23. D = D, so this cell came from the diagonal: D is in the subsequence. Move up and left.
  24. U ≠ L, and the cell above already reaches the same length (2) without U: move up.
  25. O ≠ L, and the cell above already reaches the same length (2) without O: move up.
  26. L = L, so this cell came from the diagonal: L is in the subsequence. Move up and left.
  27. C ≠ O, and the cell to the left is the larger one (1): this value came without O, so move left.
  28. C = C, so this cell came from the diagonal: C is in the subsequence. Move up and left.
  29. The walk reached the empty row or column. Reading the ✓ cells gives "CLD", a common subsequence of length 3. Ties in the walk can lead to a different, equally long answer.

Step by step: the recipe in two dimensions

The same six steps as 1D, with one more index to get wrong.

Read more · 139 words
  1. Name both indices and the cell in words

    "dp[i][j] = the fewest edits turning the first i letters of a into the first j of b."

  2. Size the table and avoid the shared-row trap

    Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)). Never fill(new Array(n + 1)).

  3. Seed row 0 and column 0

    An empty prefix, no items, a grid's first row.

  4. Write the recurrence from the last decision

    The last move, the last letters, the last item: each points at one neighbour.

  5. Fill in dependency order

    Row by row. Interval DPs loop by length.

  6. Read the answer, rebuild it, then compress

    Usually the far corner dp[m][n]. Only then drop to one row.

Play with it

Type two words, pick LCS or Edit distance, and fill the table yourself, or press Run.

Interactive

DP table lab

Two words, one table. Each cell reads its diagonal, up and left neighbours. Pick the right one, or press Run, then watch the walk back spell the answer.

∅COLD∅CLOUD000000?0000
Your move, dp[1][1] (C vs C):

Cells 0/20 · plain recursion 27 calls · your moves 0 right

Row 0 and column 0 are the empty prefix (LCS 0). Pick the move for the dashed cell, or press Run.

Example words and speed

Templates

Template 1: grid paths

Walls get 0 ways; the start gets 1.

Read more · 30 words

One row is enough: before row[c] updates it holds "above", and row[c - 1] holds "left".

Unique PathsMedium has no walls. Minimum Path SumMedium uses Math.min(above, left) + grid[r][c].

// Grid DP: dp[r][c] = number of ways to reach cell (r, c) from the top-left, moving only right or down.
// The last move came from above or from the left, so add those two cells. Walls (1) have 0 ways.
export function countPaths(grid) {
  const rows = grid.length;
  const cols = grid[0].length;
  const dp = Array.from({ length: rows }, () => new Array(cols).fill(0)); // NOT fill(new Array(...)): shared row!
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (grid[r][c] === 1) continue;                     // a wall: no path ends here
      if (r === 0 && c === 0) dp[r][c] = 1;               // the start: one way, standing still
      else dp[r][c] = (r > 0 ? dp[r - 1][c] : 0) + (c > 0 ? dp[r][c - 1] : 0);
    }
  }
  return dp[rows - 1][cols - 1];
}

// The same table kept as ONE row. Before row[c] is updated it still holds the value from the row above,
// and row[c - 1] already holds the current row's value: exactly "above + left".
export function countPathsOneRow(grid) {
  const cols = grid[0].length;
  const row = new Array(cols).fill(0);
  row[0] = 1;
  for (const cells of grid) {
    for (let c = 0; c < cols; c++) {
      if (cells[c] === 1) row[c] = 0;
      else if (c > 0) row[c] += row[c - 1];
    }
  }
  return row[cols - 1];
}

Template 2: two strings

The table above, plus the walk back that turns lengths into letters:

Read more · 69 words

Longest Common SubsequenceMedium needs only the length. Edit DistanceMedium sets row 0 and column 0 to j and i. Matching letters copy the diagonal; otherwise take 1 plus the smallest of three neighbours:

NeighbourEdit it stands for
diagonal dp[i - 1][j - 1]replace a's last letter with b's
above dp[i - 1][j]delete a's last letter
left dp[i][j - 1]insert b's last letter
// Two-string DP: dp[i][j] = length of the longest common subsequence of a.slice(0, i) and b.slice(0, j).
// Row 0 and column 0 stand for the empty prefix (LCS 0), so the loops start at 1 and read a[i - 1], b[j - 1].
// After filling, walk back from dp[m][n] to rebuild one longest common subsequence.
export function lcsString(a, b) {
  const m = a.length;
  const n = b.length;
  const dp = 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 (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; // both last letters are used
      else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);       // drop one of them
    }
  }
  const out = [];
  let i = m;
  let j = n;
  while (i > 0 && j > 0) {
    if (a[i - 1] === b[j - 1]) {
      out.push(a[i - 1]); // this cell came from the diagonal: the letter is in the answer
      i--;
      j--;
    } else if (dp[i - 1][j] >= dp[i][j - 1]) i--; // the value came from above
    else j--;                                      // the value came from the left
  }
  return out.reverse().join("");
}

Template 3: knapsack, 0/1 and unbounded

Picture a backpack with a weight limit.

Read more · 100 words

Rows are items, columns are capacities. Each cell either skips the item (the cell above) or takes it (the cell w columns back in the row above, plus its value).

One row is enough, but then the direction of the capacity loop decides the problem:

  • 0/1, high to low: dp[c - w] still means "without this item".
  • Unbounded, low to high: dp[c - w] already includes this item, so it can be taken again.

Try your own items with Edit input:

Partition Equal Subset SumMedium is 0/1. Coin Change IIMedium is unbounded: ways[c] += ways[c - coin], with coins in the outer loop.

0/1 knapsack: items × capacity

dp: rows = items considered, columns = capacity (4×6)
012345
none000000
#1 w2 $3
#2 w3 $4
#3 w4 $6
W
5

dp[i][c] = the best total value using only the first i items, with capacity c. Row 0 (no items) is all 0. Each item can be taken at most once.

function knapsack(weights, values, W) {
  const n = weights.length;
  const dp = Array.from({ length: n + 1 }, () => new Array(W + 1).fill(0));
  for (let i = 1; i <= n; i++) {
    const w = weights[i - 1], v = values[i - 1];
    for (let c = 0; c <= W; c++) {
      dp[i][c] = dp[i - 1][c];
      if (w <= c) dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - w] + v);
    }
  }
  return dp[n][W];
}
All steps as text
  1. dp[i][c] = the best total value using only the first i items, with capacity c. Row 0 (no items) is all 0. Each item can be taken at most once.
  2. Item #1 weighs 2, so capacities 0 to 1 can't hold it. Skipping is the only option there: copy the row above.
  3. Capacity 2: skip item #1 (above: 0) or take it (row above at capacity 2 - 2 = 0, worth 0, plus $3 = 3). Best: 3.
  4. Capacity 3: skip item #1 (above: 0) or take it (row above at capacity 3 - 2 = 1, worth 0, plus $3 = 3). Best: 3.
  5. Capacity 4: skip item #1 (above: 0) or take it (row above at capacity 4 - 2 = 2, worth 0, plus $3 = 3). Best: 3.
  6. Capacity 5: skip item #1 (above: 0) or take it (row above at capacity 5 - 2 = 3, worth 0, plus $3 = 3). Best: 3.
  7. Item #2 weighs 3, so capacities 0 to 2 can't hold it. Skipping is the only option there: copy the row above.
  8. Capacity 3: skip item #2 (above: 3) or take it (row above at capacity 3 - 3 = 0, worth 0, plus $4 = 4). Best: 4.
  9. Capacity 4: skip item #2 (above: 3) or take it (row above at capacity 4 - 3 = 1, worth 0, plus $4 = 4). Best: 4.
  10. Capacity 5: skip item #2 (above: 3) or take it (row above at capacity 5 - 3 = 2, worth 3, plus $4 = 7). Best: 7.
  11. Item #3 weighs 4, so capacities 0 to 3 can't hold it. Skipping is the only option there: copy the row above.
  12. Capacity 4: skip item #3 (above: 4) or take it (row above at capacity 4 - 4 = 0, worth 0, plus $6 = 6). Best: 6.
  13. Capacity 5: skip item #3 (above: 7) or take it (row above at capacity 5 - 4 = 1, worth 0, plus $6 = 6). Best: 7.
  14. The answer is dp[3][5] = 7. Walking up from it, a cell that differs from the one above means that item was taken (✓): items #1 and #2.

One row: the loop direction decides 0/1 or unbounded

dp (best value at each capacity) (7)
0
0
0
0
0
0
0
W
6

One row instead of a table: dp[c] = the best value with capacity c using the items processed so far. The shaded band will show which cells already include the current item.

function bestValue(items, W, unbounded) {
  const dp = new Array(W + 1).fill(0);
  for (const [w, v] of items) {
    if (unbounded) {
      for (let c = w; c <= W; c++)
        dp[c] = Math.max(dp[c], dp[c - w] + v);
    } else {
      for (let c = W; c >= w; c--)
        dp[c] = Math.max(dp[c], dp[c - w] + v);
    }
  }
  return dp[W];
}
All steps as text
  1. One row instead of a table: dp[c] = the best value with capacity c using the items processed so far. The shaded band will show which cells already include the current item.
  2. Item #1: weight 2, value 3. Every cell still holds the answer without this item. Sweep capacities from high to low.
  3. dp[6] = max(0, dp[4] + 3 = 3) = 3. dp[4] is to the left, not yet touched for this item, so it is the value without item #1: the item is counted at most once.
  4. dp[5] = max(0, dp[3] + 3 = 3) = 3. dp[3] is to the left, not yet touched for this item, so it is the value without item #1: the item is counted at most once.
  5. dp[4] = max(0, dp[2] + 3 = 3) = 3. dp[2] is to the left, not yet touched for this item, so it is the value without item #1: the item is counted at most once.
  6. dp[3] = max(0, dp[1] + 3 = 3) = 3. dp[1] is to the left, not yet touched for this item, so it is the value without item #1: the item is counted at most once.
  7. dp[2] = max(0, dp[0] + 3 = 3) = 3. dp[0] is to the left, not yet touched for this item, so it is the value without item #1: the item is counted at most once.
  8. Item #2: weight 3, value 5. Every cell still holds the answer without this item. Sweep capacities from high to low.
  9. dp[6] = max(3, dp[3] + 5 = 8) = 8. dp[3] is to the left, not yet touched for this item, so it is the value without item #2: the item is counted at most once.
  10. dp[5] = max(3, dp[2] + 5 = 8) = 8. dp[2] is to the left, not yet touched for this item, so it is the value without item #2: the item is counted at most once.
  11. dp[4] = max(3, dp[1] + 5 = 5) = 5. dp[1] is to the left, not yet touched for this item, so it is the value without item #2: the item is counted at most once.
  12. dp[3] = max(3, dp[0] + 5 = 5) = 5. dp[0] is to the left, not yet touched for this item, so it is the value without item #2: the item is counted at most once.
  13. dp[6] = 8, with each item used at most once. Sweeping low to high would have reused items and claimed 10. High to low is right for 0/1 problems like Partition Equal Subset Sum.
// 0/1 knapsack in one row: each item at most once. dp[c] = best value with capacity c, using the items so far.
// Capacities go DOWN, so dp[c - w] is still the value from BEFORE this item: no item is counted twice.
export function knapsack01(weights, values, capacity) {
  const dp = new Array(capacity + 1).fill(0);
  for (let i = 0; i < weights.length; i++) {
    for (let c = capacity; c >= weights[i]; c--) {
      dp[c] = Math.max(dp[c], dp[c - weights[i]] + values[i]);
    }
  }
  return dp[capacity];
}

// Unbounded knapsack: each item as many times as you like.
// Capacities go UP, so dp[c - w] may already include this item, which is exactly what allows reuse.
export function knapsackUnbounded(weights, values, capacity) {
  const dp = new Array(capacity + 1).fill(0);
  for (let i = 0; i < weights.length; i++) {
    for (let c = weights[i]; c <= capacity; c++) {
      dp[c] = Math.max(dp[c], dp[c - weights[i]] + values[i]);
    }
  }
  return dp[capacity];
}

// 0/1 knapsack with the full table, so the chosen items can be read back.
// dp[i][c] = best value using the first i items with capacity c. Item i was taken iff dp[i][c] !== dp[i - 1][c].
export function knapsackItems(weights, values, capacity) {
  const n = weights.length;
  const dp = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0));
  for (let i = 1; i <= n; i++) {
    const w = weights[i - 1];
    for (let c = 0; c <= capacity; c++) {
      dp[i][c] = dp[i - 1][c];                                                // skip item i
      if (w <= c) dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - w] + values[i - 1]); // or take it once
    }
  }
  const chosen = [];
  for (let i = n, c = capacity; i > 0; i--) {
    if (dp[i][c] !== dp[i - 1][c]) {
      chosen.push(i - 1); // 0-based index of the item
      c -= weights[i - 1];
    }
  }
  return { best: dp[n][capacity], items: chosen.reverse() };
}

Reconstructing the answer

The table stores values, not choices, but each value came from a neighbour you can find again:

Read more · 40 words
  • LCS: matching letters mean the diagonal. Otherwise move to the larger of above and left.
  • Knapsack: if dp[i][c] !== dp[i - 1][c], item i was taken. Move to c - w. Otherwise move up.

This needs the full table.

Try it

try-it.js
// The shared-row trap: fill() evaluates its argument ONCE, so every row is the same array.
const shared = new Array(3).fill(new Array(4).fill(0));
shared[0][1] = 7;
console.log(shared.map((row) => row.join(' ')).join(' | ')); // 0 7 0 0 | 0 7 0 0 | 0 7 0 0

// Array.from calls the factory once per row: independent rows.
const table = Array.from({ length: 3 }, () => new Array(4).fill(0));
table[0][1] = 7;
console.log(table.map((row) => row.join(' ')).join(' | ')); // 0 7 0 0 | 0 0 0 0 | 0 0 0 0

// Two strings, with the answer rebuilt from the table.
console.log(lcsString('CLOUD', 'COLD'));                     // CLD

// Same items, two loop directions.
console.log(knapsack01([2, 3], [3, 5], 6), knapsackUnbounded([2, 3], [3, 5], 6)); // 8 10
console.log(knapsackItems([3, 2, 2], [5, 3, 3], 4));          // { best: 6, items: [1, 2] }

Complexity

Read more · 49 words
VariantTimeSpace
Grid or two stringsO(m × n)O(n) with one row
KnapsackO(n × W)O(W) with one row
Interval DPO(n³)O(n²)

O(n × W) depends on the value of W: fine up to about 10^5, hopeless at 10^12.

JavaScript you'll use

JavaScript refresher: Arrays: create, read, update Gotchas and debugging Recursion, the call stack and memoization
Read more · 69 words

The shared-row trap fails silently: fill(x) puts the same array in every slot, so dp[1][2] = 5 also sets dp[0][2]. Array.from builds a fresh row each time.

const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); // m + 1 independent rows
const bad = new Array(m + 1).fill(new Array(n + 1).fill(0));             // ONE row, shared m + 1 times
const grid2 = grid.map((row) => row.slice());                             // copy before writing into the input
let prev = new Array(n + 1).fill(0), cur = new Array(n + 1).fill(0);
[prev, cur] = [cur, prev];                                                // two rows, swapped each iteration

Interview questions

Why must 0/1 knapsack run from high capacity to low with one row?

Going down, cells to the left still hold the previous item's row. Going up, they may already include this item, which is unbounded knapsack.

Coin Change II and Combination Sum IV look alike. Why different answers?

Coin Change II counts combinations: coins outside, one order. Combination Sum IV counts ordered sequences: amounts outside. For 3 with coins 1 and 2, that is 2 and 3.

How would you print the actual edits between two words?

Walk back from dp[m][n] in the full table. Each cell names the neighbour it came from: diagonal is replace, above is delete, left is insert.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. In the LCS table, why does dp have m + 1 rows and n + 1 columns instead of m × n?

Practice problems

Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.

Open these in the problem list →

Next: Last stop: bits, where tiny operators replace whole data structures.