LeetCode 238:除自身以外数组的乘积,从暴力枚举到前后缀乘积

LeetCode 238:除自身以外数组的乘积,从暴力枚举到前后缀乘积

前言

刷数组题时,经常会遇到这样一种场景:

对于数组中的每一个位置,需要统计它左边和右边所有元素的信息。

例如:

  • 除自身以外数组的乘积
  • 接雨水
  • 分糖果
  • 股票问题
  • 前缀和问题

LeetCode 238《除自身以外数组的乘积》就是其中最经典的一道题。

这道题最大的价值并不在于代码本身,而在于它体现了一种非常重要的优化思想:

用预计算换时间,避免重复计算。


一、题目描述

给定一个整数数组 nums。

返回一个数组 answer,使得:

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

示例:

python 复制代码
nums = [1,2,3,4]

输出:

[24,12,8,6]

解释:

python 复制代码
answer[0] = 2×3×4 = 24

answer[1] = 1×3×4 = 12

answer[2] = 1×2×4 = 8

answer[3] = 1×2×3 = 6

并且题目还有两个限制:

  • 不允许使用除法
  • 时间复杂度必须为 O(n)

二、最容易想到的方法

对于每一个位置:

重新遍历整个数组。

跳过自己。

把其余元素全部乘起来。

例如:

python 复制代码
nums = [1,2,3,4]

计算 answer2

python 复制代码
1 × 2 × 4

计算 answer3

python 复制代码
1 × 2 × 3

...

代码类似:

python 复制代码
for i in range(n):
    product = 1

    for j in range(n):
        if i != j:
            product *= nums[j]

问题在哪?

很多乘法被重复计算了。

例如:

python 复制代码
1 × 2

被算了很多次。

python 复制代码
2 × 3

也被算了很多次。

时间复杂度:

text 复制代码
O(n²)

题目要求 O(n)。

因此必须优化。


三、关键突破口

先看一个位置:

python 复制代码
nums = [1,2,3,4]

计算:

python 复制代码
answer[2]

实际上就是:

python 复制代码
1 × 2 × 4

可以拆开:

python 复制代码
(1 × 2) × (4)

也就是:

python 复制代码
左边所有元素乘积
×

右边所有元素乘积

于是得到核心结论:

python 复制代码
answer[i]
=
左侧乘积
×
右侧乘积

问题瞬间变简单了。


四、前缀积与后缀积

对于数组:

python 复制代码
nums = [1,2,3,4]

前缀积(左侧乘积)

定义:

python 复制代码
left[i]
=
i左边所有元素的乘积

得到:

python 复制代码
left = [1,1,2,6]

为什么?

python 复制代码
left[0] = 1

left[1] = 1

left[2] = 1×2 = 2

left[3] = 1×2×3 = 6

后缀积(右侧乘积)

定义:

python 复制代码
right[i]
=
i右边所有元素的乘积

得到:

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

为什么?

python 复制代码
right[3] = 1

right[2] = 4

right[1] = 3×4 = 12

right[0] = 2×3×4 = 24

五、答案如何得到?

直接相乘即可:

python 复制代码
answer[i]
=
left[i] * right[i]

例如:

python 复制代码
answer[2]
=
2 × 4
=
8

结果:

python 复制代码
[24,12,8,6]

完全正确。


六、双辅助数组解法

第一步:计算左侧乘积

递推公式:

python 复制代码
left[i]
=
left[i-1] * nums[i-1]

为什么乘 i-1?

因为题目要求:

python 复制代码
不包含自己

第二步:计算右侧乘积

递推公式:

python 复制代码
right[i]
=
right[i+1] * nums[i+1]

同样不能包含自己。


第三步:组合答案

python 复制代码
answer[i]
=
left[i] * right[i]

完整代码

python 复制代码
from typing import List

class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:

        n = len(nums)

        left = [1] * n
        right = [1] * n

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

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

        answer = [0] * n

        for i in range(n):
            answer[i] = left[i] * right[i]

        return answer

七、空间优化版

面试官通常会追问:

能不能不使用两个辅助数组?

观察:

最终答案只需要:

python 复制代码
left[i]

python 复制代码
right[i]

相乘。

其实没必要把 right 数组全部存下来。


优化思路

第一遍:

直接把左侧乘积存进答案数组。

例如:

python 复制代码
answer = [1,1,2,6]

第二遍从右往左遍历。

用一个变量:

python 复制代码
right_product

实时维护右侧乘积。

每走一步:

python 复制代码
answer[i]
=
answer[i] * right_product

然后更新:

python 复制代码
right_product *= nums[i]

即可。


八、空间优化版代码

python 复制代码
from typing import List

class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:

        n = len(nums)

        answer = [1] * n

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

        right_product = 1

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

        return answer

九、为什么额外空间是 O(1)?

很多同学会疑惑:

python 复制代码
answer 不也是数组吗?

为什么还能说 O(1)?

原因是:

题目要求返回结果数组。

这个数组无论如何都必须存在。

算法分析时:

text 复制代码
结果数组不计入额外空间

因此:

text 复制代码
额外使用的空间
=
right_product 一个变量
=
O(1)

十、高频易错点

1. 把当前元素算进去

错误:

python 复制代码
left[i]
=
left[i-1] * nums[i]

这样会把自己乘进去。

正确:

python 复制代码
left[i]
=
left[i-1] * nums[i-1]

2. 右侧乘积遍历方向错误

必须:

python 复制代码
从右往左

因为当前位置依赖右边结果。


3. 边界初始化错误

正确:

python 复制代码
left[0] = 1

right[n-1] = 1

因为:

python 复制代码
空乘积 = 1

不是 0。


4. 倒序 range 写错

正确:

python 复制代码
range(n-2, -1, -1)

如果写:

python 复制代码
range(n-2, 0, -1)

索引0不会被遍历到。


总结

这道题真正考察的不是乘法,而是:

如何通过预计算消除重复计算。

核心公式只有一句:

python 复制代码
answer[i]
=
左侧乘积 × 右侧乘积

利用前缀积和后缀积,我们把原本 O(n²) 的暴力枚举优化到了 O(n)。

这也是很多数组题、动态规划题和区间统计题中经常出现的经典思想,值得重点掌握。