1. 问题背景与核心挑战
这道来自LeetCode Hot 100的题目(编号238)要求我们实现一个算法:给定一个整数数组nums,返回一个数组answer,其中answer[i]等于nums中除nums[i]之外其余各元素的乘积。题目要求不能使用除法,并且在O(n)时间复杂度内完成。
这个问题的难点在于:
- 禁止使用除法运算(否则直接计算总乘积后除以当前元素即可)
- 需要处理数组中可能存在的0元素
- 要求时间复杂度严格控制在O(n)
- 空间复杂度除输出数组外只能使用常数空间
2. 解决方案设计思路
2.1 前后缀乘积分解法
最优雅的解决方案是将问题分解为前缀乘积和后缀乘积两部分:
- 前缀乘积:从左到右遍历,计算每个位置左侧所有元素的乘积
- 后缀乘积:从右到左遍历,计算每个位置右侧所有元素的乘积
- 最终结果:将每个位置的前缀乘积和后缀乘积相乘
这种方法完美满足题目所有约束条件:
- 时间复杂度:两次遍历O(n)
- 空间复杂度:除输出数组外只用了两个临时变量
- 无需除法运算
- 能正确处理0元素
2.2 算法步骤详解
def productExceptSelf(nums): n = len(nums) answer = [1] * n # 计算左侧乘积 left_product = 1 for i in range(n): answer[i] = left_product left_product *= nums[i] # 计算右侧乘积并合并结果 right_product = 1 for i in range(n-1, -1, -1): answer[i] *= right_product right_product *= nums[i] return answer3. 关键实现细节解析
3.1 边界条件处理
- 空数组:直接返回空数组
- 单元素数组:返回[1](因为没有其他元素)
- 包含0的数组:算法能自然处理,因为乘积会通过前后缀传播
3.2 空间优化技巧
虽然题目允许使用输出数组不算额外空间,但我们还可以进一步优化:
- 第一个循环直接用answer数组存储前缀乘积
- 第二个循环使用一个变量动态计算后缀乘积
- 这样连临时数组都不需要
3.3 复杂度分析
- 时间复杂度:O(n),两次线性遍历
- 空间复杂度:O(1)(不考虑输出数组)
4. 常见问题与解决方案
4.1 为什么不能用除法?
题目明确禁止使用除法,主要考察的是对数组操作的技巧。此外,当数组中有0元素时,除法会导致除零错误。
4.2 如何处理多个0的情况?
- 如果有两个及以上0,所有结果都将是0
- 如果只有一个0,只有该位置的乘积不为0
- 算法能自动处理这些情况
4.3 为什么选择前后缀分解?
这是典型的空间换时间策略:
- 预计算前后缀乘积避免重复计算
- 将O(n^2)的暴力解法优化为O(n)
- 符合题目对时间复杂度的严格要求
5. 算法扩展与变种
5.1 允许使用除法的情况
如果允许使用除法,可以:
- 计算总乘积
- 对于每个元素,用总乘积除以当前元素 但需要注意处理0的情况
5.2 三维数组乘积问题
类似的思路可以扩展到多维数组,计算每个元素"周围"的乘积,只需要增加维度的遍历方向。
5.3 乘积最大子数组问题
这是LeetCode另一道经典题目(152),可以使用类似的分解思想,但要额外考虑负负得正的情况。
6. 实际应用场景
这种前后缀分解的技巧在以下场景很有用:
- 图像处理中的邻域计算
- 信号处理中的滑动窗口统计
- 数据库查询优化中的预计算
- 金融分析中的移动平均计算
7. 代码优化与测试用例
7.1 优化后的Python实现
def productExceptSelf(nums): res = [1] * len(nums) prefix = 1 for i in range(len(nums)): res[i] = prefix prefix *= nums[i] postfix = 1 for i in range(len(nums)-1, -1, -1): res[i] *= postfix postfix *= nums[i] return res7.2 关键测试用例
test_cases = [ ([1,2,3,4], [24,12,8,6]), ([-1,1,0,-3,3], [0,0,9,0,0]), ([0,0], [0,0]), ([1], [1]), ([2,3], [3,2]) ]8. 不同语言实现要点
8.1 C++实现注意事项
vector<int> productExceptSelf(vector<int>& nums) { vector<int> res(nums.size(), 1); int prefix = 1; for(int i = 0; i < nums.size(); i++) { res[i] = prefix; prefix *= nums[i]; } int postfix = 1; for(int i = nums.size()-1; i >= 0; i--) { res[i] *= postfix; postfix *= nums[i]; } return res; }注意:
- 使用vector容器更安全
- 注意整数溢出问题(题目没说数字范围)
- 可以使用size_t代替int作为索引
8.2 Java实现特点
public int[] productExceptSelf(int[] nums) { int[] res = new int[nums.length]; Arrays.fill(res, 1); int prefix = 1; for(int i = 0; i < nums.length; i++) { res[i] = prefix; prefix *= nums[i]; } int postfix = 1; for(int i = nums.length-1; i >= 0; i--) { res[i] *= postfix; postfix *= nums[i]; } return res; }注意:
- 数组初始化要用Arrays.fill
- 没有元组解包,代码稍显冗长
- 同样要注意整数溢出问题
9. 算法可视化理解
想象数组元素排成一排:
- 第一趟从左到右:每个位置记录左边所有元素的乘积
- 第二趟从右到左:每个位置乘以右边所有元素的乘积
- 最终结果就是左右乘积的合并
例如数组[1,2,3,4]:
- 前缀乘积:[1,1,2,6]
- 后缀乘积:[24,12,4,1]
- 最终结果:[24,12,8,6]
10. 性能优化进阶
10.1 并行计算优化
对于超大数组,可以考虑:
- 将数组分段
- 并行计算各段的前后缀乘积
- 合并结果
10.2 内存访问优化
- 尽量保证内存访问的连续性
- 对小数组使用寄存器变量
- 避免不必要的内存分配
10.3 SIMD指令利用
现代CPU支持单指令多数据操作,可以:
- 使用SIMD指令并行计算乘积
- 特别适合处理大型数组
11. 面试常见问题
11.1 如何解释算法思路?
建议回答结构:
- 明确问题约束条件
- 提出暴力解法及其缺点
- 介绍前后缀分解的优化思路
- 分析时间/空间复杂度
- 讨论边界条件和特殊情况
11.2 可能的follow-up问题
- 如果允许使用除法会怎样?
- 如何处理浮点数数组?
- 如何扩展到多维数组?
- 如果内存非常有限怎么办?
12. 类似题目推荐
- LeetCode 42:接雨水(同样使用前后缀分解)
- LeetCode 152:乘积最大子数组
- LeetCode 135:分发糖果
- LeetCode 53:最大子数组和
- LeetCode 121:买卖股票的最佳时机
13. 个人实现心得
在实际编码中发现几个关键点:
- 初始时answer数组要初始化为1而不是0
- 第二个循环要从后往前遍历
- 后缀乘积的计算要与前缀乘积相乘,不是赋值
- 注意乘积可能溢出(虽然题目没说)
一个容易犯的错误是在第二个循环中错误地使用:
answer[i] = right_product # 错误!应该是 *=这会覆盖之前计算的前缀乘积。