news 2026/10/22 10:57:58

前缀后缀分解:LeetCode 238除自身以外数组的乘积全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前缀后缀分解:LeetCode 238除自身以外数组的乘积全解析

手写一个“前后缀数组”,其实没那么难——我先把这道经典题的思路拆透,再把两种写法和边界条件全部摊开。

如果你刷过LeetCode Hot100,大概率会遇到这道“238. 除自身以外数组的乘积”。它表面是数组题,实际上是考“前缀/后缀分解”的入门代表,也是面试里高频出现的一道“看着简单、动手就有细节”的题。题目要求返回一个新数组,其中每个元素等于原数组中除了该位置以外所有元素的乘积,并且强调不能用除法,时间复杂度要求O(n)。这句话里的“不能用除法”基本就是最大的提示,也是最大的陷阱——很多人先想到算总乘积再逐个除,但这一思路在数组包含0时就会翻车,而前/后缀分解恰好能优雅绕开所有特殊情况。

我刚刷到它的时候,第一反应也是“这题不是有手就行?”,但真正落笔才发现边界条件、空间优化、代码顺序的讲究比想象中多。这篇文章把这道题的完整思路、两种实现、关键坑位和扩展方向都拆开讲透,适合正在刷题准备面试的同学,也适合想系统整理“前缀/后缀分解”这一套路的人,看完能直接复用同类型题目。

1. 题目拆解与核心考点评估

1.1 这题到底在考什么

题目本身很短,没有绕弯:给一个整数数组nums,返回一个新的数组answer,其中answer[i]等于nums数组中除了nums[i]之外所有元素的乘积。这里有两个关键限定:

  1. 不能用除法。
  2. 时间复杂度要求在O(n)以内。

第一个限定直接堵死了“先算全部乘积,再除以当前值”的常规思路。第二个限定则说明暴力双重循环(O(n²))不可接受。所以,这道题实际是在考察:你是否掌握用“前缀乘积”和“后缀乘积”在O(n)时间内构造结果,以及能否进一步把额外空间从O(n)压到O(1),只借助输出数组本身完成计算。

这两点环环相扣,也是面试官最喜欢追问的部分。很多同学能写出前缀后缀双数组的版本,但问“能不能优化到O(1)额外空间”时就会卡壳。其实优化思路也不难:把右边的后缀乘积用一个临时变量滚动维护,左边用输出数组answer[0..i-1]暂存前缀乘积,最后一遍从右往左合并即可。

1.2 为什么说“不让用除法”才是题眼

我把这道题给身边的朋友试过,十个人里有六七个第一反应都是“先乘一遍再除”。这个思路本身没有错,问题出在两个地方。

第一是数组里有0。假设nums = [1, 0, 2],全乘积为0,然后你算answer[1] = 0 / 0,直接未定义,程序会崩。也许你想特殊处理:只有一个0时,除了0所在位置以外都是0;有两个及以上0时,所有位置都是0。这样分情况讨论也能做,但代码判断逻辑就会多一些,而且面试官会继续追问“如果真让你用除法,你要怎么处理溢出和边界”。这一来二去,反而暴露了思维不够“无懈可击”的地方。

第二是除法本身带来了负数和溢出的额外心智负担。虽然后缀和前缀记忆起来开headache,但它本质上是把“组合来看”转换成“分治来看”:每个位置的结果 = 左边所有数字的乘积 × 右边所有数字的乘积。这是一个非常通用的建模方式,不光能解决这一道题,还能迁移到很多类似“处理自身之外信息”的题目里。

所以我一直强调:不让你用除法不是刁难你,而是在逼你换一种更优雅的思考方式。前后缀分解就是这道题的标准答案,也是它成为Hot100经典题的根本原因。

2. 从朴素思路到前后缀分解的演进

2.1 朴素思路:暴力枚举

先来看最直白的暴力想法:对每个位置i,把数组里所有nums[j](j≠i)乘起来,得到answer[i]。

def product_except_self_bruteforce(nums): n = len(nums) ans = [1] * n for i in range(n): for j in range(n): if i != j: ans[i] *= nums[j] return ans

这个版本的时间复杂度是O(n²),如果数组长度是1万,就要计算1亿次乘法,明显超出题目要求。但写出来的好处是,它能很直观地验证“什么是除自身以外数组的乘积”,作为理解题意的辅助工具是没问题的。

暴力思路的另一个问题是有大量重复计算。第0位和第1位的乘积里,绝大多数数组元素都被重复乘了一遍。优化的核心就是把重复计算缓存下来,这也是前后缀分解的最初动机。

2.2 除法思路:为什么危险

有人会说:“既然O(n),我先算总乘积,再一个个除,不是更快吗?”

def product_except_self_division(nums): total = 1 for x in nums: total *= x return [total // x for x in nums]

这段代码简洁,但在含0数组面前直接崩。试想nums = [0, 1, 2],total = 0,计算answer[0] = 0 // 0,Python会抛ZeroDivisionError。即便避开除零错误,如果数组有两个0,比如[0, 1, 0],所有位置的结果都应该是0,用除法版本则需要在逻辑里额外判断0的个数,代码会变得分支很多。

再有就是整数溢出和取余问题。题目没有明确说数据范围,但在很多变体题里,乘积可能会很大,除法版本在“溢出后”的结果直接不可信。而前缀/后缀分解从头到尾只用乘法,这里的每次乘法都是局部累积,逻辑更加干净,也更符合“工程上宁可多算几次也不要分情况照顾0”的直觉。

2.3 前后缀分解的核心思想——拿空间换时间,再拿时间换空间

前后缀分解,翻译成人话就是:先把每个位置左边的乘积都算好存在一个数组里,再把每个位置右边的乘积算好存另一个数组里,最后把两个数组逐位相乘。

用生活化的例子来说:假设你要给一排人发“除自己外所有人的合影”任务,暴力做法是每个人去找所有其他人拍照;聪明做法是提前洗出每个人的“左侧所有人的合照”和“右侧所有人的合照”,自己把两张拼在一起就行。前者浪费时间,后者虽然占用额外空间,但每个人做事的代价变成了O(1)。

这个思想是很多数组题的母题。包括“2070. 最大公因数等于 K 的子数组数目”有多因子处理,也包括一些区间最值题,本质都在用“前缀信息 + 后缀信息”做合并。搞懂了它,你会发现在这些题里都看到同一个影子。

空间优化的路径也很清晰:既然右边的后缀可以用一个变量在遍历时“滚动算”,那就不需要额外开后缀数组;左边的前缀又可以临时存在输出数组本身里,所以最终额外空间可以做到O(1)。两步走下来,最优解就出来了。

3. 两种经典实现:清晰版与极致版

3.1 方案一:前后缀数组

这是最直观、最好想到的写法。三步走:

  1. 构建前缀乘积数组left,left[i]表示nums[0] * nums[1] * ... * nums[i-1]。
  2. 构建后缀乘积数组right,right[i]表示nums[i+1] * nums[i+2] * ... * nums[n-1]。
  3. 结果answer[i] = left[i] * right[i]。

边界约定:left[0] = 1(0左侧没有数字,乘积视为1),right[n-1] = 1(最后一个位置右侧没有数字,乘积也视为1)。

def product_except_self_prefix_suffix(nums): n = len(nums) left = [1] * n for i in range(1, n): left[i] = left[i-1] * nums[i-1] right = [1] * n for i in range(n-2, -1, -1): right[i] = right[i+1] * nums[i+1] ans = [1] * n for i in range(n): ans[i] = left[i] * right[i] return ans

我当时写下这个版本之后,用示例[1,2,3,4]推演过一遍:

  • left计算为 [1, 1, 2, 6](表示“当前位置左边所有数的乘积”)
  • right计算为 [24, 12, 4, 1](表示“当前位置右边所有数的乘积”)
  • answer = [124, 112, 24, 61] = [24, 12, 8, 6]

整个过程非常符合直觉,代码也不容易出边界错误。面试时候如果面试官只是想考“你会不会前后缀”,这版完全够用。但如果你想展示更进一步的空间掌控力,下面是更优解。

3.2 方案二:只用输出数组(滚动复用)

这一版是很多大厂面试官想看到的“优化答案”。核心思想是在同一个answer数组上做事:

  1. 先把answer当作前缀乘积数组填充。answer[i] = nums[0] * ... * nums[i-1]。
  2. 用一个变量right_product从右往左滚动维护“右侧所有数的乘积”。
  3. 每遍历到一个位置i,把answer[i] *= right_product,同时更新right_product *= nums[i]。
def product_except_self_optimized(nums): n = len(nums) ans = [1] * n # 第一遍:从左往右,ans[i] = 左边所有数的乘积 for i in range(1, n): ans[i] = ans[i-1] * nums[i-1] # 第二遍:从右往左,用right变量维护右边乘积 right = 1 for i in range(n-1, -1, -1): ans[i] *= right right *= nums[i] return ans

这个版本里最关键的一点是:第二遍遍历必须从右往左。因为right变量要表示“当前下标右侧所有数字的乘积”,从右往左才能保证每步都能利用到已经更新的right。如果从左往右,right还没积累到右边的信息,算出来的结果就是错的。

另外注意第一遍填充完后,ans[i]存的是“左侧乘积”,第二遍乘上right之后,才变成最终结果,因此不需要再额外开数组,空间复杂度降为O(1)。这个“借输出数组暂存中间结果”的思路,在做空间优化时很常用。

3.3 复杂度对比与选型建议

方案时间复杂度额外空间适用场景
暴力双重循环O(n²)O(1)仅用于理解题意
前后缀双数组O(n)O(n)面试稳妥、代码直观
滚动后缀单数组O(n)O(1)面试拉满、空间受限
除法O(n)O(1)不含0且不溢出的特例

我自己的建议是:平时刷题练习,先把双数组版本练熟,因为它出错概率低,写起来快;在面试前冲刺阶段,再重点练会滚动后缀那版,同时能解释清楚为什么第二遍从右往左。如果时间紧张,至少要把双数组版本做到闭着眼能写出来的程度。

4. 边界条件、示例推演与细节验证

4.1 三种典型测试场景演示

纸上谈兵没意思,直接跑几个典型例子验证code行为。

场景一:普通正常数组

nums = [1, 2, 3, 4],预期结果是 [24, 12, 8, 6]。

用双数组版本走一遍:

  • 第一遍前缀:ans = [1, 1, 2, 6]
  • 第二遍后缀:right初始为1;i=3时 ans[3] = 61=6,right变为14=4;i=2时 ans[2] = 24=8;right变为43=12;i=1时 ans[1] = 112=12;right变为122=24;i=0时 ans[0] = 1*24=24。最终 [24,12,8,6],完全正确。

场景二:数组含一个0

nums = [1, 0, 3, 4],预期 [0, 12, 0, 0]。

用滚动版本:

  • 前缀:ans = [1, 1, 0, 0]
  • i=3:ans[3] = 0*1=0,right=4
  • i=2:ans[2] = 0*4=0,right=12
  • i=1:ans[1] = 1*12=12,right=0
  • i=0:ans[0] = 1*0=0,最终 [0,12,0,0]

看到没有,0的存在没有产生任何除零问题,因为乘法天然包容了0。

场景三:数组含多个0

nums = [0, 1, 2, 0],预期全0:[0, 0, 0, 0]。

  • 前缀:ans = [1, 0, 0, 0]
  • i=3:ans[3] = 0*1=0,right=0
  • i=2:ans[2] = 0*0=0,right=0
  • i=1:ans[1] = 0*0=0,right=0
  • i=0:ans[0] = 1*0=0,最终全0。

这种“全0”结果如果用除法做就还得考虑0的个数判断,用乘法做反而是最自然的。

4.2 边界与特殊输入梳理

  • n=1:nums = [5],预期答案是[1]。因为除了自身以外没有其他元素,乘积为空积,按数学约定为1。两个版本都能正确处理:前缀初始化ans[0]=1,第二遍right=1,ans[0]=1*1=1。
  • 负数:nums = [-1, 2, -3],预期[ -6, 3, -2 ]。乘法中正负号自然累积,不需要额外处理。
  • 超大数:如果题目限制“乘积可能超过32位整数”,不同语言选择不同。Python的整数无限制,C++可能要考虑用long long,同时确保中间结果不溢出。
  • 空数组:题目一般会保证长度至少为1或2,但如果遇到[], 返回[]即可;严谨代码可以加个防御性判断:if not nums: return nums。

边界处理是这类题的隐形考点。很多人写的思路正确,但n=1或者含0时翻了车。所以我的习惯是写完代码先跑一遍特例,把[nums=[]]、[nums=[x]]、[含0]、[全0]这几种情况都过一遍,确认无误再提交。

5. 常踩的坑与排查技巧实录

5.1 坑位速查表

坑位现象原因对策
右边界更新顺序错结果差一位先更新ans再更新right的顺序反了先ans[i]=right,再right=nums[i]
第二遍从左往右结果全错right变量没维护好右侧乘积从右往左遍历
数组含0时用除法直接异常除零错误或分支逻辑复杂放弃除法,用前后缀
前缀初始值不是1第一个位置出错left[0]错误设置成nums[0]空区间乘积为1
空间优化时忘了ans已经存了前缀又开新数组或覆盖错误没理解ans在第二遍之前只是中间状态把“暂存”和“最终结果”分清楚

5.2 我踩过的两个具体坑

第一个坑是第二遍遍历时更新right的顺序。我最初写的代码是这样:

for i in range(n-1, -1, -1): right *= nums[i] ans[i] *= right

这个写法在nums=[1,2,3,4]上得到的是 [48, 24, 12, 4],明显不对。原因很简单:我在用ans[i]之前就把当前nums[i]乘进了right,那把“自身”也乘进去了。正确顺序必须先把right乘给ans[i],再更新right。这个顺序问题,样例数组小的时候很难一眼看出来,但一定要多跑几个用例验证。

第二个坑出现在“只用输出数组”时误以为ans直接就是最终结果,于是第二遍在ans上又乘了一次前缀,导致每个位置都被算成了“左侧乘积 × 右侧乘积 × 左侧乘积”。排查方法也简单:在每个位置打印ans[i]和right,就能立刻发现多乘了什么。后来我养成习惯,把“ans暂存前缀”和“ans作为最终结果”理解成两个不同阶段,第二遍开始前心里默念“现在for i是在给每个位置补上右侧乘积”,就会顺手很多。

5.3 排查套路总结

如果最终结果和预期差一点,建议按以下顺序排查:

  1. 检查前缀初始化:第一个位置对不对。
  2. 检查第二遍遍历方向:是不是从n-1往0走。
  3. 检查right更新时机:是否“先用后乘”。
  4. 用长度2的数组[1,2]手动走一遍,期望是[2,1],这一用例最容易暴露顺序问题。
  5. 用含0用例验证:比如[0,1,2],期望是[2,0,0]。

这些套路花不了两分钟,但能省下调试刷题时的大量时间。

6. 进阶与扩展:从前缀分解到更多变体

6.1 如果允许除法,分情况讨论的陷阱

面试时面试官可能会追问:“如果允许除法,你会怎么写?”其实这是一个陷阱题。理论上可以分三类:

  • 数组中没有0:算总乘积totol,answer[i] = total / nums[i]。
  • 数组中有一个0:只有0所在位置答案是“其他非0元素乘积”,其余位置都是0。
  • 数组中有两个及以上的0:所有位置答案都是0。

这个写法逻辑上能通过所有用例,但代码明显啰嗦,还需要先遍历统计0的个数。面试官真正想看的其实是你对“特殊情况”的处理能力,以及你是否能意识到“除法思路不优雅”。如果你能条理清晰地把三个分类讲明白,并且说明“这正是我不推荐用除法的原因”,反而能展现你对问题的透彻理解。

6.2 变体题:除自身以外数组的最大值

了解这道题后,可以顺手做一道变体:给定数组,返回一个新数组,每个位置是除自身以外所有元素的最大值。还是同样的前后缀框架,只不过把“前缀乘积”换成“前缀最大值”,“后缀乘积”换成“后缀最大值”。

def max_except_self(nums): n = len(nums) ans = [1] * n # 前缀最大值 for i in range(1, n): ans[i] = max(ans[i-1], nums[i-1]) # 后缀最大值 right_max = float('-inf') for i in range(n-1, -1, -1): # 注意:这里需要前缀最大值保存在ans里 if i == n-1: ans[i] = ans[i] # 左前缀 else: ans[i] = max(ans[i], right_max) right_max = max(right_max, nums[i]) return ans

这段代码能帮你加深对“前缀/后缀信息合并”的理解。这么一改,你就不只是背题,而是掌握了这类题的通法。

6.3 这类题型的通用解题框架

我把“除自身以外”这一类题的通用步骤总结为:

  1. 明确每个位置需要什么信息,拆成“左边信息”和“右边信息”。
  2. 用一趟正向遍历计算左边信息;
  3. 用一趟反向遍历计算右边信息;
  4. 合并两部分信息,得到最终答案。

这个框架不仅适用于乘积,还适用于最大值、最小值、累计和、个数统计等任意“可合并的运算”。遇到新题时,你只要套这个框架,基本就能快速找到切入点。这也是“238. 除了自身以外数组的乘积”刷一遍,远不止会一道题的原因。

我个人刷这道题时最大的体会是:前后缀分解这套思路,越早掌握越受益。很多“看似要双重循环”的题,用分隔点拆成“左边+右边”两个单趟扫描就能O(n)解决。后面刷到接雨水、每日温度这类更复杂的题,其实都隐约能看到前缀/后缀信息的影子。把这题练熟,后面的路会顺很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 10:57:31

L曲线选参可视化:MATLAB regu工具箱解决病态问题调参

简介:这份MATLAB正则化工具箱围绕L曲线方法实现正则化参数的自适应选择,面向进行回归、反问题求解和统计建模的开发者。压缩包共68个文件,包含67个.m函数脚本和1个说明文档,整体仅76KB,轻量高效。目前已有920人学习下载…

作者头像 李华
网站建设 2026/10/22 10:56:32

HCIA/HCIP综合实验:从VLAN到NAT的完整网络搭建指南

从零开始搭一个HCIA/HCIP综合实验,很多人第一反应是“单个知识点我都会,凑到一起就崩”。这话我听过无数遍。单独练VLAN没问题,单独练OSPF也没问题,但当你面前同时摆着Trunk、STP、DHCP、PPP认证、缺省路由、NAT、ACL,…

作者头像 李华
网站建设 2026/10/22 10:54:55

C++模拟操作系统进程调度:FCFS与SJF算法实现与避坑指南

简介:面向操作系统课程设计与期末大作业的C课程设计资源,完整覆盖进程调度FCFS先来先服务与SJF短作业优先两种经典算法的实现。资源包含cpp源码文件、说明文档与可直接运行的exe程序,共3个文件,压缩包仅36KB,轻量便携&…

作者头像 李华
网站建设 2026/10/22 10:54:27

前端安全实战:CSP配置、XSS防护与HTTPS改造完整指南

黑客攻防永远在被动的局面里循环,很多团队在安全上的心态是"先上线,出事了再补",我见过太多项目因为一个存储型XSS漏洞被刷走用户凭证,也见过改造全站HTTPS后噩梦般的混合内容报错。技术债迟早要还,前端安全…

作者头像 李华
网站建设 2026/10/22 10:53:58

应用层CC攻击的隐形威胁:从排查到分层防御的实战指南

先讲一件我亲身经历的事。凌晨两点多,值班手机震个不停,告警只有一句话:Web服务平均响应时间从80毫秒直接飙到15秒以上。我爬起来打开监控大屏,第一眼去看带宽曲线——平坦得像一条直线,流量型DDoS的特征完全没有。再切…

作者头像 李华
网站建设 2026/10/22 10:53:57

多智能体协作系统架构设计:角色分工与任务调度实战

1. 从单兵作战到团队协同:为什么我们需要重新思考智能体架构 做过智能体开发的人都有一个共同感受:单个智能体再强,遇到复杂任务时也容易“翻车”。你让它写代码,它写得不错;你让它做需求分析,它也能凑合&a…

作者头像 李华