先问一个问题:“前缀和”这套手法,到底依赖什么?
我们用前缀和求“区间和”,靠的是这一行:
sum(i, j) = pre[j] − pre[i−1]它成立的前提是:加法有一个可逆的逆运算(减法),且减法总能用。听起来像废话——直到你把加法换成乘法:
product(i, j) = pre[j] ÷ pre[i−1]乘法在整数上的逆运算是除法,而除法不总能进行:
- 遇到 0:任何前缀积都变成0,
0 ÷ 0无意义; - 遇到负数:除法不再保序——最大的乘积可能来自“最小的负数 × 一个负数”。
所以今天的两道题,正好是这条裂缝的两个方向:
- LC.238:前缀积仍然可用,但题目禁用了除法,得改用“左右两趟”绕开;
- LC.152:前缀积彻底失效,必须换成DP,而且状态不是一个数,是一组 (max, min)。
第二道尤其值得讲——它是“状态设计”这件事的一次现场演示,正好呼应了那句“DP的灵魂是状态设计”。
📦 题目速览(30 秒读懂)
题目1:除自身以外数组的乘积(LC.238)
返回
answer[i]= 除nums[i]外其余元素的乘积。禁用除法,O(n)时间,进阶要求O(1)额外空间。示例:
[1,2,3,4]→[24,12,8,6]
示例:[-1,1,0,-3,3]→[0,0,9,0,0]
题目2:乘积最大子数组(LC.152)
找出乘积最大的非空连续子数组,返回该乘积。
示例:
[2,3,-2,4]→6([2,3])
示例:[-2,0,-1]→0
示例:[-2,3,-4]→24(整个数组)
🧠 核心思路
LC.238:不能用除法,那就“左一趟 + 右一趟”
有除法时,answer[i] = total / nums[i]一行搞定,只需特判0的个数:
| 0的个数 | 结果 |
|---|---|
| 0个 | answer[i] = total / nums[i] |
| 1个 | 只有那个0的位置非零,其余全0 |
| ≥ 2个 | 全部为0 |
但题目禁用除法。
换个思路:answer[i]其实是“nums[i] 左边所有数的积”乘上“nums[i]右边所有数的积”。
这两半各自都能用一趟前缀积算出来——正好是 Day 04 前缀和思路的“乘法版”。
第一趟(左→右):ans[i] = nums[0..i-1]的积 ← 左侧前缀积 第二趟(右→左):ans[i] *= nums[i+1..n-1]的积 ← 右侧后缀积两趟各O(n),ans数组按题意不计入额外空间,所以严格满足O(1)额外空间的进阶要求。
LC.152:为什么前缀积救不了它
先说结论:这道题不能用前缀积,必须用DP。理由有两条,任何一条单独出现就足以让前缀积失效:
理由一:0让除法无从谈起。nums = [-2, 0, -1]。若用前缀积,pre[1] = -2 × 0 = 0,此后所有前缀积都是0,你再也分不清“哪个区间真的包含0”、“哪个区间的乘积是多少”。
理由二(更致命):负数让“最大值”不再只由“最大值”产生。
在一个只有正数的世界里,max(product)可以放心地只维护最大值。但负数会把最小值翻成最大值:
nums = [-2, 3, -4] 扫到3时:最大值是3,最小值是-6 扫到-4时:3 × (-4) = -12(小) -6 × (-4) = 24(大!)← 最大值来自上一步的【最小值】所以只维护max是不够的——你必须同时维护min。min在这里不是配角,它是“负负得正”的燃料,是下一次翻盘的种子。
于是状态从“一个数”变成“一组 (max, min)”,转移也变成了三选一:
新max = max(x, x · 旧max, x · 旧min) 新min = min(x, x · 旧max, x · 旧min)那个裸的x不能省——它代表“从当前位置重新起步”,这正是0的处理方式。
0为什么不需要特判
很多人会在代码里加if x == 0: 重置。其实不必:当x = 0时,x · 旧max和x · 旧min都是0,而裸x也是0,所以三项全是0——max和min同时被拉回0,下一轮自然重新起步。这就是三选一里必须带上裸x的原因,它把“遇到0重置”这件事内置了。
回到开头那个问题
“前缀”这套手法到底依赖什么?
依赖一个可逆、且能保持我们关心的那种“序”的二元运算。
| 运算 | 逆运算 | 是否总能用 | 序是否稳定 | 前缀手法 |
|---|---|---|---|---|
| 加法 | 减法 | ✅ | ✅ | ✅ 可用(前缀和) |
| 乘法 | 除法 | ❌ 除以0无意义 | ❌ 负数翻转 | ⚠️ 部分可用 |
| 乘法(题目禁除法) | — | — | — | ⚠️ 改左右两趟(LC.238) |
| 乘法(求最值 + 有负数) | — | — | — | ❌ 失效,改 DP(LC.152) |
这张表是本周最值得带走的东西之一——它告诉你什么时候该放弃一个熟悉的套路。
🖼️ 图解算法(手把手走一遍)
LC.238:[1,2,3,4]的两趟扫描
第一趟(左 → 右):维护左侧累积l,ans[i]先记下nums[i]左边所有数的积。
| i | nums[i] | 写入前的l | ans[i] | 更新后l |
|---|---|---|---|---|
| 0 | 1 | 1(初始) | 1 | 1 |
| 1 | 2 | 1 | 1 | 2 |
| 2 | 3 | 2 | 2 | 6 |
| 3 | 4 | 6 | 6 | 24 |
第一趟后:ans = [1, 1, 2, 6]
第二趟(右 → 左):维护右侧累积r,把右半部分的积乘进去。
| i | nums[i] | 写入前的r | ans[i]变为 | 更新后r |
|---|---|---|---|---|
| 3 | 4 | 1(初始) | 6 × 1 =6 | 4 |
| 2 | 3 | 4 | 2 × 4 =8 | 12 |
| 1 | 2 | 12 | 1 × 12 =12 | 24 |
| 0 | 1 | 24 | 1 × 24 =24 | 24 |
结果[24, 12, 8, 6]✓
LC.152:[2,3,-2,4]的(max, min)迭代
| 步骤 | x | 候选三项(x, x·max, x·min) | 新max | 新min | ans |
|---|---|---|---|---|---|
| 初始 | 2 | — | 2 | 2 | 2 |
| 1 | 3 | (3, 6, 6) | 6 | 3 | 6 |
| 2 | −2 | (−2, −12, −6) | −2 | −12 | 6 |
| 3 | 4 | (4, −8, −48) | 4 | −48 | 6 |
最终ans = 6✓
关键看第2步:x = −2时,新的max不是−2 · 6 = −12,而是裸的−2(从当前位置重新起步);新的min则是−2 · 6 = −12。
再看[−2, 3, −4](答案24):
| 步骤 | x | 候选三项 | 新max | 新min | ans |
|---|---|---|---|---|---|
| 初始 | −2 | — | −2 | −2 | −2 |
| 1 | 3 | (3, −6, −6) | 3 | −6 | 3 |
| 2 | −4 | (−4, −12, 24) | 24 | −12 | 24 |
第2步就是负负得正的现场:−6(上一步的最小值)乘上−4,直接翻成24。如果你的状态里只有max,这一步会算出3 × (−4) = −12,然后得出错误答案。
💻 代码实现(Python + Java)
Python版
classSolution:# ---------- LC.238 禁除法版 ----------defproductExceptSelf(self,nums:list[int])->list[int]:n=len(nums)ans=[1]*n left=1foriinrange(n):ans[i]=left left*=nums[i]right=1foriinrange(n-1,-1,-1):ans[i]*=right right*=nums[i]returnans# 除ans外只用了两个变量 ⇒ O(1)额外空间# ---------- LC.152 乘积最大子数组 ----------defmaxProduct(self,nums:list[int])->int:mx=mn=ans=nums[0]forxinnums[1:]:candidates=(x,x*mx,x*mn)# 必须用旧mx/mnmx=max(candidates)mn=min(candidates)ans=max(ans,mx)returnansJava版
classSolution{// ---------- LC.238 ----------publicint[]productExceptSelf(int[]nums){intn=nums.length;int[]ans=newint[n];intleft=1;for(inti=0;i<n;i++){ans[i]=left;left*=nums[i];}intright=1;for(inti=n-1;i>=0;i--){ans[i]*=right;right*=nums[i];}returnans;}// ---------- LC.152 ----------publicintmaxProduct(int[]nums){intmx=nums[0],mn=nums[0],ans=nums[0];for(inti=1;i<nums.length;i++){intx=nums[i];inta=x,b=x*mx,c=x*mn;// 三个候选,用旧值mx=Math.max(a,Math.max(b,c));mn=Math.min(a,Math.min(b,c));ans=Math.max(ans,mx);}returnans;}}⚠️防坑提醒(必看):
- LC.152 的候选三项里必须有裸
x——代表“从当前位置重新起步”,也是0的正确处理方式。省掉它,[-2,0,-1]会算错。- LC.152 必须用
nums[0]初始化,不能用1或0。- LC.238 的
ans不计额外空间,但不要新开left[]/right[],滚动变量才是O(1)。mx/mn必须同时更新:先用旧值算出三个候选,再一起赋值。
⏱️ 复杂度分析(面试必问)
| 题目 | 时间 | 空间 | 说明 |
|---|---|---|---|
| LC.238(两趟) | O(n) | O(1)(不计输出) | 两个滚动变量 |
| LC.152 | O(n) | O(1) | 只维护三个变量 |
本机实测(LC.238,nums元素取1~9):
| 实现 | n = 1000 | n = 4000 |
|---|---|---|
| 左右前缀积(标准解) | 1.292ms | 60.854ms |
| 除法版 | 0.368ms | 5.811ms |
| 倍率 | 3.51× | 10.47× |
一个纯Python特有的陷阱:
total在n = 4000时已经是一个两千多位的大整数,此后每次乘法/除法都不再是O(1)。所以“除法版更快”这件事只在小n成立,而且倍率随n扩大。
更要紧的是:左右两趟前缀积同样在做大整数乘法,所以在真实的大n场景里,两者一起退化。
Java用long会直接溢出,反而没这个烦恼——这是双语言差异里最反直觉的一条。
🚀 举一反三:5道高频变体题
| 题号 | 题目 | 与本题的关系 |
|---|---|---|
| LC.628 | 三个数的最大乘积 | 同样要处理“两个最小负数 × 最大正数”,与 LC.152同源 |
| LC.713 | 乘积小于K的子数组 | 全正数 ⇒ 前缀积 + 滑动窗口可行,负数一出现就失效 |
| LC.1567 | 乘积为正数的最长子数组长度 | LC.152的“长度版”,同样要维护正负两端 |
| LC.560 | 和为K的子数组 | 加法世界的对应题 |
| LC.53 | 最大子数组和 | LC.152的加法版,只需一个状态(没有翻转问题) |
💬 面试追问模拟(提前准备,惊艳全场)
Q1:LC.152 为什么不能用前缀积 + 除法?
两条:
① 有0时除以0无意义,且所有前缀积都被0污染;
② 有负数时“最大值”不再由“最大值”产生(-6 × -4 = 24)。
第二条才是根本——它意味着“局部最优推出全局最优”这条链断了,而这正是前缀手法赖以成立的东西。
Q2:LC.152 能不能用动态规划的标准形式写?
今天的写法其实就是DP。
定义dp_max[i]/dp_min[i]为“以i结尾的子数组的最大/最小乘积”,转移就是那个三选一;
我们只是把两个数组滚动成了两个变量。当发现“一个数不够描述局面”时,就勇敢地把状态扩成一组。
Q3:如果数组里只有正数和0呢?
那就不需要
min了,但仍然不能用前缀积(0的问题还在)。正确做法是“以0分段,段内用滑动窗口或前缀积”。
Q4:LC.238 若要求原地修改nums且不开ans数组呢?
理论上可以,但第二趟需要从右往左读右侧累积值而
nums已被第一趟污染——必须用两个变量交替暂存,实现非常绕。工程上不建议。
🧩 实战小技巧(刷题党必备)
- 口诀:前缀和靠减法可逆,前缀积遇0和负数就崩;LC.238左右两趟,LC.152维护 (max, min)。
- 模板:LC.238 = 左前缀积 + 右后缀积;LC.152 = 候选三项
(x, x·mx, x·mn)同时更新。 - 防坑:LC.152 裸
x不能省;mx/mn必须同时更新。
📈 实际应用场景(不止是刷题)
- LC.238:计算一组指标中“剔除自身后的综合评分”
- LC.152:金融里“连续持仓的最大复利区间”、信号处理中“连续窗口的最大增益”
- 通用:任何“区间聚合”需求,先问一句——这个聚合运算可逆吗?序稳定吗?
🎁 今日思考题
LC.238的实测发现,在Python里“除法版”比“左右前缀积”快3.5~9.5倍,而且倍率随n扩大——因为
total变成了上千位的大整数,两种写法其实都在做大整数运算。
你有没有在别的场景里遇到过“Python无限精度整数悄悄把算法拖垮”的情况?