☰
高频必考!当前缀遇上乘法:前缀积为什么救不了LC.152?
2026/10/27 10:31:21 网站建设 项目流程

先问一个问题:“前缀和”这套手法,到底依赖什么?

我们用前缀和求“区间和”,靠的是这一行:

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]左边所有数的积。

inums[i]写入前的lans[i]更新后l
011(初始)11
12112
23226
346624

第一趟后:ans = [1, 1, 2, 6]

第二趟(右 → 左):维护右侧累积r,把右半部分的积乘进去。

inums[i]写入前的rans[i]变为更新后r
341(初始)6 × 1 =64
2342 × 4 =812
12121 × 12 =1224
01241 × 24 =2424

结果[24, 12, 8, 6]✓

LC.152:[2,3,-2,4]的(max, min)迭代

步骤x候选三项(x, x·max, x·min)新max新minans
初始2—222
13(3, 6, 6)636
2−2(−2, −12, −6)−2−126
34(4, −8, −48)4−486

最终ans = 6✓

关键看第2步:x = −2时,新的max不是−2 · 6 = −12,而是裸的−2(从当前位置重新起步);新的min则是−2 · 6 = −12。

再看[−2, 3, −4](答案24):

步骤x候选三项新max新minans
初始−2—−2−2−2
13(3, −6, −6)3−63
2−4(−4, −12, 24)24−1224

第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)returnans

Java版

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.152O(n)O(1)只维护三个变量

本机实测(LC.238,nums元素取1~9):

实现n = 1000n = 4000
左右前缀积(标准解)1.292ms60.854ms
除法版0.368ms5.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无限精度整数悄悄把算法拖垮”的情况?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询