news 2026/10/22 5:36:18

除自身以外数组的乘积:前后缀分解与O(1)空间优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
除自身以外数组的乘积:前后缀分解与O(1)空间优化

1. 题目理解与核心考点

1.1 题面拆解:这题到底在考什么

“238. 除了自身以外数组的乘积”这道题,我刷了不下三遍,每次面试前翻Hot 100还是会重新瞄一眼。原因很简单:它长得太像一道“暴力题”了,但真正动手写的时候,大多数人都会掉进“除法陷阱”里出不来。

题目原文不长:给你一个整数数组nums,返回一个数组answer,其中answer[i]等于nums中除了nums[i]之外其余各元素的乘积。题目还给了两个硬性约束:不能使用除法,时间复杂度要求 O(n)。

先别急着往下看,我建议你拿起笔,随便写一个数组试一下。比如nums = [1,2,3,4],用手算的结果是[24,12,8,6]。24 是 2×3×4,12 是 1×3×4,8 是 1×2×4,6 是 1×2×3。手算很容易,可一旦约束你“不能用除法”,很多人的第一反应就卡壳了。

这题背后的核心考点,其实是前后缀分解(也常叫“左右乘积法”)。你不需要算全体乘积再除掉某个数,而是把“除了自身以外”拆成“左边的乘积 × 右边的乘积”。这个思维模型不仅这一题用得上,很多数组类、动态规划类的题目都能复用,所以我一直觉得它是Hot 100里性价比极高的一道题。

1.2 为什么“不能用除法”是这题的灵魂

有的读者可能会问:就算不能用除法,我先算出所有数的乘积total,然后answer[i] = total / nums[i]不就行了?如果题目没禁用除法,这确实是最快的方法,代码两行就写完。但面试官设置这个约束,背后有很实际的考量。

第一个理由是0 的问题。假设数组是[0, 1, 2, 3],总乘积是 0,如果直接除以nums[i],那么answer[0]就是0 / 0,根本没法算。你当然可以专门处理多个0的场景,但代码会一下子变得很啰嗦,而且容易漏边界。更别说数组里有两个0的情况 —— 只有0所在的位置结果非零,其余位置全是0,用除法实现你得单独开一遍逻辑判断。

第二个理由是溢出风险。虽然题目说数组元素在32位整数范围内,但“乘积”可不一定还在这个范围内。如果全部元素乘起来再逐一除,中间这个total很可能先溢出了。前后缀法只做局部乘法,每一步的乘积范围天然受约束,安全得多。

第三个理由更贴近面试本质:题目想筛选的是建模能力,不是计算能力。能想到“每个位置的结果 = 左边乘积 × 右边乘积”,说明你对数组扫描、状态累积这类基础技巧是理解的;而只会写除法的人,往往只是背过某个模板,换个约束就露馅。所以,把这题的“为什么不让用除法”想透,比多刷十道简单题都有价值。

2. 解法一:左右乘积数组(最直观的标准答案)

2.1 思路建模:把“除了自身”拆成两半

“除了自身以外”这个表述,乍一听是一个整体,但你可以换个角度看:对任意位置i来说,自身左边有一串数,右边有一串数,结果就是这两串数的乘积。左边那一串的乘积,可以提前算好存起来;右边那一串同理。最后每个位置只需要做一次乘法,把左和右拼起来。

具体来说,维护两个数组:

  • left[i]:nums[0]到nums[i-1]的乘积,也就是位置i左侧所有元素的乘积。注意left[0]没有左侧元素,按数学约定空乘积为 1。
  • right[i]:nums[i+1]到nums[n-1]的乘积,也就是位置i右侧所有元素的乘积。同理right[n-1] = 1。

那么答案就是answer[i] = left[i] * right[i]。

这个思路有一点像把一列队伍从中间某个位置分成左右两段,左边的人数和右边的互不相干,先把两段的人数分别数清楚,再相乘。它不依赖任何除法,也天然规避了0的困扰,因为乘法中任何一个0都只是让结果归零,不会引发运行时错误。

2.2 代码实现:分步扫描,边界一目了然

以 Python 为例,最清晰的写法是分两次遍历,一次从左往右填left,一次从右往左填right,最后合成答案:

def productExceptSelf(nums): n = len(nums) left = [1] * n right = [1] * n answer = [1] * n # 从左往右,left[i] 存 nums[0..i-1] 的乘积 for i in range(1, n): left[i] = left[i - 1] * nums[i - 1] # 从右往左,right[i] 存 nums[i+1..n-1] 的乘积 for i in range(n - 2, -1, -1): right[i] = right[i + 1] * nums[i + 1] # 合并 for i in range(n): answer[i] = left[i] * right[i] return answer

这里有几个容易写错的地方,我特别提醒一下:

  • 遍历起点:填left时,循环从1开始,因为left[0]已经是初始值 1,不需要再乘任何东西。填right时,循环从n-2开始,因为right[n-1]也是初始值 1。
  • 下标对应:left[i] = left[i-1] * nums[i-1],不是nums[i]。如果你写成nums[i],那就把当前位置自己也乘进去了,结果会错得离谱。
  • 空数组:如果n = 0,三个数组都是空列表,返回空即可,代码自然兼容。如果面试官问“单元素数组[5]”,答案应该是[1],因为“除了自己以外没有任何元素”,空乘积约定为 1,这种约定在数学和工程里都很常见。

你可以把以上逻辑翻译成 C++ 或 Java,结构一模一样:

vector<int> productExceptSelf(vector<int>& nums) { int n = nums.size(); vector<int> left(n, 1), right(n, 1), answer(n, 1); for (int i = 1; i < n; ++i) { left[i] = left[i - 1] * nums[i - 1]; } for (int i = n - 2; i >= 0; --i) { right[i] = right[i + 1] * nums[i + 1]; } for (int i = 0; i < n; ++i) { answer[i] = left[i] * right[i]; } return answer; }

这个版本的时间和空间复杂度都是 O(n),代码逻辑非常直白,适合作为面试时的“保底答案”。如果你只想让考官看到你可复现、稳扎稳打的功底,完全可以就停在这里。但如果想多拿一点印象分,往下看优化版。

3. 解法二:空间优化到 O(1) —— 面试加分的关键一步

3.1 优化动机:left 和 right 能不能只留一个

上面的解法和“暴力算总乘积再除法”相比,已经算是标准答案了,但考官通常还会追问一句:“能不能把额外空间降到 O(1)?”注意这里的 O(1) 指的是除了返回数组本身之外不用额外数组。换句话说,answer是必须返回的,不能算在额外空间里;但left和right这两个辅助数组是可以省掉的。

怎么省?我再观察一下计算过程:answer[i] = left[i] * right[i]。如果我先用answer把left的结果存下来,然后从右往左扫一遍,同时用一个变量right动态累积右侧乘积,那么每个位置都可以当场算出答案。这样唯一保留下来的“数组”只有answer本身,额外空间就是常数级。

这其实是一种很常见的优化技巧:第一个循环把中间结果暂存在输出数组里,第二个循环用滚动变量覆盖。理解了这个套路,以后再遇到“原地修改数组原地求值”的题,你会敏锐很多。

3.2 代码实现:两遍遍历,一个变量搞定

直接看代码:

def productExceptSelf(nums): n = len(nums) answer = [1] * n # 第一遍:从左往右,answer[i] 暂时存左侧乘积 for i in range(1, n): answer[i] = answer[i - 1] * nums[i - 1] # 第二遍:从右往左,用一个变量累积右侧乘积 right = 1 for i in range(n - 1, -1, -1): answer[i] *= right right *= nums[i] return answer

我把这段代码的每一步手动推演一遍,方便你看清它为什么是对的。假设nums = [1, 2, 3, 4]:

  • 第一遍结束后,answer = [1, 1, 2, 6]。这四个数分别代表每个位置的左侧乘积:位置0左侧没有元素,是1;位置1左侧是1;位置2左侧是1×2=2;位置3左侧是1×2×3=6。
  • 第二遍开始时right = 1。处理i = 3,answer[3] = 6 * 1 = 6,然后right = 1 * 4 = 4;处理i = 2,answer[2] = 2 * 4 = 8,然后right = 4 * 3 = 12;处理i = 1,answer[1] = 1 * 12 = 12,然后right = 12 * 2 = 24;处理i = 0,answer[0] = 1 * 24 = 24。

最后得到[24, 12, 8, 6],完全正确。整个过程只有right这一个额外变量在不断滚动,空间占用不随数组规模变化。

C++ 版本同样简洁:

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 right = 1; for (int i = n - 1; i >= 0; --i) { answer[i] *= right; right *= nums[i]; } return answer; }

很多第一次看这个解法的读者会困惑:为什么要先乘right再更新right?顺序能不能反过来?这里的关键是:处理answer[i]时,right必须表示“i 右侧所有元素的乘积”,也就是nums[i+1..n-1]的乘积。如果先更新再乘,right就会把nums[i]自己也乘进去,结果就错了。这个细节我面试时亲耳听过有人答反,当场就能看出对方对状态变量的理解是否扎实。

3.3 为什么“输出数组不算额外空间”是合理约定

有些同学会举手提问:answer明明也是个 O(n) 的数组,凭什么说空间复杂度是 O(1)?这其实是算法题里一条约定俗成的规则:函数必须返回一个结果数组,这个数组占的空间不参与额外空间统计。就像你写排序算法,如果允许原地排序,交换变量占了 O(1),但数组本身就是输入参数,自然不能算额外开销。

在实际工程里,这种约定也说得通:内存申请者本来就要为返回值分配空间,能否省掉的是“临时辅助”这块开销。面试时如果你想表达得更严谨,可以主动说一句“不算输出数组的话,额外空间是 O(1)”,大多数考官立刻就会给你加分。

4. 复杂度对比与面试答题节奏

4.1 一表看懂三种思路的取舍

面试过程中,很多时候你不需要立刻写出最优解,但你需要在到达最优解之前,清楚地说明每一层优化的理由。强行背一个答案并不难,难的是让面试官觉得你“想明白了”。我整理了一张表,方便对照:

解法时间复杂度额外空间是否满足“不用除法”适用场景推荐指数
先求总乘积再逐个相除O(n)O(1)否(违反约束)仅当题目允许除法且无0时不推荐
左右乘积数组O(n)O(n)是思路最直观,适合初学时理解推荐
输出数组复用 + 滚动变量O(n)O(1)是面试首选,空间敏感场景强烈推荐

横向对比后你会发现,除法方案因为踩了题意红线,几乎可以无视;左右数组方案是教学意义上的“标准答案”;而复用输出数组的方案,才是实战面试中最值得写的版本。它没有牺牲可读性,又把空间降到了最优,是性价比最高的一条路。

4.2 面试话术:从读题到写出最优解的完整示范

我当年在模拟面试里练过这道题很多次,后来也帮别人mock过多次,总结出一套比较好用的表达节奏,分享给你:

第一步,先复述题意并确认约束。我会说:“输入是整数数组,要求返回每个位置除自身以外元素的乘积,不能用除法,O(n) 时间内。”这一步能让面试官确认你没跑偏。

第二步,说朴素想法,但主动否定:“最直接的做法是求出总乘积再除以每个元素,但一是题目禁止除法,二是数组里有0时会出问题,所以得另想办法。”

第三步,切换到前后缀思路:“每个位置的结果可以看作‘左侧乘积 × 右侧乘积’。我先把每个位置的左侧乘积算出来,再从右往左把右侧乘积乘上去。”这里可以顺手画个数组下标示意图,面试时边画边说,比自己闷头写代码效果好得多。

第四步,写代码,同时轻声讲关键点。写到第二遍循环时,顺口提一下right变量“先乘后更新”的原因。写完之后,立刻用[1,2,3,4]手动验证一遍,再补测[0,1,2]或单元素数组。

第五步,主动总结复杂度:“时间复杂度 O(n),三次循环或两次循环都是线性;额外空间 O(1),因为复用了输出数组。”

这一整套流程大约3到5分钟,比直接贴出最优解更能体现你的思考过程。面试官要的不是一个能背答案的人,而是一个遇到陌生问题也能拆解出思路的人。

4.3 现场推演的边界用例

手算demo时,我建议至少准备三个用例:

  1. 常规场景:nums = [1,2,3,4],预期[24,12,8,6]。这是你写代码后第一遍自测的用例。
  2. 含零场景:nums = [0,1,2],预期[2,0,0]。用前后缀法完全不需要特判,直接套公式就得到正确答案,顺便展示“不依赖除法”的优势。
  3. 单元素场景:nums = [5],预期[1]。很多人会在这里犯迷糊,其实空乘积约定为1,answer[0]初始化就是 1,两遍循环都不进入或只进入一次,结果自然正确。

除了这三个用例,还可以看一眼nums = [1, -1],预期[-1, 1],验证负数乘积没问题。边界用例不是走过场,它能帮你既说服面试官,也防止自己写着写着下标越界。

5. 常见问题与边界条件速查

5.1 高频出错点和排查思路

虽然这道题代码很短,但我见过太多人栽在一些不起眼的小地方。下面这几个问题,是我在平时帮别人review代码时最常遇见的:

第一个是循环边界写错。填left时用了range(n),结果把nums[i]自己也乘了进去;或者从右往左时写成range(n, -1, -1),越界访问answer[n]。这类问题靠语法检查很难发现,因为数组初始化为1时,多乘一个1不会让结果立刻报错,只有数据不凑巧时才露馅。所以我每次写这种前后缀循环,都会先在纸上确认一遍:第一个循环从1开始,第二个循环从n-2开始,或者用range(n-1, -1, -1)配合变量滚动。

第二个是对“0”的处理时机。如果面试官追问“数组里有0怎么办”,有些同学会下意识地想在代码里加if nums[i] == 0之类的分支。其实前后缀法根本不需要这个分支,0 会自动让对应位置的乘积归零。你只需要自信地解释一遍原理,而不是慌慌张张地补补丁。

第三个是整数溢出。题目通常在 32 位整数范围内给出答案,但如果你在本地测试时用了更大的数,可能会溢出。LeetCode 的测试用例一般不会让你溢出,但在 C++ 里写乘法时养成用long long的习惯,或者心里清楚哪些位置可能结果特别大,总归不是坏事。

5.2 一题多语言实现的细节差异

这道题在主流语言里写起来大同小异,但有几个细节需要注意。

Python 版本最简洁,风险在于answer = [1] * n这种初始化是否满足你的需求(这里是创建独立列表,不用担心引用共享问题)。Java 版本要注意int[] answer = new int[n]; Arrays.fill(answer, 1);,因为 Java 默认数组元素是 0,不填 1 会让乘法结果全变 0。C++ 版本和Java类似,vector<int> answer(n, 1)一行搞定,没有默认值陷阱。

还有个跨语言的通病:有人在第二遍循环时用了两个变量,一个right一个left,其实完全没必要。左乘积已经存在answer里了,右乘积用滚动变量即可。少一个变量,代码更简洁,也更容易解释。

5.3 区域误区:面试中千万不要说的“讨巧方案”

最后说一个我提醒过很多学生的点:不要拿“允许使用额外API”去钻空子。有些语言提供现成的累积乘法库函数,或者有人会提议“用对数把乘法变加法,再用指数还原”。这两种思路都不太合适。

为什么?因为题目明确要求不能用除法,本质是在考察你对乘法结构本身的理解。用对数技巧的话,浮点数误差会让你在某些测试用例上直接挂掉,而且面试官一看就知道你在取巧,对你的印象分会大打折扣。老老实实写前后缀优化版,比什么花活都强。

如果真的想多聊一个话题,可以是“如果数组里只有一个0,用除法方案怎么处理”。这个延展能显示出你对边界情况的掌控力,但别把它当作主方案。

6. 复盘与扩展思考

6.1 这道题的“母题意义”:前后缀技巧的迁移

每道 Hot 100 的题目我都习惯做一次“认知提炼”,问自己:这道题除了背答案,真正教会了我什么?对于 238 题,答案是前后缀分解思想。

这个概念你在很多地方都见过:前缀和、后缀和、前缀最大、后缀最小……本质上都是“把对全体的查询,拆成对左右两侧的预处理”。举个例子,你下次遇到“求每个位置左侧比它大的元素个数”这类题,第一反应就可以是“能不能用某种方式先预处理左侧信息,再在线计算”?一旦建立了这种思维,很多题目的第一层朴素解法就不再是暴力遍历了。

再举个例子:有一类“除自身之外的最值”题目,比如“返回每个元素之外的最大值”,思路一模一样,无非是把乘积换成最大值运算。可见这个套路不是死记硬背,而是可以举一反三的。

6.2 如果允许除法但存在多个0,你会怎么处理

作为面试后的延伸讨论,你可以主动向面试官抛出这个问题。思路是这样的:

  • 统计数组里 0 的个数。
  • 如果 0 的个数为 0,直接用总乘积除以每个元素。
  • 如果 0 的个数为 1,只有那个0所在的位置结果是“其余所有非零元素的乘积”,其他位置全是 0。
  • 如果 0 的个数大于等于 2,所有位置结果都是 0。

这个方案虽然满足“能用除法”的假设,但代码分支比前后缀法多不少。把这个对比想明白,你就不难理解:前后缀法的优势不只是绕开除法约束,而是天然规避了因0引发的多种特殊情况。这也是为什么它在工程意义上更稳健。

6.3 一道题牵出的复习路线

如果你刚刷到这道题,我觉得可以顺便把下面这类题目一起过一遍:

  • 前缀和系列:求区间和、求子数组满足某条件的数量。这类题考验的是“预处理查询”,和238的思路最接近。
  • 原地修改数组系列:像“除自身以外数组的乘积”这种“输出数组复用输入数组”的技巧,在字符串轮转、数组压缩等题目里也会出现。
  • 前后缀极值系列:比如每个位置的“左边最大值和右边最小值”,常用于分治算法里的预处理环节。

把这些题目按“思维模型”归类,比按题号顺序刷要高效得多。每次碰到新题,先问自己:它的解法本质是“前缀思想”还是“滑动窗口”,还是“二分查找”?答上来,你的刷题就开始有体系了。

7. 写在最后的实操心得

这道题我刷了三遍,每一遍都有新体会。第一遍只学会了左右数组写法,觉得还挺“绕”;第二遍理解了滚动变量优化,开始惊叹于这种“一个数组顶两个用”的优雅;第三遍是在帮别人做模拟面试时,才真正把“为什么不能用除法”“为什么0不是问题”“为什么输出数组不算空间”这些问题都串了起来。所以我强烈建议你,不要只满足于提交通过,一定要做一次“口头讲解”——找一个朋友,或者在白板上对着空气讲一遍思路。如果能在不看代码的情况下把逻辑流畅地讲出来,说明你是真的理解了。

最后再分享一个小技巧:刷题时准备一个“边界用例清单”,每道题至少三个边界用例。对这道题,我的清单是[1,2,3,4]、[0,1]、[5]。这三个用例加起来刚好覆盖了常规、零值和单元素三种情况。等你在键盘上敲代码前先过一遍这几个输入,大概率就不会出现低级错误。这题可以就此打住,但前后缀的技巧,建议你继续在其他数组题里多用几次,用熟练了,它会成为你算法工具箱里一把非常顺手的家伙。

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

Spring AI Alibaba深度解析:分层架构、核心能力与生产实践

1. 开篇&#xff1a;先说清楚它是什么&#xff0c;以及我为什么关注它Spring AI Alibaba&#xff0c;社区代号"望舒"&#xff0c;是Spring AI Alibaba社区推出的官方AI应用开发框架&#xff0c;定位是在Spring AI之上构建一套更贴近国内生产环境的AI应用底座。我花了…

作者头像 李华
网站建设 2026/10/22 5:36:03

科研仪器共享平台管理系统:预约状态机与并发调度实战解析

简介&#xff1a;这是面向高校与科研机构的仪器共享平台管理系统源码包&#xff0c;适用于计算机、电子信息等专业的课程设计、期末大作业或毕业设计&#xff0c;可用于解决仪器预约、设备管理和共享记录等典型业务场景。资源包共218个文件&#xff0c;核心包括Vue前端页面&…

作者头像 李华
网站建设 2026/10/22 5:35:21

告别Xshell:现代可编程终端的技术演进与迁移实践

1. 为什么“再见 Xshell”不是一句营销口号&#xff0c;而是真实的技术演进节点“再见 Xshell~ 这款开源的终端工具逼格更高”——看到这个标题&#xff0c;很多老运维、开发、系统工程师的第一反应可能是&#xff1a;又一个蹭热度的标题党&#xff1f;Xshell 用了十年&#xf…

作者头像 李华
网站建设 2026/10/22 5:35:03

RAG检索命中但答案错误?三层鸿沟根因分析与优化策略

最近有做知识问答系统的朋友拉着我排查了一个很折磨人的问题&#xff1a;用户问某个型号的配件到底支不支持某项功能&#xff0c;RAG 检索阶段明明返回了标题和关键词都高度相似的文档块&#xff0c;相似度打分也过了阈值&#xff0c;可最后生成的答案还是错的。这种"检索…

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

Kingbase为何不提供实时准确行数?设计逻辑与替代方案

如果你在 Kingbase 上执行过select count(*) from a_table&#xff0c;而且那张表已经有几百万行&#xff0c;大概率会等到心里发毛。接着你可能开始怀疑数据库是不是坏了&#xff0c;甚至想吐槽一句&#xff1a;连个行数都拿不到&#xff0c;算什么数据库。先别急着下结论&…

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

大模型选型评测:用cost-per-success算清真实成本

1. 为什么按调用次数计费会把你带进沟里做模型选型这件事&#xff0c;我踩过最贵的一个坑&#xff0c;不是选错了模型&#xff0c;而是选错了评测口径。早些年团队要接一个文本结构化抽取的任务&#xff0c;候选方案有三四个&#xff0c;价格从每百万 token 几毛钱到几十块钱不…

作者头像 李华