☰
三数之和双指针解法:排序与去重实战指南
2026/10/20 7:09:45 网站建设 项目流程

1. 题目拆解与双指针引入

1.1 题意解读与输出要求分析

三数之和这题,可以说是双指针技术里最有代表性的一道题目。很多人第一次接触它是在面试准备阶段,但刷过几轮之后会发现,这题背后的双指针思想几乎贯穿了整个数组类题目的体系。题目本身描述并不复杂:给定一个包含 n 个整数的数组,要求找出所有满足 a + b + c = 0 的三元组,并且要求三元组不能重复。

这里要特别留意几个细节。第一,返回的是“所有”满足条件的三元组,意味着不是找到一个就收工;第二,去重是针对三元组整体而言,比如 [-1, 0, 1] 和 [1, 0, -1] 视为同一个结果,因为它们是同一组数字的排列组合;第三,数组里可能包含重复元素,这会导致同一组三元组通过不同的下标组合反复出现,必须用合理的去重策略挡住。

我见过不少同学在初次写这题时,第一个反应就是三层循环暴力枚举。这种做法在思路验证阶段没问题,三个 for 循环套起来,i 从 0 到 n-3,j 从 i+1 到 n-2,k 从 j+1 到 n-1,把所有组合都过一遍,再用 Set 或者排序后去重。但问题在于时间复杂度是 O(n^3),在 n 达到 3000 时,计算量大约是 270 亿次,任何在线判题系统都会直接判超时。

1.2 从“两层循环 + 哈希”到“排序 + 双指针”的演进

有些读者可能会想到另一条路:先固定一个数,剩下两个数用两数之和的思路解决。具体来说,固定 a 之后,问题变成在剩余数组中找两个数之和等于 -a。两数之和可以用哈希表做到 O(n),这样整体降到 O(n^2),看起来已经不错了。我第一次接触这题时也走过这条路,但很快发现一个问题:哈希表解法在去重上非常别扭。你需要小心翼翼地记录哪些组合已经出现过,而且一旦使用哈希表,数组的原始顺序会引入很多边界判断,代码写出来往往比预想中长很多。

真正优雅的解法是排序加双指针。先对数组做一次升序排序,然后仍然固定一个数 nums[i],但是剩余两个数不再用哈希表,而是在 i 之后的区间 [i+1, n-1] 上用两个指针相向移动。左指针 left 初始指向 i+1,右指针 right 初始指向 n-1,根据三数之和与 0 的比较结果决定移动方向。这个方案的时间复杂度同样是 O(n^2),但空间复杂度只有 O(1),而且天然规避了哈希表去重带来的麻烦。

为什么排序和双指针的组合能带来这种优势?关键在于数组有序之后,指针移动的方向和大小关系有了确定性。三数之和大于 0 时,右指针左移可以使和变小;小于 0 时,左指针右移可以使和变大。这个逻辑在无序数组里是没法直接成立的,排序就是给双指针提供了这种可控性。

2. 双指针核心思路:排序、固定与收敛

2.1 为什么排序是双指针方案的前提

排序是整个方案的地基,要理解这一点,可以做一个简单的类比:想象你面前有一排数字按从小到大排列,你手里有一个目标值,要在这一排数字中挑两个数达到这个目标。因为数字有序,你可以从两端开始逼近——左边太小就往右移动,右边太大就往左移动,每一步都在缩小搜索范围。如果数字是乱序的,两端移动的判断依据就完全失效了,你永远无法确定移动某个指针后和值是变大还是变小。

具体到这题,排序的代价是 O(n log n),相比三层循环的 O(n^3),这个开销完全可以接受。更重要的是,排序让后续的所有去重操作变得极其简单。比如,当 nums[i] 与前一个位置 i-1 的值相同时,以 nums[i] 为固定值所找出的所有三元组,一定已经被 nums[i-1] 那一轮全部覆盖了,直接跳过即可。这比在结果集中使用 Set 去重高效得多,代码量也少得多。

排序还有一个容易被忽视的好处:它让结果的输出本身就带有顺序性。三个数字在结果中是按非递减顺序排列的,这在人工检查结果时非常直观,调试起来也方便不少。

2.2 指针移动规则与去重的三层防线

外层循环固定 nums[i] 之后,内层进入双指针过程,具体规则是这样:

  • 计算当前三数之和 sum = nums[i] + nums[left] + nums[right]。
  • 如果 sum 等于 0,记录这个三元组,然后同时收缩左右指针,并跳过所有与当前值重复的元素。
  • 如果 sum 小于 0,说明整体偏小,需要更大的数参与求和,所以 left 右移一位。
  • 如果 sum 大于 0,说明整体偏大,需要更小的数参与求和,所以 right 左移一位。

这里最容易出错的就是去重的时机和位置。很多人在找到一组解之后只移动一个指针,导致同一组解反复出现,或者漏掉某些组合。我自己在初学阶段就踩过这个坑,后来总结出“三层去重防线”的说法:

第一层,外层固定值的去重。当 i > 0 且 nums[i] == nums[i-1] 时直接 continue,理由在前文已经说明。这里要注意判断条件用的是 nums[i-1] 而不是 nums[i+1],这是很多新手容易搞混的地方。如果写成 nums[i] == nums[i+1] 就跳过了当前值,会直接漏掉像 [-1, -1, 2] 这样的有效组合,因为两个 -1 虽然值相同,但它们是两个不同的数组元素,可以同时参与组合。

第二层,找到解后的左指针去重。在记录三元组之后,left 要一直向右移动,直到 nums[left] != nums[left+1],然后还要再移动一步,否则 left 仍然指在重复值上。

第三层,找到解后的右指针去重。right 要一直向左移动,直到 nums[right] != nums[right-1],然后再移动一步。

这三层缺一不可。我见过不少代码在找到解后只写 left++ 和 right--,而不做内层的 while 循环去重,这样结果虽然正确,但会产生大量重复三元组,提交时要么被判超时,要么在结果去重环节被干掉。

2.3 边界条件与循环终止的细节

外层循环还有一个优化的点:因为需要三个数之和为 0,固定值 nums[i] 本身如果已经大于 0,那么 i 之后的所有数都大于 0,三数之和必然大于 0,不可能等于 0,这时可以直接终止整个循环。这是一个很实用的剪枝操作,尤其在数组中有大量正数时,可以节省不少时间。

内层双指针的终止条件是 left >= right。这个条件要放在循环开头判断,并且在找到解、左右指针都完成跳跃后重新判断。有些实现会选择 while (left < right) 作为内层循环条件,这也完全没有问题,只要保证在每次循环体结束前,指针是向着对方移动的,最终一定会触发终止条件。

还有一个小细节:当 sum 恰好等于 0 时,虽然我们记录了三元组,但不应该直接 break 或 return,因为可能存在多个解。比如数组是 [-4, -1, -1, 0, 1, 2],当固定 -4 时找不到解,固定第一个 -1 时,left 指向第二个 -1,right 指向 2,得到 [-1, -1, 2];之后 left 跳到 0,right 跳到 1,又得到 [-1, 0, 1]。这两组结果都满足条件,需要全部输出。

3. 代码实现与复杂度分析

3.1 一套可直接运行的参考实现

我直接用 Python 写一版常见的实现,这个版本已经处理了所有去重细节,可以直接提交到判题系统:

def three_sum(nums): n = len(nums) if n < 3: return [] nums.sort() result = [] for i in range(n - 2): # 固定值去重 if i > 0 and nums[i] == nums[i - 1]: continue # 剪枝:固定值大于 0,后面不可能凑出和为 0 if nums[i] > 0: break left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total < 0: left += 1 elif total > 0: right -= 1 else: result.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 return result

这段代码有几个值得注意的点。外层循环的范围是 n-2,因为至少要留两个位置给 left 和 right。剪枝操作放在去重之后,作用不大但逻辑更清晰。内层 while 循环中的两个跳过重复的 while 语句,要确保 left < right 的条件成立,否则可能出现指针越界。最后 left += 1 和 right -= 1 是必须的,它保证在找到一组解后,指针能继续向中间移动,探索其他可能的组合。

3.2 时间复杂度与空间复杂度拆解

时间复杂度上,排序是 O(n log n),然后外层循环固定一个数,内层双指针扫一遍剩余区间,每个位置的元素在内层循环中最多被访问常数次,整体是 O(n^2)。两层循环加起来,最坏情况下执行次数大约是 n*(n-1)/2 的量级。空间复杂度是 O(1),这里没有考虑输出结果所需的空间,只看额外使用的变量,只有 i、left、right 和 total 几个标量,这是双指针方案对比哈希表方案的最大优势。

哈希表方案的空间复杂度是 O(n),因为它要额外存储一个哈希映射。在数组规模非常大的时候,这个空间差距是不能忽略的。我在模拟面试中经常问面试者一个问题:如果数组有 10 万个元素,两种方案在内存上的差距是多少?O(1) 和 O(n) 在极限情况下可能相差几十 MB,这在实际工程中会影响服务的稳定性。

3.3 官方写法之外的代码风格建议

不同语言在实现这题时有一些微妙的差异。Java 版本要注意 int 溢出问题,三个 int 相加理论上可能超过 int 范围,但在这题的数据范围下通常不会触发。C++ 版本要注意迭代器的使用,如果面试官要求手写,用下标访问比迭代器更直观。Go 版本要注意切片排序 API 的调用方式。

我个人推荐的代码风格是:变量名用 left 和 right 而不是 l 和 r,因为可读性更好;注释只写在关键逻辑处,比如去重和剪枝;函数返回值统一用二维数组或切片,不要在内部打印结果。这些看似微小的习惯,在实际面试中会给面试官留下很不错的印象。

4. 题目变体与思路迁移

4.1 从“和为0”到“和为 target”的推广

三数之和最经典的版本要求三数之和为 0,但在很多公司的笔试和面试中,题目会稍微变形:给定一个整数数组和一个目标值 target,要求找出所有三数之和等于 target 的三元组。处理方式几乎一样,只需要把判断条件从与 0 比较改成与 target 比较,剪枝条件也要相应调整。原来 nums[i] > 0 可以直接 break,现在改成 nums[i] > target 且 target 为正数时才能剪枝,如果 target 是负数,这个剪枝就不成立了。

这种变体实际上是考察你能否抓住问题的本质,而不是死记硬背代码。固定一个数,剩余两数用双指针搞定,判断条件换成 target 即可,核心框架完全不需要变动。

4.2 最接近的三数之和

另一道高频变体是求最接近 target 的三数之和。题目要求返回一个整数,表示所有三元组中与 target 差的绝对值最小的和。这种题的思路仍然沿用双指针,但每次计算出一个 sum 后,要更新一个全局变量 best,记录当前与 target 最近的距离。如果 sum 等于 target,可以直接返回;如果 sum 小于 target,left 右移;如果 sum 大于 target,right 左移。

这里要注意的一个细节是:需要记录的是距离最近的“和”,而不是最近的三元组本身。所以只需要一个整数变量记录最优值,不需要存储整个组合。这个变体比原题少去了重的麻烦,因为只返回一个数,不需要去重。

4.3 小于 target 的三数组合计数

还有一个有意思的变体是:给定数组,统计三数之和小于 target 的三元组个数。这种题目在思路上又有所不同,因为在找到某个满足条件的三元组后,如果 nums[i] + nums[left] + nums[right] < target,那么对于固定的 i 和 left,right 从当前值往左的所有位置都满足条件,可以直接计数。也就是说,当你发现当前组合满足条件时,可以一次性加上 right - left 个组合,然后 left 右移,这样就避免了逐个遍历的浪费。

这个变体展示了双指针技术在搜索之外的另一种用法:利用有序性进行批量计数。它不再是单纯地寻找某个目标值,而是在统计满足区间条件的组合数量。理解了这一点,很多看似不同的统计类问题就都可以用双指针的思路解决了。

4.4 四数之和与扩展到 k 数之和

四数之和是三数之和的自然延伸。思路是先排序,然后固定两个数,剩下两个数仍然用双指针解决。这样整体复杂度是 O(n^3),空间复杂度 O(1)。五数之和、k 数之和理论上也是同样的思路,每多一个数就多一层固定循环,最内层两个指针负责最终的搜索。不过在实际工程和算法面试中,四数之和已经足够少见,再往上的 k 数之和更多的是在讨论算法设计复杂度时才会涉及。

这里有一个递归写法可以实现通用的 k 数之和,但调试复杂度会显著上升。我的建议是面试时先用循环写清楚四数之和,再简单提一句可以扩展到递归形式,但不要真的在有限时间内写复杂的递归。面试官更看重你能否把已知的题目模式应用到新题上,而不是背一个通用模板。

5. 常见问题与避坑实录

5.1 去重逻辑写错的典型表现

去重是这题犯错率最高的环节。最常见的错误是外层循环写成 nums[i] == nums[i+1] 时 continue,前面已经分析过这会漏掉 [-1, -1, 2] 这类合法组合。另一种常见错误是找到解后只写 left++ 和 right--,没有跳过重复元素,导致结果中出现大量重复三元组,虽然最终可以用外部去重补救,但会额外增加时间开销。

还有人在跳过重复时写错方向。左指针跳重复应该比较 nums[left] 和 nums[left+1],右指针跳重复应该比较 nums[right] 和 nums[right-1]。方向反了的话,指针可能会停留在错误的位置上,导致死循环或者跳过有效解。

5.2 指针移动方向与终止条件的坑

内层双指针循环写成 while (left <= right) 是一个新手容易犯的错误。如果允许 left 和 right 相等,当它们指向同一个元素时,这个元素会被同时当作两个数参与求和,导致错误的组合出现。正确写法应该是 while (left < right),保证两个指针指向不同位置的元素。

另一个问题是移动指针时没有考虑边界。在 while 循环内部访问 nums[left+1] 或 nums[right-1] 时,一定要先确认 left+1 < n 或 right-1 >= 0。虽然在这个特定题目中,由于外层的终止条件限制,通常不会越界,但为了代码的健壮性,建议在所有 while 条件里都加上 left < right。

5.3 输入数组为空或长度不足 3

这是一个非常容易被忽略的边界情况。很多人在写完核心逻辑后,没有在最开头对数组长度进行判断。当数组长度小于 3 时,不可能存在三元组,应该直接返回空结果。如果不加这个判断,后续访问 nums[0]、nums[1] 等位置时会发生数组越界异常。虽然有些语言中 Python 的切片访问不会直接报错,但返回的结果也不正确。

5.4 问题排查速查表

错误现象可能原因排查与修复思路
结果漏掉 [-1,-1,2] 这类重复值组合外层去重条件写成 nums[i]==nums[i+1]改成 nums[i]==nums[i-1],并从 i>0 开始判断
输出大量重复三元组找到解后未跳过重复元素在 left 和 right 各自加 while 循环跳过重复值
死循环或超时指针移动方向错误或未移动检查 left++ 和 right-- 是否在每次循环中执行
数组越界缺少长度小于 3 的初始判断开头加 n<3 时返回空结果
剪枝过度导致漏解target 为负数时仍用 nums[i]>0 剪枝仅当 target 为正数时用 nums[i]>target 剪枝

5.5 一些个人的实测经验

我在本地用随机生成的大数组做过压测,数组长度从 10 到 10000 不等。实测下来,排序加双指针的实现即使在 10000 长度的随机数组上,运行时间也低于 1 秒。而三层暴力循环在 1000 长度时就已经明显卡顿,在 3000 长度时基本无法在合理时间内返回结果。这个差距足以说明双指针方案的必要性。

还有一个经验是:双指针方案的速度对数组的重复度并不敏感,因为去重操作让每个固定值的处理次数最少化。数组越密集重复,去重带来的收益越明显。所以在数据量大的生产环境或竞赛场景中,这个方案都很稳定。

6. 实操心得与后续拓展建议

6.1 我自己在刷题过程中的体会

这题我第一次做的时候,走了不少弯路。最开始想用哈希表,写完之后去重逻辑绕来绕去,代码长度接近六十行,还有几个隐藏的 bug。后来看了标准解法,第一次看到排序加双指针的组合时,有种豁然开朗的感觉。这个方案不仅代码简洁,而且每一步都能说出为什么这么做,面试时讲起来非常有底气。

我后来复盘了一下,为什么哈希表方案在这题上不好用?因为三数之和要求的是“组合”而不是“配对”,哈希表擅长处理配对问题,但组合的去重需要对历史状态做更多记录。双指针借助排序天然消除了顺序带来的重复,这才是它真正优雅的地方。

6.2 从面试角度看这题的价值

三数之和在算法面试中出现的频率非常高,几乎可以算是数组双指针题目的招牌。面试官用它考察的不仅是你会不会写双指针,还包括你对去重边界的敏感度、对剪枝条件的判断,以及能不能顺畅地讲清楚每一步的逻辑。如果你能把这道题讲到滴水不漏,后面遇到四数之和、最接近的三数之和、统计类问题,都可以很快迁移过去。

我的建议是,刷这题时不要只满足于通过判题。尝试做三件事:第一,把代码缩到最短但不牺牲可读性;第二,给另一个人讲一遍你的思路,看他能不能听懂;第三,把题目换成不同 target、不同出口条件重新做一遍。这三件事做完,你对双指针的理解会上一个台阶。

6.3 一个值得尝试的后续练习路径

学完三数之和之后,可以按这个顺序继续练习:两数之和(哈希表经典题)、三数之和最近版、四数之和、小于 target 的三数组合计数。这些题目共享同一个排序加双指针的骨架,变化主要集中在去重、剪枝和出口条件上。把这组题练完,数组类算法题的底子就打得很扎实了。

如果你的目标是竞赛方向,还可以进一步研究如何在双指针的基础上结合二分查找优化某些统计类问题。但在日常面试场景中,能熟练推导并正确实现排序加双指针的框架,已经足够应对绝大多数考察了。

6.4 最后分享一个调试小技巧

如果某次提交因为结果重复或漏解而失败,我建议先在本地构造几个专门针对去重的测试用例,比如 nums = [0, 0, 0, 0]、nums = [-1, 0, 1, 0]、nums = [-2, -2, 0, 2, 2] 这类含有大量重复值的数组,打印每一步的 left、right 和记录结果,很快就能定位是哪一层去重出了问题。比起直接在判题系统上反复提交,这种本地调试方式效率要高得多。

这道题本身并不难,难的是把每一步的“为什么”都想明白。当你真正理解了排序在双指针中的作用、理解了三层去重各自负责什么,你收获的就不只是一道题的解法,而是一整套处理数组搜索类问题的思考方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询