力扣热题100里的第6题三数之和,我前前后后刷了至少三遍,每次都有新体会。这题在面试里的出现频率高得吓人,考察的又恰好是排序、双指针和去重这几样最容易出bug的基本功,所以专门写一篇完整拆解,把我踩过的坑和整理过的边界情况一次性讲清楚。无论你是刚开始刷力扣的初学者,还是准备冲刺大厂面试的求职者,或者只是想系统复习算法基础的开发者,这篇内容都值得花二十分钟认真读完。
1. 题目读懂没有,直接决定你能不能 AC
1.1 先认识原题,别被"三数之和"四个字骗了
题目描述大致是这样的:给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a、b、c,使得 a + b + c = 0。请找出所有满足条件且不重复的三元组。看起来好像只是把两数之和加了一层循环,但真正动手写的时候就会发现,坑全藏在"不重复"这三个字里。
给你一个直观的例子,输入是 [-1,0,1,2,-1,-4],期望输出是 [[-1,-1,2],[-1,0,1]]。这里有个特别容易忽略的细节:数组里有两个 -1,但 [-1,0,1] 这个三元组只能出现一次。如果只按数值判断,你可能在结果里塞进去两个值完全相同的三元组,然后怎么测试都不过。所以我在刷题时养成了一个习惯,拿到任何题目都先把"输出要求"圈出来,尤其是去重、排序这一类描述,往往才是真正的考点。
1.2 它凭什么能占据热题 100 的经典位置
热题100里没有一道题是白放的,三数之和能稳稳占住第6的位置,主要是因为它在面试里承担了三个功能。第一,它能快速筛掉代码基本功不扎实的候选人,因为三层循环谁都会写,但能不能主动想到排序加双指针,是另一回事。第二,它考察内存思维和边界处理,指针移动、去重位置、越界判断,每一样都能写错。第三,它是一切 N 数之和问题的地基,搞懂了三数之和,四数之和这种题目其实就是套模板多套一层循环的事情。
我在实际面试交流中见过太多候选人,一上来就讲"我做过这题",然后写出来的代码要么重复解删不掉,要么越界崩溃,面试官一追问就支支吾吾。原因很简单,他们只是把标准答案背下来了,没有真正理解每个边界条件为什么存在。所以这一篇不会只丢一段代码给你,而是会把每一步决策背后的理由都拆开。
1.3 这道题到底在考察什么能力
按我的理解,面试官看这道题,核心就考察三件事。第一,你能不能从 O(n^3) 优化到 O(n^2),这反映了你对时间复杂度的敏感度,以及有没有掌握"排序后利用有序性 + 双指针"这个高频套路。第二,你的去重逻辑是否干净利落,因为错误的去重方案会导致两个极端,要么结果里塞满重复三元组,要么漏掉本应存在的答案。第三,你有没有考虑极端输入,比如数组长度不足 3、全部是 0、大量重复数字,这些边界条件往往隐藏了最多 bug。
我经常和身边准备面试的朋友说,这题就算让你照着答案抄,能一次写对的人也很少。原因就在于代码很短,但每一行都有讲究。接下来我把暴力解法、哈希解法、双指针解法一层层递进地过一遍,让大家明白最终方案是怎么"长"出来的,而不是天上掉下来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解和哈希解为什么都差点意思
2.1 三层循环为什么会被面试官直接否掉
最直白的解法就是三层循环暴力枚举,把所有可能的三元组全部检查一遍。听起来很省事,但时间复杂度是 O(n^3),如果数组长度是 1000,那就要执行约 10 亿次加法,在力扣上是绝对过不了的,面试官听到这个方案也基本不会再往下聊。我以前也犯过这种懒,以为刷题只要解出来就行,后来才意识到,算法题的核心目标从来不是"解出来",而是"在约束条件下解出来"。
三层循环还有一个致命问题:去重变得极其别扭。因为你得在循环内部维护一个 set,把所有已经添加的三元组序列化后存进去,查重的时候还得先排序三元组,判断顺序是否一致,代码又长又慢。所以暴力解法唯一的价值就是帮初学者理解题意,真正写算法,这个方案从一开始就该排除。
2.2 哈希表优化,看起来有戏但坑更深
两数之和这题用哈希表非常漂亮,所以很多人很自然想到:先固定一个数,然后剩下两个数用哈希表来找,目标值等于 0 - nums[i]。这个思路的时间复杂度确实降到了 O(n^2),听上去已经不错了,但实际操作起来去重问题比暴力法更闹心。
哈希表方案在完成"找两个元素"之后,你拿到的是两数的值,但你还需要判断这两个数值组合之前有没有被用掉过,位置信息容易丢失。如果你粗暴地把所有结果存进 HashSet,那么需要维护一个类似 string 的序列化键,比如把三元组排序后转成 "1,2,3" 这种字符串再存。加上这个逻辑之后,代码变得又长又容易错,而且很多语言在字符串拼接上的开销也不小。所以哈希方法在面试里可以作为"头脑风暴"提一嘴,真到了落代码阶段,双指针明显是更优选择。
2.3 从哈希到双指针,核心思路是这样转变的
哈希表解决的是"无序数组里快速定位目标值"的问题,但它没有利用数值之间的大小关系。如果我们先把数组排序,那问题就变了个样子:有序数组里找两个数和为固定值,完全可以用两个指针从两端往中间扫,通过比较当前和与目标值,决定移动左指针还是右指针。
这条思路的转折点,就是意识到"排序本身只花 O(n log n),但能省下后面 O(n) 级别的查找时间"。再用固定循环包住双指针,总体复杂度就是排序加上 n 次 O(n) 的扫描,最终是 O(n^2)。我把这个优化过程在草稿纸上推演过很多遍,每次都会感慨,算法题里最值钱的其实不是某个高级数据结构,而是这种"通过排序给数据增加结构,从而简化问题"的思维。
3. 双指针解法的完整推导与去重细节
3.1 排序是双指针方案的大前提
用双指针之前,先把数组 sort 一遍,这一步是整个方案的基石。排序后数组满足单调性,左指针指向的数小,右指针指向的数大,当三数之和小于 0 时,说明整体偏小,应该把左指针往右挪,让和变大;当三数之和大于 0 时,说明整体偏大,应该把右指针往左挪,让和变小。整个过程只需要 O(n) 的时间就能搜完一对组合。
我见过有人写代码时忘了排序,直接拿原数组跑双指针,结果得到的结果完全随机,因为指针移动的依据根本不成立。排序这一步真的不能省,面试时也最好先说清楚,为什么排序在此处是必要的准备工作,这会让面试官觉得你不是在背模板,而是真正理解算法结构。
3.2 固定一个数,剩下两个数交给双指针
双指针解法的框架可以概括成一句话:外层循环固定第一个数 i,然后 left 从 i + 1 出发,right 从数组末尾出发,两者相向移动,寻找满足 nums[i] + nums[left] + nums[right] == 0 的组合。因为三元组要求三个下标互不相同,所以左指针的起点至少比 i 大 1,右指针从最后开始,能确保不会重复使用同一个元素。
这里有一个细节值得单独说:在 while (left < right) 内部,当三数之和为 0 时,存完一组结果不能急着只移动某一边,而是需要同时移动两边,再跳过所有和当前值重复的位置。因为只移动一边的话,另一边不动,即使值变了,三数之和也不可能还是 0,而且后续还可能出现重复组合。
3.3 去重到底该怎么去,位置和顺序有讲究
去重是三数之和的灵魂,也是翻车重灾区。外层循环的 i,正确写法是 if (i > 0 && nums[i] == nums[i - 1]) continue;,这里用 nums[i - 1] 而不是 nums[i + 1],我第一次学的时候踩过坑,因为用 nums[i + 1] 判断会把合法的 [-1, -1, 2] 直接跳过。想一想就明白,如果连续两个值相同,第一个值已经往后找了所有可能的组合,第二个值再找必然是重复,所以应该跳过当前这个重复开头,而不是下一个。
双指针内部的去重也要做,但位置必须放在找到一组有效答案之后,也就是先记录结果,再执行 while (left < right && nums[left] == nums[left + 1]) left++; 和 while (left < right && nums[right] == nums[right - 1]) right--;。如果把去重逻辑放在判断和之前,很容易把正确答案过滤掉。建议没写过这道题的朋友,先自己去写一遍,然后故意反过来试一次,看看会产出什么匪夷所思的结果。
3.4 剪枝优化:不是必须,但能显著提速
三数之和还有一个非常经典的剪枝:外层循环里如果 nums[i] > 0,直接 break。因为数组已经排序过了,最左边的数都大于 0,后面的数只会更大,三个正数加起来永远不可能等于 0。这个剪枝能省掉不少无效扫描,尤其是当输入数组大量正数时,性能提升肉眼可见。
还有一层优化是判断当前最小组合是否已经超了,比如 nums[i] + nums[i + 1] + nums[i + 2] > 0,那说明当前 i 及后面的值已经不存在可能更小的组合了,直接 break;如果 nums[i] + nums[n - 2] + nums[n - 1] < 0,说明当前 i 太小,哪怕配上最大的两个数都凑不到 0,应该直接 continue 到下一个 i。这两种优化不是必须的,但写上去能体现你的边界分析能力,面试聊起来是个加分点。
4. 多语言版本实现,附可直接抄的代码
4.1 C++ 版本,最经典的写法
C++ 版本在力扣上是最常见的,因为语言自带排序函数,指针用下标实现也很直观。下面是完整代码,细节都按前面讲过的去重逻辑写的。
cpp复制class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> res;
int n = nums.size();
if (n < 3) return res;
sort(nums.begin(), nums.end());
for (int i = 0; i < n - 2; ++i) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1;
int right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.push_back({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 < 0) {
++left;
} else {
--right;
}
}
}
return res;
}
};
注意循环边界 i < n - 2,因为后面至少要留两个位置给 left 和 right,写成 n 虽然多半不会越界然后报错,但逻辑上不严谨。我见过有人在这里纠结要不要 n - 2,其实就记住一个原则:三数之和至少要三个数,i 最多到倒数第三位。
4.2 Java 版本,注意集合的写法差异
Java 版本整体结构和 C++ 几乎一样,唯一要多留神的是结果集初始化 List<List<Integer>> 以及 Arrays.sort 的调用。Java 的 Arrays.asList 在拼三元组时很方便,但要注意基本类型数组和包装类型的坑,别把 int[] 直接往 List<int[]> 里塞。
java复制class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
int n = nums.length;
if (n < 3) return res;
Arrays.sort(nums);
for (int i = 0; i < n - 2; ++i) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1;
int right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.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 < 0) {
++left;
} else {
--right;
}
}
}
return res;
}
}
Java 版本我补充一个点:如果你面试的时候用 Java,建议把 res 初始化改成 new ArrayList<>(),不要写 LinkedList,因为这里只涉及追加操作,顺序遍历,ArrayList 更合适。这个小细节能看出你平时对集合性能的敏感度。
4.3 Python 版本,代码最短但性能依赖排序
Python 的代码写起来最爽,列表操作非常方便,但要注意 Python 的 sort() 是原地排序,不需要重新赋值。Python 版本在力扣上一般能过,因为数据规模限制在 3000 左右,O(n^2) 配合 Python 的解释性本质其实刚好压线。
python复制class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
res = []
if n < 3:
return res
nums.sort()
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
while left < right:
total = nums[i] + nums[left] + nums[right]
if total == 0:
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 < 0:
left += 1
else:
right -= 1
return res
Python 版本里如果用 typing.List,记得把 from typing import List 加在顶部,很多初学 Python 刷题的朋友会漏掉这一行,导致本地运行直接报 NameError。力扣的在线编辑器其实会自动引入,但你在本地 IDE 里测试就跑不起来。
4.4 刷题时最好主攻一门语言,但看懂三种
我自己刷题的经验是,主攻一门语言容易形成肌肉记忆,面试时才能做到手不生。但我也强烈建议把另外两门的写法看一遍,因为高级语言之间的实现差别会帮你理解算法本质。比如 C++ 的 vector 和 Python 的 list 底层虽然都是动态数组,但 C++ 的排序迭代器模型和 Python 列表切片思想完全不同,交叉对比能让你的记忆更立体。
更重要的是,在刷题训练中要刻意关注代码风格。三数之和这种题,答案高下不在于"能不能过",而在于代码是否简洁、边界是否完备、变量命名是否清晰。我见过太多人写出来的代码功能对,但一个函数里塞了五层缩进,变量名全是 i j k l,这种代码面试官看着也头疼。
5. 实际刷题踩坑记录与自测清单
5.1 去重写在错误位置,结果要么丢解要么重解
我把去重问题单独拿出来说,因为这是全题最容易翻车的地方。一种常见错误是拿到了结果才想到去重,结果用了暴力排序加字符串拼接的方式,时间复杂度原地爆炸。另一种错误是去重写在外层循环判断 if (nums[i] == nums[i + 1]) continue;,这个写法会把有效答案 [-1, -1, 2] 直接丢掉。
正确的记忆口诀是:外层用 nums[i] == nums[i - 1],是在进入该数作为第一个元素的循环之前判断;内层用 nums[left] == nums[left + 1] 和 nums[right] == nums[right - 1],是在记录结果之后去跳重复。我每次写这题前都会默念一遍"外层比前一个,内层比下一个,顺序不能反",基本能避开所有去重坑。
5.2 指针移动的边界条件比你想的更精细
双指针内部的移动,最常见的问题是在 while (left < right) 循环里只写 left++ 或只写 right--,导致死循环或者漏解。还有一个问题是去重跳过后忘了再移动一次,比如 while (nums[left] == nums[left + 1]) left++; 写完就结束了,此时 left 指向的还是重复序列的最后一个元素,必须再补一次 left++ 才能指向新值。
如果觉得这段逻辑绕,我建议你在纸上画一个简单的有序数组,比如 [-4, -1, -1, 0, 1, 2],把 i、left、right 三个下标的移动过程一步一步标出来。我当年就是这么干的,画完一遍之后,去重的顺序和指针移动的逻辑就再也不会搞混了。
5.3 极端输入必须优先考虑,否则就是白练
刷题训练到了后期,要养成一拿到题目就自动枚举边界条件的习惯。对三数之和来说,至少要想清楚以下几种输入:数组长度小于 3,直接返回空;数组全为 0,应返回 [[0,0,0]] 而不是空;数组里全是相同负数或正数,结果为空;数组里有大量重复数字,去重后结果数量要正确。
这些边界条件非常好用,每次写完代码先手动测一遍,再丢给力扣判题,能省下大量反复提交的时间。我还见过有人用随机测试来验证正确性,写一段随机数组生成器,同时在代码里跑一个暴力版本和双指针版本做对比,这种方法在本地调试时非常香,推荐给想提升排查能力的同学。
5.4 常见问题速查表,面试前翻一遍
以下这份速查表是我自己整理的三数之和高频坑点,面试前花两分钟扫一遍,能避免大多数低级错误。
| 问题类型 | 错误示例 | 正确做法 |
|---|---|---|
| 外层去重 | nums[i] == nums[i+1] 时 continue |
nums[i] == nums[i-1] 时 continue |
| 内层去重 | 记录结果前跳过重复 | 记录结果后跳过重复,再左右指针各走一步 |
| 剪枝 | 未判断 nums[i] > 0 |
排序后若第一个数大于 0 直接 break |
| 循环边界 | i < n 或 left <= right |
i < n - 2,left < right |
| 复杂度 | 说成 O(n^3) | 排序 O(n log n),查找 O(n^2),总 O(n^2) |
6. 从三数之和延伸出去,一通百通
6.1 两数之和变体,双指针同样适用
两数之和如果给定的是有序数组,完全可以用双指针在 O(n) 时间内解决:左指针指向开头,右指针指向结尾,根据当前和与目标值的大小关系移动指针。这个思路可以说是三数之和的基础,只不过没有外层循环。
我在刷题时习惯把两数之和、三数之和、四数之和放在同一天连续做,因为它们属于同一类"求和 + 去重"问题,解法内核完全一样。对比着刷的好处在于,能够把"排序 + 双指针"这个模型彻底内化,后面遇到任何变种都能快速反应。
6.2 最接近的三数之和,几乎就是套模板
力扣第 16 题最接近的三数之和,给定一个数组和目标值 target,找三个数使得它们的和最接近 target。解法同样是先排序,然后固定一个数,双指针扫描,每次更新最接近的差值。它比三数之和还少了一个去重的烦恼,因为只需要返回一个值,不需要收集所有组合。
强烈建议学完三数之和之后立刻做这题,因为它是检验你双指针掌握程度的绝佳试金石。如果你能不看题解写出这道题的代码,说明三数之和你已经吃透了。
6.3 四数之和与 N 数之和,核心就是循环加递归
四数之和的常规解法是固定前两个数,然后双指针扫描剩下的区间,复杂度为 O(n^3)。看起来只是多套了一个循环,但去重逻辑更复杂,两个固定元素的去重顺序不能错,否则很容易重解。想明白三数之和的去重原理,再去看四数之和中的去重位置,会清晰很多。
更通用的 N 数之和可以用递归模板解决,不过这属于进阶玩法了,面试里一般问不到这么深。我在学习的时候做了一道四数之和就暂停了,因为再往上就不符合高频面试题的定位,投入产出比不高。大家刷题也要学会控制深度,先把高频模型吃透。
6.4 这个双指针模型,在真实工程里也很常见
很多人觉得刷算法题和日常工作没关系,其实双指针在真实工程里无处不在。比如两个有序数组合并、合并两个有序链表、在有序数组里寻找某段区间、甚至数据库索引合并查询,背后都是"双指针按大小顺序推进"的思想。
三数之和这道题的价值,不在于你能在面试里默写多少行代码,而在于它帮你建立起一种"先排序、后扫描、去重走边界"的解题直觉。我后来写业务代码做数据校验时,也常常会想起这道题里对重复数据的处理方式,这种思维迁移能力,才是刷题训练真正的长期回报。
回到开头那句话,力扣热题100每一道都值得反复琢磨。三数之和我刷了三遍,每一遍都能写出更干净的代码、发现更隐蔽的边界问题。如果你也在刷这道题,别急着背答案,先按自己的思路写一遍,再对照文中这版实现,重点看外层去重和内层去重这两个位置,吃透了你会发现,N 数之和的大门已经打开了一半。
