文章目录
1. 题目描述
给你一个整数数组 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
- -10^5 <= nums[i] <= 10^5
2. 理解题目
这道题要求我们找出数组中所有和为0的三个数的组合,且结果不能包含重复的三元组。理解以下几个关键点:
- 需要找出数组中三个数,使它们的和为0
- 三个数的下标必须不同(即不能使用同一个元素多次)
- 结果中不能包含重复的三元组(如[-1,0,1]和[0,-1,1]被视为相同的三元组)
- 需要返回所有满足条件的三元组
三元组 [a, b, c]
的和为0,意味着 a + b + c = 0
,也可以转化为 a + b = -c
。这个思路可以帮助我们设计算法。
3. 解法一:暴力法(三重循环)
3.1 思路
最直观的方法是使用三重嵌套循环来枚举所有可能的三元组,然后检查它们的和是否为0:
- 使用三层循环,分别选择三个不同的数
- 检查它们的和是否为0
- 如果是,将这个三元组加入结果集
- 为了避免重复,可以对结果进行去重处理
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 代码详解
- 使用一个
HashSet
存储结果,自动去除重复的三元组 - 使用三层循环遍历所有可能的三元组组合:
- 第一层循环选择第一个数,索引
i
从 0 到 n-1 - 第二层循环选择第二个数,索引
j
从 i+1 到 n-1 - 第三层循环选择第三个数,索引
k
从 j+1 到 n-1
- 第一层循环选择第一个数,索引
- 检查三数之和是否为0:
nums[i] + nums[j] + nums[k] == 0
- 为了避免重复,将三个数排序后再加入结果集
- 最后将
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 思路
一个更高效的方法是先对数组排序,然后使用双指针技巧:
- 首先对数组进行排序
- 固定第一个数,然后使用双指针在剩下的数中寻找和为目标值的两个数
- 使用去重技巧避免重复的三元组
核心思想:
- 排序后,可以通过移动指针来避免重复
- 如果三数之和大于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 代码详解
- 首先对数组进行排序,这样可以更容易地处理重复元素,也便于使用双指针
- 使用一个循环,固定第一个数(索引i)
- 跳过重复的第一个数(如果
nums[i] == nums[i-1]
) - 优化:如果第一个数大于0,由于数组已排序,三数之和必然大于0,可以提前结束
- 对于固定的第一个数,使用双指针(
left
和right
)在剩余的数中寻找和为-nums[i]
的两个数 - 根据
nums[left] + nums[right]
与目标值的比较,移动左指针或右指针 - 当找到一组解时,将其加入结果集,并移动左右指针跳过重复元素
- 最后返回所有找到的三元组
4.4 复杂度分析
- 时间复杂度:O(n²),其中 n 是数组长度
- 排序需要 O(n log n) 的时间
- 双指针操作需要 O(n²) 的时间
- 总体时间复杂度由双指针部分主导,为 O(n²)
- 空间复杂度:O(log n) 到 O(n),取决于排序算法的实现。如果不考虑排序的空间复杂度,则为O(1)(不包括存储结果的空间)
4.5 优化与技巧
-
提前结束条件:
- 当第一个数大于0时,可以直接结束循环,因为三数之和必然大于0
- 当第一个数等于前一个数时,可以跳过,避免重复计算
-
去重技巧:
- 找到一组解后,跳过左右指针指向的重复元素
- 对于固定的第一个数,也跳过重复值
-
边界处理:
- 注意循环的边界,第一个固定的数最多到倒数第三个位置(
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. 特殊情况和边界处理
在实现解决方案时,需要考虑以下特殊情况:
-
数组长度小于3:
- 按照题意,数组长度至少为3,但如果做额外检查,可以在开始时判断
if (nums == null || nums.length < 3) { return new ArrayList<>(); }
-
全是零的数组:
- 例如
[0, 0, 0, 0]
,需要确保只返回一个[0, 0, 0]
而不是多个 - 排序 + 双指针法已经自动处理了这种情况,因为会跳过重复元素
- 例如
-
没有解的情况:
- 例如
[1, 2, 3]
,没有三个数的和为0 - 算法会返回空列表
- 例如
-
包含正数和负数的大数组:
- 这是最常见的情况,使用排序 + 双指针可以有效处理
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 应用场景
三数之和问题的思想在实际中有多种应用:
- 数据分析:在数据分析中,可能需要找出多个变量的组合使其满足特定条件
- 金融领域:在金融产品的组合中,可能需要找出多个资产的组合,使得其风险或收益达到特定值
- 化学计算:在分子模拟中,可能需要找出多个原子的组合,使其电荷平衡或达到特定能量状态
- 游戏开发:在某些游戏逻辑中,可能需要找出多个角色或道具的组合,使其能力值达到特定标准
9.2 扩展问题
-
四数之和(4Sum):
- 类似于三数之和,但需要找出四个数的组合使其和为目标值
- 可以通过添加一层循环来扩展三数之和的解法
- 时间复杂度为 O(n³)
-
K数之和(KSum):
- 一般化的问题,找出K个数的组合使其和为目标值
- 可以使用递归方法解决,时间复杂度为 O(n^(K-1))
-
三数最接近和:
- 找出三个数,使其和最接近目标值
- 可以修改三数之和的解法,记录最接近的结果
-
不同于零的目标值:
- 如果目标和不是0,而是任意值t,解法几乎相同
- 只需要将目标值从0改为t即可
10. 常见问题与解答
10.1 为什么需要排序?
排序有两个主要目的:
- 便于使用双指针技巧(从两端向中间移动)
- 帮助跳过重复元素,避免生成重复的三元组
10.2 如何处理重复的三元组?
主要有三种方法:
- 使用Set数据结构自动去重(如暴力法中所示)
- 排序后,在添加新的三元组前检查是否与前一个相同
- 排序后,跳过重复的元素(第一个数、左边界和右边界)
10.3 双指针法为什么比暴力法更高效?
双指针法将时间复杂度从 O(n³) 降低到 O(n²):
- 暴力法需要三重循环遍历所有组合
- 双指针法固定第一个数后,利用排序数组的特性,在线性时间内找到剩余的两个数
10.4 什么情况下可以提前结束?
- 如果第一个固定的数大于0,由于数组已排序,后续三数之和一定大于0,可以提前结束
- 如果数组长度小于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 解题要点
- 理解问题要求:找出三个数和为0,且结果不能有重复的三元组
- 排序的重要性:排序是高效解决此问题的关键步骤
- 双指针技巧:固定一个数后,使用双指针高效查找其他两个数
- 去重策略:跳过重复的元素,避免生成重复的三元组
- 提前结束条件:合理的提前结束条件可以进一步优化算法
12.2 常用技巧
- 排序 + 双指针:这是解决多数和为目标值问题的常用技巧
- 跳过重复元素:通过比较相邻元素跳过重复值
- 提前退出条件:根据排序后的数组特性设置提前退出条件
- 目标转化:将"三数之和为0"转化为"两数之和为-第一个数"
- 特殊情况处理:处理边界情况和特殊输入
12.3 面试技巧
在面试中遇到此类问题时:
- 先提出暴力解法,说明理解问题,但指出其低效
- 提出排序 + 双指针的优化方法
- 讨论如何处理重复元素
- 分析时间和空间复杂度
- 考虑边界情况和特殊输入
- 如果有时间,讨论进一步优化或扩展问题