Rust LeetCode238 算法题解

除自身以外数组的乘积:解法分析笔记

题源: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 正确性证明(循环不变量)

  1. 第一遍结束后 :对任意 i,result[i] = ∏_{k<i} nums[k]。
    • 初始 prefix = 1(空积),归纳可得第 i 次迭代写入的正是前 i 个元素的积。
  2. 第二遍开始时 :令 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. 为什么能想到这个方案

3.1 从最直白的公式出发

答案的定义是「去掉一个元素后的全量乘积」。第一反应通常是对每个位置重算,这就是暴力法,复杂度 O(n²)。观察到每个位置的计算高度重复,自然会问:能不能预计算一部分结果,让每个位置 O(1) 组装?

3.2 除法直觉以及它为什么不可靠

最诱人的优化是:

text 复制代码
total = ∏ nums
answer[i] = total / nums[i]

一次遍历 O(n)。但它有两个致命问题:

  1. nums[i] = 0 时直接除以 0,而且此题允许 0;
  2. 即使特判 0,当存在多个 0 时也要分类讨论,代码迅速变得臃肿;
  3. 题目本身要求「不使用除法」(进阶要求),在整数除法下还存在截断与溢出的隐患。

更本质地说: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 概率仍需前缀积

几个值得展开的点:

  1. 「不能撤销」是核心动机,而不是「性能」。加法场景用「总和 − 自身」就够了;只有当运算没有逆元(0 乘法、取模、min/max)时,前后缀方案才不可替代。
  2. 业务上常见的是「动态剔除一项」。典型需求如「这个渠道的销售额剔除后,总体指标是多少」「如果这个商品不参与活动,整单折扣变成多少」。若每次重算 O(n),在批量报表/实时看板里会非常昂贵;预计算前缀/后缀后单次查询 O(1)。
  3. 大数场景通常转对数域 。乘积容易上溢/下溢时,工程上更常见的是 log 后求和、exp 还原;但一旦需要精确整数、模运算,或数据中存在 0,前后缀方案仍然是首选。
  4. 它是流式可用的。第一遍只需顺序读、第二遍只需逆序读(或缓存一遍),除输出外内存恒定,适合无法一次性把数据全放进内存的场景(分块读文件、按时间窗聚合)。

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 的单位元场景

如果想再加强,可以考虑:

  1. 两种实现互相对拍 :随机生成数组,断言 product_except_self 与 product_except_self_2 结果一致,用性质测试替代手写样例;
  2. 性能冒烟测试 :构造 n = 10^5 的输入,确保 O(n) 版本在毫秒级完成(注意此规模下乘积会溢出 i32,测试里可改用模运算或仅测耗时);
  3. 边界长度 :n = 1(题目约束外,但代码返回 [1],值得固定行为)。

8. 小结

维度 结论
输入 Vec<i32>,长度 ≥ 2,元素可正可负可零
输出 每个位置为「其余元素之积」的 Vec<i32>
最优解 两遍扫描:正序写前缀积,逆序乘后缀积
时间 O(n),共 2n 次乘法
空间 O(1) 额外(复用输出数组 + 滚动变量)
关键思想 排除一个元素 = 左段 × 右段;前缀/后缀聚合不依赖逆元
易错点 循环内「先用后更」;含 0 时不要尝试除法
相关推荐
云上工程笔记31 分钟前
SRT字幕一键转手绘动画:srt-whiteboard-animation的复刻实录
大数据·开发语言·javascript·人工智能·机器学习
自信1504130575933 分钟前
重生之从0开始学习c++之——vector-中篇
c++·学习·算法
zhanghaha131433 分钟前
C# 系列教程_12 字符串 string 保姆级系统教程(面向新手,大量示例 + 原理讲解)
开发语言·数据库·c#
eggwyw34 分钟前
PHP8.5怎么用ffmpeg提取视频里的字幕轨道
开发语言·php
奶油话梅糖1 小时前
快过 IDM 5 倍、开源免费:4,200 Star 的 Rust 下载器用 7 组对照实验“单挑“行业老大
rust·开源·idm
瑞雪兆丰年兮1 小时前
JavaWeb-day07-MySQL(DQL&多表设计)
java·开发语言·数据库·sql·mysql·javaweb·web
ShineWinsu4 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial
Java后端的Ai之路6 小时前
RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了
开发语言·人工智能·prompt·agent·rag
starzy19906 小时前
深入理解Scala高阶函数:函数式编程核心
开发语言·scala