LeetCode 238 除自身以外数组的乘积:前缀积与后缀积详解

前言

这道题最容易想到的办法,大概都是先把数组中所有元素乘起来,再用总乘积除以当前位置的元素。

思路看起来没有问题,题目却直接补了一句:不能使用除法

如果数组中还可能出现 0,总乘积除以当前元素这条路本身也会变得麻烦。第一次做到这里时,很容易把注意力一直放在"怎样从总乘积中去掉 nums[i]"上。实际上,换一个角度会简单很多:既然不能把当前元素除掉,那就从一开始不让它参与计算。

对于位置 i 来说,除 nums[i] 以外的元素只有两部分:它左边的元素和它右边的元素。因此:

复制代码
answer[i] = i 左边所有元素的乘积 × i 右边所有元素的乘积

这就是前缀积与后缀积解法的出发点。

一、题目说明

给定一个整数数组 nums,返回数组 answer,其中:

复制代码
answer[i] = nums 中除 nums[i] 以外其余所有元素的乘积

题目还有两个重要限制:

  • 不能使用除法;

  • 需要在 O(n) 时间复杂度内完成。

例如:

复制代码
输入:nums = [1,2,3,4]
输出:[24,12,8,6]

其中:

复制代码
answer[0] = 2 × 3 × 4 = 24
answer[1] = 1 × 3 × 4 = 12
answer[2] = 1 × 2 × 4 = 8
answer[3] = 1 × 2 × 3 = 6

二、暴力做法为什么不合适

如果不考虑题目的时间要求,可以为每个位置重新遍历整个数组,跳过当前位置,将其他元素全部相乘。

数组长度为 n 时,一共有 n 个位置;每个位置又要遍历接近 n 个元素,因此时间复杂度是:

复制代码
O(n²)

这种写法不难,但重复计算很多。例如,在计算 answer[2]answer[3] 时,前面的 1 × 2 被反复计算了。题目要求 O(n),实际上就是在提醒我们:这些重复的乘积应该提前保存下来。

三、关键转化:把答案拆成左右两部分

仍然以数组 [1,2,3,4] 为例。

如果要计算下标 2 对应的答案,也就是排除元素 3,剩余元素可以分为:

复制代码
左侧:[1,2]
右侧:[4]

所以:

复制代码
answer[2] = (1 × 2) × 4 = 8

这时可以引入两个数组:

  • pre[i]nums[i] 左侧所有元素的乘积;

  • suf[i]nums[i] 右侧所有元素的乘积。

那么每个位置的答案就是:

复制代码
res[i] = pre[i] * suf[i];

注意,pre[i]suf[i] 都不包含 nums[i] 本身。这一点是整个定义中最重要的地方。

四、前缀积是怎样计算的

前缀积从左向右计算:

复制代码
pre[0] = 1;
for (int i = 1; i < n; i++) {
    pre[i] = pre[i - 1] * nums[i - 1];
}

为什么公式中乘的是 nums[i - 1],而不是 nums[i]

因为 pre[i] 只保存下标 i 左侧元素的乘积,不能包含当前位置。pre[i - 1] 已经包含了 i - 1 左侧的全部元素,再乘上 nums[i - 1],刚好得到 i 左侧所有元素的乘积。

对于 [1,2,3,4]

复制代码
pre[0] = 1
pre[1] = pre[0] × nums[0] = 1 × 1 = 1
pre[2] = pre[1] × nums[1] = 1 × 2 = 2
pre[3] = pre[2] × nums[2] = 2 × 3 = 6

所以:

复制代码
pre = [1,1,2,6]

五、后缀积是怎样计算的

后缀积与前缀积完全对称,只不过需要从右向左计算:

复制代码
suf[n - 1] = 1;
for (int i = n - 2; i >= 0; i--) {
    suf[i] = suf[i + 1] * nums[i + 1];
}

suf[i] 只包含下标 i 右侧的元素,所以使用的是 nums[i + 1]

对于 [1,2,3,4]

复制代码
suf[3] = 1
suf[2] = suf[3] × nums[3] = 1 × 4 = 4
suf[1] = suf[2] × nums[2] = 4 × 3 = 12
suf[0] = suf[1] × nums[1] = 12 × 2 = 24

最终得到:

复制代码
suf = [24,12,4,1]

六、为什么两端要初始化为 1

这段代码里最容易让人疑惑的是:

复制代码
pre[0] = 1;
suf[n - 1] = 1;

nums[0] 的左侧没有任何元素,nums[n - 1] 的右侧也没有任何元素。这里不能初始化为 0,因为任何数乘以 0 都会变成 0,后续结果会全部受到影响。

乘法中的单位元是 1:

复制代码
x × 1 = x

因此,可以把"空的一侧"的乘积理解为 1。这样既符合乘法规则,也让首尾位置可以使用与其他位置完全相同的计算公式。

七、完整演算

把三个数组放在一起看,逻辑会更直观:

下标 i nums[i] 左侧乘积 pre[i] 右侧乘积 suf[i] res[i]
0 1 1 24 24
1 2 1 12 12
2 3 2 4 8
3 4 6 1 6

最终:

复制代码
res = [24,12,8,6]

整个过程没有使用除法,也没有为每个位置重新遍历数组。

八、Java 完整代码

复制代码
class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n = nums.length;

        int[] pre = new int[n];
        pre[0] = 1;

        // 计算前缀积:pre[i] 表示 nums[i] 左侧所有元素的乘积
        for (int i = 1; i < n; i++) {
            pre[i] = pre[i - 1] * nums[i - 1];
        }

        int[] suf = new int[n];
        suf[n - 1] = 1;

        // 计算后缀积:suf[i] 表示 nums[i] 右侧所有元素的乘积
        for (int i = n - 2; i >= 0; i--) {
            suf[i] = suf[i + 1] * nums[i + 1];
        }

        int[] res = new int[n];
        for (int i = 0; i < n; i++) {
            res[i] = pre[i] * suf[i];
        }

        return res;
    }
}

十一、复杂度分析

代码虽然进行了三次循环,但每次最多遍历数组一次,因此总时间复杂度仍然是:

复制代码
O(n)

presuf 都是长度为 n 的辅助数组,因此额外空间复杂度为:

复制代码
O(n)

输出数组 res 通常不计入额外空间,但两个辅助数组仍然需要计入。

相关推荐
Brilliantwxx26 分钟前
【C++】初入嵌入式C++复习-----经典面试题100道
开发语言·c++·算法·面试·职场和发展
动词ing29 分钟前
【学习笔记】字符串(遍历+反转+替换+子串匹配+KMP)+题目解析
学习·算法
大模型码小白39 分钟前
MCP协议开发实战:从零搭建AI Agent工具链
运维·人工智能·算法·机器学习·自动化
luj_17681 小时前
断舍离中的项目管理智慧
c语言·开发语言·c++·经验分享·算法
老王爱玩车1 小时前
关于函数递归的优缺点分析
开发语言·数据结构·学习·算法
无限码力1 小时前
拼多多 春招&秋招 笔试真题题库目录|笔试题库 + 算法考点详解
算法·pdd笔试真题·pdd笔试真题题解·拼多多春招笔试真题·拼多多秋招笔试真题·拼多多笔试真题题解
OPEN-F1 小时前
C++进阶教程:文件流与字符串流
开发语言·c++·算法
维克兜率天1 小时前
4.1.3 策略类型全景图:六大策略,你适合哪个
笔记·python·算法·量化
01_ice2 小时前
c++类和对象(中)
c++·算法