152. 乘积最大子数组 (maximum-product-subarray)

3,851 阅读3分钟

"子序列、子数组、子串,傻傻分不清楚"

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

152. 乘积最大子数组 题目描述:给你一个整数数组 numsnums,请你找出数组中乘积最大非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。(NOTE: 测试用例的答案是一个 32-位 整数。a子数组 是数组的连续子序列)

示例1示例2
输入: nums = [2,3,2,4][2,3,-2,4]
输出: 66
解释:  子数组 [2,3][2,3] 有最大乘积 66
输入: nums = [2,0,1][-2,0,-1]
输出: 00
解释:  结果不能为 22, 因为 [2,1][-2,-1] 不是子数组。

本题与 # 53. 最大子数组和 有相似的地方,但是隐藏着巨大的坑,一不小心就着了面试官的道。

暴力输出

暴力算法很好理解,就是把所有子数组枚举出来,然后分别计算子数组内元素元素的乘积,并选择出最大值。为避免无意义的重复计算,我们定义,

product[i,j)=nums[i]×nums[i+1]×...×nums[j1]product[i,j) = nums[i] \times nums[i + 1] \times ... \times nums[j-1]

当计算 product[i,j+1)product[i,j+1) 时,不再从 nums[i]nums[i] 一直累乘到 nums[j]nums[j],而是选择 product[i,j+1)=product[i,j)×nums[j]product[i,j+1) = product[i,j) \times nums[j],从而减低暴力算法的时间复杂度。

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n^2),n是nums数组的长度
 */
function maxProduct(nums: number[]): number {
    const n = nums.length;
    let ans = Number.MIN_SAFE_INTEGER;

    for(let i = 0; i < n; i++) {
        let product = 1;
        for(let j = i; j < n; j++) {
            product = product * nums[j];
            ans = Math.max(ans, product);
        }
    }

    return ans
}

中规中矩的动态规划

设问:我们是否可以参考 # 53. 最大子数组和 的求解思想,定义 dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组元素最大乘积,且同样规划「最优子结构」为 dp[i]=max(dp[i1]×nums[i],nums[i])dp[i] = max(dp[i-1] \times nums[i], nums[i])

❌ 答案是否定的。至关重要的一点就是 非正整数 对子数组元素乘积的影响,非正整数的存在可以使上一个状态变成最大值或者最小值,因此当前位置的最优解(状态)未必是由前一个位置的最优解转移得到的。简单证明一下:

假设我们只需要一维状态 dpmax[i]dp_{max}[i]。对于 nums[i]nums[i] 的符号,一定有三种情况,分别是

  • nums[i]>0nums[i] \gt 0
  • nums[i]<0nums[i] \lt 0
  • nums[i]=0nums[i] = 0

同理,对于 dpmax[i]dp_{max}[i] 的符号,也一定有三种情况,分别是

  • dpmax[i]>0dp_{max}[i] \gt 0
  • dpmax[i]<0dp_{max}[i] \lt 0
  • dpmax[i]=0dp_{max}[i] = 0

🤔 按照 # 53. 最大子数组和 解题的递推方式来看,当 nums[i]>0nums[i] \gt 0dpmax[i1]<0dp_{max}[i-1] < 0dpmax[i]=nums[i]dp_{max}[i] = nums[i]。 💥 NOTE: 此时我们发现 dpmax[i1]dp_{max}[i-1] 这个状态完全舍弃。如果在区间 k(i,n)k\in(i, n),仅存在一个元素 nums[k]<0nums[k] < 0,那么我们丢失了一个使子数组元素乘积变为更大的“潜在因子”(nums[k]nums[k] 可让最后的结果瞬间反转,最小值突然变成最大值),即 dpmax[i1]dp_{max}[i-1]

因此我们可以多设置一种状态 dpmindp_{min} 来保存 nums[i]nums[i] 结尾子数组元素的最小乘积,防止丢失非正整数可能来子数组元素最大乘积带来的“正向”影响。

1、确定 dp 状态数组

dpmax[i]dp_{max}[i] 是以 nums[i]nums[i] 结尾的子数组元素最大乘积

dpmin[i]dp_{min}[i] 是以 nums[i]nums[i] 结尾的子数组元素最小乘积

其中 i[0,n),n=nums.lengthi \in [0, n), n = nums.length

2、确定 dp 状态转移方程

为保证连续性,dpmax[i]dp_{max}[i]dpmin[i]dp_{min}[i] 必须得有 nums[i]nums[i] 的参与。

为保证最大性,如果 dp[i1]dp[i-1] 是负数,那我们希望 nums[i]nums[i] 也同时为负数,让乘积负负为正,扭转“劣势”;如果 dp[i1]dp[i-1] 是正数,那我们希望 nums[i]nums[i] 也同时为正数,让乘积更大。故,我们得出如下状态转移方程:

dpmax[i]=max(dpmax[i1]×nums[i],dpmin[i1]×nums[i],nums[i])dp_{max}[i]=max(dp_{max}[i-1] \times nums[i],dp_{min}[i-1] \times nums[i], nums[i])
dpmin[i]=min(dpmax[i1]×nums[i],dpmin[i1]×nums[i],nums[i])dp_{min}[i]=min(dp_{max}[i-1] \times nums[i],dp_{min}[i-1] \times nums[i], nums[i])

3、确定 dp 初始状态

dpmax[0]=dpmin[0]=nums[0]dp_{max}[0] =dp_{min}[0] = nums[0]

4、确定遍历顺序

i=1i=1 遍历到 n1n-1

5、确实最终返回值

重温初心:dpmax[i]dp_{max}[i] 是以 nums[i]nums[i] 结尾的最大的子数组乘积,故 dpmax[n1]dp_{max}[n-1] 仅仅是以 nums[n1]nums[n-1] 元素结尾的最大子数组乘积,并不代表全局的最大,故需要全局对比。所以,返回值为 max(...dpmax)max(...dp_{max})

NOTE: ...dp...dp 代表将数组中所有元素按照其索引按顺序传到 maxmax 函数。

6、代码示例

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n)
 */
function maxProduct(nums: number[]): number {
    const n = nums.length;
    const dp_max = Array.from({ length: n }, () => 0);
    const dp_min = Array.from({ length: n }, () => 0);
    dp_max[0] = nums[0];
    dp_min[0] = nums[0];

    for(let i = 1; i < n; i++){
        dp_max[i] = Math.max(dp_max[i - 1] * nums[i], dp_min[i - 1] * nums[i], nums[i]);
        dp_min[i] = Math.min(dp_max[i - 1] * nums[i], dp_min[i - 1] * nums[i], nums[i]);
    }

    return Math.max(...dp_max);
}

空间压缩

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n),n是nums数组的长度
 */
function maxProduct(nums: number[]): number {
    const n = nums.length;
    let dpMax = nums[0];
    let dpMin = nums[0];
    let ans = nums[0];

    for(let i = 1; i < n; i++){
        const tempMax = dpMax * nums[i];
        const tempMin = dpMin * nums[i];
        dpMax = Math.max(tempMax, tempMin, nums[i]);
        dpMin = Math.min(tempMax, tempMin, nums[i]);
        ans = Math.max(ans, dpMax);
    }

    return ans;
}

思维提升

我们把数组元素分成两类,1)值为非 00 的元素,2)值为 00 的元素。

  • 在第一种情况下,如果一个子列中负数有偶数个,那所有数乘起来就是最大值;如果负数有奇数个,那从最左一直乘到最后一个负数最大 或者从右一直乘到最前一个负数最大。

  • 在第二种情况下,出现 00 元素就保持这个元素(因为这个元素是唯一能确定值的大小),下一次循环迭代时重新计算。

因此这个问题就是被0划分的每个小数组中做负数奇偶个数的讨论。此处采用正向、反向两次“前缀积”的方式,找到子数组元素的最大乘积。

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n)
 */
function maxProduct(nums: number[]): number {
    const n = nums.length;
    const inOrder = [...nums];
    const reOrder = [...nums.reverse()];

    for(let i = 1; i < n; i++) {
        inOrder[i] *= (inOrder[i - 1] === 0 ? 1 : inOrder[i - 1]);
        reOrder[i] *= (reOrder[i - 1] === 0 ? 1 : reOrder[i - 1]);
    }

    return Math.max(...inOrder, ...reOrder);
}