LeetCode 238 & 2906.构造乘积数组与乘积矩阵 从一维到二维:前缀积与后缀积的妙用
前言
在算法题中,有一类经典问题:给定一个数组(或矩阵),计算每个位置除自身以外所有元素的乘积 。
如果直接使用除法,虽然思路简单,但需要处理元素为 0 的情况,并且题目通常禁止使用除法。
本文将深入解析两题:
- LeetCode 238. 除自身以外数组的乘积(一维)
- LeetCode 2906. 构造乘积矩阵(二维)
通过对比,展示如何利用前缀积 × 后缀积的技巧,以 O(n) 或 O(n×m) 的时间复杂度优雅求解。
一、LeetCode 238:除自身以外数组的乘积
题目描述
给你一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。
要求:不使用除法,且在 O(n) 时间复杂度内完成。
解法思路
将 answer[i] 分解为两部分:
- 左边所有元素的乘积(前缀积)
- 右边所有元素的乘积(后缀积)
因此,我们可以先计算每个位置的前缀积,再乘以后缀积,得到最终结果。
代码实现(C++)
cpp
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> pre(n, 1); // 结果数组,先存储前缀积
// 计算前缀积
for (int i = 1; i < n; ++i) {
pre[i] = pre[i - 1] * nums[i - 1];
}
int suf = 1;
// 从右向左乘以后缀积
for (int i = n - 1; i >= 0; --i) {
pre[i] *= suf; // 左边乘积 × 右边乘积
suf *= nums[i]; // 更新后缀积
}
return pre;
}
};
模拟过程(以 nums = [1, 2, 3, 4] 为例)
| 步骤 | 操作 | pre 数组变化 |
后缀积 suf |
|---|---|---|---|
| 初始 | [1, 1, 1, 1] |
--- | |
| 前缀积 | i=1: pre[1] = 1×1=1 | [1, 1, 1, 1] |
|
| i=2: pre[2] = 1×2=2 | [1, 1, 2, 1] |
||
| i=3: pre[3] = 2×3=6 | [1, 1, 2, 6] |
||
| 后缀积 | suf=1 | 1 | |
| i=3: pre[3]=6×1=6; suf=1×4=4 | [1, 1, 2, 6] |
4 | |
| i=2: pre[2]=2×4=8; suf=4×3=12 | [1, 1, 8, 6] |
12 | |
| i=1: pre[1]=1×12=12; suf=12×2=24 | [1, 12, 8, 6] |
24 | |
| i=0: pre[0]=1×24=24; suf=24×1=24 | [24, 12, 8, 6] |
24 |
最终结果 [24, 12, 8, 6] 符合预期。
二、LeetCode 2906:构造乘积矩阵
题目描述
给你一个二维整数矩阵 grid,构造一个同样大小的矩阵 p,使得
p[i][j] 等于矩阵中除 grid[i][j] 以外所有元素的乘积,结果对 12345 取模。
要求:不能使用除法。
解法思路
将二维矩阵按行优先展开为一维视角,本质与一维完全相同。
我们将每个位置 (i, j) 的乘积拆分为:
- 前缀积:所有行号 < i 的格子,以及同一行中列号 < j 的格子(即左上部分)
- 后缀积:所有行号 > i 的格子,以及同一行中列号 > j 的格子(即右下部分)
两次遍历:
- 逆序遍历(从右下角向左上角),计算每个位置的后缀积,暂存在结果矩阵中。
- 正序遍历(从左到右、从上到下),同时维护前缀积,与之前暂存的后缀积相乘,得到最终结果。
代码实现(C++)
cpp
class Solution {
public:
vector<vector<int>> constructProductMatrix(vector<vector<int>>& grid) {
constexpr int MOD = 12345;
int n = grid.size(), m = grid[0].size();
vector<vector<int>> p(n, vector<int>(m));
// 第一遍:后缀积(右侧 + 下侧)
long long suf = 1;
for (int i = n - 1; i >= 0; --i) {
for (int j = m - 1; j >= 0; --j) {
p[i][j] = suf;
suf = suf * grid[i][j] % MOD;
}
}
// 第二遍:乘以前缀积(左侧 + 上侧)
long long pre = 1;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
p[i][j] = p[i][j] * pre % MOD;
pre = pre * grid[i][j] % MOD;
}
}
return p;
}
};
模拟过程(以 grid = [[1,2],[3,4]] 为例)
初始矩阵
1 2
3 4
第一步:逆序遍历,计算后缀积(suf 初始 1)
| 位置 (i,j) | 操作 | 结果矩阵 p | suf 更新 |
|---|---|---|---|
| (1,1) | p[1][1] = 1 | [[?,?],[?,1]] |
suf = 1×4 = 4 |
| (1,0) | p[1][0] = 4 | [[?,?],[4,1]] |
suf = 4×3 = 12 |
| (0,1) | p[0][1] = 12 | [[?,12],[4,1]] |
suf = 12×2 = 24 |
| (0,0) | p[0][0] = 24 | [[24,12],[4,1]] |
suf = 24×1 = 24 |
此时 p 存储的是每个位置右侧及下侧所有元素的乘积(不含自身)。
第二步:正序遍历,乘以前缀积(pre 初始 1)
| 位置 (i,j) | 操作 | 结果矩阵 p 变化 | pre 更新 |
|---|---|---|---|
| (0,0) | p[0][0] = 24×1 = 24 | [[24,12],[4,1]] |
pre = 1×1 = 1 |
| (0,1) | p[0][1] = 12×1 = 12 | [[24,12],[4,1]] |
pre = 1×2 = 2 |
| (1,0) | p[1][0] = 4×2 = 8 | [[24,12],[8,1]] |
pre = 2×3 = 6 |
| (1,1) | p[1][1] = 1×6 = 6 | [[24,12],[8,6]] |
pre = 6×4 = 24 |
最终结果:
24 12
8 6
验证:
- (0,0) 除自身乘积 = 2×3×4 = 24 ✓
- (0,1) 除自身乘积 = 1×3×4 = 12 ✓
- (1,0) 除自身乘积 = 1×2×4 = 8 ✓
- (1,1) 除自身乘积 = 1×2×3 = 6 ✓
三、两题的共通点与技巧总结
1. 核心思想:前缀积 × 后缀积
无论是数组还是矩阵,都能将"除自身外所有元素的乘积"分解为:
结果 = 左边/上边所有元素的乘积 × 右边/下边所有元素的乘积
通过两次遍历分别计算出这两部分,并相乘得到最终答案。
2. 空间优化
- LeetCode 238:直接将结果数组作为前缀积存储,再通过一个变量维护后缀积,实现 O(1) 额外空间。
- LeetCode 2906:同样将结果矩阵作为后缀积的暂存,再乘以前缀积,额外空间只有几个变量。
3. 避免除法
题目明确禁止使用除法,且元素可能包含 0。本方法完全规避了除法,通过乘法累积完成。
4. 模运算
LeetCode 2906 要求对 12345 取模。在乘法过程中及时取模,防止溢出,并保证结果在 int 范围内。
5. 遍历顺序
- 前缀积通常采用正向遍历(从左到右、从上到下)。
- 后缀积采用反向遍历(从右到左、从下到上)。
四、复杂度分析
| 题目 | 时间复杂度 | 空间复杂度(不计输出数组) |
|---|---|---|
| LeetCode 238 | O(n) | O(1) |
| LeetCode 2906 | O(n×m) | O(1) |
两算法均只遍历两次原数组/矩阵,且只使用常数个额外变量,非常高效。
五、总结
通过对比一维和二维的"除自身外乘积"问题,我们看到了同一思想在不同数据结构上的迁移能力。
- 核心技巧:前缀积 + 后缀积
- 优势:无需除法,时间空间复杂度优秀,易于扩展
- 适用场景:任何需要计算"除自身外其他元素积"的场景,都可以考虑本方法。