Maximum Product Subarray
Rolling max/min pair
Track both max AND min at each position (negatives flip signs).
Problem
Given an integer array nums, return the largest product of any contiguous non-empty subarray.
Intuition
The obvious first attempt — track only max_ending_here — breaks immediately. Walk [-2, 3, -4]:
- After
-2: best ending here is-2. (It's the only option.) - After
3: best ending here is3. (Starting fresh beats-6.) - After
-4: best ending here is-4. (3 * -4 = -12is worse.)
We return 3. Wrong — the right answer is 24.
The fix is that today's min can become tomorrow's max when tomorrow's element is negative. A very negative product, multiplied by another negative, flips to a large positive. So we have to carry both extremes forward:
new_max = max(x, prev_max * x, prev_min * x)
new_min = min(x, prev_max * x, prev_min * x)
Every transition considers three options: extend the max, extend the min, or start fresh at the current element (resetting both). The global answer is the max we've ever seen for max_ending_here across the whole walk.
This is the same "two rolling scalars" shape as #198, with one twist: we roll two different quantities, and their roles can swap on a negative number.
Approach
Brute Force
O(n²)O(1)Enumerate every subarray; multiply through; keep the max. Correct but unnecessary — shown here to motivate the O(n) reframe.
impl Solution {
pub fn max_product(nums: Vec<i32>) -> i32 {
let mut best = nums[0];
for i in 0..nums.len() {
let mut prod: i32 = 1;
for j in i..nums.len() {
prod = prod.saturating_mul(nums[j]);
best = best.max(prod);
}
}
best
}
}
function maxProduct(nums: number[]): number {
let best = nums[0];
for (let i = 0; i < nums.length; i++) {
let prod = 1;
for (let j = i; j < nums.length; j++) {
prod *= nums[j];
best = Math.max(best, prod);
}
}
return best;
}Rolling Max/Min Pair
O(n)O(1)Keep (curr_max, curr_min) as the best/worst subarray products ending at the current index. Before computing new values, cache the old curr_max — you need the pre-update value to compute curr_min, and overwriting it first introduces a subtle bug.
On a negative x, the multiplication flips order: prev_max * x becomes a small (very negative) number, and prev_min * x becomes a large positive one. The max/min over three candidates handles that correctly without branching on sign.
impl Solution {
pub fn max_product(nums: Vec<i32>) -> i32 {
let mut curr_max = nums[0];
let mut curr_min = nums[0];
let mut best = nums[0];
for &x in &nums[1..] {
let prev_max = curr_max;
curr_max = x.max(prev_max * x).max(curr_min * x);
curr_min = x.min(prev_max * x).min(curr_min * x);
best = best.max(curr_max);
}
best
}
}
function maxProduct(nums: number[]): number {
let currMax = nums[0];
let currMin = nums[0];
let best = nums[0];
for (let i = 1; i < nums.length; i++) {
const x = nums[i];
const prevMax = currMax;
currMax = Math.max(x, prevMax * x, currMin * x);
currMin = Math.min(x, prevMax * x, currMin * x);
best = Math.max(best, currMax);
}
return best;
}Trace
Rolling pair on nums = [2, 3, -2, 4]:
| i | nums[i] | prev_max | prev_min | curr_max | curr_min | best |
|---|---|---|---|---|---|---|
| 0 | 2 | — | — | 2 | 2 | 2 |
| 1 | 3 | 2 | 2 | 6 | 3 | 6 |
| 2 | -2 | 6 | 3 | -2 | -12 | 6 |
| 3 | 4 | -2 | -12 | 4 | -48 | 6 |
best = 6. ✓ Note how index 2 flipped the roles: what was the biggest product (6) became the most-negative candidate once multiplied by -2.
Edge cases
- Single element — return
nums[0]. The loop doesn't execute;bestis seeded correctly. - Contains zero — zero resets both rolling values (since
xalone beatsprev_* * 0). That's correct: a subarray crossing a zero is zero. - All negatives, odd count — some single element is the answer. Example:
[-3, -1, -2]→6(subarray[-3, -1],[-1, -2], or[-3, -1, -2]gives-6— wait, actually that's(-3)*(-1) = 3,(-1)*(-2) = 2, and the whole is-6, so best is3). The rolling pair handles this without special casing. - Integer overflow — LeetCode caps the product at
i32::MAX. The brute form usessaturating_multo avoid undefined-behavior panics; the rolling form can't overflow in practice because a negative product would immediately beat a positive one on the next negative.
The "two rolling scalars" pattern generalizes to any monotone-under-operation quantity: sum under max, product under max, concatenation length under max. When the operation isn't monotone (like multiplication crossing zero), you track both extremes. This is why #53 Maximum Subarray uses one scalar and #152 uses two — addition is monotone in its argument; multiplication is not.
Takeaway
When a rolling-DP looks like it should work but fails on signs, the usual fix is to roll both extremes. #152 is the canonical example. It's the same skeleton as #198 (state = best value ending at i, transition = small choice), but the non-monotonicity of multiplication forces you to double the state.
This reflex carries forward: whenever an operation can flip extremes, you carry both. Interval DP over trees (see #337 House Robber III) uses the same trick with a (rob, skip) pair per node — rooting choices can flip which option dominates.
More in 1D DP
Fibonacci in disguise. dp[i] = dp[i-1] + dp[i-2]. Start here.
dp[i] = max(dp[i-1], dp[i-2] + nums[i]). The ‘take or skip’ template.
O(n²) DP first, then learn the O(n log n) patience sorting approach.
dp[i] = can we segment s[0..i]? Try all possible last words.