除自身以外数组的乘积:左右乘积法详解与O(1)空间优化
2026/9/9 18:39:18 网站建设 项目流程

如果你正在刷 LeetCode 的热门100题,大概率会撞上 238 题《除自身以外数组的乘积》。我第一次看到它,脑子里冒出来的解法是:先把所有元素乘成一个 total,再逐个除以 nums[i]。结果刚想动手就被题目规则按住了——不能用除法。当时我还有点不服气,后来把这道题从直觉方案到边界条件完整过了一遍,才发现这条限制其实是命题人帮你避坑。我打算按这个顺序讲:先从“为什么除法走不通”说起,再给左右乘积法的标准解,接着把空间压到 O(1) 的原地解法,单独讨论 0 这种特殊输入,最后延伸到接雨水、前缀和这类同套路题目。照着走一遍,你不仅能拿下这道题,还能顺手掌握一种很常用的左右扫描套路。

1. 为什么“先乘再除”根本走不通

1.1 谁都会想到的 total / nums[i] 方案

题目描述其实很朴素:给定一个整数数组 nums,返回一个数组 answer,其中 answer[i] 等于数组中除 nums[i] 之外所有元素的乘积。看到这个描述,第一直觉一定是先算全数组乘积,再逐位相除:

total = 1 for x in nums: total *= x return [total // x for x in nums]

如果数组里全是正数,这段代码在数学上毫无问题,时间复杂度 O(n),空间复杂度 O(1),看起来相当完美。LeetCode 之所以专门加一条“不要使用除法”,就是因为这条路一旦遇到 0 就会彻底崩掉,而且崩得很隐蔽。很多人第一次刷这道题时根本没意识到数组允许出现 0,等提交报错才回过头来排查,浪费了时间。

1.2 单个 0 就把信息抹掉了

以 nums = [1, 2, 0, 4] 为例,total = 0。逐个算:

  • answer[0] = 0 // 1 = 0,结果其实是 2 * 0 * 4 = 0,这个位置恰好是对的;
  • answer[1] = 0 // 2 = 0,这个位置也是对的;
  • answer[2] = 0 // 0,直接抛异常;
  • answer[3] = 0 // 4 = 0,这个位置也是对的。

问题就出在 answer[2]。它本应是 1 * 2 * 4 = 8,但在 total 里,这个 8 已经被那个 0 乘成了 0,除数本身又是 0,任何语言都没法从 0 反推出 8。换句话说,除法方案把整个数组的信息压缩成了一个数,一旦压缩过程中出现 0,逆向操作就丢失了关键信息。数学上这叫除法是乘法的逆运算,但乘法一旦碰到 0 就不是单射了,信息不可逆。

1.3 多个 0 时,特判也不优雅

如果数组长成 [0, 1, 0, 3],total 也是 0。这种情况一眼能看出答案是全零数组,因为每个位置的“其余元素”里必然包含至少一个 0。真正麻烦的是只有一个 0 的情况:除了 0 所在位置能算出一个非零结果,其他位置全是 0。想用除法就得先统计 0 的个数;个数为 1 时,还要单独扫一遍求“去掉那个 0 以后的乘积”,再填到对应位置。这套分支逻辑写出来比前缀后缀解法还长,而且面试时特别容易把自己绕晕。所以这道题用除法的隐藏成本是:为了规避除法,反而写出一堆特判,代码的可读性和正确性都在下降。

1.4 题目的真实用意:不要逆运算,要拆分

把“禁止除法”理解成出题人故意刁难,就误解了这道题。它的真实用意是让你放弃“把一个数组揉成一个数再反向解出来”的方向,换成更底层的视角:每个位置的答案其实由“它左边的乘积”和“它右边的乘积”两块互不干扰的信息组成。你不需要知道全数组的乘积,只需要知道每个位置两侧分别乘出来了什么。顺着这个想法走,就自然进入了前缀/后缀乘积的框架。这个思路一旦建立,你会发现在 LeetCode 大量题目里都能看到它的影子,接雨水、前缀和、柱状图中的最大矩形等,后面我会展开讲。

2. 核心思路:把“除自身”拆成“左侧乘积 × 右侧乘积”

2.1 一个关键等式

对于任意位置 i:

answer[i] = (nums[0] 到 nums[i-1] 的乘积) × (nums[i+1] 到 nums[n-1] 的乘积)

我把左边这半段记作 L[i](前缀乘积),右边半段记作 R[i](后缀乘积)。于是问题被拆成两个完全独立的小任务:先填好 L,再填好 R,最后把对应位置相乘。这个等式看起来简单,但它把“除自身以外”这种全局条件,变成了“左右两边各看各的,最后合并”的局部条件。全局条件往往很难直接计算,但局部条件可以用递推轻松搞定,这就是这题的核心转化。

2.2 前缀乘积 L 怎么递推

定义 L[i] = nums[0] × nums[1] × ... × nums[i-1],表示从数组开头一直到 i 左侧一个元素为止的乘积。边界是 L[0] = 1,因为 i = 0 时左侧没有任何元素,数学上把空集的乘积约定为 1,也就是乘法单位元。从第二个位置开始可以递推:

L[i] = L[i-1] × nums[i-1]

注意这里的下标是 nums[i-1],不是 nums[i]。原因很简单:L[i] 的终点在 i 的左边一个位置,也就是 i-1;L[i-1] 代表已经乘到了 i-2 位置,再乘一个 nums[i-1] 才能覆盖到 i-1。很多第一次写这题的人会下意识写成 L[i-1] * nums[i],导致结果整体错位一位,对小样本很难一眼看出来。

2.3 后缀乘积 R 怎么递推

R[i] = nums[i+1] × nums[i+2] × ... × nums[n-1],表示从 i 右侧一个元素起到数组末尾的乘积。边界是 R[n-1] = 1,最后一个元素右侧为空集。从右往左递推:

R[i] = R[i+1] × nums[i+1]

同样,这里的下标是 nums[i+1],不是 nums[i]。每当写这种递推式时,我建议你在旁边标注“这个 R[i] 的终点在哪里”,只要把终点的下标想清楚,索引错误基本就能避免。后缀乘积必须从右往左填,因为它依赖靠右位置的已知结果,这和前缀乘积必须从左往右填是对称的。

2.4 用手算跑一遍 [1, 2, 3, 4]

有些读者可能觉得递推公式抽象,我直接手工跑一遍。nums = [1, 2, 3, 4]。

先算 L:

  • L[0] = 1;
  • L[1] = L[0] * nums[0] = 1 * 1 = 1;
  • L[2] = L[1] * nums[1] = 1 * 2 = 2;
  • L[3] = L[2] * nums[2] = 2 * 3 = 6。

再算 R:

  • R[3] = 1;
  • R[2] = R[3] * nums[3] = 1 * 4 = 4;
  • R[1] = R[2] * nums[2] = 4 * 3 = 12;
  • R[0] = R[1] * nums[1] = 12 * 2 = 24。

最终乘积如下表:

inums[i]L[i]R[i]answer[i]
0112424
1211212
23248
34616

输出 [24, 12, 8, 6],和题目示例完全一致。整个计算过程没有出现除法,也不需要判断某个位置是不是 0,非常统一。这也是我为什么特别喜欢这道题:它的正确性和特殊条件彻底解耦,代码写出来像流水线一样规整。

3. 标准解法:左右乘积表,空间换时间的教科书答案

3.1 算法流程

左右乘积表解法就是把上面的手算过程写成循环。流程一共四步:

  1. 初始化两个长度 n 的数组 L 和 R,全部填 1;
  2. 从左到右遍历 i = 1 到 n-1,用 L[i-1] * nums[i-1] 填 L[i];
  3. 从右到左遍历 i = n-2 到 0,用 R[i+1] * nums[i+1] 填 R[i];
  4. 输出 answer[i] = L[i] * R[i]。

复杂度非常清晰:时间复杂度 O(n),额外空间 O(n)(输出数组不计入的情况下)。LeetCode 这题的 n 最大可以到 10^5,所以 O(n) 是必须的,任何 O(n^2) 的暴力做法都没法通过。这也是这个解法能成为标准答案的原因:它既没有使用除法,又保证了线性时间内完成。

3.2 Python 代码

from typing import List class Solution: def productExceptSelf(self, nums: List[int]) -> List[int]: n = len(nums) L = [1] * n R = [1] * n for i in range(1, n): L[i] = L[i - 1] * nums[i - 1] for i in range(n - 2, -1, -1): R[i] = R[i + 1] * nums[i + 1] return [L[i] * R[i] for i in range(n)]

这个版本是教科书式写法,优点是每一步都对应着清晰的数学含义,非常适合在面试里先讲思路、再给代码。你甚至可以边写边念:“这里 L 是前缀乘积,R 是后缀乘积,最后合起来就是答案。”面试官通常不会有任何质疑,因为逻辑链条非常完整。

3.3 四个容易写错的地方

第一个,range 的起点。右侧遍历如果写成 range(n - 1, -1, -1),第一次循环就会访问 R[n] 和 nums[n],直接越界。正确写法是从 n-2 开始,因为 R[n-1] 已经用初始值 1 表示空乘积了,不需要再算。

第二个,L 和 R 的初始化值。把 [1] * n 写成 [0] * n,乘积永远都是 0,而且运行时不报错,肉眼很难发现。记住口诀:前缀和用 0 初始化,因为 0 是加法单位元;前缀积必须用 1 初始化,因为 1 是乘法单位元。

第三个,递推式里的乘数下标。L[i] 乘的是 nums[i-1],R[i] 乘的是 nums[i+1],两个都跟当前位置 i 错开一个位置,因为我们要排除“自身”。这个错位正是“除自身以外”的体现,写循环时一定要对着式子检查一遍。

第四个,可以拿一个 n=2 的极简样例验证。比如 nums = [2, 3],答案应该是 [3, 2]。如果写出 [2, 3] 或者越界报错,说明你的边界初始化有问题。这种微型样例在调试时比大样例好用得多,一眼就能看清递推方向对不对。

3.4 为什么会设置 L[0] = 1、R[n-1] = 1

边界初始化看起来是硬编码,其实是数学约定在代码里的自然体现。空乘积定义为 1,是为了让递推式从第二步开始仍然成立:L[1] = L[0] * nums[0],如果 L[0] 不是 1,这个式子的语义就被破坏了。同理,R[n-1] 必须是 1,因为最后一个位置的右侧没有任何元素。这个约定和“空数组的和是 0”完全对称,只是一个对应加法、一个对应乘法。理解和记住这一点,比背代码重要得多,因为一旦题目变形,你就能自己推出正确的初始化值。

4. 进阶解法:原地复用结果数组,把额外空间压到 O(1)

4.1 关键洞察:让结果数组先扮演 L

题目里有一个 follow-up:能不能只用 O(1) 的额外空间?输出数组不计入额外空间。换句话说,你必须把左右两个辅助数组省掉,把中间结果存进 answer 本身。

核心思路不复杂:第一趟从左到右,不再往 L 里填,而是直接把 answer[i] 变成“nums[0] 到 nums[i-1] 的乘积”。也就是说,answer 先兼职当 L 用。第二趟从右往左,用一个普通变量 R 维护已经扫过的后缀乘积,每到一个位置就执行 answer[i] *= R,这时 answer[i] 就从“左侧乘积”升级成了“左侧乘积 × 右侧乘积”,也就是真正的最终答案。整个过程只需要一个额外整数变量 R,空间确实压到了 O(1)。

4.2 代码实现

from typing import List class Solution: def productExceptSelf(self, nums: List[int]) -> List[int]: n = len(nums) answer = [1] * n for i in range(1, n): answer[i] = answer[i - 1] * nums[i - 1] R = 1 for i in range(n - 1, -1, -1): answer[i] *= R R *= nums[i] return answer

如果面试官要求用 C++ 写,逻辑一模一样:

class Solution { public: vector<int> productExceptSelf(vector<int>& nums) { int n = nums.size(); vector<int> answer(n, 1); for (int i = 1; i < n; ++i) { answer[i] = answer[i - 1] * nums[i - 1]; } int R = 1; for (int i = n - 1; i >= 0; --i) { answer[i] *= R; R *= nums[i]; } return answer; } };

C++ 里要注意一下类型溢出问题。LeetCode 这题的数据范围比较友好,题目保证了任意前缀乘积、后缀乘积以及整体乘积都在 32 位整数范围内,所以直接用 int 没问题。但如果自己扩展成更大数据,建议换 long long,或者用 Python 这种不溢出的语言来兜底。

4.3 更新顺序的坑:先乘旧 R,再更新 R

第二趟的循环体是两行:

answer[i] *= R R *= nums[i]

很多人会把这两行写反,变成:

R *= nums[i] answer[i] *= R

这种写法的含义是:先把 nums[i] 乘进 R 再给 answer[i] 用,等于把“右侧乘积”里也塞进了当前位置本身。结果就是 answer[i] 比正确答案多乘一个 nums[i],而且不是全部位置统一多乘,是每个位置都错位,debug 起来非常痛苦。你必须在心里保持一个画面:当循环走到位置 i 时,R 表示的是“从 i+1 到数组末尾所有数的乘积”,也就是当前位置右侧还没被处理的元素乘积。所以必须先用这个旧 R 去补乘 answer[i],然后再把 nums[i] 纳入 R,为下一个位置 i-1 做准备。顺序反了,整个结果数组没有任何一个位置是对的。

4.4 两种解法怎么选

对比项左右乘积表原地复用
辅助数组L 和 R
遍历次数3 趟2 趟
额外空间O(n)O(1)
可读性思路直白,适合讲解需要一点抽象思维
面试推荐先讲这个作为 follow-up 给出

我自己的习惯是:面试时先花一分钟把左右乘积表讲清楚,证明 O(n) 时间可以做到,然后在“能不能再省空间”的追问下亮出原地版本。这样既展示你懂原理,又展示你有空间优化的意识。直接上来写原地版本虽然也能过,但可能给面试官一种“背过答案”的感觉,少了一次展示思路推进的机会。

5. 边界条件专项:数组里出现 0 的时候

5.1 从官方示例 2 说起

LeetCode 官方给的第二个示例是:

输入 nums = [-1, 1, 0, -3, 3],输出 answer = [0, 0, 9, 0, 0]。

为什么中间那个位置是 9?因为 nums[2] 是 0,对于下标 2 来说,其余四个数是 -1、1、-3、3,乘积 = (-1) × 1 × (-3) × 3 = 9。其他任何位置的结果里都至少含有一个 0,所以全是 0。这个例子把 0 的坑摆到了明面上,但如果你用前面的前缀后缀法或原地解法跑一遍,会发现根本不需要对 0 做任何特殊处理,结果自然就是对的。这就是这套解法的优雅之处:特殊输入不会破坏算法的统一流程。

5.2 没有 0、单 0、多 0 三种情况

如果面试官喜欢追问边界,你可以把情况分成三类来回答:

  • 数组里没有 0:所有位置都能正常用前缀后缀算,没有任何例外;
  • 恰好一个 0:只有 0 所在位置的结果是“其余所有非零元素乘积”,其他位置的结果全部为 0;
  • 至少两个 0:所有位置的结果都为 0,因为任一位置的其余元素里至少包含一个 0。

从数学上看,原因就是除法方案失败的同一个根源:0 一旦参与乘法,就会把整段乘积归零,而且这种归零不可逆。前缀后缀方案不依赖逆推,所以遇到 0 也完全正常,这也是它比除法方案优雅的根本原因。

5.3 如果面试官偏要你写除法版

有些面试官会接着问:“如果允许除法,你会怎么写?”这时候你心里要有一版预案。思路是先统计 0 的个数,再分三种情况填充:

zero_count = nums.count(0) if zero_count > 1: return [0] * n if zero_count == 1: idx = nums.index(0) prod = 1 for i, x in enumerate(nums): if i != idx: prod *= x ans = [0] * n ans[idx] = prod return ans total = 1 for x in nums: total *= x return [total // x for x in nums]

把这版和前面的标准解法放在一起对比,你会发现它更长、分支更多,而且最核心的单 0 分支还是要额外扫一遍数组。这正好印证了题目为什么禁止除法:没有除法,你的代码反而更短、更不容易踩雷。

5.4 负数会不会带来额外问题

数组元素允许为负。前缀乘积和后缀乘积在负数参与下可正可负,但乘法运算本身不关心符号,连乘出来的正负号会自然保留,最终 answer 也是正确的。唯一要注意的是:零没有符号,任何数乘 0 都是 0,所以不要因为数组里存在负数就对“乘积是否为 0”产生迷惑。单 0 分支里,非零位置乘积的正负号由负数个数决定,比如 [-1, 1, -3, 3] 里两个负数,乘积为正 9;如果只有一个负数,乘积就是负的。这些都在乘法规则内,不需要额外处理。

6. 前缀/后缀思想可以迁移到哪些题

6.1 剑指 Offer 66:构建乘积数组

《剑指 Offer》第 66 题“构建乘积数组”和 LeetCode 238 是完全同一道题,区别只是描述方式不同。你在很多刷题平台搜“productExceptSelf”或“构建乘积数组”,会看到一堆变体。刷完这一道,等于同时覆盖了两本“教材”里的高频题,性价比很高。我甚至见过一些同学把这道题的代码原封不动背下来,然后去面试里默写,看起来效果还行,但一旦面试官追问“为什么 L[i] 乘的是 nums[i-1]”,背题的人就容易露馅。所以建议还是把递推的来龙去脉搞清楚再上考场。

6.2 接雨水:同样靠左右两个数组

LeetCode 42 接雨水是另一道经典的“左右扫描”题。对每根柱子,它能接住的水量等于 min(左边最高柱子, 右边最高柱子) - 当前柱子高度。很多标准解法也是先从左到右记录每个位置左边的最高值,再从右到左记录右边的最高值,最后取两者较小值合并。你对比一下就会发现,它的结构跟本题的 L 和 R 几乎如出一辙,只是 L、R 存的信息从“乘积”换成了“最大高度”。一旦理解这种“一维数组上的左右预处理”套路,这类题就不再需要死记硬背了,因为你会主动去想:“这个位置的结果是不是也可以拆成左边信息和右边信息?”

6.3 前缀和与前缀积是同一个家族

前缀和的思想比前缀积更普及:一维数组 nums,定义 prefix[i] = nums[0] 到 nums[i-1] 的和,那任意区间 [l, r) 的和就能用 prefix[r] - prefix[l] 快速求出。本题的前缀积本来也能做类似查询,用除法把区间积还原出来,但 LeetCode 直接禁掉了除法,于是我们只好用 L 和 R 两边逼近。同样地,前缀最大值、后缀最小值这类“预先把每个位置左右两侧的信息存好”的办法,在一堆看似不相关的题里反复出现。所以我常说,刷题不要只盯着单题解法,要总结模式;这道题最值得带走的模式就是:当某个位置的结果同时依赖它左右两侧的信息时,左右两趟扫描往往是突破口。

6.4 我自己的三个刷题习惯

最后分享几个我刷这道题时沉淀下来的小习惯,希望能帮你少走弯路。第一,拿到题先拿 [1, 2, 3, 4] 手工推导一遍 L 和 R,再写代码,这样基本能避免下标错位问题,推导过程也就一分钟。第二,代码写完不要直接提交,先用题目给的两个示例验证,尤其第二个带 0 的示例,能直接暴露和 0 相关的逻辑错误;有条件的话再补一个全 0 的样例,比如 [0, 0],确认结果是 [0, 0]。第三,做空间优化时先别急着看题解,给自己五分钟想“到底能不能少用一个数组”,这个思考过程比背题解更有价值。我踩过不少次“拿到题就开写、写完错到怀疑人生”的坑,后来发现都是因为没有先花一分钟把边界条件在纸上跑通。这道题本身不难,真正难的是把前缀/后缀这种思维方式内化成你自己的东西,然后迁移到下一道题上去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询