1. 问题背景与核心思路
今天我想和大家分享一个LeetCode经典题目"除自身以外数组的乘积"的优化解法。这道题要求我们计算数组中每个元素除自身外所有其他元素的乘积,且不能使用除法运算。初次看到这个问题时,很多人的第一反应可能是先计算整个数组的乘积,然后对每个元素进行除法操作。但题目明确禁止使用除法,这就迫使我们寻找更巧妙的解法。
这道题的核心难点在于如何在O(n)时间复杂度和O(1)额外空间复杂度(不考虑输出数组)的条件下完成计算。经过多次尝试和优化,我发现了一种非常优雅的"双向遍历"解法,它完美地满足了这些要求。
提示:这里的O(1)空间复杂度是指除了输出数组外,我们只使用了固定数量的额外变量(如循环计数器和一个累乘变量)。
2. 算法思路详解
2.1 基本思路分解
这个优化解法的核心思想可以概括为:"先往一个方向走,再往回走,顺手把结果算出来"。具体来说:
- 第一次从左到右遍历,计算每个元素左边所有元素的乘积,并将结果存储在输出数组中。
- 第二次从右到左遍历,使用一个变量实时维护右边所有元素的乘积,并将其与之前存储的左边乘积相乘,得到最终结果。
这种方法的精妙之处在于它充分利用了输出数组来存储中间结果,避免了使用额外的存储空间。
2.2 空间复杂度分析
在LeetCode的评价体系中,输出数组不计入额外空间复杂度。因此,我们只需要考虑算法本身使用的额外空间:
- 输出数组ans:不计入(题目要求)
- 变量len:O(1)
- 变量i(循环计数器):O(1)
- 变量R(右侧乘积):O(1)
因此,总的空间复杂度确实是O(1),完全符合题目要求。
3. 代码实现与逐行解析
3.1 完整代码展示
让我们先看一下完整的C++实现代码:
cpp复制class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int len = nums.size();
vector<int> ans(len);
// 第一步:计算左侧积
ans[0] = 1; // 第一个元素左边没有数,设为1
for (int i = 1; i < len; i++) {
ans[i] = ans[i - 1] * nums[i - 1];
}
// 第二步:计算右侧积并合并结果
int R = 1;
for (int i = len - 1; i >= 0; i--) {
ans[i] = ans[i] * R;
R *= nums[i];
}
return ans;
}
};
3.2 代码逐行解析
初始化阶段:
cpp复制int len = nums.size();
vector<int> ans(len);
这里我们先获取输入数组的长度,并初始化一个大小相同的输出数组ans。
第一步:计算左侧积
cpp复制ans[0] = 1;
for (int i = 1; i < len; i++) {
ans[i] = ans[i - 1] * nums[i - 1];
}
这个循环计算每个元素左边所有元素的乘积:
- ans[0]初始化为1,因为第一个元素左边没有元素
- 对于i>0,ans[i]等于ans[i-1](即nums[0]到nums[i-2]的乘积)乘以nums[i-1]
第二步:计算右侧积并合并结果
cpp复制int R = 1;
for (int i = len - 1; i >= 0; i--) {
ans[i] = ans[i] * R;
R *= nums[i];
}
这个反向遍历完成两件事:
- 将之前计算的左侧积(ans[i])与右侧积(R)相乘,得到最终结果
- 更新R为当前元素右侧所有元素的乘积(包含当前元素)
4. 示例演示与逐步推演
为了更好地理解这个算法,让我们用一个具体例子[1, 2, 3, 4]来逐步推演整个过程。
4.1 第一步:计算左侧积
初始化:
- ans = [_, _, _, _]
第一次遍历(从左到右):
- ans[0] = 1
- ans[1] = ans[0] * nums[0] = 1 * 1 = 1
- ans[2] = ans[1] * nums[1] = 1 * 2 = 2
- ans[3] = ans[2] * nums[2] = 2 * 3 = 6
此时ans数组为:[1, 1, 2, 6]
4.2 第二步:合并右侧积
初始化:
- R = 1
- ans = [1, 1, 2, 6]
第二次遍历(从右到左):
- i=3:
- ans[3] = ans[3] * R = 6 * 1 = 6
- R = R * nums[3] = 1 * 4 = 4
- i=2:
- ans[2] = ans[2] * R = 2 * 4 = 8
- R = R * nums[2] = 4 * 3 = 12
- i=1:
- ans[1] = ans[1] * R = 1 * 12 = 12
- R = R * nums[1] = 12 * 2 = 24
- i=0:
- ans[0] = ans[0] * R = 1 * 24 = 24
- R = R * nums[0] = 24 * 1 = 24
最终ans数组为:[24, 12, 8, 6],这正是我们期望的结果。
5. 算法优化与变种思考
5.1 为什么这种方法有效?
这种双向遍历的方法之所以有效,是因为它将问题分解为两个部分:
- 计算每个元素左边所有元素的乘积
- 计算每个元素右边所有元素的乘积
然后将两部分相乘得到最终结果
通过巧妙地利用输出数组存储中间结果,我们避免了使用额外的存储空间。
5.2 可能的变种与扩展
虽然这个解法已经很高效,但我们还可以考虑一些变种:
-
并行计算优化:如果允许使用除法,我们可以先计算整个数组的乘积,然后对每个元素进行除法操作。但题目明确禁止这种做法。
-
多线程实现:对于超大数组,可以考虑将左右乘积的计算分配到不同线程中并行执行,但需要注意同步问题。
-
流式处理:如果数据是以流的形式输入的,我们需要调整算法以适应这种场景。
6. 常见问题与调试技巧
6.1 边界条件处理
在实际编码中,有几个边界条件需要特别注意:
- 空数组:应该返回空数组
- 单元素数组:按照题意应该返回[1]
- 包含0的数组:算法本身能正确处理这种情况
6.2 调试技巧
如果在实现过程中遇到问题,可以尝试以下调试方法:
- 打印中间结果:在两次遍历之间打印ans数组,确保左侧积计算正确
- 小规模测试:先用小数组(如[1,2]或[1,2,3])测试,便于人工验证
- 检查索引:特别注意循环的起始和结束索引,避免off-by-one错误
6.3 性能优化
虽然这个算法已经是O(n)时间复杂度,但在实际实现中还可以考虑:
- 循环展开:对于已知的小规模数组,可以手动展开循环
- SIMD指令:利用现代CPU的并行计算能力加速乘法运算
- 缓存友好:确保内存访问模式是连续的,这对大型数组尤为重要
7. 实际应用与相关问题
7.1 实际应用场景
这种类型的算法在实际中有多种应用:
- 信号处理:计算滑动窗口内的乘积
- 统计分析:计算累积乘积
- 图像处理:某些滤波操作需要类似的乘积计算
7.2 相关LeetCode题目
掌握这个算法后,可以尝试解决以下类似题目:
- 前缀和数组:很多题目使用类似的预处理思路
- 滑动窗口最大值:另一种常见的数组处理技巧
- 乘积最大子数组:需要结合动态规划的思想
8. 个人实现心得
在实际实现这个算法的过程中,我总结了几个关键点:
-
理解空间复杂度的定义:一开始我困惑于为什么输出数组不计入空间复杂度,后来明白这是题目设定的评价标准。
-
双向遍历的直观性:虽然代码很简洁,但需要花时间理解为什么这样能得到正确结果。画图帮助很大。
-
边界条件的测试:特别是空数组和单元素数组的情况容易被忽略,需要特别注意。
-
变量命名的清晰性:使用有意义的变量名(如R表示右侧乘积)可以大大提高代码可读性。
这个算法展示了如何通过巧妙的遍历顺序和空间利用来优化解决方案,是算法设计中"空间换时间"和"时间换空间"权衡的经典案例。