除自身以外数组的乘积:解法分析笔记
题源:LeetCode 238 - Product of Array Except Self
实现:https://github.com/delight-rs/rust-algorithm/blob/main/src/arrary/product_of_array_except_self.rs
一句话概括:用两遍扫描把「除自己外的乘积」拆成「左边前缀积 × 右边后缀积」,时间 O(n),除输出外只多用 O(1) 空间。
1. 输入与输出
1.1 输入
| 项 | 内容 |
|---|---|
| 参数 | nums: Vec<i32> |
| 长度 | 题目约束 2 <= n <= 10^5;代码对 n = 0、n = 1 也不会 panic |
| 元素 | -30 <= nums[i] <= 30,可正、可负、可以为 0 |
| 数值 | 题目保证所有前缀/后缀乘积以及最终结果都落在 32 位整数范围内 |
1.2 输出
返回一个新的 Vec<i32>,长度与输入相同,满足:
text
answer[i] = ∏ nums[j] (j = 0..n-1, j != i)
即第 i 位放「其余所有元素的乘积」,不含 nums[i] 自身。原数组不要求保留(本题也不允许使用除法,见 LeetCode 的进阶要求;本实现本身也没有用除法)。
1.3 示例
输入 nums |
输出 answer |
说明 |
|---|---|---|
[1, 2, 3, 4] |
[24, 12, 8, 6] |
无 0 的常规情况 |
[-1, 1, 0, -3, 3] |
[0, 0, 9, 0, 0] |
恰好一个 0:只有 0 所在位置非 0 |
[0, 0, 4, 5] |
[0, 0, 0, 0] |
两个及以上 0:所有位置都是 0 |
[-1, -2, -3] |
[6, 3, 2] |
负数个数影响符号 |
[3, 5] |
[5, 3] |
最短长度(n = 2) |
2. 文件里的两个解法
2.1 product_except_self_2:暴力双层循环
rust
for i in 0..nums.len() {
let mut acc = 1;
for j in 0..nums.len() {
if j != i {
acc *= nums[j];
}
}
result.push(acc);
}
- 思路 :严格按照定义,对每个
i把除自己外的元素重新乘一遍。 - 时间:O(n²),每个位置约 n−1 次乘法。
- 空间:除输出外 O(1)。
- 问题 :
n = 10^5时约10^10次乘法,远超时间限制。它只适合作为「定义验证版」或小数据对拍基准。
2.2 product_except_self:前后缀乘积(最优解)
核心恒等式只有一条:
text
answer[i] = (nums[0] × ... × nums[i-1]) × (nums[i+1] × ... × nums[n-1])
└────── 前缀积 ──────┘ └────── 后缀积 ──────┘
第一遍(正序):把前缀积写进 result。
rust
let mut prefix = 1;
for i in 0..n {
result[i] = prefix; // 此时 prefix = nums[0..i] 的乘积
prefix *= nums[i]; // 再把 nums[i] 纳入前缀
}
第二遍(逆序):把后缀积乘进 result。
rust
let mut suffix = 1;
for i in (0..n).rev() {
result[i] *= suffix; // suffix = nums[i+1..n] 的乘积
suffix *= nums[i]; // 再把 nums[i] 纳入后缀
}
两遍的更新顺序 是容易写错的地方:一定是「先使用、再更新」,否则会把 nums[i] 自己乘进去。
2.3 运行时状态追踪
以 nums = [1, 2, 3, 4] 为例:
第一遍 i |
写入 result[i] |
更新后 prefix |
result 状态 |
|---|---|---|---|
| 0 | 1 | 1 | [1, _, _, _] |
| 1 | 1 | 2 | [1, 1, _, _] |
| 2 | 2 | 6 | [1, 1, 2, _] |
| 3 | 6 | 24 | [1, 1, 2, 6] |
第二遍 i |
乘入的 suffix |
更新后 suffix |
result 状态 |
|---|---|---|---|
| 3 | 1 | 4 | [1, 1, 2, 6] |
| 2 | 4 | 12 | [1, 1, 8, 6] |
| 1 | 12 | 24 | [1, 12, 8, 6] |
| 0 | 24 | 24 | [24, 12, 8, 6] |
含 0 的例子 [-1, 1, 0, -3, 3] 无需任何特判,前缀/后缀会把 0 自然地传播到该传播的位置,最终得到 [0, 0, 9, 0, 0]------这也正是当前单元测试覆盖的场景。
2.4 正确性证明(循环不变量)
- 第一遍结束后 :对任意
i,result[i] = ∏_{k<i} nums[k]。- 初始
prefix = 1(空积),归纳可得第i次迭代写入的正是前i个元素的积。
- 初始
- 第二遍开始时 :令
suffix为∏_{k>i} nums[k]的滚动值。- 初始
i = n-1时suffix = 1,为空积,成立。 - 每轮先做
result[i] *= suffix,结合第 1 点即得result[i] = ∏_{k≠i} nums[k]; - 随后
suffix *= nums[i],使不变量在下一轮i-1处继续保持。
- 初始
- 遍历结束后所有位置都满足定义式,算法正确。
3. 为什么能想到这个方案
3.1 从最直白的公式出发
答案的定义是「去掉一个元素后的全量乘积」。第一反应通常是对每个位置重算,这就是暴力法,复杂度 O(n²)。观察到每个位置的计算高度重复,自然会问:能不能预计算一部分结果,让每个位置 O(1) 组装?
3.2 除法直觉以及它为什么不可靠
最诱人的优化是:
text
total = ∏ nums
answer[i] = total / nums[i]
一次遍历 O(n)。但它有两个致命问题:
nums[i] = 0时直接除以 0,而且此题允许 0;- 即使特判 0,当存在多个 0 时也要分类讨论,代码迅速变得臃肿;
- 题目本身要求「不使用除法」(进阶要求),在整数除法下还存在截断与溢出的隐患。
更本质地说:0 在乘法下没有逆元。任何「先聚合总量、再撤销单个元素」的写法都依赖逆运算,而 0 让撤销失效。这才是除法方案不可用的根本原因,而非单纯的边界情况。
3.3 换视角:把「排除 i」拆成两段
既然不能撤销,就换一种聚合方式。注意 i 把数组天然分成互不重叠的两段:
text
nums: [ a0, a1, ..., a(i-1) | a(i) | a(i+1), ..., a(n-1) ]
↑
被排除的元素
answer[i] = (左段全乘) × (右段全乘)
两段都只依赖下标区间,而区间乘积可以用前缀/后缀预计算 ------这正是「前缀和」套路在乘法上的直接移植:把 + 换成 ×,单位元从 0 换成 1。
3.4 空间优化:让输出数组兼职前缀数组
如果按教科书写法,会开 prefix[] 和 suffix[] 两个辅助数组,额外 O(n) 空间。进一步观察:
result[i]在第二遍之前只需要保存前缀积 ,而最终结果恰好也是「前缀积 × 后缀积」,于是可以直接把前缀积写进输出数组,省掉prefix[];- 第二遍从右往左扫描时,后缀积是一个只进不出 的滚动值,用单个变量即可,无需
suffix[]。
于是额外空间降到 O(1)(输出数组不计入额外空间)。这个「先存一半、滚动另一半」的技巧是本题最漂亮的部分。
3.5 可迁移的思维模板
这道题真正有价值的是它的抽象:
若运算满足结合律 且存在单位元 ,那么「去掉单个元素的聚合值」都可以用「前缀聚合 + 后缀聚合」两遍扫描在 O(n) 内求出,且不需要逆元。
对照表:
| 运算 | 单位元 | 「排除 i」的聚合 | 能否用总量撤销 |
|---|---|---|---|
+ |
0 | 前缀和 + 后缀和 | 可以(减去) |
× |
1 | 前缀积 + 后缀积 | 遇到 0 就不行 |
min / max |
±∞ | 前缀最值 + 后缀最值 | 不行(无逆元) |
gcd |
0 | 前缀 gcd + 后缀 gcd | 不行 |
| 模乘 | 1 | 前缀积 + 后缀积 | 元素与模不互素时不行 |
这也解释了为什么「接雨水」「除自身以外数组的最大值」「左右两边最近的更大元素」等题会反复出现前后缀思想------它们都是同一个模板的不同实例。
4. 复杂度对比
| 实现 | 时间 | 额外空间 | 是否依赖除法 | 适用性 |
|---|---|---|---|---|
product_except_self_2 |
O(n²) | O(1) | 否 | 仅小数据/对拍 |
product_except_self |
O(n) | O(1) | 否 | 本题最优 |
理论上的 total / nums[i] |
O(n) | O(1) | 是 | 遇 0 或模运算时失效 |
补充说明:
- 两遍扫描共
2n次乘法,常数极小,缓存友好(正序 + 逆序各一次顺序访问)。 - 这是一个典型的 scan(前缀扫描) 原语,GPU/并行场景下可用 parallel prefix 实现,把两遍扫描进一步并行化。
- Rust 下
i32乘法在 debug 模式溢出会 panic,release 模式会回绕。本题由题目保证结果在 32 位范围内,因此无需处理;但在真实业务代码里应改用i64/i128、checked_mul或对数域,避免静默错误。
5. 实际应用场景
「去掉某一个维度后的整体聚合」在工程中非常常见。下面是几类真实场景,以及它们与本题的对应关系。
| 场景 | 被聚合的量 | 为什么要用前后缀而不是除法 |
|---|---|---|
| 电商购物车/订单:去掉某件商品后的总折扣、总价 | 各商品折扣因子连乘 | 折扣因子可能为 0(赠品/免单),除法失效 |
金融:累计净值 ∏(1 + r_t),剔除某日收益后的曲线 |
日收益率连乘 | 有 -100% 收益(因子为 0)时无法回除;且流式数据不能回头重算 |
可靠性工程:串联系统可靠度 ∏ p_i,求单点故障影响 |
各部件可靠度连乘 | p_i = 0(必然失效)时无逆元 |
| 游戏/数值策划:多个乘法 buff 叠加,移除某个 buff 后的最终伤害 | 倍率连乘 | 同上,且需要实时响应、不能每次 O(n) 重算 |
| 风控/评分:多因子乘法模型的总分,做 leave-one-out 归因 | 因子得分连乘 | 因子可能为 0;需要解释「去掉这一项影响多大」 |
| 渲染/图像:滤镜链、增益链,去掉某一层后的整体增益 | 增益连乘 | 同上;常配合对数域 |
| 密码学/数论:批量求模逆(Montgomery's trick) | 前缀积 + 一次求逆 + 回代 | 这是同一思想的反向用法:用前缀积把 n 次求逆降为 1 次 |
组合计数:各维独立选择数 ∏ c_i,求「去掉某一维」后的方案数 |
各维方案数连乘 | 某维方案数为 0 时无法回除 |
| 机器学习:朴素贝叶斯/似然连乘做特征 leave-one-out 敏感度 | 似然连乘 | 生产上通常转对数域;一旦有 0 概率仍需前缀积 |
几个值得展开的点:
- 「不能撤销」是核心动机,而不是「性能」。加法场景用「总和 − 自身」就够了;只有当运算没有逆元(0 乘法、取模、min/max)时,前后缀方案才不可替代。
- 业务上常见的是「动态剔除一项」。典型需求如「这个渠道的销售额剔除后,总体指标是多少」「如果这个商品不参与活动,整单折扣变成多少」。若每次重算 O(n),在批量报表/实时看板里会非常昂贵;预计算前缀/后缀后单次查询 O(1)。
- 大数场景通常转对数域 。乘积容易上溢/下溢时,工程上更常见的是
log后求和、exp还原;但一旦需要精确整数、模运算,或数据中存在 0,前后缀方案仍然是首选。 - 它是流式可用的。第一遍只需顺序读、第二遍只需逆序读(或缓存一遍),除输出外内存恒定,适合无法一次性把数据全放进内存的场景(分块读文件、按时间窗聚合)。
6. 延伸与变体
| 变体 | 做法 | 关键点 |
|---|---|---|
| 除自身以外的和 | 前缀和 + 后缀和,或总和减自身 | 有逆元,最简单 |
| 除自身以外的最大值 | 前缀最大 + 后缀最大 | 无逆元,必须前后缀 |
| 除自身以外的 gcd | 前缀 gcd + 后缀 gcd | gcd 无逆元 |
| 区间乘积查询(多次) | 前缀积 + 模逆元,或线段树 | 有 0 时用「0 的个数 + 非零前缀积」 |
| 带修改的版本 | 线段树 / Fenwick(乘积版) | 前后缀是静态数组的 O(1) 查询特例 |
| 二维版本 | 按行优先展平后复用一维前后缀,或按四个象限预计算 | 本质仍是「左边 + 右边」的拆分 |
7. 现有测试覆盖评价
src/arrary/product_of_array_except_self.rs 中 8 个测试全部通过,已覆盖:
- 常规无 0:
[1, 2, 3, 4]、[1, 2, 3] - 单个 0:
[-1, 1, 0, -3, 3]、[0, -5] - 多个 0:
[0, 0, 4, 5] - 最短长度:
[3, 5] - 全负数 / 符号交替:
[-1, -2, -3]、[1, -1, 1, -1] - 含 1 的单位元场景
如果想再加强,可以考虑:
- 两种实现互相对拍 :随机生成数组,断言
product_except_self与product_except_self_2结果一致,用性质测试替代手写样例; - 性能冒烟测试 :构造
n = 10^5的输入,确保 O(n) 版本在毫秒级完成(注意此规模下乘积会溢出i32,测试里可改用模运算或仅测耗时); - 边界长度 :
n = 1(题目约束外,但代码返回[1],值得固定行为)。
8. 小结
| 维度 | 结论 |
|---|---|
| 输入 | Vec<i32>,长度 ≥ 2,元素可正可负可零 |
| 输出 | 每个位置为「其余元素之积」的 Vec<i32> |
| 最优解 | 两遍扫描:正序写前缀积,逆序乘后缀积 |
| 时间 | O(n),共 2n 次乘法 |
| 空间 | O(1) 额外(复用输出数组 + 滚动变量) |
| 关键思想 | 排除一个元素 = 左段 × 右段;前缀/后缀聚合不依赖逆元 |
| 易错点 | 循环内「先用后更」;含 0 时不要尝试除法 |