☰
三数之和:排序+双指针+去重,LeetCode Hot 100必刷题深度拆解
2026/9/28 17:54:43 网站建设 项目流程

说实话,LeetCode Hot 100里的题我基本都刷过不止一遍,但每次被身边人问到“哪道题最值得反复琢磨”,我第一反应永远是三数之和。这道题看着简单——给你一个整数数组,找出所有和为0的三元组,要求不重复。可真正动手写起来,从暴力三重循环到排序双指针,从去重逻辑到边界处理,几乎每一层都有坑等着你。它不考冷门算法,也不涉及复杂数据结构,却能把“双指针 + 排序 + 去重”这套组合拳练得明明白白。今天我把这道题从解题思路到代码实现、从常错细节到延伸题型完整拆一遍,适合正在刷Hot 100、准备面试、或者想系统补一补双指针解法的人。

1. 从两数之和到三数之和:这道题的定位与考点拆解

1.1 为什么是Hot 100里绕不开的一道题

LeetCode Hot 100不是随便选的题单,它基本等于“大厂面试高频题的百人斩名单”。三数之和能进这个名单,核心原因不在于题目本身有多难,而在于它考察的是一组组合型算法思维:你能不能在O(n²)时间内解决一个看似需要O(n³)才能完成的问题,能不能在优化时间的同时把“去重”这种工程细节处理干净。

很多人在刷这道题之前刚做完两数之和,脑子里全是哈希表的影子:两数之和用哈希表可以O(n)解决,那三数之和是不是也能用哈希表?能,但代价很大。哈希表解法需要两层循环枚举前两个数,再用哈希表找第三个数,时间已经到了O(n²),而且去重逻辑极难写——因为你找到的组合可能因为顺序不同、重复元素等原因出现大量重复,光set去重就够你喝一壶。

相比之下,排序 + 双指针把整个问题的结构理清了:先让数组有序,然后用一个指针固定第一个数,剩下两个数用左右指针在有序区间里逼近。这套模式稳定、可控、去重逻辑清晰,是面试时最容易被认可的解法。所以Hot 100把它放进来,其实是想考察你能不能跳出哈希表的惯性思维,换一个数据结构去解决问题。

1.2 考点拆解:这道题到底在考什么

我平时带人刷题,会先要求他们拆考点。三数之和的考点可以拆成四层:

  • 第一层:能不能想到排序。这是整个解法的地基,没想到排序,后面全白搭。
  • 第二层:双指针的移动逻辑。和大了动哪边,和小了动哪边,为什么这么动不会漏解。
  • 第三层:去重的位置和时机。这是这道题最大的坑,很多人在字符串里得心应手,一换到数组去重就手忙脚乱。
  • 第四层:边界条件的严谨性。比如数组长度小于3直接返回空、i的范围最多到n - 3、当前数大于0直接break等。

这四层从易到难,层层递进。你如果平常只是背模板把题AC了,大概率只能过第一层和第二层;一旦面试官追问“为什么这里要去重”“为什么双指针不会漏解”,你就会卡壳。所以我建议你按这个分层去准备,别只看最终代码。

2. 暴力循环的尽头:排序加双指针是怎么一步步推出来的

2.1 暴力三重循环的问题到底在哪

先看所有人都能想到的做法:三层for循环枚举所有三元组,判断和是否为0,最后去重。时间复杂度O(n³),数组长度稍微过千就撑不住,这还不算去重的额外开销。但抛开复杂度不谈,暴力枚举还有一个隐蔽的问题:你会枚举到大量重复组合。

比如数组里有两个-1,你用暴力枚举可能得到[-1(第一个), 0, 1]和[-1(第二个), 0, 1]——这其实算同一个三元组。你要么最后用set统一去重,要么在枚举时就加一堆“当前位置和前一个位置相等就跳过”的判断。无论是哪种,都说明暴力解法在结构上就没有把“组合唯一性”这件事纳入设计,它只是一个事后补救的思路。

所以暴力解法的本质问题不是“慢”这么简单,而是它压根没有利用数组元素之间的顺序关系。数组无序时,任何两个元素都“平等”,你需要枚举所有可能性才能不遗漏;一旦数组有序,大小关系就出来了,很多组合天然就能被排除掉。

2.2 排序为什么是第一步

给数组排序,表面上是多花了O(nlogn)的时间,实际上是把后面所有问题的规模都缩小了。排序之后,你可以利用“单调性”来做决策:

  • 如果当前固定的第一个数已经大于0,那么后面所有数都大于0,三个数相加必然大于0,直接break。
  • 如果两数之和偏小,说明左边的数太小,左指针右移;如果两数之和偏大,说明右边的数太大,右指针左移。每一步都有一个明确的、可证明不会漏解的方向。
  • 相同的数会连续排列在一起,这让去重变得极其简单:只要判断当前元素和前一个元素是否相等,就能跳过所有重复情况。

这就是为什么排序是所有解法里最合理的第一步。它没有增加算法复杂度瓶颈之外的开销,却让后面的指针移动和去重都变得清晰可控。

2.3 固定一个数,剩下两个数交给双指针

整体思路是这样的:

  1. 对数组排序。
  2. 外层循环固定第一个数nums[i]。
  3. 在i后面的区间里用双指针找两个数,使三数之和为0。
  4. 左指针初始指向i + 1,右指针初始指向数组末尾。
  5. 计算nums[i] + nums[left] + nums[right]:
    • 等于0:记录答案,left右移,right左移,同时跳过重复元素。
    • 小于0:说明整体偏小,left右移,把和往大调。
    • 大于0:说明整体偏大,right左移,把和往小调。

这样外层循环O(n),双指针在区间内扫描O(n),总复杂度O(n²)。为什么这样不会漏解?因为外层i每固定一个值,剩下的问题就变成了“在有序数组中找和为-nums[i]的两个数”,这是一个标准的两数之和问题,双指针可以保证遍历到所有有效的数对组合。这个性质其实可以严格证明:由于数组有序,左指针向右移动会让和变大,右指针向左移动会让和变小,而双指针的每一步都覆盖了当前状态下所有可能的组合边界,不存在跳过的解。

为了方便理解,拿官方示例过一遍:数组[-1, 0, 1, 2, -1, -4],排序后变成[-4, -1, -1, 0, 1, 2]。

  • i=0,nums[i]=-4,目标在[-1,-1,0,1,2]里找两个数和为4。左指针=-1,右指针=2,和=1,小于4,左指针右移……最终找不到合法组合。
  • i=1,nums[i]=-1,且nums[1]等于nums[0]吗?不等于(-4≠-1),所以继续。目标在[-1,0,1,2]里找两个数和为1。左=-1,右=2,和=1,记录[-1,-1,2];随后跳过重复的-1,继续移动指针。左=0,右=1,和=0,小于1,左指针右移,结束。
  • i=2,nums[i]=-1,此时nums[2] == nums[1] == -1,跳过,避免重复三元组。
  • i=3,nums[i]=0,目标在[1,2]里找两个数和为0,找不到。
  • 最终答案:[[-1,-1,2], [-1,0,1]]。

这样走一遍就发现,排序帮我们把重复元素天然排列在一起,去重逻辑只需要判断“当前元素是不是和上一个元素相同”就够了。

3. 去重才是三数之和真正的分水岭

3.1 三处必须去重的位置,一个都不能少

我在讲解这道题时经常说一句话:能写出双指针的人很多,能一次把去重写对的人不多。很多人的代码结构是对的,但结果要么答案里有重复三元组,要么把合法的重复元素组合也给跳掉了。

三数之和一共要处理三处去重:外层i的去重、内层left的去重、内层right的去重。

第一处:外层i的去重。当nums[i] == nums[i - 1]时,说明这个数作为第一个数已经处理过,直接跳过。注意这里比较的是nums[i - 1],不是nums[i + 1]。很多人在这里写反,写成nums[i] == nums[i + 1],那就会把像[-1, -1, 2]这种合法答案直接抹掉。因为nums[i]和nums[i + 1]相等时,可能nums[i + 1]正好是左指针需要用的第二个数,你过早跳过当前i,相当于跳过了整个以当前值为首元素但第二个元素相同的组合。

第二处:记录答案后left的去重。当找到一个合法三元组后,left指针要跳过所有和nums[left]相同的元素。写法是while (left < right && nums[left] == nums[left + 1]) left++;,然后再left++一次。

第三处:记录答案后right的去重。同理,right要跳过所有和nums[right]相同的元素。写法是while (left < right && nums[right] == nums[right - 1]) right--;,然后再right--一次。

3.2 去重时机为什么重要

这三处去重里,大家最容易把left和right的去重位置写错。正确顺序是:先记录答案,再去重,最后移动指针。如果你先去重再记录答案,就可能跳过一个本应记录的合法三元组;如果你记录答案后不移动指针就直接下一轮循环,left和right没有变化,就会无限循环。

我见过一种错误写法,是找到答案后只做left++; right--;不做去重。这种写法虽然不会死循环,但结果里会出现大量重复三元组,比如同一个nums[i]遇到数组中多个相同的数对,就会产生相同的答案。如果你不把left和right的重复元素都跳过去,时间复杂度虽然还是O(n²),但常数会变大,而且结果集会长满重复项。

还有一个小细节:left和right的去重只能发生在找到合法解之后。如果在sum != 0的分支里去重,很可能把本可以找到答案的指针组合给跳过。比如当前和小于0,你本来应该left右移来增大和,结果你先判断nums[left] == nums[left + 1]就把left一直右移,虽然大多数时候结果碰巧也对,但逻辑上不够严谨,容易在某些边界样例上漏答案。

3.3 去重的本质:跳过“同一层”的重复,而不是跳过所有重复

这也算是我踩过坑后的一个感悟。去重的本质是:每一层循环里,同一个值只能作为该位置的候选元素一次。在i这一层里,同一个nums[i]值只能被固定一次;在left这一层里,同一个left位置上的值只能被使用一次。但数组里本身允许有重复元素,所以不能一看到重复就跳过,而是要结合“位置语义”来判断。

举个例子,数组[-1, -1, 2],如果i=0时用第一个-1,和后面数组合得到了[-1, -1, 2];i=1时如果用第二个-1作为第一个数,又会得到[-1, -1, 2]。这时候i层去重的作用就是把第二次出现的-1作为首元素的情况跳过去。但如果你在一开始就把所有-1都删掉,那合法答案也没了。所以去重的位置和比较对象,必须放在“已经处理完当前位置的使命之后”。

4. 边界条件与代码实现细节:一份能直接AC的参考实现

4.1 完整代码与关键注释

我用Python写一份比较标准的实现,每一处关键逻辑都加了注释:

def threeSum(nums): # 特判:数组长度小于3,直接返回空列表 if not nums or len(nums) < 3: return [] nums.sort() n = len(nums) res = [] # i最多到 n - 3,因为后面至少还要两个位置给 left 和 right 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 while left < right: total = nums[i] + nums[left] + nums[right] if total == 0: # 记录一个合法的三元组 res.append([nums[i], nums[left], nums[right]]) # left去重 while left < right and nums[left] == nums[left + 1]: left += 1 # right去重 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

4.2 几个值得细说的边界细节

先说range(n - 2)。为什么要减2?因为你要固定i、left、right三个位置,i最大只能到n - 3的下标,此时left取n - 2,right取n - 1,刚好是三个数。如果i取到n - 2,left、right就没位置了。很多初版代码在这里会写成range(n)然后空指针越界,属于比较低级的边界问题,但面试时一旦出现,印象分会直线下降。

再说nums[i] > 0 break。这个剪枝是排序后最直观的性质:数组是升序的,nums[i]已经大于0了,后面两个数更大,三个正数的和不可能是0。这一剪枝在数组里正数很多时能大幅减少不必要的循环。

还有一个细节是Python代码里while left < right and nums[left] == nums[left + 1]的判断顺序。必须先保证left < right,再访问nums[left + 1],否则left到达数组末尾时可能越界。同理,right指针需要先判断left < right再访问nums[right - 1]。

4.3 常见错误一览表

我把日常答疑里见到的错误汇总成一张表,你可以对照自查。

错误类型错误写法示例后果正确写法
外层去重比较对象写反nums[i] == nums[i + 1]漏掉合法解,如[-1, -1, 2]i > 0 and nums[i] == nums[i - 1]
i遍历范围过大for i in range(n)left/right越界for i in range(n - 2)
记录答案后不移动指针只append不left++/right--死循环去重后left += 1; right -= 1
left去重时忘记跳过本身只left += 1不循环跳过相等元素结果集出现重复三元组先while跳过相等,再统一+1
忘记剪枝nums[i] > 0不写break多跑无效循环if nums[i] > 0: break
省略长度特判不检查len(nums) < 3后续逻辑出错开头补特判

4.4 复杂度分析

时间上,排序O(nlogn),外层for循环O(n),内层while双指针在平均情况下扫描O(n),整体O(n²)。空间上,如果不考虑结果存储,只用了常数个额外变量,所以额外空间是O(1)。这些值在面试时基本是必答项,建议你不仅背下来,还要能口算出为什么。

有一点需要额外说明:我在面试里见过有人把去重逻辑写在一个while里,导致代码非常长,还容易出错。更简洁的思路是——不记录答案时,不进行去重;一旦记录答案,就集中把left和right的重复值全部跳过。这个原则让逻辑分支变得很清楚,不容易遗漏。

5. 一题带一串:从三数之和延伸出去的题型打法迁移

5.1 两数之和:哈希表法与双指针法的分岔口

三数之和的解法其实可以反向迁移回两数之和。两数之和的最优解是哈希表,O(n)搞定;但在数组有序的场景下,双指针也可以做到O(n)。LeetCode 167题就是有序数组的两数之和,双指针是标准解法。很多人在面试时遇到“两数之和”会直接条件反射地用哈希表,但面试官如果追问一句“如果数组是有序的呢”,你要能立刻切换到双指针。

理解两数之和的哈希表和双指针两种解法很重要,因为三数之和本质上就是“遍历第一个数 + 有序数组两数之和”。如果你把167题的双指针吃透了,三数之和的内层逻辑对你来说就只是平移。

5.2 最接近的三数之和:指针移动策略微调

LeetCode 16题“最接近的三数之和”就是从三数之和直接改出来的。目标不再是找等于0的三元组,而是找和与target最接近的三元组。解法几乎一样:排序 + 固定i + 双指针。唯一的变化是,在计算diff = abs(sum - target)时,如果diff更小就更新答案;然后根据sum与target的大小关系移动指针。这个题难度比三数之和略低,因为在找最接近值的过程中不需要处理复杂去重,适合作为三数之和的配套练习。

5.3 四数之和:结构性套娃

四数之和(LeetCode 18)则是三数之和的直接扩展。思路是:双层外层循环固定前两个数,剩下两个数继续用双指针,整体O(n³)。去重逻辑也要扩展到两层外层循环上。你会发现,每多一个维度,循环嵌套就多一层,去重位置就多一处,但双指针的核心思想完全没变。换言之,三数之和学会了,四数之和只是一个“要不要多套一层for循环”的问题,而不是新问题。

如果你愿意继续推下去,五数之和、k数之和其实都是同一个模式:固定k-2个数,剩下两个数交给双指针。复杂度从O(n^(k-1))开始起步。这也是为什么很多面试官喜欢拿三数之和当基础题,因为它能检验你是不是真的理解了“排序 + 双指针”这套范式,而不是死记硬背某道题。

5.4 其他变体:小于K、不重复三数组等

还有一些变体,比如“统计三数之和小于K的三元组个数”,这类题的常用解法也是排序 + 双指针,核心是当nums[i] + nums[left] + nums[right] < K时,right从right到left+1的所有组合都满足条件,直接计数即可。这个技巧在“滑动窗口 + 双指针”的计数类题目里特别常用,属于从三数之和延伸出来的进阶能力。

所以三数之和真的不只是“背一道题”,它是一个算法范式入口。

6. 面试现场的拆题思路与刷题策略建议

6.1 拿到题目后的思考顺序

如果你在面试或笔试里遇到三数之和,建议按下面的顺序来拆:

  1. 先确认数据范围。问清楚数组长度、元素是否可能重复、是否要求返回不重复三元组。这直接决定你用什么解法。
  2. 从暴力法开始讲起,展示“我知道这个问题的下限在哪里”。
  3. 提出排序 + 双指针,解释为什么排序是合理的预处理。
  4. 代码写到一半,主动说明去重的三处位置,以及为什么比较nums[i - 1]而不是nums[i + 1]。
  5. 最后主动补上复杂度分析。

这套顺序的好处是,面试官能全程看到你的思考过程,而不是直接甩一个背好的模板。很多人代码能力不差,但面试挂在“讲不清楚”,就是因为跳过了第2步和第4步。

6.2 Hot 100整体刷题的小心得

聊回Hot 100本身。我自己的刷题习惯是分模块:数组与双指针、哈希表、链表、二叉树、动态规划、图论,每个模块先挑几道经典题建立框架,再反覆刷同类延伸题。三数之和属于“数组与双指针”模块里的枢纽题,在它之前应该先刷两数之和,在它之后应该立刻接最接近的三数之和和四数之和,这样一条线下来知识是连贯的。

很多人有一个误区,喜欢按题号从头到尾刷。但Hot 100的排列顺序并不完全由易到难,直接按顺序刷很容易在三数之和这种中等题上卡很久,然后挫败感飙升。我更建议按标签刷,一个标签吃透再换下一个,效率高很多。

6.3 一个实战小技巧:不要只刷一遍

三数之和这种题,刷三遍都不算多。第一遍在大致理解思路后AC;第二遍隔几天不看答案重新写,重点检验自己对去重位置和边界条件是不是真的有肌肉记忆;第三遍限时做,模拟面试手写。三遍之后,你会发现脑子里留下的不是代码,而是一个“排序,固定一个,双指针,三次去重”的完整思维框架。

我个人在带人复盘这道题时还有一个习惯:让他们故意写错一个地方,比如把去重的比较对象写反,然后看能不能通过测试用例。这个反向练习非常有助于加深理解,因为在排查自己制造的bug时,你被迫重新走一遍完整的指针移动逻辑,比单纯读正确答案要记忆深刻得多。

最后说一个我和身边朋友都深有同感的点:三数之和这道题真正的价值不在于AC那一刻的快感,而在于它强迫你同时思考“算法复杂度”“组合去重”“边界条件”这三件程序员日常里最容易犯错的细碎事情。把这个过程走完整,后面刷滑动窗口、二分答案、双指针的进阶题都会顺手很多。如果你现在还在Hot 100的开头挣扎,碰到这道题卡住了别灰心——几乎所有刷过这道题的人,都曾经在这里丢过大把头发。

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

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

立即咨询