有没有人和我一样? 刚学双指针的时候,觉得两数之和简直手到擒来,直到遇上三数之和。 思路我都懂啊,不就是固定一个数,剩下两个用双指针嘛。 结果一写代码,要么超时,要么一堆重复结果,改了三四遍都没过,给我整懵了。
题目说的啥
其实题意特别简单: 给你一个整数数组,找出所有下标不同的三元组,让三个数加起来等于 0。 唯一的坑就是,结果里不能有重复的三元组 ------ 元素一样、顺序不一样也算重复。
暴力法:想都不用想,肯定超时
最开始我图省事,直接三重循环枚举所有组合。 写完跑了下示例,好像还挺对? 一提交直接超时。 毕竟数组长度最多 3000,O (n³) 的复杂度根本顶不住,而且手动去重还特别麻烦,写着写着自己都绕晕了。
双指针才是这题的正确打开方式
后来我反应过来,两数之和能用双指针,三数之和不就是多固定一个数吗? 不过双指针有个前提:数组必须有序。所以第一步,先给数组排序。
整体逻辑其实很顺: 遍历数组里的每个数,把它当成三元组的第一个数。 然后在它右边的区间里,用左指针指向开头,右指针指向结尾,找两个数加起来等于第一个数的相反数。 和太小就左指针右移,和太大就右指针左移,刚好等于 0 就存起来。
听起来是不是很简单? 但我当初就是在 "存起来" 之后的步骤里,翻了大车。 而且跑多了测试用例我发现,其实还有很多没必要的循环,可以提前剪枝跳过,能让代码快上不少。
两个必加的剪枝优化
这两个优化我一开始没写,后来看了更精炼的写法才反应过来,加上之后运行时间直接砍半。
第一个:如果当前固定的数,加上它后面最小的两个数,和都已经大于 0 了。 那后面不管怎么组合,和只会更大,不可能等于 0,直接 break 跳出循环就行。
第二个:如果当前固定的数,加上数组最后最大的两个数,和都还是小于 0。 说明当前这个数太小了,怎么凑都凑不到 0,直接跳过,去看下一个数。
去重的细节一个都不能少
这题真正的考点根本不是双指针,是去重。 三个地方要去重,少一个都过不了。
第一个,遍历第一个数的时候要去重。 如果当前数和前一个数一模一样,直接跳过。 不然数组里有两个相同的数,分别当第一个数的时候,找出来的三元组肯定是重复的。
第二个和第三个,找到符合条件的三元组后,左右指针都要去重。 这里我把写法简化成了一行 for 循环,移动指针的同时直接跳过重复值,不用分开写好几行,干净很多。
拿示例 1 走一遍流程
光说细节有点干,咱们拿示例 1 手算一遍,一下子就通透了。
示例输入是 [-1,0,1,2,-1,-4],第一步先排序,得到: [-4, -1, -1, 0, 1, 2]
我们一步步来看:
第一轮:i = 0,固定值 x = -4 左指针 j 从 1 开始(值 -1),右指针 k 从 5 开始(值 2)。 三数之和 = -4 + (-1) + 2 = -3 < 0,和太小了,j 右移。 j=2(值 -1):和 = -4 + (-1) + 2 = -3 < 0,j 继续右移。 j=3(值 0):和 = -4 + 0 + 2 = -2 < 0,j 继续右移。 j=4(值 1):和 = -4 + 1 + 2 = -1 < 0,j 继续右移。 j=5,和 k 重合了,本轮结束,没找到符合条件的三元组。
第二轮:i = 1,固定值 x = -1 左指针 j = 2(值 -1),右指针 k = 5(值 2)。 三数之和 = -1 + (-1) + 2 = 0,刚好等于 0! 我们把 [-1, -1, 2] 加入结果。 然后开始去重:j 右移,下一个是 0,和之前的 -1 不一样,停在 j=3; k 左移,下一个是 1,和之前的 2 不一样,停在 k=4。
现在 j=3(值 0),k=4(值 1),继续算: 和 = -1 + 0 + 1 = 0,又等于 0! 把 [-1, 0, 1] 加入结果。 继续去重移动指针,j 和 k 相遇,本轮结束。
第三轮:i = 2,固定值 x = -1 这时候要注意,nums 2 和 nums 1 都是 -1,重复了! 直接执行 continue 跳过,避免生成重复的三元组。
后面 i=3、i=4 的时候,要么和已经大于 0,要么指针不够用,循环就结束了。
最后得到的结果就是 [[-1,-1,2], [-1,0,1]],和示例输出完全对上。

代码实现
把这些细节都揉进去,最终的代码就非常精炼了。 我把每个坑和优化点都写在注释里了,你们对着看就行。
javascript
var threeSum = function(nums) {
nums.sort((a, b) => a - b);
const n = nums.length;
const ans = [];
// 只需要遍历到倒数第三个,后面留两个数给双指针
for (let i = 0; i < n - 2; i++) {
const x = nums[i];
// 第一个数去重,和前一个相同就跳过,别写错成后一个
if (i > 0 && x === nums[i - 1]) continue;
// 优化一:当前数 + 后面最小的两个数都 >0,后续只会更大,直接结束循环
if (x + nums[i + 1] + nums[i + 2] > 0) break;
// 优化二:当前数 + 数组最大的两个数都 <0,当前数太小,直接跳过本轮
if (x + nums[n - 2] + nums[n - 1] < 0) continue;
let j = i + 1, k = n - 1;
while (j < k) {
const s = x + nums[j] + nums[k];
if (s > 0) {
k--;
} else if (s < 0) {
j++;
} else {
// 找到符合条件的三元组,加入结果集
ans.push([x, nums[j], nums[k]]);
// 左指针去重:先右移一位,再跳过所有重复值
for (j++; j < k && nums[j] === nums[j - 1]; j++);
// 右指针去重:先左移一位,再跳过所有重复值
for (k--; k > j && nums[k] === nums[k + 1]; k--);
}
}
}
return ans;
};
复杂度怎么样
渐进时间复杂度还是 O (n²),排序是 O (nlogn),外层循环加双指针遍历是 O (n²)。 但加了两个剪枝之后,实际运行时会跳过大量无效循环,在大数据量的测试用例下表现会好很多。 空间复杂度不算结果数组的话,就是排序占用的 O (logn)。
最后唠两句
回头看这题,其实真不算难。 核心就是排序 + 固定一个数 + 双指针,套路很固定。 但就是去重的几个小细节,还有剪枝的思路,特别容易忽略,我当初卡了快半小时才把所有用例跑通。
刷这种题最忌讳的就是 "一看就会,一写就废",觉得思路懂了就不动手。 真自己敲一遍代码,才知道哪里藏着坑。
你当初刷三数之和的时候,是被哪个点卡住了? 是双指针写不对,还是去重一直有问题? 评论区聊聊你的踩坑经历,我每条都会看~
如果觉得这篇题解对你有帮助,点个赞让更多刷题的朋友看到就更好啦。