☰
力扣热题最接近的三数之和:双指针+贪心全解析
2026/10/10 19:24:17 网站建设 项目流程

在力扣热题100的榜单里,第16题"最接近的三数之和"一直被当成第15题"三数之和"的加长版来刷。名字像、参数像,连题解模板都只要改两三行。但我自己刷完这道题之后发现,如果只是照着15题的代码改几个符号,你会漏掉这道题真正的价值——它把双指针和贪心揉在了一起,值得单独拆开讲一遍。

这篇文章我不会只贴一份能AC的代码,而是把这题的完整思考链路走一遍:暴力为什么不行,排序+双指针为什么行,贪心移动为什么不会漏答案,以及边界条件里那些"一提交就报错"的坑。你可以把它当成刷题笔记,也可以当成面试前双指针专题的复习提纲。

1. 从"换皮题"到"送命题":最接近的三数之和到底难在哪

1.1 题目描述与两种刷题反应

先把题目摆出来。给一个整数数组nums和一个目标值target,要求从数组里选出三个整数,使它们的和与target的差的绝对值最小,返回这个和。注意这里不是返回三元组,只返回那个最接近的和。

就这么一句话,刷题的人大概分两派。第一派刚刷完第15题三数之和,看到这题会心一笑:把等于target改成最接近target,不就是把所有候选和都算一遍、维护最小差值吗?第二派拿到题直接懵了:等于是找组合,那三数之和那套"排序+双指针"还能用吗?——能用,但你要想清楚怎么用。

我用"换皮题"来形容它,是因为面试里它真的能区分两类人。一类人背模板,改完代码能过,但你问他"指针为什么这样动",他答不上来;另一类人把模板背后的贪心逻辑看透了,稍微一变题也能解。这篇文章想把大家往第二类推一把。

1.2 暴力枚举先走一遍:O(n³) 的复杂度到底能不能接受

在谈双指针之前,先把最朴素的做法写出来。很多同学觉得这种"求最接近"的题当然得枚举,三个 for 循环套一起,把所有组合算一遍,挑离target最近的和返回。

def threeSumClosest(nums, target): n = len(nums) best = float('inf') for i in range(n): for j in range(i + 1, n): for k in range(j + 1, n): s = nums[i] + nums[j] + nums[k] if abs(s - target) < abs(best - target): best = s return best

这段代码逻辑上完全正确,时间复杂度 O(n³)。如果你在笔试里提交,n 比较小的时候它甚至能过。但这道题在力扣上是中等难度,数据范围给的 n 上限通常是 1000 甚至 3000,O(n³) 在 3000 的规模下是 270 亿次运算,再怎么剪枝也扛不住。

那能不能稍微优化一下?如果先对数组排序,枚举i和j,然后对k用二分查找,可以把复杂度压到 O(n² log n)。排序本身 O(n log n),后面对每对(i, j)做一次二分是 O(log n),总复杂度 O(n² log n)。这个方案很多新手能想到,也算是一个不错的过渡思路。

但双指针方案能做到 O(n²),而且写起来比"枚举两个+二分第三个"更简洁。关键思路是:排序之后,固定一个数i,让left和right两个指针从i + 1和n - 1两端向中间走。这里面的取舍逻辑,就是这篇文章的核心。

2. 排序+双指针的主框架:固定一个数,剩下的交给左右指针

2.1 排序为什么是必需的:把无序问题变成有序搜索

先回答一个很多人忽略的问题:为什么这题要先排序?第15题三数之和也用了排序,它排序是为了配合双指针去重;这题排序同样是为了让双指针成立,但本质原因是排序给了数组一个"单调性"。

有了单调性之后,当你固定住i,看当前三数之和s与target的关系时,你可以确定下一步应该往哪个方向走:s偏小就找更大的数,s偏大就找更小的数。而"更大/更小"在排序数组里对应的是指针往右/往左移动。如果没有排序,数组元素大小是跳跃的,同样的一次比较结果,你完全不知道是该试下一个元素还是跳过好几个元素,也就谈不上收敛。

打个不精确的比方:你在一条直线上找目标点,身边的人都告诉你目标在左边还是右边,你每次都能排除一半方向,自然走得快;如果身边的人位置是乱的,告诉你"目标大概在那个方向"也没用,因为你不知道哪个方向才是更近的。

所以这道题的第一步永远是nums.sort()。排序这个动作本身不改变答案——三个数的和与它们在数组里的顺序无关,这就给了我们安全排序的前提。

2.2 核心实现:Python 代码逐行拆解

直接上代码,这是我提交过、也拿来讲过很多次的版本:

from typing import List class Solution: def threeSumClosest(self, nums: List[int], target: int) -> int: nums.sort() n = len(nums) best = nums[0] + nums[1] + nums[2] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: s = nums[i] + nums[left] + nums[right] if s == target: return s if abs(s - target) < abs(best - target): best = s if s < target: left += 1 else: right -= 1 return best

逐行说几个关键点。

第一,best初始化为排序后前三个元素之和。后面会专门讲为什么这样取比float('inf')更稳,这里先记住这个写法。

第二,外层循环i从 0 到n - 3。i每固定一次,问题就退化成:在i右侧的有序区间里找两个数,使nums[i]加两数之和最接近target。这一步其实把"三数之和"降维成了"两数之和最接近版"。

第三,内层 while 循环里,每次计算s,如果差值为 0 直接返回——因为不可能有比 0 更小的差值了。这就是天然的剪枝,也是这道题里最爽的一行:除了最坏情况,运气好可以提前结束。

第四,也是最关键的,当s != target时,先用一个if判断差值是否刷新best,然后根据s与target的大小关系决定移动left还是right。这两行的顺序不能反:先更新答案,再移动指针,因为移动之后当前组合就不存在了。

2.3 指针移动的贪心方向:差值符号决定取舍

这里详细说说那个if s < target: left += 1 else: right -= 1到底在干什么。

固定i之后,区间[left, right]是有序的。s是当前三数之和。如果s等于target,直接结束。如果s小于target,说明现在三个数的和差一点,还差一个正数才能到target。我们要把和变大,只有两个选择:left右移(取更大的数)或right左移(取更小的数)。right左移会让和更小,明显偏离target,所以在s < target的情况下,应该让left右移。

反过来,s大于target时,要让和缩小,应该让right左移。left右移会让和更大,只会更远。

这就是"贪心"在这道题里的具体形态:每一步都根据当前信息选择一个让结果更可能接近target的移动方向。你可能会问,万一left右移之后,后面的组合都偏大,还不如刚才这个偏小的组合呢?——那也没关系,因为我们在移动前已经把当前组合的差值记录到best里了,错过不了。这个"一边记录、一边逼近"的思路,正是双指针解这类题的灵魂。

我有个小口诀:"小了动左边,大了动右边,等于就回家。"给团队讲的时候,这个口诀帮不少人记住了代码,但真要理解,还是得看下一章的正确性推导。

3. 贪心正确性推导:为什么左移/右移永远不会错过最优解

3.1 有序数组带来的单调性:一切收敛的基础

理解了代码之后,更要紧的问题是:凭什么每次朝一个方向挪指针,最后得到的best一定是全局最优解?这需要用到排序数组的一个基本性质——单调性。

固定i之后,看区间[left, right]。如果left固定,随着right不断左移,nums[right]单调不增,所以三数之和s单调不增。如果right固定,随着left不断右移,nums[left]单调不减,所以s单调不减。

这个单调性听起来很基础,但它是整个贪心能够成立的基石。因为s关于left和right的变化是"可预测"的:往一个方向移动,结果只会朝确定的方向变。有了这种可预测性,我们才能在当前状态判断"哪个方向更可能有戏"。

3.2 排除法证明:丢掉某个指针为什么是安全的

现在我们做一个严格的排除论证,这是这道题最精华的部分。

假设当前s < target。因为数组单调不减(固定right时),如果把当前left保留下来、把right往左移,得到的所有和s'都会满足s' ≤ s < target。

关键在这里:既然s' ≤ s < target,那么s和target的距离一定小于s'和target的距离。也就是说,当前left配合任何更小的right,都不可能比当前组合更接近target。既然当前组合已经尝试并记录过了,那么当前的left就"没有利用价值了",可以放心地left += 1,把这个左指针丢掉。

反过来,当s > target时,如果把right保留下来、让left右移,得到的所有s'都满足s' ≥ s > target,距离只会更远而不是更近。所以当前right也没有留下来的必要,right -= 1是安全的。

这个论证用到的其实就是一个排除法:每一步移动后,被丢掉的指针与区间内任意元素的组合都不可能优于已经记录过的解。因为排序保证的方向性,我们丢弃的是一个"确定没希望"的方向,而不是碰运气。这就是为什么双指针在这类题目上是"不重不漏"的。

跟冒泡排序那种"两两比较交换"不一样,双指针不是通过比较一次就把所有候选都算一遍,而是利用排序后的单调性,把不可能成为最优解的候选成片地排除掉。每一轮循环排除一条"边",整体复杂度才从 O(n³) 降到 O(n²)。

3.3 打靶类比与二分搜索的区别

为了更直观,我习惯把这道题想象成打靶。

靶心是target,你有一发子弹,目标是打出最小的偏差。现在你有两个旋钮:left控制"低端弹药",right控制"高端弹药"。一开始把left放在最小、right放在最大。如果当前这一发的落点偏左(s < target),那说明问题出在"低端弹药"太小,你需要把left往右拨,换取更大的落点;如果偏右,就把right往左拨。每一轮你拨动一个旋钮,落点都在朝靶心收拢。

这个图景和二分搜索有点像,但要注意区别:二分搜索是"区间减半、目标值在寻找一个精确点";这道题是"区间收缩、目标值是逼近一个最优和"。它们的共同点是都依赖有序性,不同点是二分搜索每一步可以直接砍掉一半空间,而双指针每一轮只移动一个位置,收缩的粒度更小,也因此能适应"最接近"这种比"精确相等"更宽松的要求。

如果你在面试里被问到这题,把这段"为什么敢贪心"的论证讲出来,面试官一般就会点头了。很多人只背代码,问到这里就卡壳,非常可惜。

4. 那些一提交就暴露的边界细节

4.1 初始答案取前三项和,而不是取无穷大

先说说best的初始化。我见过不少写法是best = float('inf'),因为这样第一个候选一定可以更新best。你会发现虽然代码能过,但在裸写代码时可读性差一些;而且从直觉来说,直接取排序后前三项之和更自然——它本身就是一个合法的三数之和,后续的if abs(s - target) < abs(best - target)比较也能正常进行。

为什么取前三项是安全的?因为题目保证数组长度至少为 3,排序之后nums[0] + nums[1] + nums[2]一定存在。假设target是正数、数组全是负数,前三项和可能是负数,这也没关系,best只是一个"当前已知最好",不一定是最优,后续会不断更新。

还有一个小细节:如果数组长度恰好是 3,外层循环i只会执行一次(i = 0),内层 while 也只跑一次,返回的就是这个 sum。所以边界上不用额外判断长度小于 3 的情况——当然,严谨的代码可以在开头加一个if len(nums) < 3: return 0之类的保护,面试时会显得更细心。

4.2 重复元素要不要跳过:这里和三数之和不同

第15题三数之和要求返回三元组,遇到重复元素必须跳过,否则结果里会出现重复组合。但第16题只返回最接近的和,不要求返回具体是哪三个数。这种情况下,跳过重复元素严格来说不是必需的——就算有重复,计算出来的和是同一个,差值也一样,best不会变错。

那为什么我的代码里还写了那两行跳过逻辑?

首先,它不会影响正确性。其次,在特定测试用例上它有剪枝作用:如果数组里大量重复元素,固定同一个值反复跑内层 while 是浪费的,因为固定的nums[i]相同、后面的双指针搜索范围又一样,得到的候选和集合完全相同,没必要重复算。

但是要注意:这里跳过的只是外层固定的i,不是left和right。内层指针我们不去重,因为我们需要把区间完整扫一遍,去重反而可能漏掉"两个重复值加另一个唯一值"这种组合。很多从15题迁移过来的同学在left/right上去重,结果在某些测试用例上答案错误,这是这题一个很典型的坑。

所以记住这句话:这道题里,外层i去重是优化,内层不去重是正确性保证。

4.3 C++/Java 的溢出问题与 Python 的"偷懒"

Python 选手在这题上确实可以偷懒——整数是任意精度的,三个 int 相加不存在溢出。但如果你在面试里用 C++ 或 Java 写,n 的范围稍大时,nums[i] + nums[left] + nums[right]可能超出 int 范围(虽然力扣原题的数据一般不会卡这个,但面试官可能会主动问)。

稳妥的做法是把s定义成long long或long,比较差值的时候也统一用long。Java 里可以写成long s = (long) nums[i] + nums[left] + nums[right];,先转long再加,顺序很重要,别写成long s = (long)(nums[i] + nums[left] + nums[right]);——后者在括号里就已经溢出成 int 了,转long也没用。

这个细节是典型的"看起来是小问题,真爆了就是 WA"。我在给团队 code review 时特意强调过:遇到可能溢出的求和,永远先转类型再运算,而不是先运算再转类型。

4.4 排序会原地修改数组:一个容易被忽略的副作用

最后一个容易被忽略的坑:nums.sort()是原地排序。

在力扣的评测环境里,你只关心返回值,数组本身被改掉没有关系。但如果你把这段逻辑嵌到更大的业务代码里,nums是外部传入的引用,排序之后外部看到的就是一个被打乱顺序的数组,可能会引发一系列问题。

如果你需要保留原来的顺序,就改成nums_sorted = sorted(nums),然后后面所有地方都用nums_sorted。虽然多一次拷贝,但安全性提升很多。这也是我在实际项目里更推荐的做法:算法题可以随便原地改,工程代码对入参要怀有敬畏之心。

5. 举一反三:力扣"排序+双指针"题型的识别与迁移

5.1 同族题目一览:一路打过去

把这道题吃透之后,可以顺手把整个"排序+双指针"家族扫一遍。我按依赖顺序列了个清单,这也是我自己刷题时验证过的顺序:

题目一句话思路与本题的关联
两数之和 II - 输入有序数组(167)双指针收尾,和为 target 时返回最简版,只有一层双指针
三数之和(15)固定 i + 双指针,需去重同一框架,但要去重
最接近的三数之和(16)固定 i + 双指针维护最近差值本题
四数之和(18)固定 i、j + 双指针多套一层循环而已
盛最多水的容器(11)双指针从两端向中间,根据高度决定移动哪边贪心方向由高度决定
有效三角形的个数(611)排序后固定最长边,双指针数组合反过来用三数关系

每个题我都建议在裸写代码之前,先自己说一遍"为什么这个方向移动是安全的",能说出来,说明你掌握了这套方法的思维内核。

5.2 什么时候不能套这套路:识别反例特征

学会用这套手法的同时,也要知道它的局限。

第一个反例特征:目标函数不满足单调性,或者数组排序后会破坏问题约束。比如"保持原数组相对顺序"的题目,排序会直接改变顺序,双指针就不适用。又比如要找"方差最接近的三数之和"这类非线性的目标函数,排序后单调性不成立,双指针的贪心论证彻底失效。

第二个反例特征:数组元素可以重复使用。这种题往往不能用"排序+双指针"直接套,因为它等价于带放回的组合问题,需要考虑元素复用对候选集的影响,常见解法是回溯或动态规划。

第三个反例特征:数据规模极小,比如 n < 10,这时候 O(n³) 暴力反而写起来最快最不容易错。不要为了炫技而用更复杂的解法,工程上简单优于聪明。

识别这些特征的能力,需要靠一定量的题来养。我的经验是:拿到一个"和、最接近、最大最小、是否存在"这类关键词的题,先问自己三个问题——能不能排序?固定一个变量后剩余问题是否单调?双指针移动方向是否可以用排除法论证?三个都满足,就大胆上排序+双指针。

5.3 在热题100里的定位与刷题顺序建议

最后把这题放回力扣热题100的坐标里看。热题100是很多人的复健清单,第15、16、18这三道三数题目是连号出现的,它们正好是一组"由浅入深"的梯度:15题训练去重和双指针,16题训练差值维护和贪心方向,18题训练循环嵌套的层次感。

我的建议是,把这三题放在同一个晚上刷完,先写15题,再写16题,最后尝试18题。刷16题的时候,别急着看题解,先自己在15题代码的基础上改一版,感受一下"只是把相等改成最接近"这句话背后其实隐藏了多少细节。

等你能不看任何笔记把这道题的关键论证讲清楚,热题100里大部分数组类中等题,你都有思路可循了。我自己后来刷接雨水、合并区间这些题时,反复受益于这道题打下的底子——所谓刷题能力,靠的从来不是背下一百道题的答案,而是吃透几十道题背后的那几十种思维模型。

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

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

立即咨询