238. 除了自身以外数组的乘积 — 面试向深度解析

13 阅读6分钟

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) 随机访问

三、思路推导(核心)

🔴 暴力解 —— 双重循环

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^510^10 次乘法,远超 JS 单线程极限(约 1 秒可处理 10^7~10^8 次简单运算),必然超时。

🟡 优化动机 —— 从哪里想到更好的解法?

暴力痛点:每个 answer[i] 独立计算,大量重复乘法(nums[0] * nums[1] * ... 反复计算)。

关键观察
对于 answer[i],它其实就是「左边所有数的乘积 × 右边所有数的乘积」。

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

思维演进路径

  1. 先想「能不能一次性算出所有前缀积?」→ 可以,从左往右扫一遍。
  2. 再想「后缀积是不是也能同理?」→ 可以,从右往左扫一遍。
  3. 最后想「能不能省掉存后缀积的数组?」→ 可以,用一个变量边走边累乘,直接乘到 answer 上。

这就是两次扫描(Two-Pass) 的由来。


🟢 手把手模拟(示例 nums = [1, 2, 3, 4]

第一趟(左→右):计算每个位置左侧乘积,存入 answer

索引操作answer 当前值说明
初始answer[0] = 1[1, _, _, _]第一个元素左边没有数,乘积为 1
i=1answer[1] = answer[0] * nums[0] = 1*1[1, 1, _, _]nums[1] 左边是 [1]
i=2answer[2] = answer[1] * nums[1] = 1*2[1, 1, 2, _]nums[2] 左边是 [1,2]
i=3answer[3] = answer[2] * nums[2] = 2*3[1, 1, 2, 6]nums[3] 左边是 [1,2,3]

第二趟(右→左):用 suffix 累乘右边乘积,乘到 answer

索引suffix 当前值操作answer 更新后
i=31answer[3] = 6 * 1 = 6suffix *= nums[3] → 4[1, 1, 2, 6]
i=24answer[2] = 2 * 4 = 8suffix *= nums[2] → 12[1, 1, 8, 6]
i=112answer[1] = 1 * 12 = 12suffix *= nums[1] → 24[1, 12, 8, 6]
i=024answer[0] = 1 * 24 = 24suffix *= nums[0] → 24[24, 12, 8, 6]

✅ 得到最终答案 [24, 12, 8, 6]


四、代码与避坑

完整代码(已给,带注释增强版)

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用除法:「总乘积 / nums[i]」用两次扫描乘法除数为 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」「空间复杂度的严谨表述」

💡 记忆口诀:左扫存前缀,右扫乘后缀,一变量滚到底,原地输出不用愁。