ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LeetCode 238题详解:除自身以外数组的乘积,前缀思想与O(1)优化

LeetCode 238题详解:除自身以外数组的乘积,前缀思想与O(1)优化 LeetCode第238题“除自身以外数组的乘积”是我印象很深的一道数组题它常年挂在LeetCode热门100题里也是我面试时反复遇到的原题。题面特别简单给你一个整数数组nums返回一个数组answer其中answer[i]等于nums中除nums[i]之外其余所有元素的乘积。乍一看像在考乘法运算实际上它考的是你能不能跳出“算出总乘积再除以自己”的惯性思维。更关键的是题目明确要求不能用除法额外空间还要做到O(1)。这篇文章就把这道题的三种解法、边界条件、面试追问和延伸出的前缀思想一次性讲透不管你是刚开始刷题的新手还是准备冲刺热门100题的老手都能从这里拿走一套能直接复用的思路。1. 题目到底在问什么先别急着写代码1.1 读懂题面与两个隐藏要求先看一个标准样例。假设输入是[1, 2, 3, 4]输出应该是[24, 12, 8, 6]。我来手算一下answer[0]是除nums[0]1以外其他三个数的乘积也就是2*3*424answer[1]是除nums[1]2以外的乘积也就是1*3*412answer[2]是1*2*48answer[3]是1*2*36。大多数第一次看到这题的人脑子里冒出来的都是先把整个数组的乘积total算出来然后answer[i] total / nums[i]。这思路本身没错但题目有两个坑第一题目明确禁止使用除法。这个限制不是故意刁难你而是因为除法的解法有个致命问题如果数组里有0那么除数为0直接崩溃。就算有办法用判断绕过0面试官想考察的也根本不是除法API而是你能不能想到“左右两侧乘积”这种拆分思想。第二进阶要求是额外空间复杂度O(1)。注意这里的O(1)指的是不计算输出数组本身占用的空间这意味着连一个额外的前缀数组都不能开。很多人在这一步卡住因为“用两个数组分别记录左边乘积和右边乘积”的解法很容易想到但进一步压缩到O(1)就需要一点技巧了。1.2 为什么这道题这么热门我在准备面试的时候专门统计过这套题出现在算法面试里的概率非常高。原因不是它难而是它考察的角度特别好数组的遍历方向、边界下标处理、状态压缩思想全都不动声色地藏在一道看似简单的题里。先说“遍历方向”。前缀后缀解法需要你先从左往右算一遍再从右往左算一遍这种双向遍历的思路在很多经典题里都会复用。比如接雨水每个位置能接多少水取决于它左边最高的柱子和右边最高的柱子本质上就是左右两边各自算一遍再比如买卖股票的最佳时机你需要在一次遍历里维护“到当前为止的最小买入价”和“当前卖出的最大收益”这也是一种前缀信息的滚动维护。再说“状态压缩”。238题的O(1)空间解法是利用输出数组自己当作临时存储再配一个变量滚动更新右侧乘积。这种“用一个变量代替整个数组”的技巧在动态规划的状态压缩里特别常见很多二维DP优化成一维DP靠的就是这个思路。所以这道题才被归入热门100题它是一道典型的“入门简单、越挖越深”的代表作。这道题适合谁去看我觉得主要有三类准备算法面试的人需要把高频题和对应思想练到形成肌肉记忆刚刷完基础数据结构、想系统练前缀/后缀思想的人以及在日常开发里写数组操作比较多想提升代码敏感度的工程师。接下来我就按暴力到最优解的演进路线把这套解法彻底拆开。2. 三种解法从暴力到O(1)空间的演进2.1 暴力法先写一个能跑通的版本做算法题有个习惯我特别推荐拿到题目先不要追求最优解把最简单、最容易证明正确性的思路写出来然后再逐步优化。这道题的暴力解法就是嵌套循环对于每个位置i遍历数组中所有位置j把j ! i的元素全部乘起来。vectorint productExceptSelf(vectorint nums) { int n nums.size(); vectorint ans(n, 1); for (int i 0; i n; i) { int prod 1; for (int j 0; j n; j) { if (j ! i) { prod * nums[j]; } } ans[i] prod; } return ans; }这个写法有两个细节要注意。第一ans的初始化一定要是vectorint ans(n, 1)把每个位置设成1。如果你写成vectorint ans(n)C默认会初始化为0后面ans[i] prod直接覆盖倒还好但如果有人写成ans[i] * prod就会永远得0。第二内层循环里必须跳过自己也就是if (j ! i)这一步漏掉的话结果恒为0因为每个位置都会把自己乘进去一次。复杂度也好算外层循环跑n次内层循环跑n次总时间O(n^2)空间上除了输出数组只用了一个临时变量是O(1)。当n是10^4量级时10^8次乘法在LeetCode上基本会超时所以暴力法只能用来验证思路不能AC。很多新手会问反正都是乘为什么非要用嵌套循环先算总乘积total再用total除以nums[i]不是更快吗这个问题正好引出题目那个“禁止除法”的限制。用除法的话数组里有任何一个0都能让代码崩溃即使你特判0如果有两个0整个数组除了answer[0]和answer[1]以外全是0特判逻辑会变得很丑。而且除法还会涉及到整数整除还是浮点除法的语义问题。面试官设置这个限制本质上是逼你想出更优雅的解法。暴力法给我们的价值是先把问题本身理解透并且能在面试时说清楚“为什么暴力不行”这已经是加分项了。2.2 前缀后缀数组最稳妥的正式解法暴力法的问题在于每个位置都要把所有元素重新乘一遍做了大量重复计算。还是用[1, 2, 3, 4]这个例子观察一下answer的构成answer[0] 2*3*4这恰好是nums[0]左边所有元素的乘积空集记作1乘以右边所有元素的乘积2*3*4。answer[1] 1*3*4左边乘积是1右边乘积是3*4。answer[2] 1*2*4左边乘积是1*2右边乘积是4。answer[3] 1*2*3左边乘积是1*2*3右边乘积是空集记作1。所以answer[i]可以拆成两部分nums[0]到nums[i-1]的乘积乘以nums[i1]到nums[n-1]的乘积。左边乘积和右边乘积互不干扰完全可以预处理出来。这就是前缀后缀数组解法的核心思想。具体做法是准备两个数组left[i]表示nums[0]到nums[i-1]的乘积left[0] 1表示空集乘积right[i]表示nums[i1]到nums[n-1]的乘积right[n-1] 1表示空集乘积。然后answer[i] left[i] * right[i]。vectorint productExceptSelf(vectorint nums) { int n nums.size(); vectorint left(n, 1), right(n, 1), ans(n); // left[i] nums[0] * nums[1] * ... * nums[i-1] for (int i 1; i n; i) { left[i] left[i - 1] * nums[i - 1]; } // right[i] nums[i1] * nums[i2] * ... * nums[n-1] for (int i n - 2; i 0; i--) { right[i] right[i 1] * nums[i 1]; } for (int i 0; i n; i) { ans[i] left[i] * right[i]; } return ans; }这里最容易写错的是下标。left数组从i 1开始遍历每次是left[i] left[i-1] * nums[i-1]。为什么左边乘的是nums[i-1]而不是nums[i]因为left[i]只包含nums[i]之前的元素如果把nums[i]乘进去那answer[i]就会多算一个nums[i]整个结果就错了。同理right数组从n-2开始倒着遍历right[i] right[i1] * nums[i1]乘的也是nums[i]右边的第一个元素。用[1, 2, 3, 4]手算一遍left [1, 1, 2, 6]对应下标0到3分别是空集乘积、nums[0]、nums[0]*nums[1]、nums[0]*nums[1]*nums[2]。right [24, 12, 4, 1]分别对应nums[1]*nums[2]*nums[3]、nums[2]*nums[3]、nums[3]、空集乘积。逐项相乘[1*24, 1*12, 2*4, 6*1] [24, 12, 8, 6]和预期一致。这个解法的时间复杂度是O(n)因为三次线性遍历空间复杂度是O(n)因为多开了两个数组。它在面试里是我比较推荐的主答案逻辑清晰、下标不容易错、解释起来也顺畅。先讲暴力法再讲这个解法面试官会认为你有清晰的优化思路而不是只会背答案。2.3 空间优化版用一个变量滚动计算前后缀数组解法好是好但额外开了两个数组还差一步就能达到题目进阶要求的O(1)空间。优化的思路很巧妙既然left数组算完answer之后就没用了那不如直接把左侧乘积存到answer数组里右侧乘积用一个变量R边遍历边更新。这样连right数组都不用建。先说第一遍遍历。还是用answer数组充当原来的leftvectorint productExceptSelf(vectorint nums) { int n nums.size(); vectorint ans(n, 1); // 第一遍ans[i] 暂时存左侧乘积 for (int i 1; i n; i) { ans[i] ans[i - 1] * nums[i - 1]; } // 第二遍R 从右往左维护右侧乘积 int R 1; for (int i n - 1; i 0; i--) { ans[i] ans[i] * R; R R * nums[i]; } return ans; }第二遍遍历是整个解法的精华我详细解释一下。初始化R 1这个1对应的是nums[n-1]右侧没有任何元素时的空集乘积。当i n-1时ans[n-1]此时存的是nums[0]到nums[n-2]的乘积乘以当前的R 1正好等于“除nums[n-1]以外所有元素的乘积”也就是正确答案。然后执行R R * nums[i]注意这里乘的是nums[i]也就是当前这个元素这样更新完之后R就变成了nums[n-1]单独一个数的乘积它可以当作“从nums[n-2]视角看右侧所有元素的乘积”。接着到i n-2时ans[n-2]存的是nums[0]到nums[n-3]的乘积乘以现在的R nums[n-1]得到nums[0]*...*nums[n-3]*nums[n-1]正好缺了nums[n-2]所以这是正确答案。然后R * nums[n-2]R变成nums[n-1]*nums[n-2]继续供下一个位置使用。这样一路往左走每个位置都能拿到“自己右侧所有元素的乘积”。这里有个细节值得单独说为什么先更新ans[i]再更新R如果你把顺序反过来写for (int i n - 1; i 0; i--) { R R * nums[i]; ans[i] ans[i] * R; // 错误R 已经把 nums[i] 乘进去了 }那么ans[i] ans[i] * R里R已经包含了一个nums[i]结果就多乘了一遍自己整个数组全错。所以更新R的操作必须放在ans[i]赋值之后除非你换一个循环起点和写法用i n-2开始那就要先更新R再更新ans。这两种写法都能对但要在心里明确R的语义是“当前下标右侧所有元素的乘积”这样就不会乱。空间优化版的时间复杂度依然是O(n)额外空间变成O(1)因为只多了一个变量R输出数组不算额外空间。如果你在面试时能一口气从暴力讲到这个版本并且把R的更新顺序解释清楚这题的考察点基本就全拿下了。为了对比我把三种解法整理一下解法时间复杂度空间复杂度适用场景暴力嵌套循环O(n^2)O(1)小规模数据、验证思路前缀后缀数组O(n)O(n)面试首选思路直观滚动变量优化O(n)O(1)达到进阶要求推荐终版我在实际写题的时候一般会直接先默写优化版但面试讲解时一定按“暴力 → 前缀后缀 → 滚动变量”的顺序讲这样能充分展示思考过程。尤其是最后一步“用输出数组存左侧乘积”这种状态压缩的思路在面试官眼里很加分。3. 延伸知识点一道题串起一片知识网3.1 语言细节C语言指针数组、动态分配与数组越界很多用C语言刷题的人看到数组题第一反应是“要不要用指针数组”“是不是需要动态分配一个二维数组”。这里我明确一下LeetCode 238在C语言里只需要一个一维动态数组连二维都用不上更不用指针数组。热词里有“指针数组存放字符串”“c 多维数组 指针”这类搜索确实容易让初学者误解以为数组操作离不开指针其实这道题的核心是逻辑层面的前后缀拆分跟指针关系不大。但C语言确实有一个绕不开的问题动态内存管理。C不支持vector你需要自己malloc一个数组用完再free。一个常见错误是忘记初始化malloc出来的内存里是随机值如果不手动把ans[0]设成1第一遍遍历就会基于一个垃圾值计算。正确的C写法大概是int* productExceptSelf(int* nums, int numsSize, int* returnSize) { int* ans (int*)malloc(numsSize * sizeof(int)); *returnSize numsSize; ans[0] 1; for (int i 1; i numsSize; i) { ans[i] ans[i - 1] * nums[i - 1]; } int R 1; for (int i numsSize - 1; i 0; i--) { ans[i] ans[i] * R; R R * nums[i]; } return ans; }这里还要强调一下数组越界。C/C对数组越界的检查几乎为零你访问ans[n]或者nums[-1]可能不会立即崩溃而是读到一个随机内存值程序继续跑最后结果莫名其妙。我在第二遍循环里就栽过一次把R R * nums[i]写成了R R * nums[i 1]当i numsSize - 1时访问了nums[numsSize]越界但没报错返回的结果却全错了。排查了很久才发现是越界读到了脏数据。所以在写这种带边界遍历的循环时我习惯先在草稿纸上把i的起点、终点、循环次数手推一遍再落到代码里。另外热词里还有“c语言数组变量的类型转换”这个点。在本题中nums[i]和ans[i]都是int但中间乘法ans[i - 1] * nums[i - 1]可能瞬间溢出比如100000 * 100000 10^10这超出了32位int的范围。题目说了结果保证在32位整数范围内但中间过程并没有保证。稳妥的做法是在乘法前先把一个因子转成long long最终再截断回int。在C/C里(long long)ans[i - 1] * nums[i - 1]这种写法能避免大部分溢出问题C里也可以直接把ans定义成vectorlong long最后再转回去。二维数组、指针数组这些概念在本题里确实用不到但如果你在数据结构408备考数组作为最基本的线性结构它的下标计算、存储方式和指针指向关系是需要打牢的这些基础不过关后面图、树、哈希表都会很吃力。238这道题虽然简单但它能帮你巩固“数组下标即路径”这种直觉这种直觉对后续刷图论和树的题目特别有帮助。3.2 从乘法到加法前缀思想才是核心这道题全名叫“除自身以外数组的乘积”但它真正的考点是前缀和/后缀和思想的变体。如果你把题目改一下“返回answer[i]表示nums中除nums[i]以外所有元素的和”解法完全一样第一遍从左往右累加前缀和第二遍从右往左用变量维护后缀和。你看乘法和加法只是运算符变了思路框架一脉相承。这个概念在求区间和时体现得更明显。假设你有一个长度很大的数组需要反复查询nums[l]到nums[r]的区间和如果每次都遍历累加一次查询就是O(n)m次查询就是O(m*n)。但如果先预处理一个前缀和数组pre[i] nums[0] ... nums[i-1]那么sum(l, r) pre[r1] - pre[l]单次查询变成O(1)。这就是“空间换时间”的典型例子也是很多区间问题的根。更近一步如果数组还会被频繁单点修改前缀和每次都要重新构建代价太高这时候就需要树状数组Fenwick Tree出场。树状数组的核心是一个长度和原数组相同的辅助数组它通过lowbit把一个点的影响分散到若干个节点里让单点修改和前缀和查询都变成O(log n)。比如热词里有人搜“树状数组维护长度 n16 的序列查询前缀和 sum(11) 与单点修改 add(3, x)”这里的sum(11)会把11拆成11 lowbit(11)这样的步长去累加add(3, x)也会从3开始逐步向上更新。虽然写起来比普通前缀和复杂但思想本质还是“预先维护一些区间的聚合信息查询时再组合”。我为什么要把这些串在一起因为刷题最怕的就是“只见树木不见森林”。如果你只背了238的解法遇到“二叉树中每个节点返回除自身以外子树和”这类题可能又懵了。但如果你理解“除自身以外 左侧一部分 右侧一部分”这个抽象模型就会发现它无处不在接雨水是左边最高柱加右边最高柱二叉树递归里左子树信息加右子树信息区间统计里前缀加后缀。把一道题升华成一个模型比多做十道题都有用。3.3 多语言写这道题的差异Python、Java、C#、脚本语言同一道题用不同语言写踩的坑完全不一样。先说Python。Python有个很吸引人的写法用列表切片和math.prod一行搞定from math import prod def product_except_self(nums): n len(nums) return [prod(nums[:i] nums[i1:]) for i in range(n)]看起来很酷但千万别在面试里这么写。问题在于nums[:i]和nums[i1:]每次都会创建新列表一趟下来时间复杂度实际上是O(n^2)而且两个切片加起来的大小逼近整个数组空间也很浪费。面试官要是追问复杂度这个解法直接暴露你没想清楚。最稳妥的Python版本还是老老实实走两遍循环写法跟C基本一样只是不用管内存释放def product_except_self(nums): n len(nums) ans [1] * n for i in range(1, n): ans[i] ans[i - 1] * nums[i - 1] R 1 for i in range(n - 1, -1, -1): ans[i] * R R * nums[i] return ansPython的ans [1] * n是常见的初始化方式相当于Java里的Arrays.fill这个细节很重要。Java里如果写int[] ans new int[n];所有元素默认是0直接乘结果全是0必须先用循环或者Arrays.fill(ans, 1)初始化。这道题在Java里还有一个坑int[]的乘法中间结果溢出不会报错只会默默变成负数所以如果严格按题目说的“结果在32位整数范围内”通常没问题但如果你在本地测试时用更大的数最好直接用long[]再转回int[]。C#里有人搜“c# 不同的class可以组成数组吗”这其实是数组基础概念C#的数组可以装任意引用类型的元素但每个元素本身要单独new。放到本题里int[]是值类型数组直接用new int[n]即可不需要new每个元素。只要分清楚值类型数组和引用类型数组的区别在这道题上就不会踩坑。至于PHP、JavaScript、VBA这类脚本语言它们的数组本质上是哈希表或者更高级的动态结构比如PHP的“数组”其实是有序映射JS的filter、map很方便但不适合处理大数组的算法场景。业务代码里“php接口数组对象”“es6提取数组对象一部分”“数组去重”这些操作很常见但刷算法题时最好还是用原生循环不要用花哨API否则复杂度和内存开销都不可控。现在很多公司的算法面试允许用Python或JavaScript但你在写高级API之前一定要能说出它的时间复杂度和是否创建了新数组不然很容易在复杂度分析那一步翻车。4. 实战中的常见问题与排查技巧4.1 边界条件与Bug清单我在给同事review这道题的代码时见过太多五花八门的错误归纳下来有以下几类高频Bug场景典型错误写法正确做法数组长度为1直接返回空数组或者下标越界answer[0]按定义应为1空集乘积需要单独处理初始化vectorint ans(n);后直接ans[i] * ...必须初始化为1如vectorint ans(n, 1)下标错位第一遍写成ans[i] ans[i-1] * nums[i]应乘nums[i-1]只包含当前元素之前的乘积第二遍顺序先R * nums[i]再ans[i] * R先更新ans[i]再更新R保证R不含当前元素乘法溢出用int直接乘大数中间结果用long long或long暂存空数组n 0时直接访问ans[0]题目一般保证n 2但健壮代码应先判空“下标错位”这个Bug我特别想多说一句。很多人看代码时会觉得ans[i] ans[i-1] * nums[i-1]和ans[i] ans[i-1] * nums[i]差不了一两个下标但运行结果天差地远。前者算出来ans[2] nums[0]*nums[1]这是正确的左侧乘积后者算出来ans[2] nums[0]*nums[1]*nums[2]把nums[2]自己都乘进去了。这类错误光靠肉眼很难发现尤其当数组元素有1的时候多乘一个1不影响结果但改成大数就全错了。我的排查技巧是用[1, 2, 3, 4]这样不含1的连续数组手推一遍只要有一位对不上立刻能定位是哪边的下标写错。数组里有0的情况也要想清楚。假设输入是[0, 1, 2, 3]按照本题的解法answer[0] 1*2*3 6answer[1] 0*2*3 0answer[2] 0answer[3] 0。完全没有问题因为我们的乘法是left[i] * right[i]0只会在left或right的对应位置出现一次不会引发除零异常。这正好展现了“禁止除法”这一限制的妙处它让解法天然对0安全。但如果有人用除法解nums[0] 0时直接崩溃需要特判0的个数逻辑立刻复杂起来。所以在面试里如果被追问“数组里有0怎么办”你可以理直气壮地说这道题本来就禁止除法我的解法不需要任何特殊处理。还有一个实际中容易忽略的点空集乘积约定为1。left[0] 1和right[n-1] 1不是拍脑袋定的而是数学上“空乘积”的惯例就像“空数组的和等于0”一样。如果面试官问为什么边界设成1不要答“为了代码能跑”要从数学约定说起。这一点比很多人想象的重要因为它体现了你对边界条件的理解深度也能跟“前缀和pre[0]0”这类约定互相印证。4.2 面试追问一次遍历、大数溢出与变体题如果前两种解法你都写出来了面试官可能会追加几个问题。最常听到的是“能不能只遍历一次”严格来说每个位置要结合它左侧的乘积和右侧的乘积这两部分信息分别需要从左往右和从右往左各扫一遍所以“完全一次遍历”是不可能的。但你可以用双指针同时从数组两端往中间走一个指针维护从左到当前位置的左侧乘积另一个维护从右到当前位置的右侧乘积这样“一轮循环”里左右两半同时推进视觉效果上像只遍历了一次但实际还是每个位置都处理了两侧信息。这种写法常数上略优一点点但代码可读性差不少面试时我一般作为加分项提一下不推荐作为首选答案。第二个常问的是“如果结果大到超出int范围怎么办”题目原话一般保证“所有元素乘积都在32位整数范围内”但中间计算不一定。稳妥的做法是用long long或者高精度思路。如果你用的是Python整数是任意精度的完全不用操心溢出但Java和C/C必须注意。我在实际项目里也踩过类似坑不是算法题而是数组合并时中间结果溢出导致算出来的金额是负数排查了半天。所以凡是乘加累计算我都有个习惯先估算量级再决定用int还是long。面试官还可能让你现场做变体我遇到过几个把题目改成“除自身以外数组的和”把乘法换成加法思路完全不变代码几乎照抄。改成“二维矩阵里每个位置返回除自身以外所有元素乘积”需要用二维前缀和/后缀和组合四个角各算一遍复杂度变成O(m*n)。改成“查询区间积但允许单点修改”这就要上线段树或者树状数组了因为区间积不满足减法性质不能像区间和那样用pre[r] - pre[l]直接求。如果你能立刻说出“区间和可以用前缀和做差区间积不行得用线段树或树状数组”面试官会对你另眼相看。这些变体题在LeetCode热门100题里也经常出现比如“和为K的子数组”需要前缀和加哈希表“区间和的个数”需要树状数组或归并排序“接雨水”是左右最大值的经典应用。你如果能把238吃透这些题的学起来会顺畅很多。LeetCode周赛里也时不时出现前缀思想的变形比如第430场周赛里就有一道基于前缀和优化的题目核心思路一脉相承。这也是为什么我特别强调“刷这一题不是目的理解前缀拆分才是目的”。4.3 刷题心态与方法论这道题教会我的事最后聊点方法论。很多人刷题有个误区看到题面直接翻题解把最优解抄一遍AC了就Next。但这样做导致的结局是过两天再写这题还是卡住。我的建议是刻意训练“由浅入深”的过程。第一次做这道题时先给自己15分钟尽可能写出一个能跑通的版本哪怕是暴力都行。写不出来的话看题解里“前缀后缀数组”那一部分不要往下翻空间优化合上代码自己补全第二个版本。通过AC之后再想一个挑战问题能不能不用额外数组想不出来或者写错了再去看优化版并手动跑一遍[1, 2, 3, 4]。这只是第一遍。第二遍是隔一天左右合上所有代码只凭记忆还原空间优化版。还原不了没关系重点不是背代码而是用文字在纸上写下“先从左往右算左侧乘积再从右往左用一个变量维护右侧乘积”这个思路然后照着文字写代码。如果连思路也描述不出来说明你还没有理解得足够深重新看题解但这次要重点看那张手推图。第三遍是隔一周重刷这道题这次要求自己15分钟内写完并一次AC然后不看代码向旁边人把这三种解法完整讲一遍。能顺畅讲出来才算真的拿下了。这套流程听起来费时间但刷热门100题其实不需要追求数量而要把每道题的“模型”吃透。我刷了这么多题之后最深的一个体会是算法面试里最重要的不是解出多少题而是能不能把一个清晰、正确的思路用自然语言讲出来再把思路翻译成边界处理干净的代码。238这道题正好能同时练到这两件事所以我建议所有准备面试的人都把它当作“讲题训练”的第一题。5. 初始版本手写实现的核心细节上一节我把三种解法的推理逻辑完整讲了一遍这里再补一个我自己在实现时特别关注的版本不依赖标准库的暴力但可验证的中间版本以及对应的调试思路。虽然最终提交肯定用优化版但从“实现可复现”的角度手推验证是必不可少的环节。先用一句大白话总结手推验证原则不要只盯着输出结果对不对要盯着每一轮循环结束后变量值是不是你预期的那样。我调试这道题时会在第二遍循环里打印i、ans[i]、R三个值比如数组[1, 2, 3, 4]的理想过程应该是i 3, ans[3] 6, R 1 i 2, ans[2] 2, R 4 i 1, ans[1] 1, R 12 i 0, ans[0] 1, R 24注意看R永远表示“当前下标右侧所有数的乘积”i每减一R就额外乘上一个“新遇到的右侧元素”。如果你把打印结果贴到草稿纸上和预期不一致的那一行就是Bug所在。这个方法不仅适用于这道题几乎所有带滚动变量更新的数组问题都能用。再补一个我自己踩过的坑在C里如果你第一遍遍历用的是for (int i 1; i n; i)第二遍用的是for (int i n - 1; i 0; --i)很多编译器会警告i是有符号整数和n无符号整数比较。如果你把n定义成size_t那么i 0这个判断会变成恒真因为size_t是无符号类型减到0再减会回绕成巨大的正数循环直接死循环。这个问题在LeetCode的C环境里尤其隐蔽因为nums.size()返回的就是size_t。经验之谈涉及倒序遍历时老老实实把i声明成int或者先int n (int)nums.size();转换一下避免无符号数回绕这个魔鬼细节。6. 常被忽略的事一题多解的平衡感前面讲了不少“怎么解”最后我想分享一个可能被忽略的点这道题真正考验的不只是算法还包括你在“可读性”和“极致优化”之间的选择。前缀后缀数组版本空间是O(n)但代码清晰、不容易错滚动变量版本空间是O(1)但你需要解释R的语义和更新顺序。我的个人习惯是面试时先给前缀后缀数组版本然后说“这道题还可以进一步优化把左侧乘积放到输出数组里右侧用一个变量滚动维护做到额外空间O(1)但需要仔细处理更新顺序”。然后等面试官点头再写优化版。这样既展示了思路的演进又不会因为一上来就写空间优化版而让面试官跟不上。说到底这道题“除自身以外数组的乘积”的核心魅力在于它用一个很简单的场景把数组遍历、前缀后缀思想、状态压缩、边界处理全部串起来了。即使你刷过1000道题回过头来再看这一道依然能品出点新东西。我个人在实际操作中的体会是把[1, 2, 3, 4]这个例子手推一遍比看十遍题解都管用。推完之后那个“左边乘积乘以右边乘积”的画面会深深印在脑子里以后遇到任何带“左右两侧组合”的题目你都会第一时间想起今天的这份推导。
返回列表