Part 14 · Lesson 55 · read
1-D Dynamic Programming
House robber, coin change, decode ways, word break and the longest increasing subsequence.
On this page
Defining the state in words
In lesson 54 you made slow recursion fast by remembering answers. Now we turn that into a habit. Dynamic programming (DP) is a method that solves a big problem by splitting it into smaller problems that repeat, solving each small problem only once, and saving its answer. Memoization (also called a memo) means saving the answer of a function call, so the next call with the same input just reads it. Think of writing a result in a notebook instead of calculating it again. The most important sentence in any DP solution is the one that starts “dp[i] means …”. This sentence defines the state. The state is the small question whose answer you save for each value of i. When the state is clear, three more things follow almost by themselves:
- Base case: the answer for the smallest inputs. You know it without thinking (for example
dp[0]). - Transition: a formula that gives
dp[i]from answers you already have. To find it, ask “what was the last decision?” - Order: fill the array so that every answer is ready before you use it (usually from left to right).
1-D DP means the state needs only one index, so the table is a single array. Say the sentence out loud before you write any code. This is the best habit in this whole chapter. If you cannot finish the sentence, you do not have a solution yet.
House robber
A row of houses holds nums[i] money each. You may not rob two houses that are next to each other. What is the most money you can take? For every house the last decision is simple: skip it or rob it. If you rob house i, you cannot rob house i + 1, so you continue from i + 2. Here is the state, looking forward:
// State: best(i) = the most money you can take from houses i, i+1, ... to the end.function rob(nums) { const memo = new Map(); function best(i) { if (i >= nums.length) return 0; // no houses left if (memo.has(i)) return memo.get(i); const skip = best(i + 1); // leave house i alone const take = nums[i] + best(i + 2); // rob it, so house i+1 is off limits const result = Math.max(skip, take); memo.set(i, result); return result; } return best(0);}console.log(rob([1, 2, 3, 1])); // 4console.log(rob([2, 7, 9, 3, 1])); // 12Without the memo, this recursion calls itself twice per house, so the time is O(2n). With the memo, each best(i) is worked out only once. The time and space are O(n). You can also state the same problem looking backward. That gives a loop with no recursion at all. This is called tabulation: filling a table (an array) from the smallest answers up to the biggest one. In one line: memoization is top-down (start from the big question and save answers as you go), and tabulation is bottom-up (start from the small answers and build up).
// State: dp[i] = the most money you can take from the FIRST i houses.function rob(nums) { const n = nums.length; const dp = new Array(n + 1).fill(0); // dp[0] = 0: no houses, no money dp[1] = nums[0]; for (let i = 2; i <= n; i++) { dp[i] = Math.max(dp[i - 1], // skip house i (it is nums[i - 1]) dp[i - 2] + nums[i - 1]); // rob it } return dp[n];}console.log(rob([2, 7, 9, 3, 1])); // 12| i | house i | skip: dp[i-1] | rob: dp[i-2] + money | dp[i] |
|---|---|---|---|---|
| 0 | - | - | - | 0 |
| 1 | 2 | 0 | 0 + 2 = 2 | 2 |
| 2 | 7 | 2 | 0 + 7 = 7 | 7 |
| 3 | 9 | 7 | 2 + 9 = 11 | 11 |
| 4 | 3 | 11 | 7 + 3 = 10 | 11 |
| 5 | 1 | 11 | 11 + 1 = 12 | 12 |
Each row takes the larger of the two choices. The answer 12 is 2 + 9 + 1.
Notice that each row only looks at the two rows before it. When a table is used like this, you can throw the rest away and keep just two variables. This cuts the space from O(n) to O(1):
// dp[i] only reads dp[i - 1] and dp[i - 2], so two variables are enough.function rob(nums) { let twoBack = 0; // dp[i - 2] let oneBack = 0; // dp[i - 1] for (const money of nums) { const current = Math.max(oneBack, twoBack + money); twoBack = oneBack; oneBack = current; } return oneBack;}console.log(rob([2, 7, 9, 3, 1])); // 12console.log(rob([5])); // 5House robber II: a circle
Now the houses stand in a circle. The first and the last house are neighbours, so you cannot rob both. Do not invent a new formula. Instead, split the problem into two problems with a straight row. Any valid plan skips the first house or skips the last one. Solve both rows and take the better answer.
function robLine(nums, lo, hi) { // the line version, restricted to nums[lo..hi] let twoBack = 0, oneBack = 0; for (let i = lo; i <= hi; i++) { const current = Math.max(oneBack, twoBack + nums[i]); twoBack = oneBack; oneBack = current; } return oneBack;}function robCircle(nums) { if (nums.length === 1) return nums[0]; // the two ranges below would both be empty const withoutLast = robLine(nums, 0, nums.length - 2); const withoutFirst = robLine(nums, 1, nums.length - 1); return Math.max(withoutFirst, withoutLast);}console.log(robCircle([2, 3, 2])); // 3console.log(robCircle([1, 2, 3, 1])); // 4console.log(robCircle([1])); // 1nums.length === 1 on its own.Coin change
You get coin values and a target amount. Find the fewest coins that add up to it. You can use each coin value as many times as you like. If it is impossible, return -1. A greedy method (a method that always takes the best-looking choice right now and never looks back) fails here. Here that means always taking the biggest coin first. For coins 1, 3, 4 and amount 6, the biggest coin first gives 4 + 1 + 1 = 3 coins, but 3 + 3 uses only 2 coins.
State: dp[a] = the fewest coins that make exactly the amount a. The last decision is which coin you used last. If it was coin, the rest is the smaller problem dp[a - coin], plus one. Try every coin and keep the smallest result. For amounts that cannot be made, we store Infinity. This works well because Infinity never wins a “smallest” comparison, and Infinity + 1 is still Infinity. We turn it into -1 only at the very end.
// State: dp[a] = the fewest coins that make exactly the amount a (Infinity if impossible).function coinChange(coins, amount) { const dp = new Array(amount + 1).fill(Infinity); dp[0] = 0; // zero coins make zero for (let a = 1; a <= amount; a++) { for (const coin of coins) { if (coin <= a && dp[a - coin] + 1 < dp[a]) { dp[a] = dp[a - coin] + 1; // use one 'coin', then solve the smaller amount } } } return dp[amount] === Infinity ? -1 : dp[amount];}console.log(coinChange([1, 3, 4], 6)); // 2 (3 + 3, where greedy would pick 4 + 1 + 1)console.log(coinChange([2], 3)); // -1console.log(coinChange([5], 0)); // 0Here is the top-down version of the same state. It uses a memo (a saved-answers notebook) instead of a loop:
// The same idea top-down: ask for the smaller amount, remember each answer.function coinChange(coins, amount) { const memo = new Map(); function fewest(rest) { if (rest === 0) return 0; if (rest < 0) return Infinity; // overshot: this path is impossible if (memo.has(rest)) return memo.get(rest); let best = Infinity; for (const coin of coins) best = Math.min(best, fewest(rest - coin) + 1); memo.set(rest, best); return best; } const answer = fewest(amount); return answer === Infinity ? -1 : answer;}console.log(coinChange([1, 3, 4], 6)); // 2console.log(coinChange([2], 3)); // -1The time is O(amount × number of coins) and the space is O(amount). Both versions are correct, but the table avoids deep recursion for large amounts.
Traced: coin change
Infinity means 'no way found yet'. We use it (not -1 or 0) so the check dp[a - coin] + 1 < dp[a] stays correct, because Infinity + 1 is still Infinity.
▶const dp = new Array(amount + 1).fill(Infinity);2dp[0] = 0;3for (let a = 1; a <= amount; a++) {4 for (const coin of coins) {5 if (coin <= a && dp[a - coin] + 1 < dp[a]) {6 dp[a] = dp[a - coin] + 1;7 }8 }9}10return dp[amount] === Infinity ? -1 : dp[amount];| coins | [1, 3, 4] |
| amount | 6 |
| dp | [Infinity, Infinity, Infinity, Infinity, Infinity, Infinity, Infinity] |
Decode ways
A message of digits is decoded with 1 = A, 2 = B, …, 26 = Z. Count the ways to read a string of digits. State: dp[i] = the number of ways to decode the first i characters. The last letter used either one digit (valid when it is 1 to 9, never 0) or two digits (valid when they make a number from 10 to 26). The two counts add up:
// State: dp[i] = the number of ways to decode the first i characters of s.function numDecodings(s) { const n = s.length; const dp = new Array(n + 1).fill(0); dp[0] = 1; // one way to decode nothing: the empty message for (let i = 1; i <= n; i++) { if (s[i - 1] !== "0") dp[i] += dp[i - 1]; // last character alone is a letter (1-9) if (i >= 2) { const two = Number(s.slice(i - 2, i)); if (two >= 10 && two <= 26) dp[i] += dp[i - 2]; // last two characters form a letter (10-26) } } return dp[n];}console.log(numDecodings("12")); // 2 (AB, L)console.log(numDecodings("226")); // 3 (BZ, VF, BBF)console.log(numDecodings("06")); // 0 (a leading zero cannot be decoded)| i | prefix | one digit ok? | two digits ok? | dp[i] |
|---|---|---|---|---|
| 0 | (empty) | - | - | 1 |
| 1 | 2 | yes: dp[0] = 1 | - | 1 |
| 2 | 22 | yes: dp[1] = 1 | 22 is a letter: dp[0] = 1 | 2 |
| 3 | 226 | yes: dp[2] = 2 | 26 is a letter: dp[1] = 1 | 3 |
dp[3] = 3: BZ (2, 26), VF (22, 6) and BBF (2, 2, 6).
Word break
Can the string s be cut into pieces that are all in a dictionary (a list of allowed words)? State: dp[i] = true if the first i characters can be split into dictionary words. The last word is s[j..i) for some cut point j. If the start of the string of length j works, and the slice is a word, then the start of length i works too.
// State: dp[i] = true if the first i characters of s can be split into dictionary words.function wordBreak(s, wordDict) { const words = new Set(wordDict); // O(1) membership tests const dp = new Array(s.length + 1).fill(false); dp[0] = true; // the empty prefix is trivially fine for (let i = 1; i <= s.length; i++) { for (let j = 0; j < i; j++) { if (dp[j] && words.has(s.slice(j, i))) { // prefix j works, and s[j..i) is a word dp[i] = true; break; } } } return dp[s.length];}console.log(wordBreak("leetcode", ["leet", "code"])); // trueconsole.log(wordBreak("catsandog", ["cats", "dog", "sand", "and", "cat"])); // falseThere are O(n²) pairs (j, i), and each slice and lookup costs up to O(n) because it copies and hashes the characters. So the time is about O(n³) in the worst case. The space is O(n). Tip: if the longest dictionary word has length L, try only j values from i - L upwards. That makes it O(n × L).
Longest increasing subsequence
A subsequence keeps items in their original order but may skip some. (A subarray is different: it must be one unbroken piece.) Find the length of the longest strictly increasing subsequence, where every number is bigger than the one before. State: dp[i] = the length of the longest increasing subsequence that ends at nums[i]. Look back at every earlier smaller number nums[j] and add one to its chain. The answer is the biggest value over all i, because the best chain can end anywhere.
// State: dp[i] = the length of the longest increasing subsequence that ENDS at nums[i].function lengthOfLIS(nums) { const dp = new Array(nums.length).fill(1); // every element alone is a subsequence of length 1 let best = 0; for (let i = 0; i < nums.length; i++) { for (let j = 0; j < i; j++) { if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1); // extend a chain ending at j } best = Math.max(best, dp[i]); // the chain may end anywhere } return best;}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4 (2, 3, 7, 18)console.log(lengthOfLIS([7, 7, 7])); // 1 (strictly increasing: equal values do not extend)The time is O(n²) and the space is O(n). A cleverer method with binary search (a way to find a value in a sorted list by checking the middle and discarding half each time) reaches O(n log n). You will meet it in lesson 57.
Practice questions
For each question, first write “dp[i] means …”. Then write the base case, the transition and the order. Only then write the code.
Houses in a row hold nums[i] money each. You cannot rob two houses that are next to each other. Return the most money you can rob.
Hint 1
Hint 2
Hint 3
Show all 3 solutions with explanations
Define best(i) as the most money from house i to the end. Either skip (best(i + 1)) or rob (nums[i] + best(i + 2)). Save each result in a cache. Without the cache the time is O(2n).
function rob(nums) { const memo = new Map(); function best(i) { if (i >= nums.length) return 0; if (memo.has(i)) return memo.get(i); const result = Math.max(best(i + 1), nums[i] + best(i + 2)); memo.set(i, result); return result; } return best(0);}console.log(rob([1, 2, 3, 1])); // 4console.log(rob([2, 7, 9, 3, 1])); // 12There are only n different values of i, and each is solved once. So the time and space are O(n). The recursion goes up to n levels deep.
Let dp[i] be the best total from the first i houses. dp[0] = 0, dp[1] = nums[0], and dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1]).
function rob(nums) { const n = nums.length; const dp = new Array(n + 1).fill(0); dp[1] = nums[0]; for (let i = 2; i <= n; i++) { dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]); } return dp[n];}console.log(rob([1, 2, 3, 1])); // 4console.log(rob([2, 7, 9, 3, 1])); // 12This uses the same formula, filled from left to right with no recursion. The time is O(n) and the space is O(n).
Keep only dp[i - 2] and dp[i - 1], in two variables called twoBack and oneBack. Move them forward at each step.
function rob(nums) { let twoBack = 0, oneBack = 0; for (const money of nums) { const current = Math.max(oneBack, twoBack + money); twoBack = oneBack; oneBack = current; } return oneBack;}console.log(rob([1, 2, 3, 1])); // 4console.log(rob([2, 7, 9, 3, 1])); // 12The time is O(n) and the space is O(1). Step by step on [1,2,3,1]: (0,0), then (0,1), (1,2), (2,4), (4,4). The answer is 4.
Start with the memoised recursion to get the state right. Then change it to the two-variable loop. It is short, fast and uses a fixed amount of space. (LeetCode 198.)
This is the same as house robber, but the houses stand in a circle. The first and last houses are next to each other. Return the most money you can rob.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
Take the better of two answers. One is robbing within nums[0..n-2] (last house left out). The other is robbing within nums[1..n-1] (first house left out). Solve each with the house-robber loop.
function robLine(nums, lo, hi) { let twoBack = 0, oneBack = 0; for (let i = lo; i <= hi; i++) { const current = Math.max(oneBack, twoBack + nums[i]); twoBack = oneBack; oneBack = current; } return oneBack;}function rob(nums) { if (nums.length === 1) return nums[0]; return Math.max(robLine(nums, 0, nums.length - 2), robLine(nums, 1, nums.length - 1));}console.log(rob([2, 3, 2])); // 3console.log(rob([1, 2, 3, 1])); // 4console.log(rob([5])); // 5The two ranges together cover every valid plan. A plan that uses the first house cannot use the last house (range 1). Any other plan fits inside range 2. The time is O(n) and the space is O(1).
Recurse over (i, tookFirst). If the first house was robbed, the last house is not allowed. This needs twice as many states, but the circle rule is kept inside the state itself.
function rob(nums) { const n = nums.length; if (n === 1) return nums[0]; const memo = new Map(); function best(i, tookFirst) { if (i >= n) return 0; if (i === n - 1 && tookFirst) return 0; // the last house touches the first const key = i * 2 + (tookFirst ? 1 : 0); if (memo.has(key)) return memo.get(key); const skip = best(i + 1, tookFirst); const take = nums[i] + best(i + 2, tookFirst || i === 0); const result = Math.max(skip, take); memo.set(key, result); return result; } return best(0, false);}console.log(rob([2, 3, 2])); // 3console.log(rob([1, 2, 3, 1])); // 4console.log(rob([5])); // 5There are 2n states and each takes O(1), so the time and space are O(n). It is more flexible for other circle problems, but longer than the two-range trick.
Use the two-range trick. Reuse the straight-row problem you already solved instead of inventing something new. Remember this idea: “turn the new problem into one you already solved”. (LeetCode 213.)
You are given coin values (you have as many coins of each value as you want) and a target amount. Return the fewest coins that make exactly that amount. Return -1 if it cannot be made.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
fewest(rest) tries each coin and recurses on rest - coin. A negative remainder returns Infinity (you went over the amount). Save the results by remainder. Without the cache the time grows exponentially.
function coinChange(coins, amount) { const memo = new Map(); function fewest(rest) { if (rest === 0) return 0; if (rest < 0) return Infinity; if (memo.has(rest)) return memo.get(rest); let best = Infinity; for (const coin of coins) best = Math.min(best, fewest(rest - coin) + 1); memo.set(rest, best); return best; } const answer = fewest(amount); return answer === Infinity ? -1 : answer;}console.log(coinChange([1, 2, 5], 11)); // 3console.log(coinChange([2], 3)); // -1console.log(coinChange([1], 0)); // 0There are at most amount + 1 different remainders and each one tries every coin. The time is O(amount × coins) and the space is O(amount). For a big amount, the recursion can go thousands of calls deep.
Fill dp[0..amount] with Infinity, set dp[0] = 0, then for each amount, try every coin that fits.
function coinChange(coins, amount) { const dp = new Array(amount + 1).fill(Infinity); dp[0] = 0; for (let a = 1; a <= amount; a++) { for (const coin of coins) { if (coin <= a) dp[a] = Math.min(dp[a], dp[a - coin] + 1); } } return dp[amount] === Infinity ? -1 : dp[amount];}console.log(coinChange([1, 2, 5], 11)); // 3console.log(coinChange([2], 3)); // -1console.log(coinChange([1], 0)); // 0This is the same formula, written as a loop. For [1,2,5] the table is 0, 1, 1, 2, 2, 1, 2, 2, 3, 3, 2, 3 for amounts 0 to 11. The time is O(amount × coins) and the space is O(amount). Never fill the table with -1, because -1 + 1 = 0 would look like a great answer.
The table is short and has no problem with deep recursion. The memo version is better when only a few amounts are ever reached. (LeetCode 322.)
A message is written as digits with A = 1, B = 2, …, Z = 26. Given a string of digits, return the number of ways to decode it.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
ways(i) counts the ways to decode the rest of the string, starting at i. If there is a "0" there, the answer is 0. Otherwise take one digit, and also take two digits if they make a number from 10 to 26.
function numDecodings(s) { const memo = new Map(); function ways(i) { if (i === s.length) return 1; // reached the end: one complete decoding if (s[i] === "0") return 0; if (memo.has(i)) return memo.get(i); let total = ways(i + 1); if (i + 1 < s.length && Number(s.slice(i, i + 2)) <= 26) total += ways(i + 2); memo.set(i, total); return total; } return ways(0);}console.log(numDecodings("12")); // 2console.log(numDecodings("226")); // 3console.log(numDecodings("06")); // 0When we reach the two-digit check, the first digit is not “0”, so the value is at least 10. The time and space are O(n).
Fill dp[i] from dp[i - 1] (the last digit alone) and dp[i - 2] (the last two digits). Then keep just two variables.
function numDecodings(s) { let twoBack = 0; // dp[i - 2], unused until i = 2 let oneBack = 1; // dp[0] = 1: the empty prefix for (let i = 1; i <= s.length; i++) { let current = 0; if (s[i - 1] !== "0") current += oneBack; if (i >= 2) { const two = Number(s.slice(i - 2, i)); if (two >= 10 && two <= 26) current += twoBack; } twoBack = oneBack; oneBack = current; } return oneBack;}console.log(numDecodings("12")); // 2console.log(numDecodings("226")); // 3console.log(numDecodings("06")); // 0At i = 1 the two-digit case is skipped, and twoBack is set correctly by the end of that step. The time is O(n) and the space is O(1).
Both are fine. Write the two-variable version once you trust the formula. It has the same shape as climbing stairs, with a validity check at each step. (LeetCode 91.)
Given a string s and a list of words, return true if s can be split into one or more dictionary words in a row. A word can be used more than once.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
canBreak(start) asks whether the rest of the string, from start, can be split. Try each end. If s[start..end) is a word and the rest works, the answer is true. Save the answer for each start. Failed tries are what make the plain recursion so slow.
function wordBreak(s, wordDict) { const words = new Set(wordDict); const memo = new Map(); function canBreak(start) { if (start === s.length) return true; if (memo.has(start)) return memo.get(start); let ok = false; for (let end = start + 1; end <= s.length && !ok; end++) { if (words.has(s.slice(start, end)) && canBreak(end)) ok = true; } memo.set(start, ok); return ok; } return canBreak(0);}console.log(wordBreak("leetcode", ["leet", "code"])); // trueconsole.log(wordBreak("applepenapple", ["apple", "pen"])); // trueconsole.log(wordBreak("catsandog", ["cats", "dog", "sand", "and", "cat"])); // falseThere are n starting points, and each tries up to n ends with a slice. That is O(n²) slices, or about O(n³) work on characters. Without the memo, a string like "aaaa...ab" with the word "a" repeats the same failing end part an enormous number of times.
dp[i] is true if some j < i has dp[j] true and s[j..i) is in the dictionary. We can limit j to the length of the longest word.
function wordBreak(s, wordDict) { const words = new Set(wordDict); const longest = Math.max(...wordDict.map((w) => w.length)); const dp = new Array(s.length + 1).fill(false); dp[0] = true; for (let i = 1; i <= s.length; i++) { for (let j = Math.max(0, i - longest); j < i; j++) { if (dp[j] && words.has(s.slice(j, i))) { dp[i] = true; break; } } } return dp[s.length];}console.log(wordBreak("leetcode", ["leet", "code"])); // trueconsole.log(wordBreak("applepenapple", ["apple", "pen"])); // trueconsole.log(wordBreak("catsandog", ["cats", "dog", "sand", "and", "cat"])); // falseThere are O(n × L) slices, where L is the length of the longest word. The space is O(n). For "leetcode": dp[4] = true (leet). dp[8] = true because dp[4] is true and "code" is a word.
Either one is accepted. The table is easier to think about, and the longest-word limit is a cheap way to go faster. (LeetCode 139.)
Given an array of whole numbers, return the length of the longest strictly increasing subsequence (every number is bigger than the one before).
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
best(i, prev) is the longest chain you can make from item i onward, when the last kept position is prev. Either skip item i, or keep it if it is larger than nums[prev]. Save the answers by (i, prev).
function lengthOfLIS(nums) { const n = nums.length; const memo = new Map(); function best(i, prev) { // prev = -1 means nothing kept yet if (i === n) return 0; const key = i * (n + 1) + (prev + 1); if (memo.has(key)) return memo.get(key); let result = best(i + 1, prev); // skip i if (prev === -1 || nums[prev] < nums[i]) { result = Math.max(result, 1 + best(i + 1, i)); // keep i } memo.set(key, result); return result; } return best(0, -1);}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4console.log(lengthOfLIS([7, 7, 7, 7])); // 1Without the memo, the recursion tries all 2n subsequences. With the memo there are O(n²) states and each takes O(1). So the time and space are O(n²).
Every item starts as a chain of length 1. For each earlier, smaller nums[j], set dp[i] = max(dp[i], dp[j] + 1). Return the biggest value in the table.
function lengthOfLIS(nums) { const dp = new Array(nums.length).fill(1); let best = 0; for (let i = 0; i < nums.length; i++) { for (let j = 0; j < i; j++) { if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1); } best = Math.max(best, dp[i]); } return best;}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4console.log(lengthOfLIS([7, 7, 7, 7])); // 1The time is O(n²) and the space is O(n). For [10,9,2,5,3,7,101,18] the table is 1, 1, 1, 2, 2, 3, 4, 4. A method with binary search gets O(n log n). See lesson 57.
Use the “ends at i” table. It is simple and fine for n up to a few thousand. If n reaches 105, you need the O(n log n) method in lesson 57. (LeetCode 300.)
Given an array of whole numbers, find the subarray (one unbroken piece, at least one item) with the largest product, and return that product.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
For each start position, move the end forward while you multiply a running product. Remember the largest product.
function maxProduct(nums) { let best = -Infinity; for (let start = 0; start < nums.length; start++) { let product = 1; for (let end = start; end < nums.length; end++) { product *= nums[end]; best = Math.max(best, product); } } return best;}console.log(maxProduct([2, 3, -2, 4])); // 6console.log(maxProduct([-2, 0, -1])); // 0console.log(maxProduct([-2, 3, -4])); // 24The time is O(n²) and the space is O(1). It is correct, but too slow for large inputs.
Keep curMax and curMin. These are the largest and smallest products of a subarray that ends at the current item. A negative number swaps their roles. So the new values come from the item alone, curMax * x and curMin * x.
function maxProduct(nums) { let curMax = nums[0], curMin = nums[0], best = nums[0]; for (let i = 1; i < nums.length; i++) { const x = nums[i]; const a = curMax * x, b = curMin * x; curMax = Math.max(x, a, b); // start fresh at x, or extend curMin = Math.min(x, a, b); best = Math.max(best, curMax); } return best;}console.log(maxProduct([2, 3, -2, 4])); // 6console.log(maxProduct([-2, 0, -1])); // 0console.log(maxProduct([-2, 3, -4])); // 24On [-2,3,-4]: after -2 the pair is (-2,-2). At 3 it is (3,-6). At -4 it is (24,-12), so the answer is 24. A zero resets both to 0, and the next item starts fresh. The time is O(n) and the space is O(1).
The max and min pair is the expected answer. You need the min only because a negative number can flip it into the next maximum. (LeetCode 152.)
Make it stick
- Finish the sentence “dp[i] means …” for house robber, coin change and LIS (longest increasing subsequence).
- Write house robber with two variables and say why two are enough.
- Explain how a circle becomes two straight rows in house robber II.
- Say why coin change fills the table with Infinity, and when you change it to -1.
- Name the two cases that add up in decode ways, and say when each one is valid.
- Write word break and LIS, and give their time costs.
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
So far every state had one index. Lesson 56 adds a second index. You get a grid of subproblems, used for paths on a board and for the knapsack family of problems, where you choose items without going over a size limit (the capacity).
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