力扣刷题#9:LeetCode238除自身以外数组的乘积
238. 除自身以外数组的乘积 | 刷题笔记
题目回顾
给你一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积。
约束:
- 不使用除法
- O(n) 时间复杂度
示例:
输入:nums = [1, 2, 3, 4]
输出:[24, 12, 8, 6]
第一步:先想"如果可以用除法"——找到直觉
刷算法题一个很好的习惯是:先不考虑限制条件,用最直觉的方式想清楚"这个答案应该怎么算"。
对 [a, b, c, d]:
总乘积 = a × b × c × d
answer[0] = b × c × d ← 总乘积去掉 a
answer[1] = a × c × d ← 总乘积去掉 b
answer[2] = a × b × d ← 总乘积去掉 c
answer[3] = a × b × c ← 总乘积去掉 d
有了这个理解,再看"不能使用除法"这个约束——本质就是不让你先算总乘积再逐个除,逼你换个角度。
第二步:发现"左边 × 右边"的规律
把我手算出来的结果重新排列,换个角度看每个 answer[i]:
answer[0] = ( b × c × d) ← 右边三数乘积
answer[1] = (a) × ( c × d) ← 左边 1 个数 × 右边 2 个数
answer[2] = (a × b) × (d) ← 左边 2 个数 × 右边 1 个数
answer[3] = (a × b × c) × ( ) ← 左边 3 个数
观察这个拆分,能明显看出:
每个 answer[i] = 左边所有数的乘积 × 右边所有数的乘积
这就是这道题的核心规律。"前缀积 × 后缀积"——一个在好几种题目中都会反复出现的模式。
第三步:两趟扫描,直观实现
顺着上面发现的规律,最自然的方式就是分两趟:
- 从左往右:记录每个位置左边所有元素的乘积
- 从右往左:记录每个位置右边所有元素的乘积
- 合并:左积 × 右积 = 答案
第一版实现
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
// 用两个额外数组分别存左积和右积
vector<int> left, right, res;
int len = nums.size();
// 先记录每个位置左边的乘积
for (int i = 0; i < len; i++) {
if (i == 0) {
left.push_back(1);
} else {
left.push_back(left.back() * nums[i - 1]);
}
}
// 再记录每个位置右边的乘积
for (int i = len - 1; i >= 0; i--) {
if (i == len - 1) {
right.push_back(1);
} else {
right.push_back(right.back() * nums[i + 1]);
}
}
// 合并
for (int i = 0; i < len; i++) {
res.push_back(left[i] * right[len - 1 - i]);
}
return res;
}
};
当时对着 right 数组的下标想了半天——它和 left 的方向是反的,最后合并时要用 len - 1 - i 来把右边的值对应到正确的位置。
第四步:踩坑与改进
坑 1:把当前元素自己乘进去了
第一次写的时候,left[i] 里存的是"前 i+1 个数的乘积",也就是说包含了 nums[i] 自己。这样后面的合并就全错了,所有位置的答案都一样。
修正方法很简单:先存,再乘,这样 left[i] 永远存的是 i 前面的乘积,不包含 i 自己。
坑 2:下标边界算错了
res[len-1] 应该是最后一个位置(右边没有元素),它的右积应该是 1 而不是某个下标计算出来的值。后来调整了初始化和下标的取法,用实际的例子手算了一遍才跑对。
改进版:用一个循环同时构建左积和右积
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
vector<int> l, r, res;
int len = nums.size();
l.push_back(1);
r.push_back(1);
for (int i = 1; i < len; i++) {
l.push_back(l.back() * nums[i - 1]);
r.push_back(r.back() * nums[len - i]);
}
res.push_back(r[len - 1]);
for (int i = 1; i < len; i++) {
res.push_back(l[i] * r[len - 1 - i]);
}
return res;
}
};
复杂度: 时间 O(n),空间 O(n)
第五步:进一步优化——省掉额外数组
上一个版本虽然 AC 了,但用了三个额外的 vector。其实可以更进一步:直接在 answer 数组上操作,用两个变量代替两个数组。
从右往左遍历时,用一个 right 变量边走边累乘:
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> answer(n);
// 第一趟:左积存入 answer
int left = 1;
for (int i = 0; i < n; i++) {
answer[i] = left;
left *= nums[i];
}
// 第二趟:从右往左,合并右积
int right = 1;
for (int i = n - 1; i >= 0; i--) {
answer[i] *= right;
right *= nums[i];
}
return answer;
}
};
复杂度: 时间 O(n),空间 O(1)(在力扣刷题中,返回值不计入额外空间)
这种写法比前面那个直观很多,而且不用操心下标映射的问题。
关键思考总结
1. 这种"两趟扫描"到底在做什么
当题目要求你计算"除了自己以外的信息"时,分两趟是一种通用思路:
- 第一趟从某个方向收集信息
- 第二趟从反方向收集信息
- 最终把两次的信息合并
这个模式在接雨水、柱状图中最大矩形等题目中都会反复出现,熟练了会很有帮助。
2. 向右是从左到右,向右也是从右到左
下标反向计算的时候,可以换一个思路理解:
从左往右是"累乘前面的",从右往左本质上也只是"累乘后面的"——顺着它走的方向来推,不要强行在脑子里反转下标。
用直观的循环方向(从 len-1 往下走到 0)配合一个变量,比手动算 len - 1 - i 容易理解得多。
3. 避免"把当前元素多乘进去"的小技巧
如果在一个循环里既要记录当前位置上方/左边的信息,又要为下一个位置更新累乘值,可以用先存再乘的顺序——这样当前元素就不会污染到本不该包含它的位置。
附录:复杂的下标推导笔记
当时卡最久的地方就是这个反向索引,专门记一笔。
建立 r 数组时的逻辑:
nums = [1, 2, 3, 4]
r[0] = 1 (初始值,还没乘任何元素)
r[1] = 1 × nums[4-1] = 1 × 4 → 4
r[2] = 4 × nums[4-2] = 4 × 3 → 12
r[3] = 12 × nums[4-3] = 12 × 2 → 24
所以 r[k] 的含义是:从右往左数 k 个元素的乘积
合并时 r[len-1-i] 的含义:
| 位置 i | 右边的元素个数 | 取 r[ ] | 值 |
|---|---|---|---|
| i=0 | 3 个(下标 1,2,3) | r[3] | 24 |
| i=1 | 2 个(下标 2,3) | r[2] | 12 |
| i=2 | 1 个(下标 3) | r[1] | 4 |
| i=3 | 0 个 | r[0] | 1 |
右边元素个数 = len - 1 - i,所以取 r[len - 1 - i]。
本文档由 AI 辅助生成,作者提供问题和思路,综合获得以上内容。

浙公网安备 33010602011771号