文章目录
1. 题目描述
给你一个整数数组 nums
,返回 数组 answer
,其中 answer[i]
等于 nums
中除 nums[i]
之外其余各元素的乘积。
题目数据保证数组 nums
之中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。
请不要使用除法,且在 O(n) 时间复杂度内完成此题。
示例 1:
输入: nums = [1,2,3,4]
输出: [24,12,8,6]
示例 2:
输入: nums = [-1,1,0,-3,3]
输出: [0,0,9,0,0]
提示:
- 2 <= nums.length <= 10^5
- -30 <= nums[i] <= 30
- 保证数组 nums 之中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内
进阶:你可以在 O(1) 的额外空间复杂度内完成这个题目吗?(出于对空间复杂度分析的目的,输出数组不被视为额外空间。)
2. 理解题目
本题要求我们计算一个数组,其中每个元素等于原数组中除了该位置元素外所有其他元素的乘积。关键点在于:
- 不能使用除法:虽然最直观的思路是先计算所有元素的乘积,然后对每个位置除以该位置的元素,但题目明确禁止使用除法。
- 时间复杂度要求O(n):意味着我们不能使用嵌套循环(会导致O(n²)复杂度)。
- 进阶要求O(1)额外空间:我们需要尽可能减少额外使用的空间(不包括输出数组)。
例如,对于数组 [1,2,3,4]
:
answer[0] = 2*3*4 = 24
answer[1] = 1*3*4 = 12
answer[2] = 1*2*4 = 8
answer[3] = 1*2*3 = 6
这道题的难点在于如何在不使用除法的情况下高效计算这些结果。我们需要找到一种方法,能够获取每个位置左边所有元素的乘积和右边所有元素的乘积。
3. 解法一:左右两次遍历(前缀积和后缀积)
3.1 思路
这个解法基于一个关键观察:对于每个位置i,我们需要的乘积可以分解为两部分:
- 位置i左侧所有元素的乘积(前缀积)
- 位置i右侧所有元素的乘积(后缀积)
具体步骤:
- 创建两个数组:
left
和right
left[i]
表示nums[0]
到nums[i-1]
的乘积right[i]
表示nums[i+1]
到nums[nums.length-1]
的乘积- 最终结果
answer[i] = left[i] * right[i]
3.2 Java代码实现
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
// 创建结果数组
int[] answer = new int[n];
// 创建左侧乘积数组
int[] left = new int[n];
// 创建右侧乘积数组
int[] right = new int[n];
// 初始化
left[0] = 1; // 最左侧没有元素,乘积为1
right[n-1] = 1; // 最右侧没有元素,乘积为1
// 计算左侧乘积
for (int i = 1; i < n; i++) {
left[i] = left[i-1] * nums[i-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;
}
}
3.3 代码详解
详细解释每一步的逻辑:
int n = nums.length;
// 创建结果数组
int[] answer = new int[n];
// 创建左侧乘积数组
int[] left = new int[n];
// 创建右侧乘积数组
int[] right = new int[n];
- 首先获取输入数组的长度
n
- 创建一个长度为
n
的结果数组answer
- 创建两个辅助数组:
left
和right
,分别用于存储每个位置左侧和右侧所有元素的乘积
// 初始化
left[0] = 1; // 最左侧没有元素,乘积为1
right[n-1] = 1; // 最右侧没有元素,乘积为1
- 对于最左侧的元素(索引0),其左侧没有元素,所以
left[0] = 1
- 对于最右侧的元素(索引n-1),其右侧没有元素,所以
right[n-1] = 1
// 计算左侧乘积
for (int i = 1; i < n; i++) {
left[i] = left[i-1] * nums[i-1];
}
- 从左到右遍历数组,计算每个位置左侧所有元素的乘积
- 对于位置
i
,其左侧乘积等于位置i-1
的左侧乘积乘以位置i-1
的元素值
// 计算右侧乘积
for (int i = n-2; i >= 0; i--) {
right[i] = right[i+1] * nums[i+1];
}
- 从右到左遍历数组,计算每个位置右侧所有元素的乘积
- 对于位置
i
,其右侧乘积等于位置i+1
的右侧乘积乘以位置i+1
的元素值
// 计算最终结果
for (int i = 0; i < n; i++) {
answer[i] = left[i] * right[i];
}
- 对于每个位置
i
,将左侧乘积和右侧乘积相乘,得到除自身外所有元素的乘积
3.4 复杂度分析
- 时间复杂度:O(n),我们进行了三次遍历,每次都是O(n),总体为O(3n) = O(n)。
- 空间复杂度:O(n),我们使用了两个额外的数组
left
和right
,各占用O(n)空间,总体为O(2n) = O(n)。
3.5 适用场景
这种解法适用于:
- 初学者理解问题,思路清晰直观
- 不要求严格的空间复杂度时
- 面试中需要快速给出正确解法时
4. 解法二:空间优化(使用输出数组)
4.1 思路
我们可以将解法一中的辅助数组left
消除,直接使用输出数组answer
来存储左侧乘积。然后在第二次遍历时,使用一个变量rightProduct
来累积右侧乘积。
具体步骤:
- 第一次遍历:计算左侧乘积并存入
answer
数组 - 第二次遍历:计算右侧乘积,并将它与
answer
中的左侧乘积相乘得到最终结果
4.2 Java代码实现
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
// 创建结果数组
int[] answer = new int[n];
// 初始化第一个元素左侧的乘积为1
answer[0] = 1;
// 计算左侧乘积并存入answer
for (int i = 1; i < n; i++) {
answer[i] = answer[i-1] * nums[i-1];
}
// 使用一个变量记录右侧乘积
int rightProduct = 1;
// 从右向左遍历,同时计算右侧乘积并更新结果
for (int i = n-1; i >= 0; i--) {
answer[i] = answer[i] * rightProduct;
rightProduct *= nums[i]; // 更新右侧乘积
}
return answer;
}
}
4.3 代码详解
详细解释优化后的代码:
int n = nums.length;
// 创建结果数组
int[] answer = new int[n];
// 初始化第一个元素左侧的乘积为1
answer[0] = 1;
- 获取数组长度并创建结果数组
- 初始化
answer[0] = 1
,因为第一个元素左侧没有元素
// 计算左侧乘积并存入answer
for (int i = 1; i < n; i++) {
answer[i] = answer[i-1] * nums[i-1];
}
- 这一步与解法一类似,但我们直接将左侧乘积存入结果数组
answer
- 完成后,
answer[i]
中存储的是位置i
左侧所有元素的乘积
// 使用一个变量记录右侧乘积
int rightProduct = 1;
- 初始化一个变量
rightProduct = 1
来累积右侧乘积 - 初始值为1,表示最右侧元素右边没有元素
// 从右向左遍历,同时计算右侧乘积并更新结果
for (int i = n-1; i >= 0; i--) {
answer[i] = answer[i] * rightProduct;
rightProduct *= nums[i]; // 更新右侧乘积
}
- 从右到左遍历数组,更新结果和右侧乘积
- 对于每个位置
i
,将当前存储的左侧乘积answer[i]
与当前的右侧乘积rightProduct
相乘 - 然后更新
rightProduct
,将当前位置的元素值纳入计算
4.4 复杂度分析
- 时间复杂度:O(n),我们进行了两次遍历,每次都是O(n),总体为O(2n) = O(n)。
- 空间复杂度:O(1),除了输出数组外,我们只使用了一个变量
rightProduct
,符合题目的进阶要求。
4.5 与解法一的比较
改进点:
- 空间复杂度从O(n)降低到O(1)(不包括输出数组)
- 减少了一个辅助数组的创建和使用
- 代码更简洁,操作更高效
5. 解法三:进一步优化(单次遍历解法)
5.1 思路
虽然解法二已经很优秀,但在某些语言和框架中,我们可以通过一种更巧妙的方式进一步优化:使用两个指针从数组两端同时向中间移动,在一次遍历中完成所有计算。
注意:这种方法在Java中并不比解法二更高效(因为仍需两次遍历数组的所有元素),但在某些情况下可能更直观,特别是当数组非常大且需要减少遍历次数时。
5.2 Java代码实现
class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] answer = new int[n];
// 初始化所有值为1
Arrays.fill(answer, 1);
int leftProduct = 1;
int rightProduct = 1;
for (int i = 0; i < n; i++) {
// 从左向右更新
answer[i] *= leftProduct;
leftProduct *= nums[i];
// 从右向左更新(使用n-1-i索引)
answer[n-1-i] *= rightProduct;
rightProduct *= nums[n-1-i];
}
return answer;
}
}
然而,这种方法在计算过程中会出现重复更新的问题,可能导致结果不正确。更准确的单次遍历解法是使用两个完全独立的循环,这与解法二本质上相同。
因此,解法二(两次遍历法)是最优且最可靠的方法。
6. 详细步骤分析与示例跟踪
让我们通过一个具体示例,详细跟踪解法二的执行过程,以加深理解。
6.1 示例跟踪:使用输出数组(解法二)
输入:nums = [1,2,3,4]
第一步:初始化
n = 4
- 创建结果数组
answer = [0,0,0,0]
- 设置
answer[0] = 1
第二步:计算左侧乘积并存入answer
- i = 1:
answer[1] = answer[0] * nums[0] = 1 * 1 = 1
- i = 2:
answer[2] = answer[1] * nums[1] = 1 * 2 = 2
- i = 3:
answer[3] = answer[2] * nums[2] = 2 * 3 = 6
此时answer = [1,1,2,6]
,每个位置存储的是该位置左侧所有元素的乘积。
第三步:计算右侧乘积并更新结果
- 初始化
rightProduct = 1
- i = 3:
answer[3] = answer[3] * rightProduct = 6 * 1 = 6
rightProduct = rightProduct * nums[3] = 1 * 4 = 4
- i = 2:
answer[2] = answer[2] * rightProduct = 2 * 4 = 8
rightProduct = rightProduct * nums[2] = 4 * 3 = 12
- i = 1:
answer[1] = answer[1] * rightProduct = 1 * 12 = 12
rightProduct = rightProduct * nums[1] = 12 * 2 = 24
- i = 0:
answer[0] = answer[0] * rightProduct = 1 * 24 = 24
rightProduct = rightProduct * nums[0] = 24 * 1 = 24
(这一步已经不需要了)
最终结果:answer = [24,12,8,6]
验证结果:
answer[0] = 2*3*4 = 24
✓answer[1] = 1*3*4 = 12
✓answer[2] = 1*2*4 = 8
✓answer[3] = 1*2*3 = 6
✓
6.2 特殊情况:包含零的数组
输入:nums = [-1,1,0,-3,3]
第一步:初始化并计算左侧乘积
answer[0] = 1
answer[1] = answer[0] * nums[0] = 1 * (-1) = -1
answer[2] = answer[1] * nums[1] = -1 * 1 = -1
answer[3] = answer[2] * nums[2] = -1 * 0 = 0
answer[4] = answer[3] * nums[3] = 0 * (-3) = 0
此时answer = [1,-1,-1,0,0]
第二步:计算右侧乘积并更新结果
- 初始化
rightProduct = 1
- i = 4:
answer[4] = answer[4] * rightProduct = 0 * 1 = 0
rightProduct = rightProduct * nums[4] = 1 * 3 = 3
- i = 3:
answer[3] = answer[3] * rightProduct = 0 * 3 = 0
rightProduct = rightProduct * nums[3] = 3 * (-3) = -9
- i = 2:
answer[2] = answer[2] * rightProduct = -1 * (-9) = 9
rightProduct = rightProduct * nums[2] = -9 * 0 = 0
- i = 1:
answer[1] = answer[1] * rightProduct = -1 * 0 = 0
rightProduct = rightProduct * nums[1] = 0 * 1 = 0
- i = 0:
answer[0] = answer[0] * rightProduct = 1 * 0 = 0
rightProduct = rightProduct * nums[0] = 0 * (-1) = 0
最终结果:answer = [0,0,9,0,0]
注意:当数组中有零时,除包含零位置外的所有位置的结果都会是0,因为它们的乘积中会包含零。只有当位置正好是唯一零元素的位置时,结果才可能非零(等于其他所有非零元素的乘积)。
7. 常见错误与优化
7.1 常见错误
-
忘记初始化边界值:
// 错误:没有正确初始化第一个元素 int[] answer = new int[n]; // 直接开始循环,此时answer[0]仍为0 for (int i = 1; i < n; i++) { // ... } // 正确:初始化第一个元素 int[] answer = new int[n]; answer[0] = 1; // 明确设置初始值 for (int i = 1; i < n; i++) { // ... }
-
使用除法:
// 错误:使用除法(题目明确禁止) int totalProduct = 1; for (int num : nums) { totalProduct *= num; } for (int i = 0; i < n; i++) { answer[i] = totalProduct / nums[i]; // 使用了除法 } // 正确:使用前缀积和后缀积 // 按照解法一或解法二实现
注意:除法方法不仅违反题目要求,且当数组中有零时会导致除零错误。
-
数组索引越界:
// 错误:边界条件处理不当 for (int i = 0; i <= n; i++) { // 错误的循环条件 // ... } // 正确:正确处理边界 for (int i = 0; i < n; i++) { // ... }
-
处理零值的错误:
// 错误:特殊处理零值时的逻辑错误 if (nums[i] == 0) { answer[i] = 1; // 错误处理 } // 正确:零值不需要特殊处理,按算法正常执行即可
7.2 优化技巧
-
提前检查特殊情况:
// 优化:检查是否有多个零 int zeroCount = 0; for (int num : nums) { if (num == 0) zeroCount++; if (zeroCount > 1) break; // 如有多个零,大部分结果都是0 } if (zeroCount > 1) { // 所有结果都是0,直接返回全0数组 return new int[n]; } else if (zeroCount == 1) { // 特殊处理只有一个零的情况 // ... }
这种优化在有多个零的情况下可以避免不必要的计算。
-
合并初始化:
// 优化:合并初始化和首次循环 int[] answer = new int[n]; answer[0] = 1; // 一次循环完成左侧乘积计算 for (int i = 0; i < n - 1; i++) { answer[i + 1] = answer[i] * nums[i]; }
-
使用位运算(适用于特定情况):
对于某些特殊的数值(如2的幂),可以使用位运算优化乘法,但这种情况较少见。 -
并行计算:
对于非常大的数组,可以考虑使用并行流或多线程技术来加速计算,但对于一般情况,简单的线性解法已经足够高效。
8. 各解法对比
解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
---|---|---|---|---|
解法一:使用左右数组 | O(n) | O(n) | 思路清晰,直观易懂 | 额外空间使用较多 |
解法二:使用输出数组 | O(n) | O(1) | 满足进阶要求,空间利用高效 | 代码略复杂 |
思路错误:使用除法 | O(n) | O(1) | 代码简单 | 违反题目要求,有除零风险 |
最优解:解法二(使用输出数组)。它满足所有题目要求:O(n)时间复杂度,O(1)额外空间复杂度,且不使用除法。
9. 扩展题目与应用
9.1 相关题目
-
LeetCode 152. 乘积最大子数组:
- 给你一个整数数组,请你找出数组中乘积最大的连续子数组的乘积
- 与本题相似,都需要处理数组元素的乘积
-
LeetCode 724. 寻找数组的中心索引:
- 寻找数组的一个位置,使其左侧所有元素相加等于右侧所有元素相加
- 与本题类似,都需要处理前缀和或前缀积
-
LeetCode 303. 区域和检索 - 数组不可变:
- 使用前缀和的思想解决范围查询问题
- 技巧类似于本题中的前缀积
9.2 实际应用
-
金融计算:
- 计算除特定日期外的其他日期的投资回报率乘积
- 评估排除某个特定因素后的综合影响
-
信号处理:
- 在某些信号处理算法中,需要计算除当前样本外的乘积滤波
-
统计分析:
- 计算除特定数据点外的几何平均数
- 异常值检测中用于比较排除某点后的数据特性
-
图形渲染:
- 在某些渲染算法中,需要计算除当前像素外周围像素的影响因子乘积
10. 实际应用场景
10.1 数据分析中的应用
在数据分析中,我们经常需要计算除特定样本外的其他样本的统计特性。例如,假设我们有一个时间序列数据,需要分析每个时间点排除自身后的趋势强度(其他时间点变化率的乘积):
public class TrendAnalyzer {
public double[] calculateTrendStrength(double[] dailyReturns) {
// 使用本题的算法计算除自身外的乘积
int n = dailyReturns.length;
double[] trendStrength = new double[n];
// 计算左侧乘积
trendStrength[0] = 1;
for (int i = 1; i < n; i++) {
trendStrength[i] = trendStrength[i-1] * dailyReturns[i-1];
}
// 结合右侧乘积
double rightProduct = 1;
for (int i = n-1; i >= 0; i--) {
trendStrength[i] *= rightProduct;
rightProduct *= dailyReturns[i];
}
return trendStrength;
}
}
10.2 概率计算
在概率论中,如果有n个独立事件,每个事件的发生概率分别为p₁, p₂, …, pₙ,那么除第i个事件外其他所有事件都发生的概率正是一个"除自身以外的乘积"问题:
public class ProbabilityCalculator {
public double[] calculateComplementaryProbabilities(double[] individualProbs) {
// 计算除每个事件外,其他所有事件发生的概率
return productExceptSelf(individualProbs);
}
private double[] productExceptSelf(double[] probs) {
int n = probs.length;
double[] result = new double[n];
// 使用本题的算法
result[0] = 1;
for (int i = 1; i < n; i++) {
result[i] = result[i-1] * probs[i-1];
}
double rightProduct = 1;
for (int i = n-1; i >= 0; i--) {
result[i] *= rightProduct;
rightProduct *= probs[i];
}
return result;
}
}
10.3 特征缩放
在机器学习的特征工程中,有时需要计算每个特征与其他特征的相对关系:
public class FeatureScaler {
public double[][] calculateRelativeFeatureImportance(double[][] features) {
int samples = features.length;
int featureCount = features[0].length;
double[][] relativeImportance = new double[samples][featureCount];
for (int i = 0; i < samples; i++) {
// 对每个样本,计算除自身外的特征乘积
relativeImportance[i] = productExceptSelf(features[i]);
}
return relativeImportance;
}
private double[] productExceptSelf(double[] array) {
// 实现本题算法
// ...
}
}
11. 完整的 Java 解决方案
以下是结合了最佳实践的完整解决方案:
class Solution {
public int[] productExceptSelf(int[] nums) {
// 处理边界情况
if (nums == null || nums.length < 2) {
throw new IllegalArgumentException("数组长度至少为2");
}
int n = nums.length;
int[] answer = new int[n];
// 快速检查是否有多个0
int zeroCount = 0;
for (int num : nums) {
if (num == 0) zeroCount++;
if (zeroCount > 1) break;
}
// 如果有多个0,结果全是0
if (zeroCount > 1) {
return answer; // 默认值全是0
}
// 如果有一个0,只有0位置的元素可能非0
if (zeroCount == 1) {
int product = 1;
int zeroIndex = -1;
for (int i = 0; i < n; i++) {
if (nums[i] == 0) {
zeroIndex = i;
} else {
product *= nums[i];
}
}
answer[zeroIndex] = product;
return answer;
}
// 正常情况(没有0)
answer[0] = 1;
for (int i = 1; i < n; i++) {
answer[i] = answer[i-1] * nums[i-1];
}
int rightProduct = 1;
for (int i = n-1; i >= 0; i--) {
answer[i] *= rightProduct;
rightProduct *= nums[i];
}
return answer;
}
}
这个解决方案处理了所有可能的边界情况和特殊情况,特别是处理了数组中包含零的情况,使得代码更加健壮。
12. 总结与技巧
12.1 解题要点
-
前缀积与后缀积:这是解决本题的核心思想。通过计算每个位置左侧和右侧的乘积,然后相乘得到最终结果。
-
空间优化:使用输出数组存储中间结果,避免额外的空间使用。
-
特殊值处理:特别注意数组中包含零的情况,可以通过提前检查来优化算法。
-
细节把控:初始化边界值,正确处理索引,避免数组越界。
12.2 学习收获
通过学习本题,你可以掌握:
- 前缀和/前缀积的计算与应用
- 空间复杂度优化的技巧
- 多次遍历数组处理复杂问题的思路
- 处理特殊值(如零)的策略
12.3 面试技巧
如果在面试中遇到此类问题:
- 首先理解题目要求,特别是关于不使用除法和空间复杂度的限制
- 可以先提出直观但不符合要求的解法(如使用除法),然后解释为什么不能用
- 提出使用两个辅助数组的解法,并解释其工作原理
- 进一步优化为只使用输出数组的解法,展示你的优化思维
- 讨论特殊情况的处理,如数组中包含零的情况
- 分析时间和空间复杂度
记住,展示你的思考过程和解决问题的能力比直接给出最优解更重要。