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

The full write-up: intuition, approaches, traces and solutions.

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

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

Trace

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

inums[i]prev_maxprev_mincurr_maxcurr_minbest
02——222
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