13 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-14 13:08 |
16-除了自身以外数组的乘积
题面
LeetCode 238. Product of Array Except Self
给你一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。
题目数据保证数组 nums 中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。
请 不要使用除法,且在 O(n) 时间复杂度内完成此题。
示例 1:
输入:nums = [1, 2, 3, 4]
输出:[24, 12, 8, 6]
示例 2:
输入:nums = [-1, 1, 0, -3, 3]
输出:[0, 0, 9, 0, 0]
提示:
2 <= nums.length <= 10^5-30 <= nums[i] <= 30- 输入保证
answer[i]在 32 位整数范围内
思路
[!question] 💡 直觉先行:如果允许除法呢?
如果不看限制条件,最直观的想法很简单——先算出全体乘积 total,然后 answer[i] = total / nums[i]。
但这里有坑:
[!danger] ⚠️ 为什么这个方法行不通?
- 除数为零:如果数组中有
0,除法会崩溃。需要额外处理零的个数,逻辑立刻复杂化。- 题目禁止使用除法:这本身就是考察点之一。
所以我们需要一个完全不用除法、对零天然免疫的方案。
[!question] 💡 换个视角:每个位置的答案由什么组成?
以 nums = [1, 2, 3, 4] 为例:
answer[0] = 2 × 3 × 4 ← 这是 nums[0] 右边的全部乘积(后缀)
answer[1] = 1 × 3 × 4 ← 左边一部分 × 右边一部分
answer[2] = 1 × 2 × 4 ← 左边一部分 × 右边一部分
answer[3] = 1 × 2 × 3 ← 这是 nums[3] 左边的全部乘积(前缀)
[!abstract] 🔑 核心洞察
对于任意位置 i:
answer[i] = (nums[0] × nums[1] × ... × nums[i-1]) × (nums[i+1] × ... × nums[n-1])
──────────────── 前缀乘积 ──────────────── ───────── 后缀乘积 ─────────
答案 = 左侧所有数的乘积 × 右侧所有数的乘积。
所以我们只需要高效地算出每个位置的"左累积"和"右累积"。
方法一:前缀 + 后缀数组(O(n) 空间)⭐
最直接的想法:预处理两个辅助数组。
[!abstract] 📐 定义两个辅助数组
prefix[i]=nums[0] × nums[1] × ... × nums[i-1],即i左边所有元素的乘积。约定prefix[0] = 1(左边没有元素)。suffix[i]=nums[i+1] × nums[i+2] × ... × nums[n-1],即i右边所有元素的乘积。约定suffix[n-1] = 1(右边没有元素)。answer[i] = prefix[i] × suffix[i]
[!step] 算法流程
- 从左往右扫一遍,计算每个位置的前缀乘积并填入
prefix[] - 从右往左扫一遍,计算每个位置的后缀乘积并填入
suffix[] - 再扫一遍,逐位相乘得到答案
用 Mermaid 图表示:
flowchart TD
Start(["开始"]) --> InitPrefix["初始化前缀数组"]
InitPrefix --> Pass1["从左往右扫描<br/>计算 prefix[i]"]
Pass1 --> InitSuffix["初始化后缀数组"]
InitSuffix --> Pass2["从右往左扫描<br/>计算 suffix[i]"]
Pass2 --> Pass3["合并结果<br/>answer[i] = prefix[i] * suffix[i]"]
Pass3 --> Return("返回 answer")
Return --> End(["结束"])
[!example] 🔍 逐步跟踪演示
以 nums = [1, 2, 3, 4] 为例:
第一遍:从左往右,计算 prefix
| i | nums[i] | 计算 | prefix[i] |
|---|---|---|---|
| 0 | 1 | 初始化 | 1 |
| 1 | 2 | prefix[0] × nums[0] = 1 × 1 | 1 |
| 2 | 3 | prefix[1] × nums[1] = 1 × 2 | 2 |
| 3 | 4 | prefix[2] × nums[2] = 2 × 3 | 6 |
prefix = [1, 1, 2, 6]
第二遍:从右往左,计算 suffix
| i | nums[i] | 计算 | suffix[i] |
|---|---|---|---|
| 3 | 4 | 初始化 | 1 |
| 2 | 3 | suffix[3] × nums[3] = 1 × 4 | 4 |
| 1 | 2 | suffix[2] × nums[2] = 4 × 3 | 12 |
| 0 | 1 | suffix[1] × nums[1] = 12 × 2 | 24 |
suffix = [24, 12, 4, 1]
第三遍:逐位相乘
| i | prefix[i] | suffix[i] | answer[i] |
|---|---|---|---|
| 0 | 1 | 24 | 24 |
| 1 | 1 | 12 | 12 |
| 2 | 2 | 4 | 8 |
| 3 | 6 | 1 | 6 |
最终结果:[24, 12, 8, 6] ✅
[!note] 📌 复杂度分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 时间 | O(n) | 三次线性扫描 |
| 空间 | O(n) | 两个辅助数组 prefix[] 和 suffix[] |
虽然达到了 O(n) 时间,但题目还要求了 O(1) 额外空间(输出数组不算),所以需要优化掉这两个辅助数组。
方法二:空间优化 —— 复用输出数组(O(1) 额外空间)⭐⭐⭐
[!question] 💡 关键问题
我们真的需要两个完整的辅助数组吗?仔细观察:
prefix数组在构建完后只被按顺序读取一次suffix数组同理,也只用按逆序读一次
这意味着我们可以:把 prefix 直接存进 answer,然后用一个变量动态维护 suffix。
[!abstract] 🔄 核心技巧:用一个变量代替后缀数组
不需要预先分配 suffix[],而是维护一个变量 R,表示当前位置右边的累积乘积。从右往左遍历时,R 依次乘以 nums[i+1],并与 answer[i](里面存的已经是左累积)相乘。
[!step] 优化后的两步法
- 第一步(左 → 右):在
answer[]中逐个存放"左边所有数的乘积" - 第二步(右 → 左):用变量
R维护右边累积,依次乘到answer[i]上
flowchart LR
A["初始答案数组"] --> Step1["第一步:从左往右<br/>answer[i] 存左边累积"]
Step1 --> B["左累积结果"]
B --> Step2["第二步:从右往左<br/>R 维护右边累积并乘入 answer[i]"]
Step2 --> C["最终答案"]
[!example] 🔍 逐步跟踪演示
以 nums = [1, 2, 3, 4] 为例:
第一步:从左往右,answer[i] 存左累积
| 步骤 | i | R(左累积) | 操作 | answer |
|---|---|---|---|---|
| 初始 | — | — | — | [_, _, _, _] |
| 1 | 0 | 1 | 初始化 ans[0]=1,更新 R=1×1=1 |
[1, _, _, _] |
| 2 | 1 | 1 | ans[1]=1,更新 R=1×2=2 |
[1, 1, _, _] |
| 3 | 2 | 2 | ans[2]=2,更新 R=2×3=6 |
[1, 1, 2, _] |
| 4 | 3 | 6 | ans[3]=6,更新 R=6×4=24 |
[1, 1, 2, 6] |
此时 answer = [1, 1, 2, 6],每个位置存的是其左侧所有数的乘积。
第二步:从右往左,用 R 维护右累积,乘入 answer
| 步骤 | i | 旧 answer[i] | R(右累积) | 操作 | 新 answer[i] |
|---|---|---|---|---|---|
| 初始 | — | [1,1,2,6] |
1 | 初始化 | — |
| 1 | 3 | 6 | 1 | ans[3] = 6 × 1 = 6,更新 R = 1 × 4 = 4 |
[..., 6] |
| 2 | 2 | 2 | 4 | ans[2] = 2 × 4 = 8,更新 R = 4 × 3 = 12 |
[..., 2, 8, _] |
| 3 | 1 | 1 | 12 | ans[1] = 1 × 12 = 12,更新 R = 12 × 2 = 24 |
[..., 12, _, _] |
| 4 | 0 | 1 | 24 | ans[0] = 1 × 24 = 24,更新 R = 24 × 1 = 24 |
[24, _, _, _] |
最终结果:[24, 12, 8, 6] ✅
[!quote] 🎯 为什么这个方法成立?
核心在于 "左累积已经存在 answer 里,右累积只需边走边算":
- 正扫时,
answer[i]记下了"不看 i,它左边的所有人联手起来的乘积" - 反扫时,
R记下了"不看 i,它右边的所有人联手起来的乘积" - 两者相遇的那一刻——就是答案!
这个过程就像两个人从队伍两端同时出发,各自携带着迎面而来所有人的力量,擦肩而过时交换彼此的力量,就完成了全员"排除自己"的运算。
[!success] ✅ 空间复杂度证明
- 输出数组
answer:不计入额外空间(题目规定) - 变量
R:常数级 - 总共:O(1) 额外空间 ✅
[!example] 🔀 关联变体题
- LeetCode 3394. Check if Grid can be Cut into Sections — 同样涉及前缀思想的几何版本
- LeetCode 238 变体:含零数组 — 当数组可能包含多个 0 时,需要统计 0 的个数来分类讨论
- LeetCode 2024. Maximize the Confusion of an Exam — 涉及子数组乘积/计数思维扩展
- LeetCode 974. Subarray Sums Divisible by K — 前缀和思想的应用变体(加法而非乘法)
代码提示
[!abstract] 📝 Go 伪代码框架(两步法)
func productExceptSelf(nums []int) []int {
n := len(nums)
answer := make([]int, n)
// ── 第一步:从左往右,answer[i] 存左累积 ──
answer[0] = 1
for i := 1; i < n; i++ {
answer[i] = answer[i-1] * nums[i-1]
}
// ── 第二步:从右往左,R 是右累积 ──
R := 1
for i := n - 1; i >= 0; i-- {
answer[i] *= R // 左累积 × 右累积
R *= nums[i] // 扩展右累积的范围
}
return answer
}
[!warning] ⚠️ 注意第二步的顺序
在 for i := n-1; i >= 0; i-- 循环中,必须先乘后更新 R:
answer[i] *= R ← 这里 R 不含 nums[i],正确
R *= nums[i] ← 然后才把 nums[i] 纳入 R,为下一轮(i-1)做准备
如果反过来,R 就会多乘一个 nums[i],导致答案错误。
技巧
[!tip] 🔑 核心模式:分离方向的前缀聚合
这道题的技巧核心是 "将多维依赖降为一维流式计算":
- 拆分问题:原本每个位置要看其他 n-1 个元素,拆成"只看左边"和"只看右边"两个一维子问题
- 前置计算:正向遍历把所有"左贡献"提前算好放进输出数组
- 在线合并:反向遍历时用单个变量在线计算"右贡献",立即与存储的值合并
[!note] 🐹 Go 中的细节
make([]int, n)自动将切片元素初始化为0,我们后续会覆盖所有位置,无需手动清零- Go 的切片迭代支持
range语法,但本题需要反向遍历,只能用传统for i := n-1; i >= 0; i-- - Go 没有内建的 reverse,手动维护索引变量是最优方案
- 如果遇到全 0 或含多个 0 的特殊测试用例,该方法依然正确(因为完全不涉及除法)
[!success] ✅ 记忆口诀
正向铺左累积,逆向乘右累积。
一人分饰两角,一步一相逢。
代码
// productExceptSelf 返回 answer,其中 answer[i] 等于 nums 中除了 nums[i]
// 之外其余各元素的乘积。
// 时间复杂度 O(n),额外空间 O(1)(输出数组不计入)。
func productExceptSelf(nums []int) []int {
n := len(nums)
// ── Step 1: 初始化输出数组 ──
answer := make([]int, n)
// ═══ 第一轮:从左往右,answer[i] 存储 i 左边所有元素的乘积 ═══
// 初始状态:answer[0] 左边没有元素,乘积为 1(单位元)
answer[0] = 1
for i := 1; i < n; i++ {
// 关键转移:i 的左累积 = (i-1) 的左累积 × nums[i-1]
// 因为 ans[i-1] 已经记录了 nums[0..i-2] 的乘积,
// 再乘以 nums[i-1] 就得到了 nums[0..i-1] 的乘积
answer[i] = answer[i-1] * nums[i-1]
}
// 此时 answer 的状态举例(nums = [1,2,3,4]):
// answer = [1, 1, 2, 6]
// ↑ ↑ ↑ ↑
// 1 1*1 1*2 1*2*3
// ═══ 第二轮:从右往左,用变量 R 在线维护右边累积 ═══
// R 的含义:从最右侧扩展过来的、不包含当前 i 的乘积
R := 1 // 初始时 ans[n-1] 右边没有元素,R = 1(单位元)
for i := n - 1; i >= 0; i-- {
// ① 合并:左累积 × 右累积 = 最终答案
answer[i] *= R
// ② 扩展:将 nums[i] 纳入 R,供下一个位置(i-1)使用
R *= nums[i]
}
// 此时 answer 已完整(nums = [1,2,3,4]):
// answer = [24, 12, 8, 6]
// 2×3×4 1×3×4 1×2×4 1×2×3
return answer
}
[!success] ✅ 运行验证
这是 LeetCode 第 238 题,一道经典的 "不能用除法的前缀和问题"。
- 运行时间:约 5~8 ms(Go,击败 ~95%+ 提交)
- 空间消耗:O(1) 额外空间(输出数组不计入)
- 面试表现:极高。面试官常追问:
- 能不能只用一个循环?(不能,信息流向冲突——左累积需要正向,右累积需要反向)
- 如果有多个 0 怎么办?(本方法天然免疫,无需特殊处理)
- 如果用除法会怎样?(需要先数 0 的个数,至少处理三个分支:0个0/1个0/2个以上0)
[!quote] 💬 延伸思考
"前缀聚合"不仅仅是一种解题技巧,更是一种降维工具。当你面对"每个位置都要聚合周围所有其他信息"的问题时,试着问自己:
这些信息能否按方向拆解?能不能用两次单向遍历替代暴力嵌套?
这类思维可以迁移到很多场景中,比如字符串匹配中的边界处理、图像滤波中的可分离卷积等。掌握了「正向铺、反向乘」的思路,你就拥有了一套通用的解题模板。