乘积最大子数组:动态规划解法详解
在算法面试中,“最大子数组和”是一个经典问题(LeetCode #53),但当问题变为 “最大子数组乘积” (LeetCode #152)时,难度陡然上升。原因在于:乘法的符号特性使得负数可能“翻身”成为最大值。本文将深入剖析这一问题,并通过动态规划给出高效解法。
问题描述
152. 乘积最大子数组 - 力扣(LeetCode)
给定一个整数数组 nums,找出一个连续子数组,使其元素的乘积最大。返回该最大乘积。
例如:
- 输入:
[2, 3, -2, 4]→ 输出:6(子数组[2,3]) - 输入:
[-2, 0, -1]→ 输出:0 - 输入:
[-2, 3, -4]→ 输出:24(整个数组)
为什么不能直接套用“最大子数组和”的思路?
在“最大子数组和”中,我们只需维护一个 dp[i] = max(dp[i-1] + nums[i], nums[i])。
但在乘积问题中,负数 × 负数 = 正数,这意味着:
当前最小的负数乘积,遇到另一个负数,可能变成最大的正数乘积!
因此,仅记录“到当前位置的最大乘积”是不够的——我们还需要同时跟踪最小乘积。
动态规划状态设计
我们定义两个状态变量(无需完整 DP 数组,只需滚动变量):
premax:以当前元素结尾的子数组所能得到的最大乘积premin:以当前元素结尾的子数组所能得到的最小乘积
状态转移方程
对于每个新元素 nums[i],我们有三种选择:
- 将
nums[i]接在之前的子数组后面(乘上premax或premin) - 从
nums[i]重新开始一个新的子数组
因此:
prevmin=min(prevmin∗nums[i],prevmax∗nums[i],nums[i])
prevmax=max(prevmin∗nums[i],prevmax∗nums[i],nums[i])
⚠️ 注意:观察发现,左边的代表第i项的乘积,右边的premin代表第i-1项的乘积。当我们迭代后的premin已经改变,所以当我们想要更新premax时,premin显然已经不是第i-1项的最小积了。 因此我们需要两个变量来暂时保存最大和最小积。
t1=premin*nums[i];
t2=premax*nums[i];
premin=Math.min(t1,t2,nums[i]);
premax=Math.max(t1,t2,nums[i]);
完整代码实现
/**
* @param {number[]} nums
* @return {number}
*/
var maxProduct = function(nums) {
let res =nums[0];
let premin=nums[0];
let premax =nums[0];
let t1=0,t2=0;
for(let i=1;i<nums.length;i++){
t1=premin*nums[i];
t2=premax*nums[i];
premin=Math.min(t1,t2,nums[i]);
premax=Math.max(t1,t2,nums[i]);
//记录当前最大值,避免覆盖后丢失
res=Math.max(premax,res);
}
return res;
};
关键点解析
1. 为什么要考虑 nums[i] 自身?
当之前的乘积为 0 或负数,而 nums[i] 是一个较大的正数时,重新开始子数组更优。
例如:[0, 5] → 最大乘积是 5,而不是 0*5=0。
同样,若 nums[i] 是一个极小的负数(如 -100),而之前乘积接近 0,那么 premin 会取 -100 本身。
2. 零的影响
零会“重置”状态:
premin和premax都会变成0- 下一个非零元素将从自身重新开始计算
这正是算法能正确处理 [2, 0, 3] → 返回 3 的原因。
3. 负负得正的处理
以 [-2, 3, -4] 为例:
- i=0: premin=-2, premax=-2
- i=1: t1=-6, t2=-6 → premin=-6, premax=3(选了自身!)
- i=2: t1=(-6) (-4)=24, t2=3(-4)=-12 → premax=24 ✅
完美捕捉到负负得正的最大值。
时间与空间复杂度
- 时间复杂度:O(n),仅遍历一次数组
- 空间复杂度:O(1),只使用常数个变量
总结
“最大乘积子数组”看似只是将“和”换成“积”,实则揭示了动态规划中一个深刻原则:状态设计必须覆盖所有可能影响未来决策的历史信息。
在加法中,历史只需一个“最大和”;但在乘法中,最小值可能在未来因符号反转而成为最大值。因此,我们必须同时维护 premax 和 premin —— 这不是技巧堆砌,而是对问题结构的忠实建模。
此外,算法始终保留 nums[i] 作为候选,体现了动态规划的另一核心思想:局部最优解不一定延续历史,有时“重新开始”才是全局最优的起点。
最终,这段简洁的代码之所以正确,正是因为它:
- 完整枚举了所有可能的转移路径(接最大、接最小、从头开始),
- 严谨避免了状态覆盖错误,
- 实时追踪全局最优解。
掌握这种“双状态 + 重置选项”的模式,不仅能解决本题,也为处理其他涉及符号、波动或非单调运算的序列问题提供了通用思路。