LeetCode 238题解析:前缀积与后缀积求数组乘积 LeetCode 238 的《除了自身以外数组的乘积》Product of Array Except Self是我刷题时反复遇到的一道经典数组题。题面很短给一个整数数组 nums要求返回一个新数组 answeranswer[i] 是 nums 中除 nums[i] 以外所有元素的乘积额外限制是算法必须在 O(n) 时间内完成并且不能使用除法。很多第一次刷到这道题的人会想把所有数乘起来再挨个除以当前位不就好了这正是题目故意堵住的一条捷径也是它被放进热门 100 题的原因。这道题适合两类人看。一类是刚开始刷 LeetCode 的新手靠着这道题可以理解一个很重要的套路用“前缀”和“后缀”把子数组信息提前算好省掉重复遍历。另一类是准备面试的开发者因为这道题太适合作为一轮面试的算法题了代码量不大考点却很密集数组遍历、空间复杂度优化、边界讨论、零元素处理。只要有一个细节没讲清楚面试官马上就能试出来你是背题还是真懂。1. 先把题目和考点拆明白1.1 题面解读与输入输出先看一个最直观的例子。输入nums [1, 2, 3, 4]输出应该是[24, 12, 8, 6]。拆开算一下answer[0] 2 * 3 * 4 24answer[1] 1 * 3 * 4 12answer[2] 1 * 2 * 4 8answer[3] 1 * 2 * 3 6这个定义里有一个微妙的点当某个位置左侧没有元素时乘积是多少数学上通常把“空集乘积”定义为 1因为 1 是乘法单位元。所以answer[0]的定义可以理解为左侧空乘积 1 乘上右侧所有元素乘积answer[n-1]同理。这个约定在后面写代码时非常重要很多边界情况都靠它兜住。LeetCode 这道题的约束一般是指定数组长度大于等于 2元素可以是负数并且题目会保证“任意前缀或后缀的乘积在 32 位整数范围内”。这意味着在 C 或 Java 里直接用int不会溢出但在面试手写时最好主动提一句“题目保证不溢出所以这里用 int 就够了”这样显得你有意识地考虑过溢出问题。1.2 为什么不用除法是真正的考点如果允许用除法这个题可以压缩成三行total 1 for x in nums: total * x ans [total // x for x in nums]但题目明确规定不能用除法而且这个限制不是随便加的。它把一条显然的捷径封死逼着你从另一个角度想问题。为什么它要封死除法因为如果只考“能不能算出结果”那就变成了一个简单数学题但面试官真正想考察的是你能否把“每个位置的结果”拆成“左侧累积结果乘右侧累积结果”这是数组前缀/后缀思想的经典应用。还有一个很实际的原因数组里可能出现 0。一旦某个元素是 0整体乘积会变成 0除法那套逻辑立刻需要分支处理需要统计 0 的个数根据 0 的个数决定答案。就算题目允许除法代码也会变得很啰嗦。前缀乘积、后缀乘积的做法天然避开了对 0 的特殊判断因为它不依赖“整体乘积”。1.3 暴力解为什么不可行在没有思路的时候最直白的写法是双层循环n len(nums) ans [1] * n for i in range(n): prod 1 for j in range(n): if i ! j: prod * nums[j] ans[i] prod这个解法的时间复杂度是 O(n²)。当 n 是 10 的 5 次方时最坏要做大约 10 的 10 次方次乘法这是不可能在 LeetCode 的时间限制内跑完的。更关键的是暴力解重复计算太多了。比如answer[0]和answer[1]都包含了nums[2] * nums[3]这部分结果但在双层循环里这个乘积会被重复计算。数组越长这种重复越严重。前缀积的思路本质上就是把这些“公共的子数组乘积”提前算好让每个位置的答案能通过两次遍历直接拼出来。这也符合我们写代码时的一个基本原则能通过预处理复用的结果就不要在每次循环里重新算。2. 核心思路前缀积 后缀积把乘法拆成两半2.1 从“左边乘一遍、右边乘一遍”理解对任意位置 ianswer[i]可以写成这样answer[i] (nums[0] * nums[1] * ... * nums[i-1]) * (nums[i1] * ... * nums[n-1])也就是说答案由两部分组成i 左边所有元素的乘积乘以 i 右边所有元素的乘积。我们可以给这两部分起名字left[i]表示 nums[0] 到 nums[i-1] 的乘积也就是 i 左侧所有元素的乘积right[i]表示 nums[i1] 到 nums[n-1] 的乘积也就是 i 右侧所有元素的乘积那answer[i]就等于left[i] * right[i]。到这里问题从“算所有元素除自己以外的乘积”变成了“算每个位置左侧的累积乘积和右侧的累积乘积”。这个转换看起来只是改了个说法但它是整个题目的突破口。可以打个比方假设你要计算 1 到 5 的乘积然后分别去掉其中一个数得到五个结果。如果每次都从头乘到尾太浪费了。更好的做法是先把“从开头到某个位置”的累积乘积记下来再把“从某个位置到结尾”的累积乘积记下来最后把两段拼在一起。这就是在利用乘法运算本身的连续性而不是每次都重新开始。2.2 先构建前缀积数组先从最容易理解的做法说起额外开两个数组一个存left一个存right。前缀积数组可以这样构建n len(nums) left [1] * n for i in range(1, n): left[i] left[i - 1] * nums[i - 1]这个递推关系要仔细理解。left[i]是“i 左侧所有元素的乘积”那么left[i]一定等于left[i-1]再乘上nums[i-1]因为 nums[i-1] 正好是 i 左侧最近的一个元素。以nums [1, 2, 3, 4]为例算出来的left数组是[1, 1, 2, 6]left[0] 1因为 0 左侧没有元素left[1] left[0] * nums[0] 1 * 1 1left[2] left[1] * nums[1] 1 * 2 2left[3] left[2] * nums[2] 2 * 3 6这里有一个新手很容易写错的点递推时用的是nums[i-1]不是nums[i]。如果误写成left[i] left[i-1] * nums[i]那left[i]就把 nums[i] 自己也乘进去了。后面再乘右侧乘积时当前元素会被额外乘一次答案就错了。left[0] 1这个初始化也值得单独强调。它对应的语义是“空乘积等于 1”不是随便填的一个数。在很多数组题里这种“哨兵值”都是解题的关键理解了它边界条件就不会慌张。2.3 后缀积的两种用法额外数组 vs 滚动变量后缀积可以用完全对称的方式构建right [1] * n for i in range(n - 2, -1, -1): right[i] right[i 1] * nums[i 1]right[i]表示 i 右侧所有元素的乘积所以它等于right[i1]再乘上nums[i1]。方向是从右往左倒着走的。构建完left和right后最后的答案就是ans [left[i] * right[i] for i in range(n)]这个版本很好理解但额外空间是 O(n)因为left和right各占一个数组。实际上还有更优的写法第二遍遍历时不需要把所有的right[i]提前算好只需要用一个变量从右往左滚动更新代表“当前位置右侧的乘积”。比如从最后一个位置开始右侧没有元素所以right 1每处理完一个 i就把nums[i]乘进right这样下一个位置 i-1 使用right时里面的值正好是 i-1 右侧所有元素的乘积。这种“滚动变量”的思路本质上是用一个变量替代了一个数组把额外空间从 O(n) 压到了 O(1)。这个优化是这道题 follow-up 的核心也是面试中最容易加分的点。3. 两遍遍历的参考实现与复杂度精细计算3.1 Python 实现一步步看过程下面的代码是 LeetCode 238 最经典的 O(1) 额外空间解法第一遍从左往右第二遍从右往左from typing import List class Solution: def productExceptSelf(self, nums: List[int]) - List[int]: n len(nums) ans [1] * n left 1 for i in range(n): ans[i] left left * nums[i] right 1 for i in range(n - 1, -1, -1): ans[i] * right right * nums[i] return ans第一遍循环里ans[i]先被赋成“当前位置左侧所有元素的乘积”然后left再把nums[i]乘进去供下一个位置使用。这个顺序非常关键必须先赋值再更新left。如果先更新left那ans[i]就会多乘一个nums[i]等于把自己也算进去了。第二遍循环从数组末尾开始right初始为 1对应最后一个位置右侧没有元素。循环里先让ans[i] * right也就是把右侧乘积乘进去然后再把nums[i]累乘进right供前一个位置使用。这里同样必须注意顺序不能先更新right再乘。用nums [1, 2, 3, 4]手工推一遍整个过程会非常清楚i第一遍后 ans[i]第二遍开始时 right乘完 right 后 ans[i]right 更新后361642248121112122401242424最终答案就是[24, 12, 8, 6]。注意看 i2 时第一遍后ans[2] 2这个 2 是 1 乘 2 得到的也就是下标 2 左侧两个元素的乘积。第二遍乘上right4而 4 恰好是 nums[3] 的值所以ans[2] 1 * 2 * 4 8刚好是除 nums[2] 以外所有元素的乘积。3.2 Java 参考实现Java 版本和 Python 版本逻辑完全一样只是语法不同class Solution { public int[] productExceptSelf(int[] nums) { int n nums.length; int[] ans new int[n]; int left 1; for (int i 0; i n; i) { ans[i] left; left * nums[i]; } int right 1; for (int i n - 1; i 0; i--) { ans[i] * right; right * nums[i]; } return ans; } }这里要提醒一个 Java 的小细节int[] ans new int[n]之后数组默认值是 0。但不用担心因为第一遍循环会给ans[i]逐个赋值所有位置都会被覆盖成left的值不会残留 0。之后第二遍循环是在已有值基础上做乘法所以没问题。题目如果明确保证前缀或后缀乘积在 32 位整数范围内那么用int就足够。如果自己在本地做扩展实验不放心溢出可以临时把left和right改成long最后再强转回int但注意最终答案必须在 int 范围内否则依然不合法。3.3 复杂度与“空间 O(1)”到底怎么算时间复杂度非常明确两个循环每个循环跑 n 次总共 O(n)。空间上除了输出数组ans以外只用了left和right两个整型变量所以额外空间是 O(1)。这里有一个 LeetCode 讨论区经常出现的疑问ans数组本身就是 O(n) 的内存怎么能说空间 O(1)原因是题目里有一个不成文的约定返回的数组本身不算额外空间。因为你不管怎么做最终都要返回一个长度为 n 的数组这部分空间是结果本身不是算法额外申请的。面试时建议主动说清楚这一点如果不把输出数组计算在内额外空间是 O(1)如果严格把所有数组都算进去那它实际上还是 O(n)。这么说会让面试官觉得你很清楚空间复杂度的边界在哪。为什么很多题解一开始会写三个数组left、right、ans因为那个版本最容易理解适合面试开头用来解释思路。如果你直接写出滚动变量版有些面试官可能觉得你背过题反而不容易展开讨论。比较好的节奏是先讲暴力再讲前缀数组和后缀数组最后说“我发现 right 数组可以用变量替代”然后给出最终代码。这样整个思考过程是递进的而不是凭空蹦出来的最优解。4. 零元素、边界条件和扩展变式4.1 数组里有 0 时这套方法为什么不需要特殊处理很多人在看到“乘积”时第一反应是担心 0。因为只要有 0整个数组的总乘积就是 0除法方案就麻烦了。但前缀乘后缀的方案完全不需要对 0 单独开分支。看一个例子nums [0, 1, 2, 3]。正确答案应该是[6, 0, 0, 0]因为answer[0] 1 * 2 * 3 6answer[1] 0 * 2 * 3 0answer[2] 0 * 1 * 3 0answer[3] 0 * 1 * 2 0用前缀后缀法走一遍。第一遍得到的ans [1, 0, 0, 0]这个数组存的是左侧乘积。第二遍从右往左更新i3 时 right1ans[3]还是 0i2 时 right 变成 3ans[2]依然是 0i1 时 right 变成 6ans[1]也还是 0i0 时 right6ans[0] 1 * 6 6。结果正确。如果面试官反过来问“如果允许用除法你会怎么写”这时要能说出分支处理数组里没有 0直接total // nums[i]数组里恰好一个 0只有该 0 所在位置的结果是非零其他元素的乘积其余位置全为 0数组里至少两个 0所有结果都为 0这个分支逻辑很容易漏所以更能看出题目禁止除法其实是在帮你避开坑。4.2 空数组和单元素数组的边界讨论LeetCode 238 的测试数据里数组长度至少是 2但面试官经常会加问一句“如果数组是空的或者只有一个元素呢”空数组确实没啥好算的直接返回空数组即可。单元素数组则有一个设计上的问题answer[0]应该是“除 nums[0] 以外所有元素的乘积”也就是一个空乘积。按照数学惯例空乘积等于 1。但如果面试官期待你说“等于 0”或者“题目没定义”就产生分歧了。所以我的建议是写代码时先实现常规逻辑然后在讨论边界时明确说“在 LeetCode 的约束下不会遇到单元素和空数组如果面试中遇到我会认为空乘积是 1返回[1]或空数组具体看题目定义”。这种回答比闷头写一个边界分支更有说服力因为它展示了你对“乘积单位元”的理解而不是机械地处理异常。4.3 取模版本和除法失效的场景这道题还有个很常见的扩展如果所有结果需要对一个模数取模比如 1e97前缀后缀法依然能用。因为乘法对取模运算是有分配性的第一遍和第二遍循环里每次乘完都对模数取余最后结果不会错。反过来除法在取模场景下就麻烦得多。模意义下的“除法”本质上要乘逆元而逆元不一定存在只有当除数和模数互质时才有逆元。如果数组元素里恰好有模数的倍数除法方案直接失效。这就是为什么“不允许使用除法”往往是更底层的限制它不只是在考察你有没有想到total / nums[i]还关系到算法的可推广性。如果要把这套前缀后缀思路迁移到二维矩阵场景比如让每个位置返回“除该位置所在行和所在列以外所有元素的乘积”思路也类似把一维的前缀乘积改成按行、按列分别累积。这类题目在周赛和竞赛题里很常见本质都是提前预处理累积信息再用组合方式回答每个查询。5. 刷题过程里常见错误与实战心得5.1 高频错误更新顺序写反这道题代码很短出错的地方也就很集中。最常见的错误是第二遍循环里先更新了right再给ans[i]做乘法# 错误写法 right 1 for i in range(n - 1, -1, -1): right * nums[i] # 先更新 ans[i] * right # 再乘此时多乘了自己比如nums [1, 2, 3, 4]i3 时 right 先变成 4然后ans[3] 6 * 4 24显然是错的。正确顺序永远是“先用 right再更新 right”。右侧乘积是“当前位置右侧元素的乘积”而不是“包含当前位置的乘积”。类似的错误也会出现在第一遍循环。如果写成left 1 for i in range(n): left * nums[i] # 先更新 ans[i] left # 再赋值左边乘积里包含了 nums[i]那ans[i]里就含了自己整体结果会错得很离谱。这个错误我刚刷题时也犯过后来总结了一个检查方法每完成一次循环后找一个 i 手工验算尤其是 i0、in-1 这两个边界位置。边界位置能过关整个循环的更新顺序大概率就对了。5.2 负数元素和整数运算的细节数组元素允许为负数但这对前缀后缀法没有任何额外负担。乘法的符号规则是自然的负号个数为奇数时结果是负数为偶数时结果是正数。你不需要为负数专门写分支因为前缀数组和后缀数组会把符号正确地累积下来。一道题的除法版本还存在一个潜在问题如果用整数除法计算total / nums[i]必须保证total能被nums[i]整除。数学上当然整除因为total是nums[i]乘上其他数的结果编程语言里的整数除法在能整除时是精确的。但一旦加了取模、负数等条件浮点数除法或者截断除法就容易出现边界差异。前缀后缀法完全没有这些麻烦这也是为什么它更干净。5.3 面试时怎么讲这道题最加分这道题在面试里的经典问法通常不是直接扔一个 LeetCode 题号而是让你在白板上写“除了自身以外数组的乘积”。我自己的经验是不要一上来就写最优解。先花半分钟把暴力法讲出来说明它的时间复杂度是 O(n²)不适合大数据量。然后顺势引出问题为什么要重复计算因为每个位置的结果可以拆成左侧乘积和右侧乘积。接着写出一个用额外两个数组的直观版本把前缀和后缀的含义讲清楚。最后再说“我发现右侧乘积其实可以滚动维护不需要数组”把代码压成两个循环。这个递进过程能明显体现你的思考深度。如果直接写出最终版面试官很难判断你是真的理解还是背过答案。反过来如果你连暴力解都懒得讲直接跳到最优解遇到“数组里如果有 0 怎么办”这种追问时就很容易翻车。另外一个小技巧写完代码后主动跑一个包含 0 的例子和负数的例子。这看起来是自检其实也是在向面试官传递“我知道边界条件在哪”的信号。很多人刷题只关注过测试用例忽略了这种现场推导能力但面试官恰恰很看重这个。5.4 最后一点个人体会这道题我前前后后刷过很多遍每次重刷都会发现新的关注点第一次学前缀后缀第二次学滚动变量第三次才真正理解为什么题目要禁止除法。后来再看 LeetCode 热门 100 题里那些数组题很多都比这道题复杂但核心思想往往是相通的。比如有些题要算“前一个更小元素的下标差”有些题要算“左边更大值的个数”本质上都是预处理左侧信息和右侧信息再组合使用。把《除了自身以外数组的乘积》这道题吃透相当于给自己打了一个很好的数组题基础。如果你正在准备面试建议把上面这个递进讲解过程练成肌肉记忆只写代码不解释是最可惜的一种准备方式。