题目描述
给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]],满足 i != j、i != k、j != k,并且:
nums[i] + nums[j] + nums[k] == 0
返回所有和为 0 且不重复的三元组。答案中不能包含重复的三元组。
最初思路
最开始的想法是先排序,然后外层循环固定一个数 nums[k],把目标值设为:
t = -nums[k]
内层从 k + 1 往后遍历,设当前数为 nums[j],再通过:
m.contains(t - nums[j])
判断第三个数是否已经出现过。如果存在,就把 nums[j]、t - nums[j] 和 nums[k] 加入答案。
这个方向抓住了"三数之和可以转成两数之和"的形式,但 m 里只保存了值,没有保存下标信息。
问题出在哪里
三数之和不仅要求值相加为 0,还要求三个数来自三个不同下标。
原思路里的 m.contains(...) 只能回答"这个值是否出现过",不能回答"这个值来自哪个下标"。例如:
nums = [-2, 4, 5]
当固定 nums[k] = -2,内层遇到 nums[j] = 4 时:
t = 2
t - nums[j] = -2
如果 m 里已经加入了当前固定的 -2,那么 contains(-2) 会成立,但这个 -2 可能就是 nums[k] 自己。这样就无法证明三个数来自不同位置。
还有一个问题是重复三元组。排序后相同数字会连续出现,如果外层起点相同,后续可能再次得到相同的值组合。只判断值是否存在,不足以处理答案去重。
正确思路
先排序,再固定第一个数 x = nums[i],然后在 i 右侧使用双指针寻找另外两个数:
j = i + 1,指向当前候选区间左端。k = n - 1,指向当前候选区间右端。- 每轮计算
s = x + nums[j] + nums[k]。 - 如果
s > 0,说明右侧数字太大,移动k--。 - 如果
s < 0,说明左侧数字太小,移动j++。 - 如果
s == 0,记录答案,然后同时移动j++和k--。
因为 j 和 k 都从 i 右侧开始,所以可以直接保证三个下标不同:
i < j < k
核心洞察
排序以后,数组从小到大排列。固定 x 后,问题就变成在右侧有序区间里找两个数,使它们的和等于 -x。
如果当前三数之和太大,就需要让总和变小,只能移动右指针 k;如果当前三数之和太小,就需要让总和变大,只能移动左指针 j。
去重也依赖排序:
- 外层如果
nums[i] == nums[i - 1],说明这个起点已经处理过,直接跳过。 - 内层找到答案后,如果当前
nums[j]和上一次使用的nums[j - 1]相同,就不重复加入答案。
固定 i 后,如果:
x + nums[j] + nums[k] == 0
那么:
nums[k] = -x - nums[j]
所以同一个 i 下,只要 nums[j] 没变,nums[k] 的值组合也被唯一确定,重复的 j 会导致重复三元组。
手推过程
以 nums = [-1, 0, 1, 2, -1, -4] 为例,排序后:
[-4, -1, -1, 0, 1, 2]
先固定 i = 0:
x = -4
j = 1, k = 5
s = -4 + (-1) + 2 = -3
总和偏小,移动 j。这一轮不会得到答案。
再固定 i = 1:
x = -1
j = 2, k = 5
s = -1 + (-1) + 2 = 0
得到 [-1, -1, 2],然后移动 j 和 k。
继续:
j = 3, k = 4
s = -1 + 0 + 1 = 0
得到 [-1, 0, 1]。
当 i = 2 时,nums[2] == nums[1],这个起点已经处理过,直接跳过,避免重复答案。
伪代码
下面的伪代码与后面的 Java 实现一一对应:
函数 threeSum(nums):
对 nums 排序
ans <- 空列表
n <- nums.length
对 i 从 0 到 n - 3:
x <- nums[i]
如果 i > 0 且 nums[i] == nums[i - 1]:
跳过当前 i
如果 x + nums[i + 1] + nums[i + 2] > 0:
结束循环
如果 x + nums[n - 2] + nums[n - 1] < 0:
跳过当前 i
j <- i + 1
k <- n - 1
当 j < k:
s <- x + nums[j] + nums[k]
如果 s > 0:
k <- k - 1
否则如果 s < 0:
j <- j + 1
否则:
如果 j == i + 1 或 nums[j] != nums[j - 1]:
把 [x, nums[j], nums[k]] 加入 ans
j <- j + 1
k <- k - 1
返回 ans
Java 代码
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> ans = new ArrayList<>();
int n = nums.length;
for (int i = 0; i < n - 2; i++) {
int x = nums[i];
if (i > 0 && x == nums[i - 1]) continue;
if (x + nums[i + 1] + nums[i + 2] > 0) break;
if (x + nums[n - 2] + nums[n - 1] < 0) continue;
int j = i + 1;
int k = n - 1;
while (j < k) {
int s = x + nums[j] + nums[k];
if (s > 0) {
k--;
} else if (s < 0) {
j++;
} else {
if (j == i + 1 || nums[j] != nums[j - 1]) {
ans.add(List.of(x, nums[j], nums[k]));
}
j++;
k--;
}
}
}
return ans;
}
}
易错点
- 只用
contains判断值是否存在,不足以证明三个数来自不同下标。 m或Set如果跨外层循环共享,里面会混入历史状态,候选范围不清晰。- 外层固定值要去重:
i > 0 && nums[i] == nums[i - 1]时直接跳过。 - 找到答案后要继续移动指针,否则会卡在同一组位置上。
- 同一个
i下,重复的nums[j]会得到重复值组合,需要避免重复加入。
建议测试用例
nums = [-1, 0, 1, 2, -1, -4]
期望:[[-1, -1, 2], [-1, 0, 1]]
说明:常规重复数字场景
nums = [0, 0, 0, 0]
期望:[[0, 0, 0]]
说明:检查重复答案去重
nums = [-2, 0, 1, 1, 2]
期望:[[-2, 0, 2], [-2, 1, 1]]
说明:同一个起点下存在多个答案
nums = [-2, 4, 5]
期望:[]
说明:检查是否错误复用固定元素本身
复杂度分析
- 时间复杂度:
O(n^2)。排序是O(n log n),主循环中每个i对应一次线性双指针扫描,总体为O(n^2)。 - 空间复杂度:
O(1)。不计算返回答案;如果计算排序递归栈,空间复杂度与语言排序实现有关。
总结
这道题的关键是把"找三个数"拆成"固定一个数,再在右侧找两个数"。
原来的 contains 思路只关注值是否存在,没有管理下标范围;双指针做法通过 i < j < k 直接保证下标不同,再借助排序处理重复值。
下次遇到"不重复组合"时,可以先问自己两个问题:候选元素的下标范围是否清楚?相同值连续出现时,哪一层循环负责去重?