โ† Back to DevBytes

Solving Maximum Product Subarray in JavaScript: Step-by-Step Guide

Introduction to the Maximum Product Subarray Problem

The Maximum Product Subarray is a classic algorithmic problem frequently encountered in coding interviews and competitive programming. Given an array of integers (which may include negative numbers and zeros), the task is to find the contiguous subarray that yields the largest product. While it sounds similar to the Maximum Subarray Sum problem, the presence of negative numbers introduces a fascinating twist: multiplying two negatives produces a positive, meaning a small local product can suddenly become the largest overall.

This tutorial walks you through the problem from first principles to an optimized dynamic programming solution in JavaScript. By the end, you will understand not only how to solve it but also why the solution works and how to write production-quality code around it.

Understanding the Problem

Formally, given an integer array nums, find a contiguous non-empty subarray that has the largest product and return that product. A subarray is a contiguous slice of the original array.

Consider the array [2, 3, -2, 4]. The subarray [2, 3] produces a product of 6, which is the maximum. Note that including -2 would flip the sign, and continuing to 4 would give -48, which is far smaller. The challenge is to detect these sign flips efficiently.

Why It Matters

Why a Naive Approach Fails to Scale

The brute-force approach is to compute the product of every possible subarray and track the maximum. This works but runs in O(n^2) time, which becomes impractical for large inputs.

function maxProductNaive(nums) {
  let max = nums[0];
  for (let i = 0; i < nums.length; i++) {
    let product = 1;
    for (let j = i; j < nums.length; j++) {
      product *= nums[j];
      max = Math.max(max, product);
    }
  }
  return max;
}

console.log(maxProductNaive([2, 3, -2, 4])); // 6

While correct, this solution recomputes overlapping products repeatedly. We can do much better by recognizing that the maximum product at each position depends on the products we have already computed.

The Key Insight: Tracking Both Max and Min

The crucial observation is this: when you encounter a negative number, the current maximum product can become the minimum (and vice versa) because multiplying by a negative flips the sign. Therefore, at every step, we must track both the maximum and minimum products ending at the current position.

For each element nums[i], the new candidates for the maximum product ending at i are:

The same logic applies to the new minimum. We then update the global maximum with the local maximum at each step.

Step-by-Step Dynamic Programming Solution

Let us translate the insight into code. We maintain two variables โ€” currentMax and currentMin โ€” and update them as we iterate through the array.

function maxProduct(nums) {
  if (nums.length === 0) return 0;

  let currentMax = nums[0];
  let currentMin = nums[0];
  let globalMax = nums[0];

  for (let i = 1; i < nums.length; i++) {
    const num = nums[i];

    // Compute candidates
    const candidate1 = num;
    const candidate2 = num * currentMax;
    const candidate3 = num * currentMin;

    // Update currentMax and currentMin simultaneously
    currentMax = Math.max(candidate1, candidate2, candidate3);
    currentMin = Math.min(candidate1, candidate2, candidate3);

    // Update the global maximum
    globalMax = Math.max(globalMax, currentMax);
  }

  return globalMax;
}

console.log(maxProduct([2, 3, -2, 4]));        // 6
console.log(maxProduct([-2, 0, -1]));          // 0
console.log(maxProduct([-2, 3, -4]));          // 24
console.log(maxProduct([0, 2]));               // 2
console.log(maxProduct([-2, 3, -4, -5, 6]));   // 720

Walking Through an Example

Let us trace [-2, 3, -4] step by step:

The final answer is 24, which corresponds to the entire array [-2, 3, -4]. Notice how the negative minimum from the previous step became the maximum after multiplying by another negative number โ€” this is exactly why tracking both extremes is essential.

Handling Edge Cases

Real-world inputs are rarely clean. Your solution should gracefully handle the following scenarios:

function maxProductRobust(nums) {
  if (!Array.isArray(nums) || nums.length === 0) {
    throw new Error("Input must be a non-empty array of numbers");
  }

  let currentMax = nums[0];
  let currentMin = nums[0];
  let globalMax = nums[0];

  for (let i = 1; i < nums.length; i++) {
    const num = nums[i];

    // When num is negative, swap max and min to simplify logic
    if (num < 0) {
      [currentMax, currentMin] = [currentMin, currentMax];
    }

    currentMax = Math.max(num, num * currentMax);
    currentMin = Math.min(num, num * currentMin);

    globalMax = Math.max(globalMax, currentMax);
  }

  return globalMax;
}

// Edge case tests
console.log(maxProductRobust([-1]));           // -1
console.log(maxProductRobust([0, 0, 0]));      // 0
console.log(maxProductRobust([-2, -3, -4]));   // 12
console.log(maxProductRobust([1, -2, 3, -4])); // 24

This variant uses a swap when the current number is negative, which is a stylistic alternative that some developers find more readable. Both approaches are correct and run in O(n) time with O(1) extra space.

Best Practices

1. Validate Inputs Early

Always check that the input is a non-empty array of numbers before processing. This prevents silent bugs and makes debugging easier in production environments.

2. Prefer Constant Space

While you could store the entire DP table in a 2D array, doing so wastes memory. The iterative approach using two variables is both faster and more memory-efficient.

3. Write Clear Variable Names

Names like currentMax and currentMin are far more readable than dp1 and dp2. Clarity matters more than brevity in maintainable code.

4. Test With Diverse Inputs

Include test cases with mixed signs, zeros, single elements, and large arrays. A comprehensive test suite protects against regressions when refactoring.

const testCases = [
  { input: [2, 3, -2, 4], expected: 6 },
  { input: [-2, 0, -1], expected: 0 },
  { input: [-2, 3, -4], expected: 24 },
  { input: [0], expected: 0 },
  { input: [-2], expected: -2 },
  { input: [1, 2, 3, 4], expected: 24 },
  { input: [-1, -2, -3, -4], expected: 24 },
  { input: [0, -1, 4, -3, 0, 2], expected: 12 },
];

testCases.forEach(({ input, expected }, index) => {
  const result = maxProductRobust(input);
  console.assert(
    result === expected,
    `Test ${index + 1} failed: expected ${expected}, got ${result}`
  );
});

console.log("All tests completed.");

5. Document the Algorithm

Add a brief comment explaining the dual-tracking strategy. Future readers (including yourself) will appreciate the reminder of why both max and min are tracked simultaneously.

Complexity Analysis

The optimized solution has the following characteristics:

This is asymptotically optimal because any solution must inspect every element at least once to determine the maximum product.

Conclusion

The Maximum Product Subarray problem elegantly illustrates how a small change in the operation โ€” from addition to multiplication โ€” can transform the algorithmic approach. By tracking both the running maximum and minimum at each position, we handle the sign-flipping behavior of negative numbers without resorting to brute force. The resulting O(n) solution is efficient, readable, and robust enough to handle zeros, single-element arrays, and all-negative inputs. Mastering this pattern not only prepares you for interviews but also sharpens your intuition for dynamic programming problems where local state must capture more than a single extremum. With the code and best practices covered here, you are well equipped to implement and extend this solution in any JavaScript codebase.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles