Java详解LeetCode 热题 100(06):LeetCode 15. 三数之和(3Sum)详解

1. 题目描述

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != kj != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。

注意: 答案中不可以包含重复的三元组。

示例 1:

输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。

示例 2:

输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。

示例 3:

输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。

提示:

  • 3 <= nums.length <= 3000
  • -10^5 <= nums[i] <= 10^5

2. 理解题目

这道题要求我们找出数组中所有和为0的三个数的组合,且结果不能包含重复的三元组。理解以下几个关键点:

  1. 需要找出数组中三个数,使它们的和为0
  2. 三个数的下标必须不同(即不能使用同一个元素多次)
  3. 结果中不能包含重复的三元组(如[-1,0,1]和[0,-1,1]被视为相同的三元组)
  4. 需要返回所有满足条件的三元组

三元组 [a, b, c] 的和为0,意味着 a + b + c = 0,也可以转化为 a + b = -c。这个思路可以帮助我们设计算法。

3. 解法一:暴力法(三重循环)

3.1 思路

最直观的方法是使用三重嵌套循环来枚举所有可能的三元组,然后检查它们的和是否为0:

  1. 使用三层循环,分别选择三个不同的数
  2. 检查它们的和是否为0
  3. 如果是,将这个三元组加入结果集
  4. 为了避免重复,可以对结果进行去重处理

3.2 Java代码实现

public class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        int n = nums.length;
        Set<List<Integer>> result = new HashSet<>(); // 使用Set去重
        
        // 三重循环枚举所有可能的三元组
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                for (int k = j + 1; k < n; k++) {
                    // 检查三数之和是否为0
                    if (nums[i] + nums[j] + nums[k] == 0) {
                        // 将三个数排序后加入结果集,避免重复
                        List<Integer> triplet = Arrays.asList(nums[i], nums[j], nums[k]);
                        Collections.sort(triplet);
                        result.add(triplet);
                    }
                }
            }
        }
        
        return new ArrayList<>(result);
    }
}

3.3 代码详解

  1. 使用一个HashSet存储结果,自动去除重复的三元组
  2. 使用三层循环遍历所有可能的三元组组合:
    • 第一层循环选择第一个数,索引 i 从 0 到 n-1
    • 第二层循环选择第二个数,索引 j 从 i+1 到 n-1
    • 第三层循环选择第三个数,索引 k 从 j+1 到 n-1
  3. 检查三数之和是否为0:nums[i] + nums[j] + nums[k] == 0
  4. 为了避免重复,将三个数排序后再加入结果集
  5. 最后将HashSet转换为ArrayList返回

3.4 复杂度分析

  • 时间复杂度:O(n³ + n³log(n)),其中 n 是数组长度
    • 三重循环需要 O(n³) 的时间
    • 每次找到符合条件的三元组时,需要对三个数进行排序,这需要 O(log(3)) = O(1) 的时间
    • 总共有可能 O(n³) 个符合条件的三元组
    • 使用HashSet查找和插入操作的平均时间复杂度为 O(1)
  • 空间复杂度:O(n³),最坏情况下有 O(n³) 个符合条件的三元组

3.5 问题与改进

暴力法的主要问题是时间复杂度过高,对于中等大小的数组(如题目中提到的最大长度3000)都会超时。我们需要一种更高效的方法。

4. 解法二:排序 + 双指针

4.1 思路

一个更高效的方法是先对数组排序,然后使用双指针技巧:

  1. 首先对数组进行排序
  2. 固定第一个数,然后使用双指针在剩下的数中寻找和为目标值的两个数
  3. 使用去重技巧避免重复的三元组

核心思想:

  • 排序后,可以通过移动指针来避免重复
  • 如果三数之和大于0,移动右指针向左
  • 如果三数之和小于0,移动左指针向右
  • 如果三数之和等于0,记录结果并同时移动左右指针

4.2 Java代码实现

public class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        int n = nums.length;
        
        // 先对数组排序
        Arrays.sort(nums);
        
        // 固定第一个数,然后使用双指针在剩下的数中寻找和为0的两个数
        for (int i = 0; i < n - 2; i++) {
            // 跳过重复的第一个数
            if (i > 0 && nums[i] == nums[i - 1]) {
                continue;
            }
            
            // 如果第一个数大于0,因为数组已排序,后面的数也都大于0,三数之和必然大于0
            if (nums[i] > 0) {
                break;
            }
            
            int target = -nums[i]; // 目标值为第一个数的负数
            int left = i + 1;      // 左指针
            int right = n - 1;     // 右指针
            
            while (left < right) {
                int sum = nums[left] + nums[right];
                
                if (sum < target) {
                    // 和小于目标值,左指针右移
                    left++;
                } else if (sum > target) {
                    // 和大于目标值,右指针左移
                    right--;
                } else {
                    // 找到一组解
                    result.add(Arrays.asList(nums[i], nums[left], nums[right]));
                    
                    // 跳过重复的左边数
                    while (left < right && nums[left] == nums[left + 1]) {
                        left++;
                    }
                    // 跳过重复的右边数
                    while (left < right && nums[right] == nums[right - 1]) {
                        right--;
                    }
                    
                    // 移动左右指针寻找新的解
                    left++;
                    right--;
                }
            }
        }
        
        return result;
    }
}

4.3 代码详解

  1. 首先对数组进行排序,这样可以更容易地处理重复元素,也便于使用双指针
  2. 使用一个循环,固定第一个数(索引i)
  3. 跳过重复的第一个数(如果nums[i] == nums[i-1]
  4. 优化:如果第一个数大于0,由于数组已排序,三数之和必然大于0,可以提前结束
  5. 对于固定的第一个数,使用双指针(leftright)在剩余的数中寻找和为-nums[i]的两个数
  6. 根据nums[left] + nums[right]与目标值的比较,移动左指针或右指针
  7. 当找到一组解时,将其加入结果集,并移动左右指针跳过重复元素
  8. 最后返回所有找到的三元组

4.4 复杂度分析

  • 时间复杂度:O(n²),其中 n 是数组长度
    • 排序需要 O(n log n) 的时间
    • 双指针操作需要 O(n²) 的时间
    • 总体时间复杂度由双指针部分主导,为 O(n²)
  • 空间复杂度:O(log n) 到 O(n),取决于排序算法的实现。如果不考虑排序的空间复杂度,则为O(1)(不包括存储结果的空间)

4.5 优化与技巧

  1. 提前结束条件

    • 当第一个数大于0时,可以直接结束循环,因为三数之和必然大于0
    • 当第一个数等于前一个数时,可以跳过,避免重复计算
  2. 去重技巧

    • 找到一组解后,跳过左右指针指向的重复元素
    • 对于固定的第一个数,也跳过重复值
  3. 边界处理

    • 注意循环的边界,第一个固定的数最多到倒数第三个位置(i < n - 2
    • 双指针的移动条件是 left < right

5. 实例讲解

让我们通过一个例子详细解释排序+双指针法的执行过程:

输入数组:[-1, 0, 1, 2, -1, -4]

步骤1:对数组排序,得到 [-4, -1, -1, 0, 1, 2]

步骤2:开始遍历,固定第一个元素

循环1:固定 nums[0] = -4

  • 目标值 target = -(-4) = 4
  • 左指针 left = 1,指向 -1
  • 右指针 right = 5,指向 2
  • nums[left] + nums[right] = -1 + 2 = 1 < 4,左指针右移:left = 2
  • nums[left] + nums[right] = -1 + 2 = 1 < 4,左指针右移:left = 3
  • nums[left] + nums[right] = 0 + 2 = 2 < 4,左指针右移:left = 4
  • nums[left] + nums[right] = 1 + 2 = 3 < 4,左指针右移:left = 5
  • 此时 left = right,循环结束,没有找到解

循环2:固定 nums[1] = -1

  • 目标值 target = -(-1) = 1
  • 左指针 left = 2,指向 -1
  • 右指针 right = 5,指向 2
  • nums[left] + nums[right] = -1 + 2 = 1 = 1,找到一组解:[-1, -1, 2]
  • 跳过重复元素,左指针右移:left = 3,右指针不变
  • nums[left] + nums[right] = 0 + 2 = 2 > 1,右指针左移:right = 4
  • nums[left] + nums[right] = 0 + 1 = 1 = 1,找到一组解:[-1, 0, 1]
  • 左指针右移:left = 4,右指针左移:right = 3
  • 此时 left > right,循环结束

循环3:固定 nums[2] = -1,但由于与 nums[1] 相同,跳过

循环4:固定 nums[3] = 0

  • 目标值 target = -(0) = 0
  • 左指针 left = 4,指向 1
  • 右指针 right = 5,指向 2
  • nums[left] + nums[right] = 1 + 2 = 3 > 0,右指针左移:right = 4
  • 此时 left = right,循环结束,没有找到解

循环5:固定 nums[4] = 1,由于 nums[4] > 0,提前结束

最终结果:[[-1, -1, 2], [-1, 0, 1]]

6. 特殊情况和边界处理

在实现解决方案时,需要考虑以下特殊情况:

  1. 数组长度小于3

    • 按照题意,数组长度至少为3,但如果做额外检查,可以在开始时判断
    if (nums == null || nums.length < 3) {
        return new ArrayList<>();
    }
    
  2. 全是零的数组

    • 例如 [0, 0, 0, 0],需要确保只返回一个 [0, 0, 0] 而不是多个
    • 排序 + 双指针法已经自动处理了这种情况,因为会跳过重复元素
  3. 没有解的情况

    • 例如 [1, 2, 3],没有三个数的和为0
    • 算法会返回空列表
  4. 包含正数和负数的大数组

    • 这是最常见的情况,使用排序 + 双指针可以有效处理

7. 进阶优化

7.1 使用哈希表优化

对于每个固定的第一个数,我们可以使用哈希表而不是双指针来查找剩余的两个数:

public List<List<Integer>> threeSum(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    int n = nums.length;
    
    // 先对数组排序(仍然需要排序来处理重复元素)
    Arrays.sort(nums);
    
    for (int i = 0; i < n - 2; i++) {
        // 跳过重复的第一个数
        if (i > 0 && nums[i] == nums[i - 1]) {
            continue;
        }
        
        // 如果第一个数大于0,三数之和必然大于0
        if (nums[i] > 0) {
            break;
        }
        
        // 使用哈希表存储已遍历的第二个数
        Set<Integer> seen = new HashSet<>();
        int target = -nums[i];
        
        for (int j = i + 1; j < n; j++) {
            // 计算需要的第三个数
            int complement = target - nums[j];
            
            if (seen.contains(complement)) {
                // 找到一组解
                result.add(Arrays.asList(nums[i], complement, nums[j]));
                
                // 跳过重复的第二个数
                while (j + 1 < n && nums[j] == nums[j + 1]) {
                    j++;
                }
            }
            
            // 将当前的第二个数加入哈希表
            seen.add(nums[j]);
        }
    }
    
    return result;
}

这种方法的时间复杂度仍为O(n²),但在某些情况下可能比双指针更快,尤其是当数组中有大量重复元素时。

7.2 不使用排序的方法

我们还可以使用哈希表完全避免排序,但需要额外的逻辑来处理重复:

public List<List<Integer>> threeSum(int[] nums) {
    Set<List<Integer>> result = new HashSet<>(); // 使用Set去重
    Set<Integer> seen = new HashSet<>(); // 存储已经遍历过的第一个数
    Map<Integer, Integer> numCount = new HashMap<>(); // 存储每个数字的出现次数
    
    // 统计每个数字的出现次数
    for (int num : nums) {
        numCount.put(num, numCount.getOrDefault(num, 0) + 1);
    }
    
    // 遍历每个可能的第一个数
    for (int i = 0; i < nums.length; i++) {
        // 跳过已处理的第一个数
        if (seen.contains(nums[i])) {
            continue;
        }
        seen.add(nums[i]);
        
        // 减少第一个数的计数
        numCount.put(nums[i], numCount.get(nums[i]) - 1);
        
        // 遍历每个可能的第二个数
        for (int j = i + 1; j < nums.length; j++) {
            // 减少第二个数的计数
            numCount.put(nums[j], numCount.get(nums[j]) - 1);
            
            // 计算需要的第三个数
            int target = -nums[i] - nums[j];
            
            // 检查第三个数是否存在且计数大于0
            if (numCount.containsKey(target) && numCount.get(target) > 0) {
                // 找到一组解,排序后加入结果集
                List<Integer> triplet = Arrays.asList(nums[i], nums[j], target);
                triplet.sort(null);
                result.add(triplet);
            }
            
            // 恢复第二个数的计数
            numCount.put(nums[j], numCount.get(nums[j]) + 1);
        }
        
        // 恢复第一个数的计数
        numCount.put(nums[i], numCount.get(nums[i]) + 1);
    }
    
    return new ArrayList<>(result);
}

这种方法的时间复杂度也是O(n²),但常数因子可能更大,且实现更复杂。在大多数情况下,排序 + 双指针法是更简单且高效的选择。

8. 完整的 Java 解决方案

以下是最优解决方案(排序 + 双指针法)的完整实现:

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        int n = nums.length;
        
        // 边界检查
        if (nums == null || n < 3) {
            return result;
        }
        
        // 先对数组排序
        Arrays.sort(nums);
        
        for (int i = 0; i < n - 2; i++) {
            // 跳过重复的第一个数
            if (i > 0 && nums[i] == nums[i - 1]) {
                continue;
            }
            
            // 如果第一个数大于0,因为数组已排序,三数之和必然大于0
            if (nums[i] > 0) {
                break;
            }
            
            int target = -nums[i];
            int left = i + 1;
            int right = n - 1;
            
            while (left < right) {
                int sum = nums[left] + nums[right];
                
                if (sum < target) {
                    left++;
                } else if (sum > target) {
                    right--;
                } else {
                    // 找到一组解
                    result.add(Arrays.asList(nums[i], nums[left], nums[right]));
                    
                    // 跳过重复的左边数
                    while (left < right && nums[left] == nums[left + 1]) {
                        left++;
                    }
                    // 跳过重复的右边数
                    while (left < right && nums[right] == nums[right - 1]) {
                        right--;
                    }
                    
                    // 移动左右指针寻找新的解
                    left++;
                    right--;
                }
            }
        }
        
        return result;
    }
}

9. 实际应用与扩展

9.1 应用场景

三数之和问题的思想在实际中有多种应用:

  1. 数据分析:在数据分析中,可能需要找出多个变量的组合使其满足特定条件
  2. 金融领域:在金融产品的组合中,可能需要找出多个资产的组合,使得其风险或收益达到特定值
  3. 化学计算:在分子模拟中,可能需要找出多个原子的组合,使其电荷平衡或达到特定能量状态
  4. 游戏开发:在某些游戏逻辑中,可能需要找出多个角色或道具的组合,使其能力值达到特定标准

9.2 扩展问题

  1. 四数之和(4Sum)

    • 类似于三数之和,但需要找出四个数的组合使其和为目标值
    • 可以通过添加一层循环来扩展三数之和的解法
    • 时间复杂度为 O(n³)
  2. K数之和(KSum)

    • 一般化的问题,找出K个数的组合使其和为目标值
    • 可以使用递归方法解决,时间复杂度为 O(n^(K-1))
  3. 三数最接近和

    • 找出三个数,使其和最接近目标值
    • 可以修改三数之和的解法,记录最接近的结果
  4. 不同于零的目标值

    • 如果目标和不是0,而是任意值t,解法几乎相同
    • 只需要将目标值从0改为t即可

10. 常见问题与解答

10.1 为什么需要排序?

排序有两个主要目的:

  1. 便于使用双指针技巧(从两端向中间移动)
  2. 帮助跳过重复元素,避免生成重复的三元组

10.2 如何处理重复的三元组?

主要有三种方法:

  1. 使用Set数据结构自动去重(如暴力法中所示)
  2. 排序后,在添加新的三元组前检查是否与前一个相同
  3. 排序后,跳过重复的元素(第一个数、左边界和右边界)

10.3 双指针法为什么比暴力法更高效?

双指针法将时间复杂度从 O(n³) 降低到 O(n²):

  • 暴力法需要三重循环遍历所有组合
  • 双指针法固定第一个数后,利用排序数组的特性,在线性时间内找到剩余的两个数

10.4 什么情况下可以提前结束?

  1. 如果第一个固定的数大于0,由于数组已排序,后续三数之和一定大于0,可以提前结束
  2. 如果数组长度小于3,不可能有有效的三元组,可以直接返回空列表

11. 测试用例

为了验证解决方案的正确性,以下是一些测试用例:

public class ThreeSumTest {
    public static void main(String[] args) {
        Solution solution = new Solution();
        
        // 测试用例1:标准测试
        int[] nums1 = {-1, 0, 1, 2, -1, -4};
        System.out.println("测试用例1: " + solution.threeSum(nums1));
        
        // 测试用例2:没有解
        int[] nums2 = {0, 1, 1};
        System.out.println("测试用例2: " + solution.threeSum(nums2));
        
        // 测试用例3:全是零
        int[] nums3 = {0, 0, 0, 0};
        System.out.println("测试用例3: " + solution.threeSum(nums3));
        
        // 测试用例4:包含重复元素
        int[] nums4 = {-2, 0, 0, 2, 2};
        System.out.println("测试用例4: " + solution.threeSum(nums4));
        
        // 测试用例5:大量不同的数
        int[] nums5 = {-4, -2, -2, -2, 0, 1, 2, 2, 2, 3, 3, 4, 4, 6, 6};
        System.out.println("测试用例5: " + solution.threeSum(nums5));
    }
}

预期输出:

测试用例1: [[-1, -1, 2], [-1, 0, 1]]
测试用例2: []
测试用例3: [[0, 0, 0]]
测试用例4: [[-2, 0, 2]]
测试用例5: [[-4, -2, 6], [-4, 0, 4], [-4, 1, 3], [-4, 2, 2], [-2, -2, 4], [-2, 0, 2]]

12. 总结与技巧

12.1 解题要点

  1. 理解问题要求:找出三个数和为0,且结果不能有重复的三元组
  2. 排序的重要性:排序是高效解决此问题的关键步骤
  3. 双指针技巧:固定一个数后,使用双指针高效查找其他两个数
  4. 去重策略:跳过重复的元素,避免生成重复的三元组
  5. 提前结束条件:合理的提前结束条件可以进一步优化算法

12.2 常用技巧

  1. 排序 + 双指针:这是解决多数和为目标值问题的常用技巧
  2. 跳过重复元素:通过比较相邻元素跳过重复值
  3. 提前退出条件:根据排序后的数组特性设置提前退出条件
  4. 目标转化:将"三数之和为0"转化为"两数之和为-第一个数"
  5. 特殊情况处理:处理边界情况和特殊输入

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、付费专栏及课程。

余额充值