说个反直觉的现象:一堆人看"三数之和"的题解时觉得自己已经完全懂了,合上手机白板手写,十分钟写出三行bug,去掉重写漏、该break没break、指针越界查半天。这道题在算法面试里属于典型的"T1级别高频手撕题",LeetCode 15题,字节、腾讯、阿里、美团这些公司的题库里都拿它当过热手题,面试官用它考察的点根本不是"你会不会解",而是"你在紧张状态下能不能十到十五分钟内写出边界完全正确、还能把思路讲清楚的代码"。这篇内容就是给你一套可以直接背下来的解法模板,外加每一行的记忆点、三处去重的完整拆解、面试追问的应对方式,以及我实际练题和带人刷题时踩过的坑。适合准备校招社招算法面的人,也适合刚学双指针想找一道代表性题目吃透的读者。
1. 为什么"三数之和"值得花力气背成模板
1.1 面试定位:热手题和压力题的合体
三数之和这道题,考察频率在LeetCode所有题目里能排进前几。它的难度标注是Medium,但实际面试中的"杀伤力"远超很多Hard题。原因在于:它逻辑上不难,但细节极其密集。你需要在十分钟里同时处理排序、双指针、外层去重、内层去重、剪枝、边界条件,任何一个环节出错都会导致答案错误或者超时。
面试官往往把这道题安排在面试前半段作为"热手题",让你进入状态;同时也作为"压力题",观察你在短时间内的工程判断力。我见过不少候选人,一上来就说"我知道这题用双指针",但写到一半开始纠结去重逻辑,最后不得不涂改重写。这类表现对面试评价的杀伤力很大——不是算法能力问题,而是"稳定性"问题。
1.2 "可直接背"不等于死记硬背
很多刷题博主会告诉你"理解最重要,不要背题"。这话对一半。对于三数之和这种"范式型题目",你需要做的是把"排序+双指针"这个组合套路刻进肌肉记忆,达到闭眼默写的程度。面试时你的认知资源有限,如果连模板都要现场推导,根本没有余力去和面试官讨论优化、变体和边界case。
背模板的另一个好处是:三数之和是四数之和、最接近的三数之和、三数之和小于K等一大票变体题的母板。模板熟了,变体题就是改参数的事。所以这篇标题说"可直接背",本质是让你先拥有一份标准可靠的实现,再在它的基础上做增量理解。
1.3 这道题到底在考察什么
我把面试官想看的能力点列一下:
- 是否能主动想到排序,并说清楚排序带来的好处。
- 是否理解双指针收敛的本质,从而不会出现指针回退的错误。
- 是否能把重复分支合并,写出结构清晰的代码。
- 是否能处理空数组、长度不足3、全正数等边界case。
- 是否能在写完代码后主动测试样例,并解释去重的正确性。
这些能力点不是孤立的,它们恰好全部落在三数之和这一道题上。所以这道题才配得上"T1级别高频手撕题"的称号。
2. 背之前先把原理吃透:排序加双指针为什么能成立
2.1 暴力解法为什么注定过不了
先看最直接的思路:三重循环枚举所有 i<j<k,判断 nums[i] + nums[j] + nums[k] == 0。这个方案的代码非常短,但时间复杂度是O(n^3)。当n取3000时,组合数大约是C(3000, 3),接近45亿次,哪怕按每秒1亿次运算来算,也要45秒才能跑完,面试场景下直接不成立。
关键不是记住"O(n^3)很慢"这个结论,而是理解优化方向:如何省掉一层循环?双指针方案就是在固定一个数之后,把剩下的"两数之和"问题从O(n^2)降到O(n),从而让总复杂度变成O(n^2)。
2.2 排序带来的三个关键红利
很多人不理解为什么三数之和要先排序。排序乍一看引入了O(n log n)的开销,但它为后面省掉了更多工作:
一是相同的元素聚在一起。数组排完序后,重复值必然是相邻的。去重只需要比较nums[i]和nums[i-1]、nums[left]和nums[left+1]即可,不需要借助哈希集合。这是让代码简洁并可控的最重要原因。
二是双指针可以收敛。在有序数组中,固定住最小的数nums[i]之后,问题变成"在 i 右侧的区间里找两个数,使它们的和等于 -nums[i]"。此时如果nums[left] + nums[right]偏小,说明左指针对应的值太小、需要向右移动;如果偏大,说明右指针太大了、需要向左移动。因为数组单调,这种单向移动一定是有效的。
三是可以剪枝。排序后,如果外层循环枚举到的nums[i]已经大于0,那它右侧所有数都大于0,三数之和必然大于0,直接break,不需要继续遍历。
这三个红利不是孤立的,它们共同决定了"排序方案"是面试中的最优解。
2.3 双指针为什么不会漏解
这是面试官最爱追问的一点。你可以用"排除法"来解释:在区间[left, right]里寻找和为target的两个数。每次比较当前和:
- 若 nums[left]+nums[right] < target,则右指针是当前区间最大值,连用最大右指针都没法让和达到target,说明左打印机和任意比右指针更小的数组合都不成立。因此所有包含当前left的方案都被排除,left可以放心右移。
- 若 nums[left]+nums[right] > target,则左指针是当前区间最小值,连用最小左指针都没法让和降到target,说明右指针和任意比左指针更大的数组合都不成立。因此所有包含当前right的方案都被排除,right可以放心左移。
- 若相等,则记录答案,并把两个指针同时移动,继续搜索。
这里的核心是"每次移动都排除了一整类不可能的解",所以不会漏解。用一句通俗的话讲:双指针不是暴力尝试所有组合,而是每次根据大小关系,把不可能的那半边直接扔掉。
2.4 三个分支怎么统一记忆
内层双指针一共只有三种情况,我建议你就按这个顺序写:
- 和等于目标值:记录三元组,然后两边同时缩,并且跳过所有重复值。
- 和小于目标值:说明值太小,把左指针往右挪。
- 和大于目标值:说明值太大,把右指针往左挪。
三种情况互斥,用if/elif/else写,不需要多余的判断。很多人在现场会写出奇怪的嵌套if,多半是没想清楚这三种情况天然就是一个完整的分支结构。
3. 可直接背的 Python 模板与每一行的记忆点
3.1 主模板代码
下面这份代码是我在面试中实际用过的版本,也是我推荐你背的这一版。它把剪枝、去重和双指针组织的比较干净:
def threeSum(self, nums: List[int]) -> List[List[int]]: nums.sort() n = len(nums) res = [] for i in range(n - 2): # 剪枝:最小的数已经大于0,后面不可能凑出和为0 if nums[i] > 0: break # 外层去重:跳过作为"第一个数"的重复值 if i > 0 and nums[i] == nums[i - 1]: continue left = i + 1 right = n - 1 target = -nums[i] while left < right: s = nums[left] + nums[right] if s == 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和right都停在非重复的最后一个位置,同时收缩 left += 1 right -= 1 elif s < target: left += 1 else: right -= 1 return res3.2 记忆链条:按步骤默写
你不需要逐行背,你需要背的是下面这条链条。默写时按链条展开即可:
排序 → 取长度n → 初始化结果数组 → 外层for i in range(n - 2) → 剪枝break → 外层去重continue → 初始化left、right、target → 内层while left < right → 计算两数之和s → 三种分支 → 找到解后先跳过重复再同时收缩 → 返回结果。
这套链条的每一个环节都可以对应一段固定代码,面试时你在白板上先写出骨架,再一步步细化,逻辑就不会乱。注意外层循环的边界写成range(n - 2),这是为了保证至少有i、left、right三个位置;写成range(n)虽然内层会在left<right处兜底,但变量边界会变脏,别给自己留隐患。
3.3 C++版本:同样逻辑,换一层皮
如果你面试用的是C++,模板同样固定:
class Solution { public: vector<vector<int>> threeSum(vector<int>& nums) { sort(nums.begin(), nums.end()); vector<vector<int>> res; int n = nums.size(); 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, right = n - 1; int target = -nums[i]; while (left < right) { int s = nums[left] + nums[right]; if (s == target) { 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 (s < target) { left++; } else { right--; } } } return res; } };Java版本就不贴了,代码几乎和C++一致,只是容器换成List。你只需要记住一个语言版本的核心逻辑,其他语言只是语法翻译。
3.4 一个偷懒但管用的默写技巧
默写时最容易漏的是"找到解后的双端跳过重复"。我有两个办法防止漏写:
第一,把这一整段当成一个整体记忆,不要拆开记。第二,给自己定一个固定话术,写代码时嘴里默念"记录、跳左重、跳右重、双缩"。写完之后马上再检查一遍:这一段有没有保留left < right条件?有没有同时left++和right--?这两行检查做完,基本不会错。
4. 最容易写错的三处去重逻辑,全部拆开讲
4.1 外层i的去重:为什么比较i和i-1而不是i和i+1
很多初学者写外层去重时,会写成:
# 错误示范 if nums[i] == nums[i + 1]: continue这个写法看起来很自然,实际是错的。原因在于:当i指向一个重复值中的第一个时,我们仍然需要用它作为"第一个数"去和后面的组合,只有当i指向重复值中的第二个及以后时,才应该跳过。比较nums[i]和nums[i + 1],会把第一个重复值也跳过。
举个例子,数组[-1, -1, 2]的正确答案是[-1, -1, 2]。按错误写法,i=0时发现nums[0]==nums[1],直接continue,整个答案都丢了。所以正确写法一定是比较当前值和前一个值:if i > 0 and nums[i] == nums[i - 1]。
这个细节我建议你反复练习,因为它是面试中最高频的bug之一。
4.2 内层找到一组解后,为什么左右都要跳
当s == target时,我们得到了一组解。此时如果只移动left,或者只移动right,会发生什么?
比如数组[-1, 0, 1, 2, -1, -4],排序后是[-4, -1, -1, 0, 1, 2],不加内层去重时,i指向第一个-1,双指针会找到[-1, 0, 1];然后i指向第二个-1,又会找到[-1, 0, 1],结果数组里出现两份相同答案。所以必须在找到一组解后,把内层所有与当前left、right重复的值跳过。
为什么左右要同时收缩?因为left和right这两个位置已经合作完成了一个合法组合,固定i的情况下,left取这个值时能配对得到target的right是唯一的;同样right取这个值时能配对得到target的left也是唯一的。这个组合已经被记录过了,左右两端再任何一方停留在当前值都不会产生新的合法三元组,所以必须双向移动,同时通过跳过重复值来避免生成完全相同的结果。
注意跳过去重这个动作本身的语法:找到解后,左指针要跳的是和nums[left]相等的值,也就是while left < right and nums[left] == nums[left + 1]: left += 1;右指针要跳的是和nums[right]相等的值,也就是while left < right and nums[right] == nums[right - 1]: right -= 1。跳完之后,left和right分别停在最后一个重复值上,再统一执行left++和right--,落到新的非重复位置。
这个"先跳到最后一个重复值,再统一收缩"的顺序,比"先收缩再跳"更不容易写错。
4.3 为什么内层while里时刻都要带left < right
所有涉及left或right移动的位置,都必须保证left < right才合法。特别是去重循环里,你可能会想写:
# 错误示范:可能越界 while nums[left] == nums[left + 1]: left += 1当left撞上right之后,nums[left + 1]就可能访问到数组末尾越界,或者把一个不合法的值算进来。所以我的习惯是:凡是在循环内部出现了nums[left + 1]、nums[right - 1]这类"预测下一个位置"的代码,一律先检查前一个条件。写多了之后,这个检查会变成肌肉记忆。
4.4 用标准样例验证去重逻辑
背完模板后,建议你自己手动跑两个用例:
第一个是题目的标准样例nums = [-1,0,1,2,-1,-4],预期输出是[[-1,-1,2],[-1,0,1]]。跑的过程里注意观察:i=0,nums[0]=-4,找不到和为4的两数组合;i=1,nums[1]=-1,内层找到[-1,0,1];i=2,nums[2]=-1,被外层去重跳过。这样输出的两个三元组分别是[-1,-1,2]和[-1,0,1]。
第二个是nums = [-1, -1, 2],预期输出是[[-1,-1,2]]。这个用例专门用来检验外层去重是不是写成了nums[i]==nums[i+1]。如果按错误写法,输出会是[],一眼就能暴露问题。
这两个用例加起来用不了两分钟,却能挡住面试中最常见的两类错误,我强烈建议你把它当成模板的一部分一起背。
5. 时间复杂度和空间复杂度的标准回答
5.1 时间复杂度:O(n log n) + O(n^2)
分两部分算:
- 排序:基于比较的排序,比如Python的Timsort和C++的std::sort,时间复杂度O(n log n)。
- 外层循环加内层双指针:外层i要遍历n-2次,内层while每次从left和right两头往中间收,每一轮最多移动O(n)步,所以内层整体是O(n),外层嵌套后总成本O(n^2)。
综合起来,整体时间复杂度是O(n^2)。面试时不要只说"O(n^2)"就停,建议把排序的O(n log n)单独提一下,表示你没有忽略排序这一步。如果数组长度很大,排序的开销占比会越来越小,最终由O(n^2)主导。
5.2 空间复杂度:看你怎么看待排序的辅助空间
如果不把返回结果res算进去,额外空间主要消耗在排序上。Python的Timsort平均需要O(n)的辅助空间,C++的std::sort是原地排序,通常只使用O(log n)的递归栈空间,最坏情况下可以是O(n)。所以标准回答可以说:额外空间O(log n)到O(n),取决于语言和排序实现。
有一个容易踩的误区是:面试官问空间复杂度时,你把res数组算进去了。res是题目要求返回的结果,属于输出空间,一般不计入额外空间复杂度。如果面试官追问这一点,你可以明确说"如果不考虑返回结果占用的空间"。
5.3 为什么整体比暴力法快了一个数量级
这个说清楚,能体现出你对复杂度的理解。暴力法是三重循环,每一次枚举都要判断;双指针方案通过排序把内层变成了一个线性收缩的过程,在固定外层i的前提下,内层指针一共只从两端到中间走一次,同层不会嵌套第二层循环,因此少了一维复杂度。记住这个逻辑,面试官怎么追问你都能接住。
6. 面试追问与变体题:怎么把模板改造成四道题
6.1 目标值不是0怎么办
面试官会问:如果target不是0,而是任意整数,怎么改?非常简单,把外层循环里的target从 -nums[i] 变成 target - nums[i] 就行。其余逻辑完全不动。这道变题考察的是你有没有真的理解模板,而不是只会背原题。
6.2 最接近的三数之和
这是LeetCode第16题,也是三数之和最常见的变体。模板可以复用,区别是内层不再是"等于target就记录",而是每次计算当前和与target的差值的绝对值,如果更小就更新答案。然后仍然根据和与target的大小关系移动指针。注意:不需要去重,因为题目只要求返回一个最接近的和,不要求枚举组合。
6.3 四数之和
第18题,思路是把四数之和降成三数之和。先固定两个数nums[i]和nums[j],剩下的问题变成在j右侧找两个数和等于target - nums[i] - nums[j],这正好是我们的内层双指针。整体复杂度变成O(n^3)。去重逻辑需要在外层两层分别处理,但模式和三数之和完全一致,一个模板吃遍三兄弟。
6.4 面试官要你返回下标怎么办
这个问题很阴险,因为排序方案会破坏原数组的下标关系。你不能无脑排序。一般有两种思路:一是用哈希表存值 -> 下标列表,两数之和那套方案做改造;二是把数组值和原始下标打包成结构体再排序。不管选哪种,都要先跟面试官确认:返回的是所有组合的下标三元组去重,还是任意一个。这个场景不太可能在三数之和里问得很深,但提前知道总比现场懵好。
6.5 如果只需要判断是否存在
如果只要求回答"是否存在三个数和为0",那不需要返回所有组合,可以去掉所有去重逻辑,遇到第一组解就返回True。复杂度不变,但代码更短。更多时候面试官不会这么简化,因为题目核心就是考察去重。
7. 我实际面试和带人刷题时踩过的几个坑
7.1 命名混乱是手撕代码的第一杀手
我看到太多人写这道题时,用i、j、k来命名三个指针,结果写到最后自己都分不清哪个是外层、哪个是内层。我的建议是固定命名:外层循环变量用i,内层左指针用left,右指针用right。这样你在心里默念"固定最左边的数,left从右往中间走,right从最右往中间走",逻辑永远清晰。
7.2 先处理空数组和长度小于3的情况
虽然不处理也能通过大部分用例,但面试官看一眼边界处理就知道你的工程习惯。在函数开头写上:
if not nums or len(nums) < 3: return []这行代码永远不会错,但能体现你考虑问题的全面性。注意不要把这个判断写在排序之前或之后搞混了,顺序无关紧要,关键是别漏掉。
7.3 不要为了提升复杂度去答哈希解法
有人会提到用哈希表存两数之和来过三数之和,理论上可以做到平均O(n^2),但去重处理极其痛苦,面试官听你讲哈希去重的过程会觉得你在绕圈子。标准答案就是排序加双指针,简洁、正确、可证明。哪怕你知不知道哈希解法,都不建议作为主答案。
7.4 每天十分钟的"肌肉记忆训练"
我的经验是:这道题值得连续一周每天闭眼默写一遍。每次写完全程大约五分钟,写错了就对照模板找出差异。坚持下来后,你会发现面试时根本不会紧张,因为你写的每一步都像打字一样自然。这种题目最怕的不是不会,而是"明明会却写错",多练几遍比多看十篇题解有用。
7.5 最后分享一个临场小技巧
写代码前,先用一句话把思路告诉面试官:"我先排序,然后固定第一个数,剩下两个数用双指针在右侧区间找。每找到一个解,左右指针同时移动并跳过重复值。"这句话说完,面试官已经知道你真的懂了,接下来你写代码时,即使偶尔卡壳,面试官也更容易帮你,而不是觉得你不行。
三数之和这道题,几个月前我准备面试时最怕它,练熟了之后反而最希望面试官考它——因为它是少有的"背模板就能稳稳拿分"的手撕题。你现在花十几分钟把上面的逻辑和代码过一遍,再用一周每天默写一遍,下次在面试里遇到它,你会比大多数候选人从容得多。