1. 问题背景与核心挑战
这道题目在技术面试中出现的频率相当高,根据我的面试官经验,大约60%的候选人在首次遇到时都会陷入"使用除法"的思维定式。题目要求我们计算数组中每个元素除自身外所有元素的乘积,但有两个关键限制:不能使用除法运算,且需要将空间复杂度优化到常数级别(O(1))。
提示:这道题的难点不在于算法本身有多复杂,而在于如何突破常规思维模式,找到空间最优的解决方案。
在实际工程场景中,类似的问题也经常出现。比如在数据分析时,我们可能需要计算某个指标相对于其他所有指标的相对值;在图像处理中,可能需要计算像素与其周围像素的某种关系。理解这类问题的解法,对提升编程思维很有帮助。
2. 解题思路深度解析
2.1 暴力法的局限与优化方向
最直观的解法是对于每个元素,遍历整个数组计算其他元素的乘积。这种方法时间复杂度为O(n²),显然不符合题目要求。稍微优化一点的做法是先计算整个数组的乘积,然后对于每个元素用总乘积除以当前元素值。但题目明确禁止使用除法,且当数组中有0时会出问题。
2.2 分治思想的应用
更聪明的做法是将问题分解:对于数组中的每个元素nums[i],其答案可以看作是它左边所有元素的乘积乘以右边所有元素的乘积。这种分治思想是解决本题的关键。
举个例子,对于数组[1,2,3,4]:
- 第一个元素1的答案应该是2×3×4=24
- 第二个元素2的答案应该是1×3×4=12
- 以此类推
2.3 空间优化技巧
常规实现会使用两个数组分别存储每个元素的左右乘积,然后相乘得到结果。这样空间复杂度是O(n)。但题目要求额外空间复杂度为O(1)(不包括输出数组)。这时就需要用到巧妙的遍历技巧:
- 第一次从左到右遍历,计算每个元素的左侧乘积并直接存入结果数组
- 第二次从右到左遍历,用一个变量累乘右侧元素,同时更新结果数组
这种方法只需要常数级别的额外空间(用于存储累乘变量),完美满足题目要求。
3. 详细实现步骤
3.1 初始化阶段
java复制int n = nums.length;
int[] res = new int[n];
int left = 1, right = 1;
这里我们:
- 获取数组长度n
- 初始化结果数组res
- 初始化左右累乘变量left和right为1(因为1乘以任何数都不改变其值)
3.2 左到右遍历计算左侧乘积
java复制for (int i = 0; i < n; i++) {
res[i] = left;
left *= nums[i];
}
这个循环中:
- res[i]存储的是nums[i]左侧所有元素的乘积
- left变量累乘当前元素,为下一个元素准备左侧乘积
3.3 右到左遍历计算右侧乘积并合并结果
java复制for (int i = n - 1; i >= 0; i--) {
res[i] *= right;
right *= nums[i];
}
这个循环中:
- res[i]已经包含左侧乘积,现在乘以右侧乘积
- right变量累乘当前元素,为前一个元素准备右侧乘积
3.4 边界条件处理
在实际编码时,我们还需要考虑一些边界情况:
- 空数组输入:直接返回空数组
- 单元素数组:按照题意应该返回[1](虽然题目保证n≥2)
- 包含0的数组:我们的解法天然支持这种情况
4. 复杂度分析与优化证明
4.1 时间复杂度
我们只进行了两次线性遍历,每次遍历的操作都是常数时间(赋值和乘法),因此总时间复杂度是O(n),这是最优的,因为至少要遍历整个数组一次才能获取所有元素信息。
4.2 空间复杂度
除了输出数组res外,我们只使用了两个整型变量left和right,因此额外空间复杂度是O(1),完全符合题目要求。
5. 代码实现与注释
java复制public int[] productExceptSelf(int[] nums) {
// 处理边界情况
if (nums == null || nums.length == 0) return new int[0];
int n = nums.length;
int[] res = new int[n];
// 计算左侧乘积
int left = 1;
for (int i = 0; i < n; i++) {
res[i] = left; // 存储当前元素的左侧乘积
left *= nums[i]; // 更新左侧乘积为下一个元素准备
}
// 计算右侧乘积并合并结果
int right = 1;
for (int i = n - 1; i >= 0; i--) {
res[i] *= right; // 左侧乘积 × 右侧乘积
right *= nums[i]; // 更新右侧乘积为前一个元素准备
}
return res;
}
6. 实际案例逐步解析
让我们用一个具体例子来验证算法的正确性。假设输入数组为[1, 2, 3, 4]。
6.1 第一次遍历(左→右)
初始化:res = [0,0,0,0], left = 1
i=0:
- res[0] = left = 1
- left = 1 * 1 = 1
→ res = [1,0,0,0]
i=1:
- res[1] = 1
- left = 1 * 2 = 2
→ res = [1,1,0,0]
i=2:
- res[2] = 2
- left = 2 * 3 = 6
→ res = [1,1,2,0]
i=3:
- res[3] = 6
- left = 6 * 4 = 24
→ res = [1,1,2,6]
6.2 第二次遍历(右→左)
初始化:right = 1
i=3:
- res[3] = 6 * 1 = 6
- right = 1 * 4 = 4
→ res = [1,1,2,6]
i=2:
- res[2] = 2 * 4 = 8
- right = 4 * 3 = 12
→ res = [1,1,8,6]
i=1:
- res[1] = 1 * 12 = 12
- right = 12 * 2 = 24
→ res = [1,12,8,6]
i=0:
- res[0] = 1 * 24 = 24
- right = 24 * 1 = 24
→ res = [24,12,8,6]
最终结果[24,12,8,6]确实符合预期:
- 24 = 2×3×4
- 12 = 1×3×4
- 8 = 1×2×4
- 6 = 1×2×3
7. 常见问题与解决方案
7.1 为什么初始值是1?
因为1是乘法的单位元,任何数乘以1都保持不变。对于第一个元素,它左边没有元素,我们可以认为是1(不影响乘积结果)。
7.2 如何处理包含0的数组?
我们的算法天然支持这种情况。例如输入[0,1,2,3]:
第一次遍历后res=[1,0,0,0]
第二次遍历后res=[6,0,0,0]
这是正确的,因为:
- 第一个位置:1×2×3=6
- 其他位置都包含0,所以乘积为0
7.3 能否只用一次遍历?
虽然可以尝试在一次遍历中同时计算左右乘积,但这样实现起来会更复杂,且可能无法真正减少时间复杂度。两次线性遍历的时间复杂度已经是O(n)了。
8. 算法扩展与应用
这种"左右乘积"的思想可以应用于许多类似问题,例如:
- 计算每个元素左右两边的最小值/最大值
- 计算每个元素左右两边的和
- 在字符串处理中,计算某个字符左右两侧的某种特征
掌握这种分治思想,可以解决一大类需要同时考虑元素左右两侧信息的问题。
9. 性能优化技巧
虽然我们的算法已经是最优解,但在实际实现时还可以注意:
- 循环展开:对于特别大的数组,可以考虑循环展开来优化
- 并行计算:在支持并行的环境中,可以尝试将两次遍历并行化
- 内存访问优化:确保数组访问是连续的,提高缓存命中率
10. 不同语言实现要点
虽然我们以Java为例,但算法思想是通用的。在其他语言中实现时需要注意:
- C/C++:注意数组越界检查
- Python:可以利用列表推导简化部分代码
- JavaScript:注意数字类型的处理
11. 面试技巧
当面试中被问到这道题时,建议:
- 先明确问题要求和约束条件
- 从暴力法开始,逐步优化
- 清楚地解释每个优化步骤的思路
- 最后讨论时间空间复杂度
- 准备一些测试用例验证代码
12. 实际工程中的应用
这种算法思想在实际工程中有广泛应用,例如:
- 图像处理中的滤波器计算
- 金融分析中的滑动窗口计算
- 信号处理中的前后关联分析
理解这类基础算法,能帮助我们更好地解决实际工程问题。