三数之和:排序+双指针+去重,LeetCode Hot 100必刷题深度拆解
发布时间:2026/9/28 17:54:49来源:尧图网络
说实话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 固定一个数剩下两个数交给双指针整体思路是这样的对数组排序。外层循环固定第一个数nums[i]。在i后面的区间里用双指针找两个数使三数之和为0。左指针初始指向i 1右指针初始指向数组末尾。计算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]。i0nums[i]-4目标在[-1,-1,0,1,2]里找两个数和为4。左指针-1右指针2和1小于4左指针右移……最终找不到合法组合。i1nums[i]-1且nums[1]等于nums[0]吗不等于-4≠-1所以继续。目标在[-1,0,1,2]里找两个数和为1。左-1右2和1记录[-1,-1,2]随后跳过重复的-1继续移动指针。左0右1和0小于1左指针右移结束。i2nums[i]-1此时nums[2] nums[1] -1跳过避免重复三元组。i3nums[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]如果i0时用第一个-1和后面数组合得到了[-1, -1, 2]i1时如果用第二个-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 res4.2 几个值得细说的边界细节先说range(n - 2)。为什么要减2因为你要固定i、left、right三个位置i最大只能到n - 3的下标此时left取n - 2right取n - 1刚好是三个数。如果i取到n - 2left、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 - 1left去重时忘记跳过本身只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到left1的所有组合都满足条件直接计数即可。这个技巧在“滑动窗口 双指针”的计数类题目里特别常用属于从三数之和延伸出来的进阶能力。所以三数之和真的不只是“背一道题”它是一个算法范式入口。6. 面试现场的拆题思路与刷题策略建议6.1 拿到题目后的思考顺序如果你在面试或笔试里遇到三数之和建议按下面的顺序来拆先确认数据范围。问清楚数组长度、元素是否可能重复、是否要求返回不重复三元组。这直接决定你用什么解法。从暴力法开始讲起展示“我知道这个问题的下限在哪里”。提出排序 双指针解释为什么排序是合理的预处理。代码写到一半主动说明去重的三处位置以及为什么比较nums[i - 1]而不是nums[i 1]。最后主动补上复杂度分析。这套顺序的好处是面试官能全程看到你的思考过程而不是直接甩一个背好的模板。很多人代码能力不差但面试挂在“讲不清楚”就是因为跳过了第2步和第4步。6.2 Hot 100整体刷题的小心得聊回Hot 100本身。我自己的刷题习惯是分模块数组与双指针、哈希表、链表、二叉树、动态规划、图论每个模块先挑几道经典题建立框架再反覆刷同类延伸题。三数之和属于“数组与双指针”模块里的枢纽题在它之前应该先刷两数之和在它之后应该立刻接最接近的三数之和和四数之和这样一条线下来知识是连贯的。很多人有一个误区喜欢按题号从头到尾刷。但Hot 100的排列顺序并不完全由易到难直接按顺序刷很容易在三数之和这种中等题上卡很久然后挫败感飙升。我更建议按标签刷一个标签吃透再换下一个效率高很多。6.3 一个实战小技巧不要只刷一遍三数之和这种题刷三遍都不算多。第一遍在大致理解思路后AC第二遍隔几天不看答案重新写重点检验自己对去重位置和边界条件是不是真的有肌肉记忆第三遍限时做模拟面试手写。三遍之后你会发现脑子里留下的不是代码而是一个“排序固定一个双指针三次去重”的完整思维框架。我个人在带人复盘这道题时还有一个习惯让他们故意写错一个地方比如把去重的比较对象写反然后看能不能通过测试用例。这个反向练习非常有助于加深理解因为在排查自己制造的bug时你被迫重新走一遍完整的指针移动逻辑比单纯读正确答案要记忆深刻得多。最后说一个我和身边朋友都深有同感的点三数之和这道题真正的价值不在于AC那一刻的快感而在于它强迫你同时思考“算法复杂度”“组合去重”“边界条件”这三件程序员日常里最容易犯错的细碎事情。把这个过程走完整后面刷滑动窗口、二分答案、双指针的进阶题都会顺手很多。如果你现在还在Hot 100的开头挣扎碰到这道题卡住了别灰心——几乎所有刷过这道题的人都曾经在这里丢过大把头发。
网站建设高端定制企业官网