1. 问题解析与解题思路
在解决LeetCode 238题"除自身以外数组的乘积"时,我们需要深入理解题目要求。题目给定一个整数数组nums,要求返回一个新数组answer,其中answer[i]等于nums中除nums[i]外所有元素的乘积。这个看似简单的问题其实蕴含着几个关键约束条件:
- 不能使用除法运算
- 时间复杂度必须为O(n)
- 空间复杂度需要尽可能优化(除输出数组外)
1.1 为什么不能使用除法?
很多初学者可能会想到一个"取巧"的方法:先计算整个数组的乘积,然后对于每个元素,用总乘积除以当前元素值。这种方法看似简单高效,但实际上存在两个致命缺陷:
- 当数组中出现0时,会导致除零错误
- 题目明确禁止使用除法运算
因此,我们需要寻找其他更可靠的解决方案。
1.2 前后缀乘积的核心思想
解决这个问题的关键在于认识到:对于数组中的每个元素nums[i],我们需要的是它左边所有元素的乘积和右边所有元素的乘积的乘积。这自然引出了前后缀乘积的概念:
- 前缀乘积:元素左侧所有元素的乘积
- 后缀乘积:元素右侧所有元素的乘积
通过分别计算这两个数组,我们就能轻松得到最终结果。这种方法的时间复杂度为O(n),空间复杂度也是O(n)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础解法:前后缀乘积数组
2.1 算法步骤详解
让我们详细拆解这个基础解法:
- 初始化两个数组left和right,大小与输入数组相同
- 计算left数组:
- left[0] = 1(第一个元素左侧没有元素)
- left[i] = left[i-1] * nums[i-1](从左到右遍历)
- 计算right数组:
- right[n-1] = 1(最后一个元素右侧没有元素)
- right[i] = right[i+1] * nums[i+1](从右到左遍历)
- 计算结果数组:
- answer[i] = left[i] * right[i]
2.2 C++实现代码
cpp复制class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> answer(n);
vector<int> left(n), right(n);
left[0] = 1;
for (int i = 1; i < n; i++) {
left[i] = left[i-1] * nums[i-1];
}
right[n-1] = 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;
}
};
2.3 复杂度分析
- 时间复杂度:O(n) - 我们进行了三次线性遍历
- 空间复杂度:O(n) - 需要额外的left和right数组
注意:虽然这种方法满足题目要求,但我们可以进一步优化空间复杂度。
3. 优化解法:双指针空间优化
3.1 优化思路
观察基础解法,我们发现left和right数组实际上只被使用一次。这意味着我们可以用变量来代替数组,从而将空间复杂度优化到O(1)(不包括输出数组)。
具体思路是:
- 先用输出数组存储左侧乘积
- 再用一个变量动态计算右侧乘积并更新结果
3.2 两次遍历实现
cpp复制class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> answer(n, 1);
// 从左到右计算左侧乘积
int left = 1;
for (int i = 0; i < n; i++) {
answer[i] = left;
left *= nums[i];
}
// 从右到左计算右侧乘积并更新结果
int right = 1;
for (int i = n-1; i >= 0; i--) {
answer[i] *= right;
right *= nums[i];
}
return answer;
}
};
3.3 单次循环优化
我们可以进一步将两次遍历合并为一次,使用双指针同时从两端向中间移动:
cpp复制class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> answer(n, 1);
int left = 1, right = 1;
for (int i = 0, j = n-1; i < n; i++, j--) {
answer[i] *= left;
answer[j] *= right;
left *= nums[i];
right *= nums[j];
}
return answer;
}
};
3.4 复杂度分析
- 时间复杂度:O(n) - 无论是两次遍历还是一次遍历,都是线性时间
- 空间复杂度:O(1) - 只使用了常数个额外变量(不包括输出数组)
4. 边界条件与特殊案例处理
在实际编码中,我们需要特别注意一些边界条件和特殊案例:
4.1 处理0的情况
虽然题目保证结果在32位整数范围内,但数组中可能有0。我们的解法天然能处理0的情况,不需要特殊处理。
4.2 数组长度为2的情况
当数组只有两个元素时,结果应该是这两个元素互换位置。我们的解法也能正确处理这种情况。
4.3 负数元素
负数乘积会改变符号,但我们的解法会自动处理符号变化,不需要额外操作。
5. 性能对比与选择建议
5.1 方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 代码复杂度 |
|---|---|---|---|
| 前后缀数组 | O(n) | O(n) | 中等 |
| 双指针两次遍历 | O(n) | O(1) | 简单 |
| 双指针单次遍历 | O(n) | O(1) | 中等 |
5.2 选择建议
- 对于初学者,建议先掌握前后缀数组方法,理解核心思想
- 在面试中,双指针两次遍历是最佳选择,平衡了性能和可读性
- 单次遍历优化虽然优雅,但理解难度稍大,适合进阶学习
6. 常见错误与调试技巧
6.1 常见错误
- 初始化错误:忘记将left[0]和right[n-1]初始化为1
- 边界处理不当:循环条件或索引计算错误
- 空间优化时混淆更新顺序:先更新结果还是先更新累积变量
6.2 调试技巧
- 打印中间变量:在计算left和right数组时打印中间值
- 小规模测试:先用小数组(如[1,2,3])测试
- 特殊案例测试:测试包含0或负数的数组
7. 扩展思考与变种问题
7.1 如果允许使用除法?
虽然题目禁止使用除法,但思考这种情况有助于理解问题本质。使用除法的方法需要:
- 计算总乘积
- 处理0的情况(数组中0的个数不同,处理方式不同)
7.2 多维数组的乘积问题
如何计算二维数组中除当前元素外所有元素的乘积?这需要将前后缀思想扩展到二维情况。
7.3 乘积的更多应用
这种前后缀乘积的思想还可以应用于:
- 计算滑动窗口乘积
- 解决某些统计问题
- 图像处理中的局部操作
在实际编码练习中,我发现理解问题的核心模式比记忆具体解法更重要。这道题教会我们如何将复杂问题分解为更简单的子问题(前后缀计算),以及如何通过空间换时间或优化空间使用来提升算法效率。
对于算法学习,我建议从暴力解法开始,逐步优化,而不是直接寻找最优解。这样能更深入地理解问题本质和优化思路。另外,多画图辅助理解数据流动和变量更新过程,这对掌握双指针等技巧特别有帮助。
