Medium#1527 min readDynamic Programming1D DPLeetCode

Maximum Product Subarray

Rolling max/min pair

Track both max AND min at each position (negatives flip signs).

Published Apr 17, 2026

Problem

Given an integer array nums, return the largest product of any contiguous non-empty subarray.

Input
nums = [2, 3, -2, 4]
Output
6
Explanation
Subarray [2, 3] has product 6.
Input
nums = [-2, 0, -1]
Output
0
Explanation
The best is [0]. Any subarray containing a 0 is zero; any with only one negative is negative.
Input
nums = [-2, 3, -4]
Output
24
Explanation
The whole array: (-2) * 3 * (-4) = 24. Two negatives make a positive.

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 is 3. (Starting fresh beats -6.)
  • After -4: best ending here is -4. (3 * -4 = -12 is 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

TimeO(n²)
SpaceO(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
    }
}
Rust · runnable

Rolling Max/Min Pair

TimeO(n)
SpaceO(1)
Recommended

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
    }
}
Rust · runnable

Trace

Rolling pair on nums = [2, 3, -2, 4]:

inums[i]prev_maxprev_mincurr_maxcurr_minbest
02222
1322636
2-263-2-126
34-2-124-486

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; best is seeded correctly.
  • Contains zero — zero resets both rolling values (since x alone beats prev_* * 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 is 3). The rolling pair handles this without special casing.
  • Integer overflow — LeetCode caps the product at i32::MAX. The brute form uses saturating_mul to 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.
ℹ️
Note

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