Java详解LeetCode 热题 100(16):LeetCode 238. 除自身以外数组的乘积(Product of Array Except Self)详解

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. 理解题目

本题要求我们计算一个数组,其中每个元素等于原数组中除了该位置元素外所有其他元素的乘积。关键点在于:

  1. 不能使用除法:虽然最直观的思路是先计算所有元素的乘积,然后对每个位置除以该位置的元素,但题目明确禁止使用除法。
  2. 时间复杂度要求O(n):意味着我们不能使用嵌套循环(会导致O(n²)复杂度)。
  3. 进阶要求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,我们需要的乘积可以分解为两部分:

  1. 位置i左侧所有元素的乘积(前缀积)
  2. 位置i右侧所有元素的乘积(后缀积)

具体步骤:

  1. 创建两个数组:leftright
  2. left[i]表示nums[0]nums[i-1]的乘积
  3. right[i]表示nums[i+1]nums[nums.length-1]的乘积
  4. 最终结果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
  • 创建两个辅助数组:leftright,分别用于存储每个位置左侧和右侧所有元素的乘积
// 初始化
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),我们使用了两个额外的数组leftright,各占用O(n)空间,总体为O(2n) = O(n)。

3.5 适用场景

这种解法适用于:

  • 初学者理解问题,思路清晰直观
  • 不要求严格的空间复杂度时
  • 面试中需要快速给出正确解法时

4. 解法二:空间优化(使用输出数组)

4.1 思路

我们可以将解法一中的辅助数组left消除,直接使用输出数组answer来存储左侧乘积。然后在第二次遍历时,使用一个变量rightProduct来累积右侧乘积。

具体步骤:

  1. 第一次遍历:计算左侧乘积并存入answer数组
  2. 第二次遍历:计算右侧乘积,并将它与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 常见错误

  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++) {
        // ...
    }
    
  2. 使用除法

    // 错误:使用除法(题目明确禁止)
    int totalProduct = 1;
    for (int num : nums) {
        totalProduct *= num;
    }
    for (int i = 0; i < n; i++) {
        answer[i] = totalProduct / nums[i]; // 使用了除法
    }
    
    // 正确:使用前缀积和后缀积
    // 按照解法一或解法二实现
    

    注意:除法方法不仅违反题目要求,且当数组中有零时会导致除零错误。

  3. 数组索引越界

    // 错误:边界条件处理不当
    for (int i = 0; i <= n; i++) { // 错误的循环条件
        // ...
    }
    
    // 正确:正确处理边界
    for (int i = 0; i < n; i++) {
        // ...
    }
    
  4. 处理零值的错误

    // 错误:特殊处理零值时的逻辑错误
    if (nums[i] == 0) {
        answer[i] = 1; // 错误处理
    }
    
    // 正确:零值不需要特殊处理,按算法正常执行即可
    

7.2 优化技巧

  1. 提前检查特殊情况

    // 优化:检查是否有多个零
    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) {
        // 特殊处理只有一个零的情况
        // ...
    }
    

    这种优化在有多个零的情况下可以避免不必要的计算。

  2. 合并初始化

    // 优化:合并初始化和首次循环
    int[] answer = new int[n];
    answer[0] = 1;
    
    // 一次循环完成左侧乘积计算
    for (int i = 0; i < n - 1; i++) {
        answer[i + 1] = answer[i] * nums[i];
    }
    
  3. 使用位运算(适用于特定情况)
    对于某些特殊的数值(如2的幂),可以使用位运算优化乘法,但这种情况较少见。

  4. 并行计算
    对于非常大的数组,可以考虑使用并行流或多线程技术来加速计算,但对于一般情况,简单的线性解法已经足够高效。

8. 各解法对比

解法时间复杂度空间复杂度优点缺点
解法一:使用左右数组O(n)O(n)思路清晰,直观易懂额外空间使用较多
解法二:使用输出数组O(n)O(1)满足进阶要求,空间利用高效代码略复杂
思路错误:使用除法O(n)O(1)代码简单违反题目要求,有除零风险

最优解:解法二(使用输出数组)。它满足所有题目要求:O(n)时间复杂度,O(1)额外空间复杂度,且不使用除法。

9. 扩展题目与应用

9.1 相关题目

  1. LeetCode 152. 乘积最大子数组

    • 给你一个整数数组,请你找出数组中乘积最大的连续子数组的乘积
    • 与本题相似,都需要处理数组元素的乘积
  2. LeetCode 724. 寻找数组的中心索引

    • 寻找数组的一个位置,使其左侧所有元素相加等于右侧所有元素相加
    • 与本题类似,都需要处理前缀和或前缀积
  3. LeetCode 303. 区域和检索 - 数组不可变

    • 使用前缀和的思想解决范围查询问题
    • 技巧类似于本题中的前缀积

9.2 实际应用

  1. 金融计算

    • 计算除特定日期外的其他日期的投资回报率乘积
    • 评估排除某个特定因素后的综合影响
  2. 信号处理

    • 在某些信号处理算法中,需要计算除当前样本外的乘积滤波
  3. 统计分析

    • 计算除特定数据点外的几何平均数
    • 异常值检测中用于比较排除某点后的数据特性
  4. 图形渲染

    • 在某些渲染算法中,需要计算除当前像素外周围像素的影响因子乘积

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 解题要点

  1. 前缀积与后缀积:这是解决本题的核心思想。通过计算每个位置左侧和右侧的乘积,然后相乘得到最终结果。

  2. 空间优化:使用输出数组存储中间结果,避免额外的空间使用。

  3. 特殊值处理:特别注意数组中包含零的情况,可以通过提前检查来优化算法。

  4. 细节把控:初始化边界值,正确处理索引,避免数组越界。

12.2 学习收获

通过学习本题,你可以掌握:

  • 前缀和/前缀积的计算与应用
  • 空间复杂度优化的技巧
  • 多次遍历数组处理复杂问题的思路
  • 处理特殊值(如零)的策略

12.3 面试技巧

如果在面试中遇到此类问题:

  1. 首先理解题目要求,特别是关于不使用除法和空间复杂度的限制
  2. 可以先提出直观但不符合要求的解法(如使用除法),然后解释为什么不能用
  3. 提出使用两个辅助数组的解法,并解释其工作原理
  4. 进一步优化为只使用输出数组的解法,展示你的优化思维
  5. 讨论特殊情况的处理,如数组中包含零的情况
  6. 分析时间和空间复杂度

记住,展示你的思考过程和解决问题的能力比直接给出最优解更重要。

13. 参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

全栈凯哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值