说到力扣上的经典题目,三数之和(15. 3Sum)绝对算得上是一道分水岭。很多人刷题是在这道题上第一次感受到了“会做但过不了”的挫败:思路明明是对的,代码也写出来了,结果一提交就是超时或者重复答案。也有不少人靠背诵模板过了关,但换个问法就懵。其实这道题真正考察的并不只是“找三个数加起来等于0”,而是你对排序、双指针、去重边界这三件事的综合掌控力。无论你是准备面试、刷热题100,还是单纯想提升算法思维,这道题都值得花时间彻底吃透。今天我就把这题从思路到代码、从常见坑到调试技巧完整拆开讲清楚。
1. 题目到底在问什么:先别急着写代码
不少初学者拿到这题,第一反应就是“这不是两数之和的升级版吗?三层循环暴力解不就完了”。没错,暴力解法确实能出结果,但力扣的判题系统不会给你这个机会。数组长度动不动上千,三重循环的复杂度是O(n³),在LeetCode的测试用例下基本是超时预定。所以这道题真正考验的,是在暴力解之上有没有更聪明的办法。
1.1 三数之和和两数之和的本质区别
两数之和那道题,最经典的解法是用哈希表把遍历过的数存起来,时间复杂度能做到O(n)。但三数之和不能直接照搬这个思路,原因是“不重复”三个字把问题变复杂了。两数之和只要求返回一组下标,重复不重复无所谓;三数之和要求返回所有不重复的三元组,而且每个三元组内部还要满足a+b+c=0。
举一个简单的例子:数组是[-1, 0, 1, 2, -1, -4],正确的输出是[[-1, -1, 2], [-1, 0, 1]]。注意这里有两个-1,但你不能输出两个[-1, 0, 1],因为它们是同一个组合。哈希表解法很难优雅地处理这种“值相同但位置不同”的去重逻辑,你不得不在找到一组答案后想方设法跳过重复的匹配。与其这样,不如先排序,用双指针在有序数组上做收缩查找。
1.2 去重才是这道题真正的拦路虎
很多人把三数和做不出来归咎于“双指针没掌握”,但根据我刷题和带人刷题的经验,至少有一半的失败是栽在去重上。对于一个升序排列的数组,你在固定第一个数a之后,剩下的问题就变成了“在a的右边找两个数b和c,使b+c=−a”。此时如果b和c的取值重复了,那么生成的三元组自然就重复了。
去重的位置非常讲究:第一层循环的a要去重,找到一组答案后的b和c也要去重。而且去重的写法有讲究,绝大多数人第一次写的是“如果下一个数和当前数相同就跳过”,但正确逻辑往往是“如果当前数和上一个数相同就跳过”。这中间的细微差别直接决定你是保留第一个还是保留最后一个,用错的话会漏解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序+双指针的核心思路解析
三数之和的最优解是“排序+双指针”,时间复杂度O(n²),空间复杂度O(log n)到O(n)取决于排序算法的实现。思路本身不难理解,但每一步的细节都值得展开说说。
2.1 为什么首选排序而不是哈希表
排序的意义有两层。第一层是让数组变得有序,这样双指针才能根据当前和与目标值的大小关系决定移动方向,这是双指针策略成立的前提。第二层是让相同值的元素聚在一起,这样去重逻辑写起来非常自然——你只需要比较相邻元素是否相等即可。
哈希表方案也不是完全不能用,但实现起来更绕:你需要先固定a和b,然后在哈希表里找−(a+b),还要额外处理c不能和a、b下标冲突的情况,并且对三元组做排序去重。这一套下来代码量不比双指针少,还更容易出错。在面试场景下,双指针解法思路清晰、代码简洁、复杂度更好,自然是首选。
2.2 双指针的收缩策略:为什么一个向左一个向右
数组排序后,固定第一个数nums[i],然后在[i+1, n-1]这个区间内用两个指针来找target = -nums[i]。左指针left初始指向i+1,右指针right初始指向n-1,计算sum = nums[left] + nums[right]。如果sum等于target,找到一组解;如果sum小于target,说明整体太小,left右移;如果sum大于target,说明整体太大,right左移。
这里的核心逻辑是:因为数组有序,left右移会让sum变大,right左移会让sum变小。这个单调性是双指针能在线性时间内完成扫描的根本保证。每移动一次指针,就排除了一整批不可能的组合,所以总时间复杂度是O(n²)——外层循环O(n),内层双指针扫描O(n)。
2.3 边界条件:什么时候可以提前结束
当nums[i]大于0时,可以直接终止整个循环。因为数组是升序排列的,如果第一个数都大于0了,后面的数只会更大,三个正数相加不可能等于0。这是一个很实用的剪枝优化,虽然不能改变最坏时间复杂度,但在特定数据集上能省不少时间。
另一个隐含边界是:固定i之后,如果nums[i]和nums[i-1]相同,应该跳过本次循环。因为以nums[i-1]作为第一个数已经枚举过所有可能的三元组了,再枚举nums[i]只会得到完全相同的组合。类似的逻辑也适用于找到答案后对指针的死循环处理。
3. 完整代码实现与逐行解读
理论说再多,最终都要落到代码上。我写了一个Java版本的实现,这段代码我前前后后用了很多次,也在一些企业面试的现场写过,整体比较简洁、不容易出错。
3.1 先看核心代码
java复制class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 1. 排序:双指针的前提
Arrays.sort(nums);
int n = nums.length;
// 2. 固定第一个数
for (int i = 0; i < n - 2; i++) {
// 剪枝:第一个数大于0,三数之和不可能等于0
if (nums[i] > 0) break;
// 去重:跳过重复的第一个数
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1;
int right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
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--;
} else if (sum < target) {
left++;
} else {
right--;
}
}
}
return result;
}
}
3.2 代码逐段拆解:每一步在干什么
先看排序这一步,我用了Arrays.sort()。Java的Arrays.sort对基本类型数组用的是双轴快速排序,时间复杂度一般是O(n log n)。对引用类型数组用的是归并排序,这里恰好是基本类型int[],所以是快排。无论哪种,排序都直接把整个问题的难度降了一个维度。
第7行是外层循环的剪枝。我说过nums[i]大于0就可以直接break,这里注意是break不是continue。因为数组已经有序了,i之后的数只会更大,继续循环没有意义。
第9行是第一个数的去重,逻辑是“如果当前数和上一个数相同就跳过”。很多新手写成“如果当前数和下一个数相同就跳过”,这样会漏解。举例说明:nums = [-1, -1, 0, 1],如果你在i=0时就检查nums[0]和nums[1]是否相同,然后直接跳过i=0,你就漏掉了[-1, 0, 1]这个解。正确做法是当i=1时发现nums[1]等于nums[0],跳过i=1,这样就保留了第一个-1。
进入while循环后,sum和target的比较构成了双指针移动的核心逻辑。sum等于target时记录答案,然后两个while循环去重,再同时移动两个指针。这里有一个细节:找到答案后,left和right都要移动,因为只移动一个的话,另一个位置的数和移动后的数组合起来sum不可能再等于target(想想为什么:left右移值变大,right不变则sum变大;right左移值变小,left不变则sum变小,只移动一个已经不可能再凑出target了)。
3.3 其他语言版本对比
用Python写这个题有一个很容易踩的坑,就是列表去重。有些人喜欢用set去重,这确实能解决重复三元组的问题,但代价是需要对每个三元组排序,代码也不如双指针优雅。
python复制def threeSum(nums):
nums.sort()
n = len(nums)
res = []
for i in range(n - 2):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, n - 1
target = -nums[i]
while left < right:
total = nums[left] + nums[right]
if total == target:
res.append([nums[i], nums[left], nums[right]])
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
elif total < target:
left += 1
else:
right -= 1
return res
Python版本和Java版本思路完全一致。写过Python版本后你会发现,这题的语言差异主要在语法细节上,核心算法逻辑在任何语言里都是通用的。面试时候选人用自己最熟的语言把双指针逻辑写对,远比纠结哪种语言的写法更优雅重要。
4. 经典用例实战演示:走一遍完整流程
光看代码可能还是没有实感,我带着大家把两个典型的用例完整走一遍,包括指针的每一次移动和去重判断。这个过程很像在调试器里单步执行,但手动走一遍对你理解算法本质的帮助是调试器替代不了的。
4.1 用例一:包含重复元素的常规数组
输入:nums = [-1, 0, 1, 2, -1, -4]
第一步是排序。排序后的数组是[-4, -1, -1, 0, 1, 2]。
i=0,nums[0]=-4,target=4。left指向1,right指向5,nums[1]+nums[5]=(-1)+2=1,小于4,left++。left指向2,nums[2]+nums[5]=(-1)+2=1,仍然小于4,left++。left指向3,nums[3]+nums[5]=0+2=2,小于4,left++。left指向4,nums[4]+nums[5]=1+2=3,小于4,left++。left=5,left<right不成立,退出while循环。i=0这一轮没有找到解。
i=1,nums[1]=-1,target=1。left指向2,right指向5。nums[2]+nums[5]=(-1)+2=1,等于target,记录[-1, -1, 2]。去重:nums[2]==nums[3]成立,left++到3;nums[2]==nums[3]不再成立,退出循环。同样检查right:nums[5]==nums[4]不成立。然后left++到4,right--到4,left<right不成立,退出while循环。i=1这一轮找到一组解[-1, -1, 2]。
i=2,nums[2]=-1,但nums[2]==nums[1]成立,跳过。
i=3,nums[3]=0,target=0。left指向4,right指向5。nums[4]+nums[5]=1+2=3,大于0,right--。right指向4,right和left相等,退出while循环。i=3这一轮没有找到解。
i=4,n-2=4,i<n-2不成立,循环结束。
最终结果是[[-1, -1, 2], [-1, 0, 1]]。注意第一组解[-1, -1, 2]是在i=1时找到的,这时候i=2虽然也是-1,但直接跳过了,避免了重复输出。
4.2 用例二:全为零的特殊数组
输入:nums = [0, 0, 0, 0]
排序后不变。i=0,nums[0]=0,target=0。left=1,right=3,sum=0+0=0,记录[0, 0, 0]。去重:nums[1]==nums[2]成立,left++到2;nums[2]==nums[3]成立,left++到3。right方向的去重同理:nums[3]==nums[2]成立,right--到2,此时left和right都为2,退出循环。i=1时,nums[1]==nums[0]成立,跳过。i=2时同理跳过。最终结果是[[0, 0, 0]]。
很多人会问,数组长度为4,可以取的三元组有C(4,3)=4种,为什么结果只有1个?因为4个0组合起来只有[0,0,0]这一种数值组合。这里去重逻辑成功地把4个0的排列压缩成了一个结果,这正是题目“不重复”要求的意义所在。
4.3 用例三:数组长度不足3的边界情况
输入:nums = [1, 2]或者[],这两种情况外层循环i < n - 2直接不成立,返回空列表。还有一个容易被忽略的边界:nums = [0, 0, 0],排序后i=0时left=1、right=2,sum=0,记录[0,0,0],去重后left=right,循环结束。这个用例能过就说明你基本逻辑没问题。
5. 常见错误与排查技巧实录
代码写出来不等于能通过,我在实际刷题过程中总结了三数之和最常见的几类错误,按出现频率从高到低排列,每一类都有典型的错误写法和正确的修正方式。
5.1 去重时机不对:漏解还是重复解
这是出现频率最高的问题。错误写法一:只在找到答案后对b和c去重,但忘记对a去重,结果输出大量重复三元组。错误写法二:对a的去重写成“当前数和下一个数比较”,导致跳过有价值的数,出现漏解。
我用一个具体例子说明漏解的场景。nums = [-1, -1, 0, 1],正确输出是[[-1, 0, 1]]。如果去重写成if (nums[i] == nums[i + 1]) continue,那么i=0时发现nums[0]==nums[1]直接跳过,你根本不会得到[-1, 0, 1]这个解。因为你需要保留的是区间左边的那个-1(i=0),跳过的是右边的那个-1(i=1)。错误写法把两个都跳过了。
5.2 指针越界:死循环还是ArrayIndexOutOfBounds
在while (left < right)循环内部,做去重时没有再次检查left < right,这是另一个常见的错误。比如:
java复制while (nums[left] == nums[left + 1]) left++;
当数组是[0, 0, 0, 0],left已经走到3,再访问nums[left+1]就越界了。正确的写法必须带着边界检查:while (left < right && nums[left] == nums[left + 1]) left++; 这个&&的顺序也很重要,必须先判断left < right,再访问数组元素,否则还是会越界。Java的&&是短路运算,左边为false时右边不会执行,所以顺序绝对不能反。
5.3 答案去重失败:用了Set但还是有问题
有人觉得直接用HashSet存放结果,不就可以自动去重了吗?我用Java的HashSet试过有坑。假如你直接把List
与其花这些额外功夫,不如在生成答案时就保证不重复,这也是双指针解法优于哈希表解法的原因之一。我在代码里的手写去重虽然看起来啰嗦,但执行效率是最高的,也不依赖Set的equals行为。
5.4 常见问题速查表
| 常见错误 | 具体表现 | 修正方案 | 排查要点 |
|---|---|---|---|
| 对a去重写错方向 | 漏掉合法解 | 改成if (i > 0 && nums[i] == nums[i - 1]) continue | 检查固定第一个数时是保留左边界还是右边界 |
| 忘掉对b和c去重 | 输出重复三元组 | 找到答案后加两个while循环跳过重复 | 打印每次找到答案后left和right的指针位置 |
| 去重时忘记判断left < right | 数组越界 | 在每个while条件中加上left < right | 用[0,0,0,0]用例单测 |
| 第一个数大于0时不剪枝 | 多算很多无效循环 | if (nums[i] > 0) break | 其实不是错误,但影响性能 |
| 找到答案后只移动一个指针 | 重复扫描或死循环 | 两个指针同时移动 | 推导:只移动一个不可能再找到和为target的组合 |
5.5 调试技巧:怎么快速定位逻辑问题
我的调试习惯是,先用样例或者自己构造的小数组跑一遍,在每个关键节点打印信息:固定第一个数时打印i和nums[i],进入循环时打印left、right、sum、target。这能帮你快速看到指针移动的轨迹是否合理。比如sum一直小于target,left疯狂向右移动,可能是排序忘写了;sum等于target但三元组重复,大概率是去重逻辑问题。
更实用的方式是准备几组固定用例:特殊值用例(全负数、全正数、包含0、全是0)、重复元素用例(所有元素相同、只有一个值出现多次)、边界长度用例(长度为0、1、2、3)。每次改完代码,先把这几组用例跑一遍,能拦截掉90%以上的低级错误。
6. 复杂度分析与工程场景的启发
这题本身是算法题,但它的分析思路和优化手法放到工程场景里其实同样有价值。很多人觉得刷题和工作是两回事,我不这么认为,你从三数之和里学到的复杂度评估方法和边界处理意识,在真实代码里同样用得上。
6.1 时间复杂度:从暴力O(n³)到双指针O(n²)
暴力解法是三层循环枚举所有三元组,复杂度O(n³),n=1000时就是10亿次操作,肯定超时。排序+双指针把算法降到了O(n²),n=1000时是100万次操作,在力扣的判题环境下绰绰有余。其中排序占O(n log n),双指针扫描占O(n²),总复杂度由较高阶的O(n²)决定。
这个复杂度优化的本质是“利用有序性消除一层循环”。原本需要两个变量遍历所有组合,现在用双指针一个从左一个从右,利用有序性把遍历次数从O(n²)降到了O(n)。这种思路在后面的四数之和、最接近的三数之和、接雨水等题目里都会反复用到。
6.2 空间复杂度:排序算法带来的隐性开销
Java的Arrays.sort对int[]使用双轴快速排序,是原地排序,空间复杂度O(log n)(递归调用的栈空间)。Python的list.sort()用的是Timsort,最坏空间复杂度O(n)。如果面试官问空间复杂度,你需要根据具体语言和排序实现来回答,不能一概而论。我在代码中除了结果列表result之外,没有使用额外的哈希表等辅助结构,所以可以回答O(n)(结果列表的空间)加排序的额外空间。
6.3 从算法到工程:这题教会我们的三件事
第一件事是去重意识。在真实系统里,数据清洗、日志去重、消息幂等,到处都有去重的需求。三数之和里“先排序再判断相邻相等”的思路,比用Set无脑去重更高效,也更容易控制精度。第二件事是指针收缩思想。在有序数据上做范围查找时,双指针往往比嵌套循环高效得多,这在大规模日志分析、范围扫描的优化里很常见。第三件事是边界处理。这题里有大量左右边界条件需要小心处理,写了这题之后,你会对“数组越界”的警惕性提高不少。
6.4 题目扩展:从三数之和到四数之和
力扣上的四数之和(18. 4Sum)本质上是三数之和的套娃版:固定第一个数,然后调用三数之和的逻辑。四数之和的时间复杂度是O(n³),空间复杂度不变。理解了这一点,你会发现力扣很多题目之间都是有递进关系的,刷题不是一道一道孤立地刷,而是按题型串起来刷。这样刷一道题,相当于会了一类题。
另外一个变体叫最接近的三数之和(16. 3Sum Closest),它的区别是不要求等于0,而是找最接近target的组合。解法仍然是排序+双指针,只是判断逻辑从“等于target”变成了“更新最小差值”。如果你把三数之和的代码真的理解透了,改起来只需要十几分钟。
7. 刷题策略与个人心得
关于三数之和,我最后再说点刷题层面的经验。这道题在力扣上被标记为“中等”难度,但实际面试中出现频率很高,尤其适合考察候选人的代码质量和边界处理能力。
7.1 怎么判断自己真的会了
一个很有效的自测方法:不看任何参考代码,在纸上手写三数之和的完整代码,然后对照我上面的代码逐行检查。如果去重逻辑、指针移动方向、边界条件全部一次写对,说明你真的掌握了。如果还需要查资料或者纠结某个细节,那说明还没完全内化,建议把上面的用例再手动走一遍。
还有一种自测方式是口述思路。你能否用三句话把一个完全不懂双指针的人讲明白?这三句话可以是:先排序让数组有序;固定一个数,用双指针在剩余区间找两数之和;移动指针时根据和与目标值的大小关系决定方向,同时处理好重复值。能用自己的话把这三句话说清楚,面试官对你的评价一定不会低。
7.2 刷题顺序的建议
从热题100的角度来看,三数之和之前建议先刷两数之和(1. Two Sum),体会哈希表的应用场景;之后刷四数之和和最接近的三数之和,体会同类型题目的变形。如果再配合盛最多水的容器(11. Container With Most Water),你能把双指针的各种应用场景都过一遍。这样按主题串起来刷,比按题号死记硬背效率高得多。
在我个人的经验里,三数之和是一道“值得反复刷”的题。隔两周重新手写一遍,每次都能发现自己新的知识盲区。刷题这件事没有捷径,但找对方法之后,每一道题都不会白刷。
