Files
leetcode-go/普通数组/16-除了自身以外数组的乘积.md
T

366 lines
13 KiB
Markdown
Raw Normal View History

2026-05-14 23:30:50 +08:00
---
tags: ["LeetCode", "前缀和", "数组", "中等"]
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] ⚠️ 为什么这个方法行不通?
>
> 1. **除数为零**:如果数组中有 `0`,除法会崩溃。需要额外处理零的个数,逻辑立刻复杂化。
> 2. **题目禁止使用除法**:这本身就是考察点之一。
>
> 所以我们需要一个完全不用除法、对零天然免疫的方案。
> [!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] 算法流程
1. **从左往右扫一遍**,计算每个位置的前缀乘积并填入 `prefix[]`
2. **从右往左扫一遍**,计算每个位置的后缀乘积并填入 `suffix[]`
3. **再扫一遍**,逐位相乘得到答案
用 Mermaid 图表示:
```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] 优化后的两步法
1. **第一步(左 → 右)**:在 `answer[]` 中逐个存放"左边所有数的乘积"
2. **第二步(右 → 左)**:用变量 `R` 维护右边累积,依次乘到 `answer[i]` 上
```mermaid
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 伪代码框架(两步法)
```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] 🔑 核心模式:分离方向的前缀聚合
这道题的技巧核心是 **"将多维依赖降为一维流式计算"**:
1. **拆分问题**:原本每个位置要看其他 n-1 个元素,拆成"只看左边"和"只看右边"两个一维子问题
2. **前置计算**:正向遍历把所有"左贡献"提前算好放进输出数组
3. **在线合并**:反向遍历时用单个变量在线计算"右贡献",立即与存储的值合并
> [!note] 🐹 Go 中的细节
- `make([]int, n)` 自动将切片元素初始化为 `0`,我们后续会覆盖所有位置,无需手动清零
- Go 的切片迭代支持 `range` 语法,但本题需要**反向遍历**,只能用传统 `for i := n-1; i >= 0; i--`
- Go 没有内建的 reverse,手动维护索引变量是最优方案
- 如果遇到全 0 或含多个 0 的特殊测试用例,该方法依然正确(因为完全不涉及除法)
> [!success] ✅ 记忆口诀
> 正向铺左累积,逆向乘右累积。
> 一人分饰两角,一步一相逢。
---
## 代码
```go
// 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] 💬 延伸思考
"前缀聚合"不仅仅是一种解题技巧,更是一种**降维工具**。当你面对"每个位置都要聚合周围所有其他信息"的问题时,试着问自己:
> 这些信息能否按方向拆解?能不能用两次单向遍历替代暴力嵌套?
这类思维可以迁移到很多场景中,比如字符串匹配中的边界处理、图像滤波中的可分离卷积等。掌握了「正向铺、反向乘」的思路,你就拥有了一套通用的解题模板。