LeetCode hot 100 每日一题(6)--15. 三数之和

这是一道难度为中等的题目,让我们先来看看题目描述:

给你一个整数数组 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
  • - 1 0 5 10^5 105<= nums[i] <= 1 0 5 10^5 105

题解

nSum 系列问题的核心思路就是排序 + 双指针
这里的代码我们可以简单理解为2sum+ 递归

class Solution {

    // 主方法,解决 3Sum 问题
    public List<List<Integer>> threeSum(int[] nums) {
        // 对数组进行排序,便于后续的去重和双指针查找
        Arrays.sort(nums);
        // 调用 nSumTarget 方法,寻找三个数的和为 0 的所有组合
        return nSumTarget(nums, 3, 0, 0);
    }

    /**
     * 通用的 n 数之和问题解法
     * @param nums   已排序的数组
     * @param n      需要寻找的数字个数
     * @param start  搜索起始位置
     * @param target 目标和
     * @return       所有满足条件的数字组合列表
     */
    private List<List<Integer>> nSumTarget(int[] nums, int n, int start, long target) {
        int length = nums.length;                         // 数组长度
        List<List<Integer>> res = new ArrayList<>();      // 存放结果的列表

        // 如果要寻找的数字个数小于 2 或者数组中数字数量不足 n 个,则直接返回空列表
        if (n < 2 || length < n) {
            return res;
        }

        // 当问题退化为 2-sum 问题时,使用双指针方法进行查找
        if (n == 2) {
            int low = start;                            // low 指针从起始位置开始
            int high = length - 1;                        // high 指针从数组末尾开始

            // 当 low 指针小于 high 指针时进行查找
            while (low < high) {
                int sum = nums[low] + nums[high];         // 计算当前两数之和
                int left = nums[low];                     // 当前 low 指向的数,用于去重
                int right = nums[high];                   // 当前 high 指向的数,用于去重

                // 如果和小于目标值,说明需要增加 low 指针以增大和
                if (sum < target) {
                    // 跳过所有重复的数字
                    // 使用 while 循环跳过所有和 `left` 相同的值
                    while (low < high && nums[low] == left) {
                        low++;
                    }
                }
                // 如果和大于目标值,说明需要减小 high 指针以减小和
                else if (sum > target) {
                    // 跳过所有重复的数字
                    // 使用 while 循环跳过所有和 `right` 相同的值
                    while (low < high && nums[high] == right) {
                        high--;
                    }
                }
                // 找到满足条件的组合
                else {
                    // 将找到的组合加入结果列表中
                    res.add(new ArrayList<>(Arrays.asList(left, right)));
                    //同样跳过重复的数字,分别更新 low 和 high 指针。
                    // 跳过所有重复的数字,防止结果重复
                    while (low < high && nums[low] == left) {
                        low++;
                    }
                    while (low < high && nums[high] == right) {
                        high--;
                    }
                }
            }
        }
        // 当 n 大于 2 时,递归地将问题分解为 n-1 数之和的问题
        else {
            // 遍历从 start 到数组末尾的每个数字
            for (int i = start; i < length; i++) {
                // 递归调用,寻找 n-1 数之和为 (target - nums[i]) 的组合
                List<List<Integer>> sub = nSumTarget(nums, n - 1, i + 1, target - nums[i]);
                // 将当前数字添加到递归得到的每个组合中,形成 n 数的组合
                for (List<Integer> arr : sub) {
                    arr.add(nums[i]);
                    res.add(arr);
                }
                // 为了避免重复的组合,跳过后续与当前数字相同的数字
                while (i < length - 1 && nums[i] == nums[i + 1]) {
                    i++;
                }
            }
        }

        // 返回所有找到的组合
        return res;
    }
}

问题与解答

[NOTE] 问题1
int left = nums[low];int right = nums[high];保存当前 low 和 high 指向的数字,用于后续去重操作。这里为什么要这样做?如何在后续进行去重?是在这一段进行去重操作的吗?是因为要满足三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != kj != k这个条件吗?

// 如果和小于目标值,说明需要增加 low 指针以增大和
if (sum < target) {
   // 跳过所有重复的数字
   // 使用 while 循环跳过所有和 `left` 相同的值
   while (low < high && nums[low] == left) {
       low++;
   }
}
// 如果和大于目标值,说明需要减小 high 指针以减小和
else if (sum > target) {
	// 跳过所有重复的数字
	// 使用 while 循环跳过所有和 `right` 相同的值
	  while (low < high && nums[high] == right) {
      high--;
   }
}

解答:
int left = nums[low];int right = nums[high]; 这两行代码的目的是保存当前 low 和 high 指针指向的值,便于后续在跳过重复数字时使用。


关于是否与三元组索引不同有关?这里的去重操作主要是为了避免在结果中出现重复的组合,而不是直接针对三元组中索引必须互不相同的问题。实际上,题目中要求三元组的三个下标互不相同,这个条件在算法中已经隐含(例如在递归时,每次递归调用时选择的数字都会来自不同的下标),而去重操作主要用于防止重复组合的产生,比如输入 [-1, -1, 2, ...] 可能会多次找到 [-1, 2] 这一对数字,进而导致重复的三元组。

[NOTE] 问题2
代码中有很多通过while循环跳过重复数字的部分,那么为什么[-1,-1,2]这样出现了两个-1的不会被跳过呢?
解答:
在这段代码中,跳过重复数字的逻辑主要是为了避免在同一轮循环中重复处理相同的数值,产生相同的组合,而不是直接过滤掉所有重复的数字。

[-1,-1,2] 为例,当它形成一个有效组合时,有以下几点需要注意:

  1. 不同位置的相同数字
    数组中两个 -1 分别位于不同的位置(比如索引 0 和索引 1)。在满足题目条件时,允许组合中出现重复数字,只要它们来自不同的索引(i != ji != kj != k)。跳过重复数字的逻辑仅在同一次循环内避免用同一个数值多次产生相同的组合,而不是从数组中完全忽略它们。

  2. 跳过逻辑的时机
    在双指针(2-sum)部分,代码在找到一组符合条件的组合后,会通过 while 循环跳过所有与当前 left(或 right)相同的值。这是为了防止在下一次循环中重复计算同样的组合。例如,当 low 指针指向第一个 -1 时,如果满足条件,会将这一组合加入结果,然后用 while 循环跳过接下来连续的 -1 值,以免产生重复的组合。但在递归时(比如在 3-sum 中),固定的第一个 -1会和后面通过递归得到的 [-1,2]组合成一个合法的三元组 [-1,-1,2]。此时,这两个 -1分别来自不同层次的选择,属于合法且不同的下标组合。

  3. 具体示例

    • 在第一次遍历时,假设我们选择了第一个 -1作为一个固定数字,然后在剩余的部分寻找两数之和为 1(0 - (-1))。
    • 当双指针扫描到另一个 -12时,这一组合正好满足 -1 + (-1) + 2 = 0,于是被加入结果。
    • 此时的跳过逻辑确保的是,如果后续还有其他连续的 -1,不会重复使用相同的 -1来生成完全相同的组合,但并不会阻止在不同组合中使用必要的重复数字。

总的来说,跳过重复数字是为了避免在同一轮查找中因相邻的重复数字而产生多余的重复结果,而不是简单地把所有重复值都排除掉。这样就可以确保既不会出现多余的重复组合,同时又不会漏掉像 [-1,-1,2] 这样有效且合法的组合。

[NOTE] 问题3
那么假如在n == 2这样的递归中,假如出现了[0, 0]这样的情况呢?
解答:
在 n == 2 的递归中,如果出现了 [0, 0] 这种情况,代码也会正确处理,具体过程如下:

  1. 情况成立的条件
    当 n == 2 时,我们用双指针方法寻找两个数字之和等于 target。如果 target 为 0,且数组中有两个连续的 0,那么这两个 0 的和正好等于 target,于是 [0, 0] 就构成了一个合法的组合。

  2. 添加组合
    当 low 指针指向第一个 0,high 指针指向第二个 0 时:

    • 计算 sum = 0 + 0 = 0,满足 sum == target(这里 target == 0)。
    • 于是执行 res.add(new ArrayList<>(Arrays.asList(left, right)));,将 [0, 0] 加入结果列表。
  3. 去重操作
    添加完组合后,代码会通过 while 循环跳过所有与当前 low(或 high)相同的数字,避免重复添加相同组合:

    while(low < high && nums[low] == left) {
        low++;
    }
    while(low < high && nums[high] == right) {
        high--;
    }
    

    如果数组中有更多的 0,这两个 while 循环就会把它们全部跳过,确保 [0, 0] 只被加入一次。

  4. 合法性
    需要注意的是,题目要求的是组合中对应的下标互不相同。在这种情形下,虽然两个数字的值相同,但它们来自不同的位置(例如索引 i 和 j),因此满足题目条件。


总的来说,在 n==2 的递归过程中,出现 [0, 0] 是合法的,并且通过跳过重复数字的 while 循环确保了如果有多个连续的 0,只会添加一次 [0, 0] 这一组合,而不会重复记录。

[!NOTE] 问题4
List> res = new ArrayList<>(); 这一句声明表示什么?表示创建了一个列表的列表吗?那么这一句res.add(new ArrayList<>(Arrays.asList(left, right)));的用法呢?Arrays.asList(left, right)是将数组转换为列表吗?
解答:
List> res = new ArrayList<>(); 这里声明了一个列表 res,它的类型是 List>,也就是列表的列表
这个 res 用于存储最终所有满足条件的组合,每个组合都是一个 List
而关于res.add(new ArrayList<>(Arrays.asList(left, right)));

  • Arrays.asList(left, right)
    这一部分的作用是将传入的两个元素(这里是 leftright)转换为一个固定大小的列表
    这种方式很常用,因为可以快速将几个元素组合成一个列表。

  • new ArrayList<>(...)
    由于 Arrays.asList 返回的列表不能直接修改(它的大小是固定的),因此我们通常会将它包装在一个新的 ArrayList 中,使得这个列表成为一个可变的列表。
    这样一来,如果后续有需要对该组合进行修改(虽然在这个题目中不一定需要),也不会受到限制。

[NOTE] 问题5:
[ [-1, 2], [0, 1] ],在执行

 for (List<Integer> arr : sub) { 
 arr.add(nums[i]); 
 res.add(arr); 
 }

这一段代码是如何添加-1进入每个列表中的列表的?
解答:
假设当前固定的数字为 -1,并且递归返回的子结果为:

sub = [[-1, 2], [0, 1]]

这段代码的作用是遍历 sub 中的每个列表,然后把当前固定数字(这里是 -1)添加到每个列表中,从而形成完整的三元组。

具体步骤如下:

  1. 遍历子结果
    代码使用 for (List arr : sub) 遍历 sub 中的每个列表。
    第一次循环时,arr 指向列表 [-1, 2]
    第二次循环时,arr 指向列表 [0, 1]

  2. 添加当前数字
    在每次循环中,调用 arr.add(nums[i]);
    这里 nums[i] 就是当前固定数字(例如 -1)。

    • 对于第一次循环,arr[-1, 2] 变为 [-1, 2, -1]
    • 对于第二次循环,arr[0, 1] 变为 [0, 1, -1]
  3. 添加到结果列表
    随后,调用 res.add(arr); 将修改后的列表加入到最终的结果 res 中。

知识点:
for (List arr : sub) 是 Java 中的增强型 for 循环(也称为“foreach 循环”),用于遍历集合或数组中的每个元素。具体解释如下:

  • 语法结构
for (元素类型 变量名 : 集合或数组) {
   // 使用变量名来处理每个元素
}
  • 在这个例子中的含义

    • sub:这是一个 List> 类型的集合,也就是“列表的列表”,每个元素都是一个 List
    • List arr:声明变量 arr,其类型是 List,在每次循环中,它会依次引用 sub 中的每个列表。
    • 循环作用
      循环依次取出 sub 中的每个列表,然后在循环体中对这些列表进行操作,例如将当前固定数字加入到每个子列表中。

这种写法简洁且易读,避免了使用传统的基于索引的 for 循环。

实例

下面我们按照代码的整体流程,逐步详细拆解示例 1 的执行过程。示例 1 的输入为:

nums = [-1, 0, 1, 2, -1, -4]

步骤 1. 排序数组

代码首先对数组进行排序:

Arrays.sort(nums);

排序后数组变为:

[-4, -1, -1, 0, 1, 2]

这样做既方便后续使用双指针查找(在有序数组中移动指针更有规律),也便于去重操作。


步骤 2. 调用入口方法 threeSum

入口方法调用:

return nSumTarget(nums, 3, 0, 0);

表示在整个排序后的数组中,从索引 0 开始,寻找三个数之和等于 0 的组合。其中参数含义为:

  • nums:排序后的数组;
  • n = 3:要求找三个数;
  • start = 0:从数组索引 0 开始;
  • target = 0:目标和为 0。

步骤 3. 处理 3-sum 问题(n == 3)

进入 nSumTarget(nums, 3, 0, 0) 方法。
由于 n != 2,走递归分支,遍历下标 istartlength-1

3.1. 第一轮循环:i = 0
  • 当前数字nums[0] = -4
  • 目标:要组成三元组,其和为 0,因此在递归中,对剩余的两个数,我们希望它们的和为
    target - nums[0] = 0 - (-4) = 4

调用递归:

nSumTarget(nums, 2, 1, 4);

这里从索引 1 开始,在子数组 [-1, -1, 0, 1, 2] 中寻找两个数,其和为 4。

3.1.1. 处理 2-sum(n == 2),子数组索引 1 到 5
  • 初始化指针
    low = 1high = 5
    子数组为:[-1, -1, 0, 1, 2]

  • 第一次循环

    • nums[low] = -1nums[high] = 2

    • 计算和:sum = -1 + 2 = 1

    • 由于 1 < 4,需要增大和。
      保存当前 left = -1,然后用 while 循环跳过所有等于 -1 的值:

      while(low < high && nums[low] == left) { low++; }
      

      此时:

      • low 从 1 移动到 2,检查 nums[2] 依然是 -1,继续移动到 3。
      • 结果:low = 3high 仍为 5.
  • 第二次循环

    • 现在 nums[low] = nums[3] = 0nums[high] = 2
    • 计算和:sum = 0 + 2 = 2
    • 2 < 4,仍小于目标,保存当前 left = 0,跳过所有等于 0 的值:
      • low 从 3 移动到 4.
    • 结果:low = 4high = 5.
  • 第三次循环

    • 现在 nums[low] = nums[4] = 1nums[high] = nums[5] = 2
    • 计算和:sum = 1 + 2 = 3
    • 3 < 4,依然不满足目标。保存 left = 1,跳过所有等于 1 的值:
      • low 从 4 移动到 5.
    • 此时 low 等于 high,循环结束。
  • 返回结果:没有找到两数之和为 4的组合,返回空列表。

回到 3-sum 调用时,由于 i=0 得到的子结果为空,所以当前分支不生成有效的三元组。


3.2. 第二轮循环:i = 1
  • 当前数字nums[1] = -1
  • 目标:现在目标为 target - nums[1] = 0 - (-1) = 1

调用递归:

nSumTarget(nums, 2, 2, 1);

在子数组中,从索引 2 开始,即子数组为 [-1, 0, 1, 2],寻找两个数的和为 1。

3.2.1. 处理 2-sum(n == 2),子数组索引 2 到 5
  • 初始化指针
    low = 2high = 5
    子数组为:[-1, 0, 1, 2]

  • 第一次循环

    • nums[low] = nums[2] = -1nums[high] = nums[5] = 2

    • 计算和:sum = -1 + 2 = 1

    • 因为 sum == target(1 == 1),找到了一个组合。
      保存当前 left = -1right = 2,将组合 [-1, 2]存入结果列表。

    • 为避免重复,跳过所有与 left 相同的数字:

      while(low < high && nums[low] == left) { low++; }
      
      • 从索引 2,nums[2] = -1,移动后 low 变为 3.
    • 同时跳过 right 的重复:

      while(low < high && nums[high] == right) { high--; }
      
      • 从索引 5,nums[5] = 2,移动后 high 变为 4.
  • 第二次循环

    • 现在 low = 3nums[3] = 0),high = 4nums[4] = 1
    • 计算和:sum = 0 + 1 = 1
    • 同样满足 sum == target,记录当前 left = 0right = 1,组合 [0, 1]加入结果列表。
    • 跳过重复:
      • leftlow 从 3 移动到 4
      • righthigh 从 4 移动到 3.
  • 循环结束,因为此时 low >= high.

  • 返回结果:递归返回 2-sum 的结果列表:

    [[-1, 2], [0, 1]]
    
3.2.2. 合并 3-sum 结果

回到 nSumTarget(nums, 3, 0, 0) 的这一层(n==3):

  • 当前固定数字为 nums[1] = -1
  • 对返回的每个 2-sum 组合,都加入这个数字:
    • [-1, 2],加入 -1后变成 [-1, 2, -1](组合中三个数字分别来自不同索引:索引 1、2、5,其实际值为 -1, -1, 2)。
    • [0, 1],加入 -1后变成 [0, 1, -1](对应的实际组合为 -1, 0, 1)。

注意:虽然添加的顺序是后添加当前数字,但结果组合的顺序并不影响题目的正确性。

  • 为保证不重复,在 for 循环结束前,代码执行:

    while(i < length - 1 && nums[i] == nums[i + 1]) { i++; }
    

    由于 nums[1]nums[2]均为 -1,此 while 循环会使 i 跳过重复的 -1,避免下一次再次以相同数字作为固定数进入递归。

当前分支收集到的 3-sum 组合为:

[[-1, 2, -1], [0, 1, -1]]

调整排列后可看作 [-1, -1, 2][-1, 0, 1]


3.3. 第三轮循环:i = 3
  • 当前数字nums[3] = 0
  • 目标target - nums[3] = 0 - 0 = 0

调用递归:

nSumTarget(nums, 2, 4, 0);

在子数组中,从索引 4 开始,子数组为 [1, 2],寻找两个数的和为 0。

3.3.1. 处理 2-sum(n == 2),子数组索引 4 到 5
  • 初始化指针
    low = 4nums[4] = 1),high = 5nums[5] = 2
  • 循环
    • 计算和:sum = 1 + 2 = 3,而目标是 0。

    • 因为 sum > target,所以进入对应分支,保存 right = 2,并用 while 循环跳过所有等于 2 的值:

      while(low < high && nums[high] == right) { high--; }
      
      • high 从 5 移动到 4。
  • 此时 low 不再小于 high,循环结束,返回空列表。

因此,固定数字 0(i = 3)没有生成任何 2-sum 组合。


3.4. 后续循环:i = 4 和 i = 5
  • 当 i = 4 时,nums[4] = 1
    调用 nSumTarget(nums, 2, 5, 0 - 1 = -1)
    此时子数组仅包含 [2](索引 5),元素不足两个,直接返回空列表。

  • 当 i = 5 时,循环结束。


步骤 4. 汇总结果

从各个分支中,我们最终收集到的有效 3-sum 组合只有来自第二轮循环(i = 1):

  • 组合 1:[-1, 2, -1] → 实际组合为 [-1, -1, 2]
  • 组合 2:[0, 1, -1] → 实际组合为 [-1, 0, 1]

注意顺序不影响结果,最终返回的三元组为:

[[-1, -1, 2], [-1, 0, 1]]

这正是示例 1 中的预期输出。


以下是整体思路:

  1. 排序数组:将原数组 [-1,0,1,2,-1,-4] 排序成 [-4,-1,-1,0,1,2]
  2. 3-sum 递归调用:从索引 0 开始,以固定一个数字,然后在剩余部分用 2-sum 方法寻找合适的组合。
    • 当 i=0(固定 -4)时,尝试寻找两数之和为 4,未找到组合。
    • 当 i=1(固定 -1)时,寻找两数之和为 1,在子数组 [-1,0,1,2] 中成功找到了两组:[-1,2][0,1],合并后形成合法的三元组 [-1,-1,2][-1,0,1]
    • 当 i=3(固定 0)时,寻找两数之和为 0,在 [1,2] 中未找到满足条件的组合。
  3. 去重:在每个递归层次中,通过 while 循环跳过相邻相同的数字,保证不会重复生成相同的组合。

通过上述过程,代码最终返回正确的答案。

总结

这道3sum问题我们可以简单通过2sum+ 递归的方法来解决,而一般的nsum问题的核心思路就是排序 + 双指针。下一篇我将总结nsum问题的基本思路,欢迎点赞收藏!

你可能感兴趣的:(leetcode,leetcode,算法,数据结构)