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.
- 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].
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
Tap the signals you can see in the problem.
RememberTwo stringsA gridItems under a budget
- Grid paths
Unique Pathsdp[i][j]meansanswer for reaching(i, j) - Two strings
Edit Distancedp[i][j]meansanswer for the firstiandjletters - Knapsack
Partition Equal Subset Sumdp[i][j]meansbest from the firstiitems, capacityj - Intervals
Burst Balloonsdp[i][j]meansanswer for the slicea[i..j]
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:
| 1 | 1 | 1 |
|---|---|---|
| 1 | 2 | 3 |
| 1 | 3 | 6 |
paths[r][c] = paths[r - 1][c] + paths[r][c - 1]; // the first row and first column are all 1Two 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
- 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
- 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.
- C = C: both last letters can be used together, so take the best without them (diagonal, 0) plus 1. dp[1][1] = 1.
- 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.
- 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.
- 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.
- 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.
- 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.
- L = L: both last letters can be used together, so take the best without them (diagonal, 1) plus 1. dp[2][3] = 2.
- 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.
- 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.
- O = O: both last letters can be used together, so take the best without them (diagonal, 1) plus 1. dp[3][2] = 2.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- D = D: both last letters can be used together, so take the best without them (diagonal, 2) plus 1. dp[5][4] = 3.
- 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.
- D = D, so this cell came from the diagonal: D is in the subsequence. Move up and left.
- U ≠ L, and the cell above already reaches the same length (2) without U: move up.
- O ≠ L, and the cell above already reaches the same length (2) without O: move up.
- L = L, so this cell came from the diagonal: L is in the subsequence. Move up and left.
- C ≠ O, and the cell to the left is the larger one (1): this value came without O, so move left.
- C = C, so this cell came from the diagonal: C is in the subsequence. Move up and left.
- 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
Name both indices and the cell in words
"
dp[i][j]= the fewest edits turning the firstiletters ofainto the firstjofb."Size the table and avoid the shared-row trap
Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)). Neverfill(new Array(n + 1)).Seed row 0 and column 0
An empty prefix, no items, a grid's first row.
Write the recurrence from the last decision
The last move, the last letters, the last item: each points at one neighbour.
Fill in dependency order
Row by row. Interval DPs loop by length.
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.
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:
| Neighbour | Edit 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
- 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
- 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.
- Item #1 weighs 2, so capacities 0 to 1 can't hold it. Skipping is the only option there: copy the row above.
- Capacity 2: skip item #1 (above: 0) or take it (row above at capacity 2 - 2 = 0, worth 0, plus $3 = 3). Best: 3.
- Capacity 3: skip item #1 (above: 0) or take it (row above at capacity 3 - 2 = 1, worth 0, plus $3 = 3). Best: 3.
- Capacity 4: skip item #1 (above: 0) or take it (row above at capacity 4 - 2 = 2, worth 0, plus $3 = 3). Best: 3.
- Capacity 5: skip item #1 (above: 0) or take it (row above at capacity 5 - 2 = 3, worth 0, plus $3 = 3). Best: 3.
- Item #2 weighs 3, so capacities 0 to 2 can't hold it. Skipping is the only option there: copy the row above.
- Capacity 3: skip item #2 (above: 3) or take it (row above at capacity 3 - 3 = 0, worth 0, plus $4 = 4). Best: 4.
- Capacity 4: skip item #2 (above: 3) or take it (row above at capacity 4 - 3 = 1, worth 0, plus $4 = 4). Best: 4.
- Capacity 5: skip item #2 (above: 3) or take it (row above at capacity 5 - 3 = 2, worth 3, plus $4 = 7). Best: 7.
- Item #3 weighs 4, so capacities 0 to 3 can't hold it. Skipping is the only option there: copy the row above.
- Capacity 4: skip item #3 (above: 4) or take it (row above at capacity 4 - 4 = 0, worth 0, plus $6 = 6). Best: 6.
- Capacity 5: skip item #3 (above: 7) or take it (row above at capacity 5 - 4 = 1, worth 0, plus $6 = 6). Best: 7.
- 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
- 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
- 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.
- Item #1: weight 2, value 3. Every cell still holds the answer without this item. Sweep capacities from high to low.
- 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.
- 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.
- 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.
- 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.
- 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.
- Item #2: weight 3, value 5. Every cell still holds the answer without this item. Sweep capacities from high to low.
- 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.
- 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.
- 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.
- 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.
- 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], itemiwas taken. Move toc - w. Otherwise move up.
This needs the full table.
Try it
// 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
| Variant | Time | Space |
|---|---|---|
| Grid or two strings | O(m × n) | O(n) with one row |
| Knapsack | O(n × W) | O(W) with one row |
| Interval DP | O(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 memoizationRead 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 iterationInterview 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 3Score 2 out of 3 or better to mark this page complete.
Practice problems
Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.
- Unique PathsCount the paths from the top-left to the bottom-right of a grid when you may only move right or down.Medium
- Minimum Path SumFind the cheapest right-and-down path through a grid of costs, from the top-left to the bottom-right corner.Medium
- Longest Common SubsequenceFind the length of the longest sequence of letters that appears, in order, in both strings.Medium
- Edit DistanceCount the fewest single-letter inserts, deletes and replacements that turn one word into another.Medium
- Partition Equal Subset SumDecide whether an array of positive integers can be split into two groups with the same sum (0/1 knapsack).Medium
- Coin Change IICount the different combinations of coins that add up to an amount, with unlimited coins of each value.Medium
- Distinct SubsequencesCount the ways to pick characters from s, in order, that spell out t.Hard
- Cheapest Flights Within K StopsFind the cheapest route between two cities when you may change planes at most k times.Medium