Part 14 · Lesson 56 · read
2-D Dynamic Programming
Paths on a grid and the 0/1 knapsack problems.
On this page
When one index is not enough
Lesson 55 saved one answer for each index. Some problems need two numbers to describe a small part of the problem (a subproblem is a smaller version of the same question; solving all of them solves the big one). For example, a row and a column on a board. Or “how many items have I looked at” and “how much room is left in the bag”. Then the table is 2-D (two-dimensional, like a spreadsheet with rows and columns). Each cell dp[i][j] answers one small question for one pair of numbers. The recipe is the same as before. First say in words what dp[i][j] means. Then set the starting values (the base cases). Then write the rule (the transition) that uses the last choice you made. Last, fill the table in an order where each answer is ready before you need it.
Grid paths
A robot starts at the top-left of a grid. It can only move right or down. How many different routes reach the bottom-right? The meaning of one cell (the “state”) is: dp[r][c] = the number of ways to reach cell (r, c). The robot came into that cell from above or from the left. Those are different routes, so we add the two counts. The top row and the left column have exactly one way each (straight along the edge).
// State: dp[r][c] = the number of ways to reach cell (r, c) moving only right or down.function uniquePaths(rows, cols) { const dp = Array.from({ length: rows }, () => new Array(cols).fill(1)); // first row and column: exactly 1 way for (let r = 1; r < rows; r++) { for (let c = 1; c < cols; c++) { dp[r][c] = dp[r - 1][c] + dp[r][c - 1]; // arrive from above, or from the left } } return dp[rows - 1][cols - 1];}console.log(uniquePaths(3, 3)); // 6console.log(uniquePaths(3, 7)); // 28Time and memory are both O(rows × cols). Each cell is worked out once, using its two neighbours.
Paths with obstacles
Now some cells are blocked (marked 1). The idea stays the same. A blocked cell has 0 ways to be reached, so it adds 0 to the cells after it. Watch the edges. A blocked cell in the first row cuts off everything after it in that row. The formula does this by itself, so you need no extra code.
// Same state, but an obstacle cell has 0 ways to be reached.function uniquePathsWithObstacles(grid) { const rows = grid.length, cols = grid[0].length; const dp = Array.from({ length: rows }, () => new Array(cols).fill(0)); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (grid[r][c] === 1) { dp[r][c] = 0; continue; } // blocked if (r === 0 && c === 0) { dp[r][c] = 1; continue; } // the start const above = r > 0 ? dp[r - 1][c] : 0; const left = c > 0 ? dp[r][c - 1] : 0; dp[r][c] = above + left; } } return dp[rows - 1][cols - 1];}console.log(uniquePathsWithObstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]])); // 2console.log(uniquePathsWithObstacles([[0, 1], [0, 0]])); // 1Minimum path sum
Each cell has a cost. Find the right-and-down route with the smallest total. The moves are the same, but now we find thesmallest value instead of counting: dp[r][c] = the smallest total cost of a route from the top-left to (r, c). Pay for the cell itself, plus the cheaper of the two ways in. A cell on an edge has only one way in. So we treat the missing neighbour as Infinity (a number bigger than any other), because it can never be the smallest.
// State: dp[r][c] = the smallest sum of a path from the top-left to (r, c).function minPathSum(grid) { const rows = grid.length, cols = grid[0].length; const dp = Array.from({ length: rows }, () => new Array(cols).fill(0)); dp[0][0] = grid[0][0]; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (r === 0 && c === 0) continue; const fromAbove = r > 0 ? dp[r - 1][c] : Infinity; // off the grid = not an option const fromLeft = c > 0 ? dp[r][c - 1] : Infinity; dp[r][c] = grid[r][c] + Math.min(fromAbove, fromLeft); } } return dp[rows - 1][cols - 1];}console.log(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])); // 7Traced: minimum path sum
Each cell has a cost. We want the cheapest route from the top-left to the bottom-right. We can only move right or down.
▶const rows = grid.length, cols = grid[0].length;2const dp = Array.from({ length: rows }, () => new Array(cols).fill(0));3dp[0][0] = grid[0][0];4for (let r = 0; r < rows; r++) {5 for (let c = 0; c < cols; c++) {6 if (r === 0 && c === 0) continue;7 const fromAbove = r > 0 ? dp[r - 1][c] : Infinity;8 const fromLeft = c > 0 ? dp[r][c - 1] : Infinity;9 dp[r][c] = grid[r][c] + Math.min(fromAbove, fromLeft);10 }11}12return dp[rows - 1][cols - 1];| grid | [[1, 3, 1], [1, 5, 1], [4, 2, 1]] |
| rows | 3 |
| cols | 3 |
0/1 knapsack
You have some items. Each item has a weight and a value. You also have a bag that can hold a total weight of at most capacity. Pick items to get the biggest total value. “0/1” means you take each item once, or not at all. Think of packing a suitcase for a trip: each thing goes in once, and the case has a weight limit. The opposite, “unbounded” knapsack, lets you take the same item many times (see below).
State: dp[i][w] = the best total value when you use only the first i items and the bag holds weight w. For item i you make one yes-or-no choice. Skip it: the answer is dp[i-1][w]. Take it (only if it fits): you gain its value, and you have w - weight room left for the earlier items. That gives dp[i-1][w - weight] + value. Keep the larger of the two.
// 0/1 knapsack: each item is used at most once.// State: dp[i][w] = the best total value using only the first i items with capacity w.function knapsack(weights, values, capacity) { const n = weights.length; const dp = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0)); // row 0: no items, value 0 for (let i = 1; i <= n; i++) { for (let w = 0; w <= capacity; w++) { dp[i][w] = dp[i - 1][w]; // skip item i if (weights[i - 1] <= w) { // or take it, if it fits dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - weights[i - 1]] + values[i - 1]); } } } return dp[n][capacity];}console.log(knapsack([1, 3, 4, 5], [1, 4, 5, 7], 7)); // 9 (the items of weight 3 and 4)This takes O(n × capacity) time and memory.
One row, capacity downwards
Row i only reads row i - 1. So one array is enough. After we process item i, dp[w] holds what row i would have held. Memory drops to O(capacity). But there is one trap: the capacity loop must go from high to low.
// Row i only reads row i - 1, so one array is enough, IF capacity runs downwards.function knapsack(weights, values, capacity) { const dp = new Array(capacity + 1).fill(0); for (let i = 0; i < weights.length; i++) { for (let w = capacity; w >= weights[i]; w--) { // high to low dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]); } } return dp[capacity];}console.log(knapsack([1, 3, 4, 5], [1, 4, 5, 7], 7)); // 9| After | Item | dp[0..7] |
|---|---|---|
| start | - | [0, 0, 0, 0, 0, 0, 0, 0] |
| item 1 | weight 1, value 1 | [0, 1, 1, 1, 1, 1, 1, 1] |
| item 2 | weight 3, value 4 | [0, 1, 1, 4, 5, 5, 5, 5] |
| item 3 | weight 4, value 5 | [0, 1, 1, 4, 5, 6, 6, 9] |
| item 4 | weight 5, value 7 | [0, 1, 1, 4, 5, 7, 8, 9] |
The last number is the answer, 9. It comes from the items with weight 3 and weight 4 (value 4 + 5).
Why downwards? The update for dp[w] reads dp[w - weight], which is a smaller index. When we go from high to low, that smaller cell has not changed yet for this item. It still holds the answer from before this item, so the item is added at most once. When we go from low to high, the smaller cell was already updated for this item. It may already include the item, so we add the item again. The loop then allows unlimited copies, and nothing warns you:
// The same loop with capacity going UP: this no longer means 'each item once'.function ascending(weights, values, capacity) { const dp = new Array(capacity + 1).fill(0); for (let i = 0; i < weights.length; i++) { for (let w = weights[i]; w <= capacity; w++) { // low to high dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]); } } return dp[capacity];}// One item, weight 2 and value 3, capacity 6. Correct answer: 3 (we own just one).console.log(ascending([2], [3], 6)); // 9 the item was counted three timesPartition equal subset sum
Can an array of positive whole numbers be split into two groups with equal sums? If the total is odd, the answer is no. Otherwise we need a subset (some of the numbers) that adds up to exactly half the total. The numbers left over then make the other half. This is knapsack in disguise. The weights are the numbers, the capacity is the target, and the question is yes or no: “can we hit the target exactly?” State: dp[s] = true if some subset of the numbers seen so far adds up to exactly s. Each number is used once, so the loop runs downwards.
// Can the numbers be split into two groups with equal sums?// That is: is there a subset that adds up to exactly half the total?function canPartition(nums) { const total = nums.reduce((a, b) => a + b, 0); if (total % 2 !== 0) return false; // an odd total cannot be split evenly const target = total / 2; const dp = new Array(target + 1).fill(false); // dp[s] = can some chosen subset sum to exactly s? dp[0] = true; // the empty subset sums to 0 for (const x of nums) { for (let s = target; s >= x; s--) { // downwards: each number used at most once if (dp[s - x]) dp[s] = true; } } return dp[target];}console.log(canPartition([1, 5, 11, 5])); // true (11 and 1 + 5 + 5)console.log(canPartition([1, 2, 3, 5])); // falseUnbounded knapsack: coin change II
Unbounded means you can use each item as many times as you like. Coin change II asks this. You have unlimited coins of given values. In how many different combinations can you make the amount? (The same coins in a different order count as one answer.) The one-row loop here is the one that looked like a bug a moment ago. Capacity now runs upwards. Reading a cell that already includes the current coin is exactly what lets us use the coin again.
// Coin change II: the number of COMBINATIONS of coins that make the amount (each coin reusable).// State: dp[a] = the number of ways to make amount a using the coin types processed so far.function change(amount, coins) { const dp = new Array(amount + 1).fill(0); dp[0] = 1; // one way to make 0: take nothing for (const coin of coins) { // coins on the OUTSIDE for (let a = coin; a <= amount; a++) { // upwards: the same coin may be used again dp[a] += dp[a - coin]; } } return dp[amount];}console.log(change(5, [1, 2, 5])); // 4 (5, 2+2+1, 2+1+1+1, 1+1+1+1+1)console.log(change(3, [2])); // 0Loop order: combinations vs permutations
Put the coins on the outside. We decide how many of coin 1 to use, then coin 2, and so on. Every way is built in one fixed coin order. So 1 + 2 and 2 + 1 cannot both appear. You are counting combinations (order does not matter). Now put the amount on the outside and the coins inside. At each amount, any coin can be the last one added. So different orders of the same coins are counted separately. These are permutations (order matters). This is the question “Combination Sum IV”. The rule is the same, only the loops are swapped, and so the question changes.
// Swap the loops (amount outside, coins inside) and the count changes meaning.function countOrderedWays(amount, coins) { const dp = new Array(amount + 1).fill(0); dp[0] = 1; for (let a = 1; a <= amount; a++) { // amount on the OUTSIDE for (const coin of coins) { if (coin <= a) dp[a] += dp[a - coin]; } } return dp[amount];}console.log(countOrderedWays(3, [1, 2])); // 3 (1+1+1, 1+2 and 2+1 all count)| Loop order | Start | After coin 1 / amount 1 | After coin 2 / amounts 2 and 3 | Answer |
|---|---|---|---|---|
| coins outside, amounts inside (change) | [1, 0, 0, 0] | [1, 1, 1, 1] | [1, 1, 2, 2] | 2 (1+1+1, 1+2) |
| amounts outside, coins inside | [1, 0, 0, 0] | dp[1] = dp[0] = 1 | dp[2] = dp[1] + dp[0] = 2, dp[3] = dp[2] + dp[1] = 3 | 3 (1+1+1, 1+2, 2+1) |
Combinations: 1+1+1 and 1+2. Permutations also count 2+1 as a separate answer.
Practice questions
Before you write code, say what dp[i][j] means. Also say which direction each loop should run, and why.
A robot stands in the top-left of an m × n grid. It can only move right or down. Return the number of different routes to the bottom-right corner.
Hint 1
Hint 2
Hint 3
Show all 3 solutions with explanations
Fill the first row and the first column with 1 (there is one straight route). Then use dp[r][c] = dp[r-1][c] + dp[r][c-1].
function uniquePaths(m, n) { const dp = Array.from({ length: m }, () => new Array(n).fill(1)); for (let r = 1; r < m; r++) { for (let c = 1; c < n; c++) { dp[r][c] = dp[r - 1][c] + dp[r][c - 1]; } } return dp[m - 1][n - 1];}console.log(uniquePaths(3, 7)); // 28console.log(uniquePaths(3, 2)); // 3console.log(uniquePaths(1, 1)); // 1O(m × n) time and memory. Each cell is worked out from two neighbours that are already filled.
Keep one array dp for the current row. Before the update, dp[c] is the cell above. dp[c - 1] is already the cell to the left.
function uniquePaths(m, n) { const dp = new Array(n).fill(1); for (let r = 1; r < m; r++) { for (let c = 1; c < n; c++) { dp[c] += dp[c - 1]; // above (old dp[c]) + left (new dp[c - 1]) } } return dp[n - 1];}console.log(uniquePaths(3, 7)); // 28console.log(uniquePaths(3, 2)); // 3console.log(uniquePaths(1, 1)); // 1O(m × n) time, O(n) memory. Going left to right is correct here, because we want the left cell to be already updated for this row.
Every route has exactly m - 1 down moves and n - 1 right moves. A route is just a choice of which of the m + n - 2 moves are the down ones. The answer is C(m + n - 2, m - 1). C(a, b) is called a binomial coefficient. It means “the number of ways to choose b things from a things when the order does not matter”.
function uniquePaths(m, n) { const total = m + n - 2; const k = Math.min(m, n) - 1; let result = 1; for (let i = 1; i <= k; i++) { result = (result * (total - k + i)) / i; // this is always a whole number } return Math.round(result);}console.log(uniquePaths(3, 7)); // 28console.log(uniquePaths(3, 2)); // 3console.log(uniquePaths(1, 1)); // 1O(min(m, n)) time, O(1) memory. It is a nice shortcut here. But it stops working as soon as there are obstacles, so the DP is worth knowing.
Learn the table, because the same shape solves the obstacle and cost versions. The formula is a good way to check your answer. (LeetCode 62.)
This is the same robot and grid as the last question, but some cells hold an obstacle (1; free cells are 0). The robot cannot enter an obstacle. Return the number of routes to the bottom-right corner.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
Obstacle cell: 0. Start cell: 1. Any other cell: the cell above plus the cell to the left. A place off the grid counts as 0.
function uniquePathsWithObstacles(grid) { const rows = grid.length, cols = grid[0].length; const dp = Array.from({ length: rows }, () => new Array(cols).fill(0)); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (grid[r][c] === 1) continue; // stays 0 if (r === 0 && c === 0) { dp[r][c] = 1; continue; } dp[r][c] = (r > 0 ? dp[r - 1][c] : 0) + (c > 0 ? dp[r][c - 1] : 0); } } return dp[rows - 1][cols - 1];}console.log(uniquePathsWithObstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]])); // 2console.log(uniquePathsWithObstacles([[0, 1], [0, 0]])); // 1console.log(uniquePathsWithObstacles([[1, 0]])); // 0O(rows × cols) time and memory. If the start is blocked, every cell stays 0, so the answer is 0.
Start with dp = [1, 0, 0, ...], which says “one way to be just above the first cell”. For every cell: if it is blocked, set it to 0. Otherwise add the cell on the left.
function uniquePathsWithObstacles(grid) { const cols = grid[0].length; const dp = new Array(cols).fill(0); dp[0] = 1; for (const row of grid) { for (let c = 0; c < cols; c++) { if (row[c] === 1) dp[c] = 0; // blocked: nothing reaches it else if (c > 0) dp[c] += dp[c - 1]; // above (kept in dp[c]) + left } } return dp[cols - 1];}console.log(uniquePathsWithObstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]])); // 2console.log(uniquePathsWithObstacles([[0, 1], [0, 0]])); // 1console.log(uniquePathsWithObstacles([[1, 0]])); // 0O(rows × cols) time, O(cols) memory. The first column keeps its value from the row above until an obstacle sets it to 0.
The table is easier to get right. The single row is the follow-up question. (LeetCode 63.)
Given a grid of numbers that are zero or more, find a route from the top-left to the bottom-right (moving only right or down) with the smallest total of cell values. Return that total.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
best(r, c) is the lowest cost from (r, c) to the end: the cell plus the cheaper of going down or going right. A memo (memoization) means saving each answer after you work it out, so you never work it out twice. We save the answer for each cell. Without it, the code would walk every route one by one.
function minPathSum(grid) { const rows = grid.length, cols = grid[0].length; const memo = new Map(); function best(r, c) { if (r === rows - 1 && c === cols - 1) return grid[r][c]; const key = r * cols + c; if (memo.has(key)) return memo.get(key); const down = r + 1 < rows ? best(r + 1, c) : Infinity; const right = c + 1 < cols ? best(r, c + 1) : Infinity; const result = grid[r][c] + Math.min(down, right); memo.set(key, result); return result; } return best(0, 0);}console.log(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])); // 7console.log(minPathSum([[1, 2, 3], [4, 5, 6]])); // 12One saved answer per cell, so O(rows × cols) time and memory. The call depth is rows + cols. Without the memo, the number of calls grows as fast as the number of routes. That is exponential, which means it doubles again and again.
Walk the grid in reading order (left to right, top to bottom) and keep one array. Before the update, dp[c] is the cost from above. dp[c - 1] is the cost from the left.
function minPathSum(grid) { const rows = grid.length, cols = grid[0].length; const dp = new Array(cols).fill(Infinity); dp[0] = 0; // so the start cell costs only itself for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const fromLeft = c > 0 ? dp[c - 1] : Infinity; dp[c] = grid[r][c] + Math.min(dp[c], fromLeft); } } return dp[cols - 1];}console.log(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])); // 7console.log(minPathSum([[1, 2, 3], [4, 5, 6]])); // 12O(rows × cols) time, O(cols) memory. On the first row, dp[c] is still Infinity, so only the left neighbour counts. In the first column there is no left neighbour, so only the cell above counts.
Start with the memo, because it follows how you think. Use the row version when memory matters. A greedy method (a method that always takes the choice that looks best right now) fails here (“always pick the cheaper next step”), because a cheap step can lead into expensive cells. (LeetCode 64.)
Given an array of positive whole numbers, return true if it can be split into two subsets (groups) with equal sums.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
can(i, rest) asks: do some numbers from index i onward add up to rest? Take nums[i] or skip it. Save each answer by (i, rest).
function canPartition(nums) { const total = nums.reduce((a, b) => a + b, 0); if (total % 2 !== 0) return false; const memo = new Map(); function can(i, rest) { if (rest === 0) return true; if (i === nums.length || rest < 0) return false; const key = i * (total + 1) + rest; if (memo.has(key)) return memo.get(key); const result = can(i + 1, rest - nums[i]) || can(i + 1, rest); memo.set(key, result); return result; } return can(0, total / 2);}console.log(canPartition([1, 5, 11, 5])); // trueconsole.log(canPartition([1, 2, 3, 5])); // falseThere are at most n × target different states, so O(n × target) time and memory. Without the memo it tries up to 2n subsets.
dp[s] is true when some subset adds up to s. For each number x, go through s from high to low and set dp[s] = dp[s] || dp[s - x].
function canPartition(nums) { const total = nums.reduce((a, b) => a + b, 0); if (total % 2 !== 0) return false; const target = total / 2; const dp = new Array(target + 1).fill(false); dp[0] = true; for (const x of nums) { for (let s = target; s >= x; s--) { if (dp[s - x]) dp[s] = true; } } return dp[target];}console.log(canPartition([1, 5, 11, 5])); // trueconsole.log(canPartition([1, 2, 3, 5])); // falseO(n × target) time, O(target) memory. We go downwards, so each number joins a subset at most once. Going upwards would let a number be used again. For [2, 6] (target 4) it would wrongly say true by using the 2 twice.
Use the 1-D table. The memo is a good first step, because it shows the take-or-skip idea. (LeetCode 416.)
Given coin values (you have unlimited coins of each) and a target amount, return the number of different combinations of coins that make exactly that amount. Return 0 if it is impossible.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
dp[i][a] is the number of combinations that make a using only the first i coin types. Either do not use coin i at all (dp[i-1][a]), or use it at least once (dp[i][a - coin], in the same row, because the coin can be used again).
function change(amount, coins) { const n = coins.length; const dp = Array.from({ length: n + 1 }, () => new Array(amount + 1).fill(0)); for (let i = 0; i <= n; i++) dp[i][0] = 1; // one way to make 0 with any coins: take nothing for (let i = 1; i <= n; i++) { const coin = coins[i - 1]; for (let a = 1; a <= amount; a++) { dp[i][a] = dp[i - 1][a] + (a >= coin ? dp[i][a - coin] : 0); } } return dp[n][amount];}console.log(change(5, [1, 2, 5])); // 4console.log(change(3, [2])); // 0console.log(change(10, [10])); // 1O(n × amount) time and memory. Notice that the second term reads the same row i. That is how “use this coin as often as you like” looks in a table.
Squash the rows into one array. With coins on the outside, each combination is built in one fixed coin order, so it is counted once. Amounts going upwards lets a coin be used again.
function change(amount, coins) { const dp = new Array(amount + 1).fill(0); dp[0] = 1; for (const coin of coins) { for (let a = coin; a <= amount; a++) { dp[a] += dp[a - coin]; } } return dp[amount];}console.log(change(5, [1, 2, 5])); // 4console.log(change(3, [2])); // 0console.log(change(10, [10])); // 1O(n × amount) time, O(amount) memory. If you swap the two loops, you count ordered sequences (permutations). Then amount 3 with coins [1, 2] gives 3 instead of 2.
Write the 1-D version, and be ready to explain the loop order. Interviewers often ask exactly this: combinations or permutations. (LeetCode 518.)
Given an array of whole numbers (zero or more) and a target, put a + or - in front of every number. Return how many ways of choosing the signs make the result equal target.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
ways(i, sum) counts the sign choices for the numbers from i onward that make the running sum reach the target. Try +nums[i] and -nums[i]. Save each answer by (i, sum).
function findTargetSumWays(nums, target) { const memo = new Map(); function ways(i, sum) { if (i === nums.length) return sum === target ? 1 : 0; const key = i + "," + sum; if (memo.has(key)) return memo.get(key); const result = ways(i + 1, sum + nums[i]) + ways(i + 1, sum - nums[i]); memo.set(key, result); return result; } return ways(0, 0);}console.log(findTargetSumWays([1, 1, 1, 1, 1], 3)); // 5console.log(findTargetSumWays([1], 1)); // 1The running sum stays between -total and +total, so there are at most n × (2 × total + 1) states. Without the memo it can try 2n paths.
Let P be the sum of the numbers with a plus sign, and N the sum of the numbers with a minus sign. Then P + N = total and P − N = target, so P = (total + target) / 2. Now count the subsets that add up to P, using a 0/1 knapsack that counts. If total + target is odd, or |target| > total, the answer is 0.
function findTargetSumWays(nums, target) { const total = nums.reduce((a, b) => a + b, 0); if (Math.abs(target) > total || (total + target) % 2 !== 0) return 0; const want = (total + target) / 2; const dp = new Array(want + 1).fill(0); dp[0] = 1; for (const x of nums) { for (let s = want; s >= x; s--) { // downwards: each number is used once dp[s] += dp[s - x]; } } return dp[want];}console.log(findTargetSumWays([1, 1, 1, 1, 1], 3)); // 5console.log(findTargetSumWays([1], 1)); // 1In the first example total = 5, so P = (5 + 3) / 2 = 4. There are 5 ways to choose four of the five ones. This takes O(n × P) time and O(P) memory. Zeros in the input work correctly: a 0 doubles the counts, because it can take either sign.
The subset-sum version is shorter and uses less memory. The memo is easier to think of first. Both are fine. (LeetCode 494.)
Given a grid (matrix) of the characters "0" and "1", find the largest square made only of "1"s and return its area.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
Treat every cell as a top-left corner. Grow the square one size at a time, as long as the new bottom row and new right column are all 1s.
function maximalSquare(matrix) { const rows = matrix.length, cols = matrix[0].length; let best = 0; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (matrix[r][c] !== "1") continue; let side = 1; while (r + side < rows && c + side < cols) { let ok = true; for (let k = 0; k <= side; k++) { // the new row and the new column if (matrix[r + side][c + k] !== "1" || matrix[r + k][c + side] !== "1") { ok = false; break; } } if (!ok) break; side++; } best = Math.max(best, side); } } return best * best;}console.log(maximalSquare([["1", "0", "1", "0", "0"], ["1", "0", "1", "1", "1"], ["1", "1", "1", "1", "1"], ["1", "0", "0", "1", "0"]])); // 4console.log(maximalSquare([["0", "1"], ["1", "0"]])); // 1console.log(maximalSquare([["0"]])); // 0O(rows × cols × min(rows, cols)²) in the worst case. Each corner may grow to the full size and re-check a row and a column at each step. This is fine for small grids but too slow for big ones.
If the cell is "1", then dp[r][c] = 1 + min(dp[r-1][c], dp[r][c-1], dp[r-1][c-1]). Otherwise it is 0. The smallest of the three neighbours limits how big the square can be. Add one extra row and column of zeros, so the edges need no special case.
function maximalSquare(matrix) { const rows = matrix.length, cols = matrix[0].length; const dp = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0)); // extra row and column of zeros let side = 0; for (let r = 1; r <= rows; r++) { for (let c = 1; c <= cols; c++) { if (matrix[r - 1][c - 1] === "1") { dp[r][c] = 1 + Math.min(dp[r - 1][c], dp[r][c - 1], dp[r - 1][c - 1]); side = Math.max(side, dp[r][c]); } } } return side * side;}console.log(maximalSquare([["1", "0", "1", "0", "0"], ["1", "0", "1", "1", "1"], ["1", "1", "1", "1", "1"], ["1", "0", "0", "1", "0"]])); // 4console.log(maximalSquare([["0", "1"], ["1", "0"]])); // 1console.log(maximalSquare([["0"]])); // 0O(rows × cols) time and memory. You can shrink the memory to two rows (or one row plus one saved diagonal value). Why use the smallest of the three? A square with side 3 needs three overlapping squares with side 2 (above, left and diagonal). Any 0 in the 3 × 3 block makes one of those three smaller.
Brute force is a good way to understand the problem. But the corner DP with the smallest of three neighbours is the answer interviewers expect. (LeetCode 221.)
Make it stick
- Write the state sentence and the rule for unique paths. Then change it for obstacles and for minimum path sum.
- Write 0/1 knapsack as a 2-D table. Then write it again with one row.
- Explain why the 0/1 loop goes from high capacity to low. Say what goes wrong if it does not.
- Turn “partition equal subset sum” into the question “can a subset add up to half the total?”
- Explain why coin change II loops over the amount upwards.
- Say which loop order counts combinations and which counts permutations. Say why.
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
What's next
Tables with two numbers also fit problems on two strings. Let dp[i][j] describe the first i characters of one string and the first j characters of the other. Lesson 57 does this for longest common subsequence and edit distance. Then it comes back to the longest increasing subsequence with a faster O(n log n) method.
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series