"三数之和"这道题我这些年至少讲过几十遍,每次面试官端出来,都能筛掉一批背答案的选手——模板背得熟的人能写出来,但一问到"为什么去重要分两处"、"为什么先排序",很多人当场卡壳。LeetCode第15题,题面极短,解法极经典,但它正好把算法面试里最常考的四样东西全揉在一起:排序预处理、双指针逼近、边界控制、重复组合去重。这篇文章我不打算只给你一份能跑的代码,而是把这四样逐一拆开讲透,中间穿插我自己调试过很多次的真实经验。适合正在冲刺面试的刷题党,也适合刚学会双指针、想找个完整案例巩固的初学者。看下去之前说清楚:这篇文章默认你会基础的数组操作和循环,但所有关键逻辑我都会用最直白的话解释,不用怕跟不上。
1. 题目拆解:三数之和到底在考察什么
1.1 题面与三个容易被忽略的边界
先把原题原话复述一遍:给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a、b、c,使得 a + b + c = 0,请你找出所有满足条件且不重复的三元组。
题面里有几个字眼必须扣死。第一,数组里是整数,不是正整数,所以会有负数、零、正数混在一起,这意味着答案的三元组可能是"一负两正"、"两负一正"、也可能三个都是零。第二,返回的是所有满足条件的三元组,不是只返回一个。第三,也是最多人翻车的地方:要求三元组不重复。也就是说 [1, -1, 0] 和 [-1, 0, 1] 在题目眼里是同一个三元组,就算数值顺序不同,也算重复;同时数组本身可能有多个相同的数,比如 [-1, -1, 0, 1],里面的两个 -1 都能和 0、1 组成答案,但答案里只需要出现一次 [-1, 0, 1]。
边界条件也得提前想明白,否则写出来的代码要么越界要么漏解。数组长度小于 3 直接返回空列表;数组全为正数或全为负数时没有解;数组为 [0, 0, 0] 时答案是 [[0, 0, 0]]。这些场景我每次写题都会先在草稿纸上标出来,不是为了炫技,而是为了让主逻辑干净,少几个藏在角落里的 if。
1.2 先算复杂度:为什么暴力三重循环过不了关
很多人拿到这题的第一反应是三重循环:枚举 i、j、k,判断 nums[i] + nums[j] + nums[k] == 0。这个思路本身没错,错在复杂度上。三重循环的时间复杂度是 O(n^3),n 到几百就已经肉眼可见地慢,题目给的数据范围往往允许 n 到达几千甚至上万,O(n^3) 几乎是必死。更麻烦的是,三层枚举会产生大量重复的三元组,比如 [ -1, 0, 1 ] 会被枚举到六次,最后还得靠集合去重,集合操作本身又是一笔额外开销。
这里有一个特别重要的算法思维:拿到任何算法题,先估算数据范围,再决定暴力能不能过。如果 n ≤ 100,O(n^3) 可能勉强能跑;一旦 n 上千,就必须追求 O(n^2) 甚至更低。三数之和这道题的经典目标复杂度是 O(n^2),这也是为什么双指针解法能成为标准答案——因为它正好卡在这个复杂度上。
1.3 从"两数之和"升级,思路的进化路径
很多人刷题顺序是先做两数之和(LeetCode 1),再做三数之和。两数之和的经典解法是哈希表:遍历一次,把 target - num 存进 map,后面遇到匹配值直接返回,时间复杂度 O(n),漂亮又简洁。于是到了三数之和,第一反应是"固定一个数 a,剩下两个数之和等于 -a,这不就是两数之和吗?"
这个"降维"的思路是对的,但有一个关键差异:两数之和只需要返回一个答案,三数之和要求返回所有不重复的组合。哈希表找"一个答案"很舒服,找"全部答案"并且还要去重就很别扭——你必须在遍历过程中想办法跳过已经用过的组合,或者最后统一去重,无论哪种都绕不开额外空间和额外复杂度。所以在三数之和这个场景下,更合适的工具反而是排序加双指针。两个指针在有序数组上从两端向中间逼近,天然的可以扫出所有组合,配合跳过逻辑就能去重,没有任何多余的哈希结构。
2. 排序加双指针:为什么这成了标准答案
2.1 固定一个数,剩下的交给双指针逼近
标准解法的核心一句话:先对数组排序,然后第一层循环固定 nums[i] 作为第一个数,剩下的问题简化为在 i+1 到数组末尾这个区间里,找两个数 nums[left] 和 nums[right],使它们的和等于 target = -nums[i]。
为什么双指针能找全所有组合?用一个生活类比解释:想象一个身高升序排列的队伍,两个人分别站在队伍的左右两端。如果两个人的身高和比目标值大,说明右边那个人太高了,右指针往左挪一步,换矮一点的人;如果身高和比目标值小,说明左边的人太矮了,左指针往右挪一步,换高一点的人。每一次比较都排除掉一个候选位置,两个人往中间走直到相遇,这个区间内所有可能的配对都被覆盖到了,一趟下来是 O(n)。
这个思路最关键的前提是:数组必须有序。无序数组里左移右移没有任何方向感,指针怎么挪都像无头苍蝇;一旦排序,左边永远小于右边,比较结果才能指导指针移动方向。
2.2 排序带来的两个隐藏福利
排序在这里的作用,很多人只看到了一半。第一半是让双指针有方向感,前面已经说了。第二半才是很多人忽略的宝藏:排序让去重变得极其简单。因为排序之后相同的元素一定相邻,去重只需要检查当前元素和前一个元素是否相等,相等就跳过,成本几乎为零。如果不排序,同样的去重逻辑要么依赖哈希表记录已用组合,要么把结果集最后再排序一次,代码量和出错概率都会明显增加。
所以排序这件事,本质上是在"花小钱办大事":O(n log n) 的排序成本,换来了 O(n) 的双指针逼近和几乎免费的去重能力。这道题里,排序不是可有可无的预处理,而是整个解法成立的地基。
2.3 去重必须分两个位置,位置错了必出bug
我在讲这道题的时候,最喜欢问学员一个问题:"你觉得在这道题里,重复是怎么产生的?"很多人答不上来。重复其实只来源于两个地方:第一,外层固定的第一个数 nums[i] 重复了;第二,找到一组答案之后,指针没有跳过相同的值,下一轮又找到了同样的组合。
所以去重也必须精确地分在这两个位置。第一处是外层循环里,当 nums[i] 和 nums[i-1] 相等时直接跳过。这里有个新手必踩的细节:判断条件必须是nums[i] == nums[i-1],而不是nums[i] == nums[i+1]。因为 i-1 是已经处理过的位置,i+1 是还没处理的位置。如果拿 i+1 去比较,会把合法答案错杀。举例:数组是 [-1, -1, 2],i=0 时 nums[0] == nums[1],如果按 i+1 判断,i=0 会被跳过,但 [-1, -1, 2] 本身三数之和刚好是 0,是一组合法答案,直接被误删。
第二处去重发生在找到一组答案之后:记录完 [nums[i], nums[left], nums[right]],这个组合已经收集过了,接下来必须先把所有和当前 nums[left] 相同的值跳过,再跳过所有和当前 nums[right] 相同的值,最后统一移动指针。这两处去重缺一个,结果集里就会出现大量重复。
2.4 哈希表方案为什么在这里不划算
我见过不少人在三数之和里强行套两数之和的哈希表方案,也就是固定 i 和 j,然后去哈希表里查 target - nums[i] - nums[j]。这个思路的复杂度是 O(n^2),看似也能过,但存在两个致命问题。
第一,去重困难。哈希表方案得到的组合天然没有顺序,[0, -1, 1] 和 [1, 0, -1] 会同时出现,你得想尽办法给组合归一化,要么排序每个三元组,要么用字符串拼接后塞进 set,每一招都额外消耗时间和空间。第二,代码复杂度上升。双指针方案只需要两个 while 去重循环,哈希表方案需要在两层循环里维护 visited 集合、处理跳过逻辑,代码长度几乎是双指针版本的两倍,面试时还特别容易写乱。我的结论是:双指针不是唯一能过的方法,但绝对是最适合这道题的解法,尤其是面对"返回所有组合"这种要求时。
3. 完整代码、逐行解读与用例验证
3.1 一份可以直接上手的 Python 实现
def three_sum(nums): res = [] n = len(nums) if n < 3: return res nums.sort() for i in range(n - 2): # 第一个数已经大于0,后面的数更大,三数之和不可能为0 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: 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 += 1 right -= 1 elif s < target: left += 1 else: right -= 1 return res这份代码我写了很多遍,结构已经固定下来。在面试场景里,这就是一道标准的三数之和模板,从输入到输出含边界检查一共不到三十行,实际跑起来也很稳。
3.2 逐行过一遍,每个判断都有理由
外层循环是for i in range(n - 2),终止在 n-2 而不是 n,因为至少还要留两个位置给 left 和 right,否则根本凑不出三个数。
if nums[i] > 0: break是排序之后白送的剪枝。数组递增,第一个数都大于 0 了,后面的数不可能比它小,任意三个数之和必然大于 0。这里用 break 而不是 continue 也值得说:因为排序后数组是单向递增的,i 再往后走只会更大,整个循环已经没有继续的必要,直接跳出最干净。
if i > 0 and nums[i] == nums[i - 1]是第一层去重。注意i > 0这个条件不能少,少了第一轮就访问 nums[-1],在 Python 里不会报错但会拿到数组最后一个元素,属于隐蔽bug。判断的是 i-1 而不是 i+1,这个原因我在前面详细解释过,不再重复。
target = -nums[i]这行看着不起眼,但我建议大家都这么写。把三数和为 0 的条件转成两数和为目标值,后面比较nums[left] + nums[right] == target会清爽很多,也更贴合"固定一个数再找两个数"的思维模型。
进入 while 循环后,s = nums[left] + nums[right],然后三分支判断。相等就记录答案并做第二层去重;和小于 target 说明左边的数太小,left 右移;和大于 target 说明右边的数太大,right 左移。这套移动规则是双指针的灵魂,写错一个方向就全盘崩。
3.3 复杂度结论与边界用例速查表
时间复杂度的分解是这样的:排序 O(n log n),外层循环 O(n),内层双指针最坏 O(n),所以主流程是 O(n^2),整体取 O(n^2)。空间复杂度上,如果不考虑结果列表占用,排序是原地的话额外空间是 O(1);不过 Python 的 sort 是 Timsort,严格来说会占用 O(n) 的额外空间。面试时你可以回答"主流程 O(1) 额外空间,如果按语言实现细节算,排序可能用到 O(n)",这样既准确又显得你懂底层。
边界用例我整理成了表格,写完之后照着跑一遍可以快速验证正确性:
| 输入 | 期望输出 | 验证点 |
|---|---|---|
[-1, 0, 1, 2, -1, -4] | [[-1, -1, 2], [-1, 0, 1]] | 标准场景,去重是否正确 |
[0, 0, 0] | [[0, 0, 0]] | 三个零的特例 |
[1, 2, 3] | [] | 全正数无解 |
[]或[1] | [] | 长度小于3 |
[-2, 0, 1, 1, 2] | [[-2, 0, 2], [-2, 1, 1]] | 负数+相同数组合 |
4. 实战中踩过的坑:去重、指针与边界
4.1 去重位置不对导致漏解
这是我见过最多人踩的坑,我自己也踩过。有一种写法是把第二层去重放在 while 循环开头,也就是每次循环进来先执行while left < right and nums[left] == nums[left + 1]: left += 1。表面上看是提前跳过了重复值,但结果会漏解。原因在于:搜索过程中,left 和 right 的移动是为了探索新的组合,如果你一进来就先跳到最后一个相同值,可能会跳过本该参与配对的元素,把一个本来合法的组合给拆散了。
我调试过一份学员的代码,输入[-1, -1, 2],按他的写法输出空列表,但正确答案应该是[[-1, -1, 2]]。问题就出在他把去重放在了搜索之前,强行跳过了第一个 -1。正确逻辑是:只在确认找到一组答案之后再去重,搜索过程中不要干涉指针的移动。
还有一个相关错误是使用 set 结果集去重,也就是不管重复,把所有组合塞进 set,最后统一去重。这样确实能得到正确答案,但性能会打折扣:每个三元组都要做哈希计算,数据量大的时候纯属浪费。更重要的是,双指针解法本身就保证不会出现重复组合,你只要把去重写对,根本不需要 set。
4.2 跳过重复值没做导致答案重复
漏解的反面就是重复答案。常见写法是找到答案后直接left += 1; right -= 1,然后继续下一轮。如果下一轮 nums[left] 还是旧值,nums[right] 也还是旧值,那和又会等于 target,于是再记录一次一模一样的组合。
解决办法就是我代码里写的两个 while 循环,先跳过重复值,再统一移动指针。这里有一个小习惯我特别推荐:两个 while 循环之后,统一写left += 1; right -= 1,而不是在 while 循环里直接把指针挪到最终位置。后一种写法逻辑上等价,但很容易少写一步或者多写一步,让指针越界。统一先跳后移,思路清晰,不容易错。
调试技巧也很简单:写完后用一个含大量重复元素的数组跑一遍,比如[-1, -1, -1, 0, 0, 0, 1, 1, 1],肉眼检查输出里有没有重复。或者更直接一点,把双指针版本的结果和"set去重版本"的结果比一下长度,长度一致说明去重逻辑没问题。这个比对方法我在实际开发中用了很多次,非常省时间。
4.3 负数场景、整数边界和剪枝的正确姿势
负数场景在初学者那里特别容易出问题。有人觉得nums[i] > 0就 break,那负数呢?其实负数场景完全不用特殊处理,双指针会自然地处理它。比如数组[-3, -1, 0, 1, 2],固定 -3 后 target = 3,双指针在 [-1, 0, 1, 2] 里找两数和为 3,能找到 [1, 2],得到 [-3, 1, 2]。固定 -1 后 target = 1,双指针能找出 [0, 1],得到 [-1, 0, 1]。整个过程顺理成章,不需要额外分支。
关于整数溢出,三数之和这道题在原题范围内一般不会溢出,因为三个 int 相加仍在 int 范围内。但面试官可能会追问:如果数据范围很大,nums[left] + nums[right]会不会溢出?这里要能答出——如果题目没有保证范围,可以把和改成 long 类型存储,或者比较时用nums[left] == target - nums[right]这种移项写法,避免直接相加。能说出这一层,面试官会高看你一眼。
剪枝方面,还有一个常见写法是在nums[i] > 0之后再判断nums[i] == 0之类的东西,没必要。排序后的单调性已经决定了:第一个数大于0就没有往下搜的必要。保持代码单一职责,剪枝就只干剪枝的事,别混入去重逻辑。
4.4 面试官最常见的三种追问变体
面试官很少只考一道裸的三数之和就放你走,通常会在你写完代码之后追加几个变体。我整理了一下最常出现的三种。
第一个是"最接近的三数之和"(LeetCode 16)。题面是给定一个目标值 target,返回三个数之和最接近 target 的值。解法几乎一样:排序、固定一个数、双指针逼近,唯一区别是把"相等判断"改成"记录当前和与 target 的绝对差值,差值更小就更新答案"。这道题因为不要求去重,写起来反而比原题还简单。
第二个是"四数之和"(LeetCode 18),在数组里找四个数之和等于 target。套路就是套娃:外层再加一层循环,固定两个数,剩下的两个数继续用双指针,整体复杂度升到 O(n^3)。理解了三数之和,四数之和就是"再包一层"的问题,没有任何新东西。
第三种追问更有意思:如果题目要求不能修改原数组,怎么办?因为排序会改变原数组的顺序,如果题目禁止修改,哈希表方案就成了主要选择。但去重会变得很麻烦,通常的做法是用 set 存三元组,配合一些跳过逻辑。这种题考察的是你能不能灵活转换思路,而不是死背模板。我的建议是把这三个变体顺着刷一遍,三数之和的理解会深入很多——你真正掌握的是一套"固定 k-2 个数,剩余两数用双指针"的通用模板。
5. 刷题方法论:从做对一道题到解一类题
5.1 这道题背后真正值得练的三种思维
三数之和这道题的价值,不在于代码有多难写,而在于它浓缩了三个高频算法思维,吃透这三个,你会受益于后面一大类题。
第一个思维是降维。三数问题降成"固定一个数 + 两数问题",两数问题用双指针解决。算法题里最经典的套路之一就是把高维问题层层拆解,四数之和也不过是再降一层。第二个思维是排序引入单调性。无序数组里双指针没有方向,排序后双指针就有了"大小朝向",这种单调性可以应用到很多题目上:比如接雨水、区间合并、滑动窗口极值,本质都是利用有序性简化问题。第三个思维是对去重时机的判断。去重不是拍脑袋加一个 set,而是要思考"重复到底在哪里产生",找到产生点再去重,代码才会干净。这个思维方式在做所有"返回所有不重复组合"的题时都通用。
5.2 写代码前的自查清单
我每次给学员讲这类双指针题,都会让他们在写代码前过一遍自查清单。这个清单不是背出来的,是踩坑踩出来的,你可以直接抄走:
- 数组排过序了吗?不排序就没有双指针,这一步忘了一切白搭。
- 外层循环终止条件是 n-2 还是 n?写错要么越界,要么漏掉最后一组。
- 第一层去重比较的是 i-1 还是 i+1?用错位置会误杀合法答案。
- 找到答案后,left 和 right 跳过重复值了吗?没跳结果必然重复。
- 比较大小之后,指针移动的方向对吗?和 target 比,谁该动,往哪动,要一遍想清楚。
- 有没有画蛇添足地加 set?双指针本身就保证了去重,不需要多余结构。
- 边界用例测了吗?空数组、长度小于3、全正数、全零,这四个用例只要两分钟就能验证完。
这些自查点列出来之后,很多 bug 其实在你动手写之前就已经被排除了。我在面试现场的时候,也靠这套思路稳定发挥,不会因为紧张而漏掉关键判断。
5.3 提升通过率的一个调试小技巧
最后一个实用技巧,来自我自己的刷题习惯:遇到指针类题目,先在草稿纸上画一遍指针移动的过程,尤其是带重复元素的例子。不要上来就写代码,先在纸上模拟几步,比如[-1, -1, 0, 1],画出 left 和 right 每一步的落点,标注出去重发生在哪个位置。画完之后再写代码,出错率至少降一半。
还有一个编码层面的小偏好,也分享给你。第二层去重的两个 while 循环,我习惯写成:
while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1这跟前面代码里的写法完全一样,你要注意的就是每个 while 前面都要带left < right这个越界保护。如果不带,left 可能一路冲到 right 的位置,下一轮比较就会出错。这个小细节,看起来不起眼,却是很多超时和越界 bug 的根源。
我个人在实际带人刷题的过程里,最大的体会是三数之和这道题真的值得反复做三遍。第一遍求通过,第二遍理解去重的两个位置,第三遍尝试把代码压缩到最短、把剪枝优化到极致。每做一遍,对双指针的理解都会深一层。很多人觉得算法题是八股文,背模板就行,但三数之和这种题恰恰相反,它逼着你理解每一步背后的理由。那些面试时能从容回答追问的人,不是记性好,而是真的把排序、双指针、去重这三件事的因果关系想透了。这套思路打通之后,再遇到"两数之和变形"、"三数之和变形"、"区间内找目标值"这一整族题目,你会发现它们其实都是同一个骨架,换了层皮而已。最后再说一个想让代码跑得更快的细节:如果数组里零特别多,可以在外层循环里顺手判断一下,当 i > 0 且 nums[i] 和前面一样时直接跳过——但请记住,这个优化只能放在第一层去重之后,顺序反了还是会出问题。想验证自己是否真懂,不妨把这段代码用你熟悉的另一门语言重写一遍,写的过程中你会发现,真正的理解是跟语言无关的。