238. 除了自身以外数组的乘积 --- 面试向深度解析
一、读题
一句话复述 :给定整数数组 nums,返回一个新数组 answer,其中每个位置 i 的值等于 nums 中除了 nums[i] 以外所有元素的乘积。
关键约束高亮:
| 约束 | 含义 |
|---|---|
2 ≤ nums.length ≤ 10^5 |
数据量 10 万级 ,O(n²) 必挂 |
-30 ≤ nums[i] ≤ 30 |
元素值范围小,但有负数、有 0 |
| 不能用除法 | 直接排除「总乘积 ÷ 当前数」的简单做法 |
| O(n) 时间 + O(1) 额外空间(输出数组不算) | 强约束,不能开额外辅助数组 |
⚠️ 特别注意:题目保证「前缀乘积 × 后缀乘积」在 32 位整数范围内,所以不用担心溢出。
二、前置知识
必须掌握的 3 个知识点:
| 序号 | 知识点 | 在本題中的作用 |
|---|---|---|
| ① | 前缀积思想 | 从左到右累计乘积,得到「当前位置左边所有数的乘积」 |
| ② | 后缀积思想 | 从右到左累计乘积,得到「当前位置右边所有数的乘积」 |
| ③ | 原地复用输出数组 | 用 answer 先存前缀积,再乘后缀积,实现 O(1) 额外空间 |
非常见 API(本题几乎没用特殊 API,以下为通用 Array 操作):
| 方法 | 作用 | 参数 | 返回值 | 使用场景 |
|---|---|---|---|---|
new Array(n) |
创建指定长度的空数组 | length |
空位数组 | 预分配空间,避免动态扩容 |
arr[i] |
数组索引访问/赋值 | index |
元素值 | O(1) 随机访问 |
三、思路推导(核心)
🔴 暴力解 ------ 双重循环
javascript
var productExceptSelf = function(nums) {
const n = nums.length;
const answer = [];
for (let i = 0; i < n; i++) {
let product = 1;
for (let j = 0; j < n; j++) {
if (i !== j) product *= nums[j];
}
answer[i] = product;
}
return answer;
};
- 时间复杂度 :
O(n²)------ 对于每个位置都要遍历整个数组乘一遍 - 空间复杂度 :
O(1)(不算输出) - 代入最大数据量 :
n = 10^5→10^10次乘法,远超 JS 单线程极限(约 1 秒可处理 10^7~10^8 次简单运算),必然超时。
🟡 优化动机 ------ 从哪里想到更好的解法?
暴力痛点 :每个 answer[i] 独立计算,大量重复乘法(nums[0] * nums[1] * ... 反复计算)。
关键观察 :
对于 answer[i],它其实就是「左边所有数的乘积 × 右边所有数的乘积」。
ini
nums = [1, 2, 3, 4]
answer[0] = () × (2×3×4) = 1 × 24 = 24
answer[1] = (1) × (3×4) = 1 × 12 = 12
answer[2] = (1×2) × (4) = 2 × 4 = 8
answer[3] = (1×2×3) × () = 6 × 1 = 6
思维演进路径:
- 先想「能不能一次性算出所有前缀积?」→ 可以,从左往右扫一遍。
- 再想「后缀积是不是也能同理?」→ 可以,从右往左扫一遍。
- 最后想「能不能省掉存后缀积的数组?」→ 可以,用一个变量 边走边累乘,直接乘到
answer上。
这就是两次扫描(Two-Pass) 的由来。
🟢 手把手模拟(示例 nums = [1, 2, 3, 4])
第一趟(左→右) :计算每个位置左侧乘积,存入 answer
| 索引 | 操作 | answer 当前值 | 说明 |
|---|---|---|---|
| 初始 | answer[0] = 1 |
[1, _, _, _] |
第一个元素左边没有数,乘积为 1 |
| i=1 | answer[1] = answer[0] * nums[0] = 1*1 |
[1, 1, _, _] |
nums1 左边是 1 |
| i=2 | answer[2] = answer[1] * nums[1] = 1*2 |
[1, 1, 2, _] |
nums2 左边是 1,2 |
| i=3 | answer[3] = answer[2] * nums[2] = 2*3 |
[1, 1, 2, 6] |
nums3 左边是 1,2,3 |
第二趟(右→左) :用 suffix 累乘右边乘积,乘到 answer 上
| 索引 | suffix 当前值 | 操作 | answer 更新后 |
|---|---|---|---|
| i=3 | 1 | answer[3] = 6 * 1 = 6,suffix *= nums[3] → 4 |
[1, 1, 2, 6] |
| i=2 | 4 | answer[2] = 2 * 4 = 8,suffix *= nums[2] → 12 |
[1, 1, 8, 6] |
| i=1 | 12 | answer[1] = 1 * 12 = 12,suffix *= nums[1] → 24 |
[1, 12, 8, 6] |
| i=0 | 24 | answer[0] = 1 * 24 = 24,suffix *= nums[0] → 24 |
[24, 12, 8, 6] |
✅ 得到最终答案 [24, 12, 8, 6]。
四、代码与避坑
完整代码(已给,带注释增强版)
javascript
var productExceptSelf = function(nums) {
const n = nums.length;
const answer = new Array(n);
// === 第一趟:前缀积(左→右) ===
answer[0] = 1; // 最左侧左边无元素
for (let i = 1; i < n; i++) {
// answer[i] = nums[0] * ... * nums[i-1]
answer[i] = answer[i - 1] * nums[i - 1];
}
// === 第二趟:乘上后缀积(右→左) ===
let suffix = 1; // 记录「当前位置右边所有数的乘积」
for (let i = n - 1; i >= 0; i--) {
answer[i] = answer[i] * suffix; // 左边积 × 右边积 = 最终结果
suffix *= nums[i]; // 向右移动一位,把当前数纳入右边乘积
}
return answer;
};
边界攻防表
| 边界场景 | ❌ 错误写法 | ✅ 正确写法 | 原因 |
|---|---|---|---|
| 数组长度为 2 | 循环条件 i < n - 1 漏算最后一位 |
i < n / i >= 0 全覆盖 |
长度 2 时前缀/后缀各只有一个元素,不能少乘 |
| 含有 0 | 用除法:「总乘积 / numsi」 | 用两次扫描乘法 | 除数为 0 会报错;且题目明确禁止除法 |
| 负数乘积 | 用 suffix = 0 初始化 |
suffix = 1 初始化 |
累乘必须从单位元 1 开始,0 会"污染"所有结果 |
五、举一反三
面试追问方向
| 追问 | 回答思路 |
|---|---|
| Q1:如果允许使用除法,你会怎么写?有什么风险? | 先算总乘积,再 answer[i] = total / nums[i]。风险:① nums[i] = 0 时除零;② 多个 0 时全部为 0,需要特殊处理计数。 |
| Q2:如果数组是流式输入的(不能事先知道长度),怎么办? | 流式场景无法一次拿到所有数据,只能先存下来再算,或者用「两次遍历」在输入完成后执行。本质上是离线算法。 |
| Q3:如果要求输出结果本身也算空间,如何做到真正 O(1)? | 不可能------因为输出数组本身就有 n 个元素,空间至少 O(n)。这里的 O(1) 是**「除输出外」**的额外空间,是业界的常见表述。 |
| Q4:如果数组元素很大,乘积会溢出怎么办? | 题目已保证在 32 位范围内;如果没保证,要用 BigInt 或取模运算(如 (a * b) % MOD)。 |
相似题型推荐
| 题目 | 与本题差异 | 解法变化 |
|---|---|---|
| LeetCode 42. 接雨水 | 不是乘积,而是高度差的累积 | 同样用左右双指针/前缀最值两次扫描,核心思想是「某位置的值由左边最大值和右边最大值共同决定」 |
| LeetCode 135. 分发糖果 | 不是乘积,而是评分规则 | 同样用两次扫描(左→右满足左规则,右→左满足右规则),取最大值合并 |
总结
| 维度 | 要点 |
|---|---|
| 核心思想 | 前缀积 × 后缀积 = 除自身外乘积 |
| 时间 | O(n),两次线性扫描 |
| 空间 | O(1)(不算输出数组) |
| 关键洞察 | 用 answer 暂存前缀积,用一个变量滚动累乘后缀积 |
| 面试亮点 | 能主动说明「为什么不能用除法」「如何处理 0」「空间复杂度的严谨表述」 |
💡 记忆口诀:左扫存前缀,右扫乘后缀,一变量滚到底,原地输出不用愁。