这是一道难度为中等的题目,让我们先来看看题目描述:
给你一个整数数组
nums
,判断是否存在三元组[nums[i], nums[j], nums[k]]
满足i != j
、i != k
且j != 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 != j
、i != k
且j != 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
分别位于不同的位置(比如索引 0 和索引 1)。在满足题目条件时,允许组合中出现重复数字,只要它们来自不同的索引(i != j
,i != k
,j != k
)。跳过重复数字的逻辑仅在同一次循环内避免用同一个数值多次产生相同的组合,而不是从数组中完全忽略它们。跳过逻辑的时机:
在双指针(2-sum)部分,代码在找到一组符合条件的组合后,会通过while
循环跳过所有与当前left
(或right
)相同的值。这是为了防止在下一次循环中重复计算同样的组合。例如,当 low 指针指向第一个-1
时,如果满足条件,会将这一组合加入结果,然后用while
循环跳过接下来连续的-1
值,以免产生重复的组合。但在递归时(比如在 3-sum 中),固定的第一个-1
会和后面通过递归得到的[-1,2]
组合成一个合法的三元组[-1,-1,2]
。此时,这两个-1
分别来自不同层次的选择,属于合法且不同的下标组合。具体示例:
- 在第一次遍历时,假设我们选择了第一个
-1
作为一个固定数字,然后在剩余的部分寻找两数之和为1
(0 - (-1))。- 当双指针扫描到另一个
-1
和2
时,这一组合正好满足-1 + (-1) + 2 = 0
,于是被加入结果。- 此时的跳过逻辑确保的是,如果后续还有其他连续的
-1
,不会重复使用相同的-1
来生成完全相同的组合,但并不会阻止在不同组合中使用必要的重复数字。总的来说,跳过重复数字是为了避免在同一轮查找中因相邻的重复数字而产生多余的重复结果,而不是简单地把所有重复值都排除掉。这样就可以确保既不会出现多余的重复组合,同时又不会漏掉像
[-1,-1,2]
这样有效且合法的组合。
[NOTE] 问题3
那么假如在n == 2这样的递归中,假如出现了[0, 0]这样的情况呢?
解答:
在 n == 2 的递归中,如果出现了[0, 0]
这种情况,代码也会正确处理,具体过程如下:
情况成立的条件
当 n == 2 时,我们用双指针方法寻找两个数字之和等于 target。如果 target 为 0,且数组中有两个连续的 0,那么这两个 0 的和正好等于 target,于是[0, 0]
就构成了一个合法的组合。添加组合
当 low 指针指向第一个 0,high 指针指向第二个 0 时:
- 计算
sum = 0 + 0 = 0
,满足sum == target
(这里 target == 0)。- 于是执行
res.add(new ArrayList<>(Arrays.asList(left, right)));
,将[0, 0]
加入结果列表。去重操作
添加完组合后,代码会通过 while 循环跳过所有与当前 low(或 high)相同的数字,避免重复添加相同组合:while(low < high && nums[low] == left) { low++; } while(low < high && nums[high] == right) { high--; }
如果数组中有更多的 0,这两个 while 循环就会把它们全部跳过,确保
[0, 0]
只被加入一次。合法性
需要注意的是,题目要求的是组合中对应的下标互不相同。在这种情形下,虽然两个数字的值相同,但它们来自不同的位置(例如索引 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)
这一部分的作用是将传入的两个元素(这里是left
和right
)转换为一个固定大小的列表。
这种方式很常用,因为可以快速将几个元素组合成一个列表。
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)添加到每个列表中,从而形成完整的三元组。具体步骤如下:
遍历子结果
代码使用for (List
遍历arr : sub) sub
中的每个列表。
第一次循环时,arr
指向列表[-1, 2]
;
第二次循环时,arr
指向列表[0, 1]
。添加当前数字
在每次循环中,调用arr.add(nums[i]);
这里nums[i]
就是当前固定数字(例如 -1)。
- 对于第一次循环,
arr
从[-1, 2]
变为[-1, 2, -1]
。- 对于第二次循环,
arr
从[0, 1]
变为[0, 1, -1]
。添加到结果列表
随后,调用res.add(arr);
将修改后的列表加入到最终的结果res
中。知识点:
for (List
是 Java 中的增强型 for 循环(也称为“foreach 循环”),用于遍历集合或数组中的每个元素。具体解释如下:arr : sub)
- 语法结构:
for (元素类型 变量名 : 集合或数组) { // 使用变量名来处理每个元素 }
在这个例子中的含义:
- sub:这是一个
List
类型的集合,也就是“列表的列表”,每个元素都是一个>
List
。- List arr:声明变量
arr
,其类型是List
,在每次循环中,它会依次引用sub
中的每个列表。- 循环作用:
循环依次取出sub
中的每个列表,然后在循环体中对这些列表进行操作,例如将当前固定数字加入到每个子列表中。这种写法简洁且易读,避免了使用传统的基于索引的 for 循环。
下面我们按照代码的整体流程,逐步详细拆解示例 1 的执行过程。示例 1 的输入为:
nums = [-1, 0, 1, 2, -1, -4]
代码首先对数组进行排序:
Arrays.sort(nums);
排序后数组变为:
[-4, -1, -1, 0, 1, 2]
这样做既方便后续使用双指针查找(在有序数组中移动指针更有规律),也便于去重操作。
入口方法调用:
return nSumTarget(nums, 3, 0, 0);
表示在整个排序后的数组中,从索引 0 开始,寻找三个数之和等于 0 的组合。其中参数含义为:
进入 nSumTarget(nums, 3, 0, 0)
方法。
由于 n != 2
,走递归分支,遍历下标 i
从 start
到 length-1
。
nums[0] = -4
target - nums[0] = 0 - (-4) = 4
。调用递归:
nSumTarget(nums, 2, 1, 4);
这里从索引 1 开始,在子数组 [-1, -1, 0, 1, 2]
中寻找两个数,其和为 4。
初始化指针:
low = 1
,high = 5
子数组为:[-1, -1, 0, 1, 2]
第一次循环:
nums[low] = -1
,nums[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 = 3
,high
仍为 5.第二次循环:
nums[low] = nums[3] = 0
,nums[high] = 2
sum = 0 + 2 = 2
2 < 4
,仍小于目标,保存当前 left = 0
,跳过所有等于 0 的值:
low
从 3 移动到 4.low = 4
,high = 5
.第三次循环:
nums[low] = nums[4] = 1
,nums[high] = nums[5] = 2
sum = 1 + 2 = 3
3 < 4
,依然不满足目标。保存 left = 1
,跳过所有等于 1 的值:
low
从 4 移动到 5.low
等于 high
,循环结束。返回结果:没有找到两数之和为 4的组合,返回空列表。
回到 3-sum 调用时,由于 i=0 得到的子结果为空,所以当前分支不生成有效的三元组。
nums[1] = -1
target - nums[1] = 0 - (-1) = 1
。调用递归:
nSumTarget(nums, 2, 2, 1);
在子数组中,从索引 2 开始,即子数组为 [-1, 0, 1, 2]
,寻找两个数的和为 1。
初始化指针:
low = 2
,high = 5
子数组为:[-1, 0, 1, 2]
第一次循环:
nums[low] = nums[2] = -1
,nums[high] = nums[5] = 2
计算和:sum = -1 + 2 = 1
因为 sum == target
(1 == 1),找到了一个组合。
保存当前 left = -1
和 right = 2
,将组合 [-1, 2]
存入结果列表。
为避免重复,跳过所有与 left
相同的数字:
while(low < high && nums[low] == left) { low++; }
nums[2] = -1
,移动后 low
变为 3.同时跳过 right
的重复:
while(low < high && nums[high] == right) { high--; }
nums[5] = 2
,移动后 high
变为 4.第二次循环:
low = 3
(nums[3] = 0
),high = 4
(nums[4] = 1
)sum = 0 + 1 = 1
sum == target
,记录当前 left = 0
和 right = 1
,组合 [0, 1]
加入结果列表。left
:low
从 3 移动到 4right
:high
从 4 移动到 3.循环结束,因为此时 low >= high
.
返回结果:递归返回 2-sum 的结果列表:
[[-1, 2], [0, 1]]
回到 nSumTarget(nums, 3, 0, 0)
的这一层(n==3):
nums[1] = -1
。[-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]
。
nums[3] = 0
target - nums[3] = 0 - 0 = 0
。调用递归:
nSumTarget(nums, 2, 4, 0);
在子数组中,从索引 4 开始,子数组为 [1, 2]
,寻找两个数的和为 0。
low = 4
(nums[4] = 1
),high = 5
(nums[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 组合。
当 i = 4 时,nums[4] = 1
。
调用 nSumTarget(nums, 2, 5, 0 - 1 = -1)
。
此时子数组仅包含 [2]
(索引 5),元素不足两个,直接返回空列表。
当 i = 5 时,循环结束。
从各个分支中,我们最终收集到的有效 3-sum 组合只有来自第二轮循环(i = 1):
[-1, 2, -1]
→ 实际组合为 [-1, -1, 2]
[0, 1, -1]
→ 实际组合为 [-1, 0, 1]
注意顺序不影响结果,最终返回的三元组为:
[[-1, -1, 2], [-1, 0, 1]]
这正是示例 1 中的预期输出。
以下是整体思路:
[-1,0,1,2,-1,-4]
排序成 [-4,-1,-1,0,1,2]
。[-1,0,1,2]
中成功找到了两组:[-1,2]
和 [0,1]
,合并后形成合法的三元组 [-1,-1,2]
与 [-1,0,1]
。[1,2]
中未找到满足条件的组合。通过上述过程,代码最终返回正确的答案。
这道3sum问题我们可以简单通过2sum+ 递归的方法来解决,而一般的nsum问题的核心思路就是排序 + 双指针。下一篇我将总结nsum问题的基本思路,欢迎点赞收藏!