1. 题面拆解:三数之和到底在考什么
先聊个直白的感受:力扣上一万多人收藏、大厂面试出镜率极高的“三数之和”,本质上不是一道“难题”,而是一道“筛选思维惯性”的题。如果你上来就套三层for循环,那么恭喜你,样例能过,提交必超时,还会被评论区一句“暴力杯警告”扎心很久。
题面一句话就能说完:给你一个整数数组 nums,判断是否存在三个元素 a、b、c,使得 a + b + c = 0,返回所有不重复的三元组。注意限制条件有两个,第一,答案中不可以包含重复的三元组;第二,同一个元素在数组里的下标不能重复使用,但值相同的不同元素是可以同时入选的。
先说这道题在考什么。它考的从来不是“会不会写循环”,而是三件事:
- 能不能把 O(n³) 的暴力问题通过排序优化到 O(n²);
- 能不能说清楚双指针为什么能替代第三层循环;
- 能不能把“去重”做得既正确又高效,不靠集合硬去重,而是靠指针跳跃。
换句话说,它考查的是你对“无序问题的有序化处理”的能力。很多人在理解题意时就已经踩了坑——看到“不重复的三元组”,第一反应是用 HashSet 存三元组去重,但这种思路会带来两个新的麻烦:一是三元组内部顺序问题,[-1,0,1] 和 [1,0,-1] 在集合眼里是两个不同的东西,你还得先排序再存;二是额外空间复杂度变高,答案去重全靠哈希。
真正优雅的思路是:先把数组排好序,让重复值相邻排列。排序之后,“跳过重复元素”的操作就变得极其便宜——只需要比较相邻两个数是否相等。这个前置操作让后面的双指针解法水到渠成,也让你彻底摆脱了 HashSet 这种“用空间换时间”的妥协方案。
题目的边界条件也值得注意:数组长度小于 3 时,答案直接为空;全部元素都不满足组合要求时,返回空列表而不是 null。这些细节在面试中都是送分题,但恰恰是送分题最能看出一个人写代码有没有形成肌肉记忆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力三重循环到双指针:一条自然的演进路线
很多刷题攻略喜欢直接甩双指针模板,然后让你背。这样做不是不行,但如果你没搞懂为什么双指针能成立,遇到变体题(比如最接近的三数之和、四数之和)照样会卡壳。所以我打算先带你走一遍从暴力解到双指针的完整推导链,这条链其实非常短。
2.1 暴力方案:三重循环为什么注定被淘汰
先看最符合直觉的暴力解法:三层循环枚举 i、j、k,判定 nums[i] + nums[j] + nums[k] == 0。这个方案的时间复杂度是 O(n³),当 n = 3000 时,理论运算量约为 270 亿次,任何在线判题系统都不可能放你通过。
有人可能想狡辩:那我可以加剪枝啊,比如固定 i 之后,j 和 k 只往后扫。但剪枝只是降低了常数因子,复杂度量级还在 O(n³),本质上依然是等死的节奏。
暴力方案的麻烦还不止性能问题。由于数组中可能存在大量重复元素,三重循环会枚举出非常多的重复三元组,比如 nums = [-1, -1, 0, 1, 1] 这种输入,暴力解会搜出多个 [-1, 0, 1],想得到唯一结果,就必须配合 HashSet 或排序去重。这就让代码的丑陋程度再上一个台阶。
所以暴力方案的真实价值只有两个:一是当作最基础的“正确性参照物”,在调试优化方案时用来对拍;二是让你切身体会到,算法优化的第一步永远是找计算冗余。
2.2 排序带来的第一个质变:重复元素相邻化
在考虑优化之前,我们先做一个只花 O(n log n) 的操作——排序。排序改变不了题目本质(寻找三元组),但它带来了两个明显的好处。
第一,重复元素变得相邻,这为后续“按值跳过重复”创造了条件。设想一下,如果数组是无序的,你想跳过值等于 nums[i] 的其他位置,必须额外遍历或使用哈希表;有序之后,只需要检查 nums[i] == nums[i-1] 就能判断当前位置的值是不是首次出现。
第二,双指针(two pointers)赖以生存的单调性前提满足了。在一个有序数组中,左指针向右移动,数值单调不减;右指针向左移动,数值单调不增。这一增一减之间,我们就能像用天平秤一样,通过移动指针精确控制三个数的总和是变大还是变小。
这个思想特别像你在一个爬坡路上找某个海拔高度:如果当前位置海拔太低,你就往高处走几步;太高了,就往低处退几步。整个过程不需要回头摸索,因为有单调性作为方向指引。
2.3 固定一个指针,让另外两个指针“对撞”
接下来是核心推导。既然要枚举三个数,那我们就固定其中一个,让另外两个用一个“对撞”的过程去搜索,这样三重循环就被降成了两重循环。
具体做法是:外层循环固定第一个数 nums[i]。既然目标是三数之和为 0,那么剩下两个数 nums[j] 与 nums[k] 的目标和就明确了,是 target = -nums[i]。此时问题退化为经典的“有序数组中找两数之和等于 target”。
在有序数组中寻找两数之和,最优解法就是双指针:一个左指针 j 从 i + 1 开始向右移动,一个右指针 k 从数组末尾开始向左移动。计算 currentSum = nums[j] + nums[k]:
- 若
currentSum == target:记录三元组(nums[i], nums[j], nums[k]),然后左指针右移、右指针左移; - 若
currentSum < target:说明两数之和偏小,只有左指针向右移动、增大nums[j],才能让总和的量级往上走; - 若
currentSum > target:说明两数之和偏大,需要右指针向左移动、减小nums[k]。
每次指针移动,都会排除掉一整批不可能产生答案的组合。举例来说,当 currentSum < target 时,意味着当前 j 和任意比 j 更靠左的指针组合都不可能满足条件,因为它们只会让值更小。双指针的 O(n) 扫描就这样替代了 O(n²) 的两层循环。
这个降维思路在整个算法题体系里都非常重要。你在“盛最多水的容器”“接雨水”里见到的双指针,和这里的思路同源——都是利用有序性缩小搜索空间。不同之处在于,这里多了一层“固定一个数”的外循环,本质上是“固定枚举 + 双指针收缩”的组合模板。
3. 手把手推导双指针实现:这份伪代码可以直接背诵
思路理清之后,落地成代码并不复杂。不过有几个细节处理不好,写出来就是各种隐蔽的 bug。我先给出一份可供背诵的 Java 模板,再逐行拆解背后用意。
java复制public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 长度不足 3 时直接返回空集合
if (nums == null || nums.length < 3) {
return result;
}
Arrays.sort(nums);
int n = nums.length;
for (int i = 0; i < n - 2; i++) {
// 剪枝:最小的数已经大于 0,后续不可能凑出 0
if (nums[i] > 0) {
break;
}
// 跳过重复的外层元素
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
// 去重:跳过左侧重复值
while (left < right && nums[left] == nums[left + 1]) {
left++;
}
// 去重:跳过右侧重复值
while (left < right && nums[right] == nums[right - 1]) {
right--;
}
// 收缩指针
left++;
right--;
} else if (sum < target) {
left++;
} else {
right--;
}
}
}
return result;
}
这份模板不是我拍的脑袋,而是经过无数次提交后打磨出的最简形式。下面拆解几个容易出错的设计点。
3.1 外层循环的剪枝边界:nums[i] > 0
有些题解在外层循环里写的是 if (nums[i] > 0) return result; 而不是 break,两者效果一样,因为数组已经有序,一旦当前位置的值大于 0,后面的值只会更大,三个正数之和不可能等于 0。写下这一行代码的深层价值在于:它不是单纯的优化技巧,而是体现了算法设计中对有序数组单调性的敏感。很多刷题新手会忽略这个判断,代码依然能通过测试,只是白白多算了好几轮无意义的循环。
真正面试时,写出这一行剪枝,等于主动告诉面试官,你对有序数组的性质有直觉,而不是只会背模板。
3.2 外层去重的两种写法:nums[i] == nums[i-1] 还是 nums[i] == nums[i+1]
这是一个经典的面试提问点。一定要使用 nums[i] == nums[i - 1],也就是和前一个元素比较,而不是和后一个元素比较。原因是:当我们要跳过重复的 i 位置时,判断的是“当前这个值是否已经作为第一个数处理过了”。
使用 nums[i] == nums[i - 1] 检查的语义是:如果当前位置的取值和前一个位置相同,那么以这个值为首元素的三元组,在前一轮循环中已经被枚举过了,必须跳过。
如果反过来用 nums[i] == nums[i + 1] 去跳过,那你会跳过 [-1, -1, 2] 这种合法的三元组。原因很简单:nums[i+1] 是和 i 相邻的下一个元素,它可能不是重复值,而是真正与当前 i 搭配的伙伴。类似 [-1, -1, 2] 里两个 -1,需要 i 指向第一个 -1、left 指向第二个 -1,才能组成和为 0 的组合;如果你在 i 处看到 nums[i] == nums[i+1] 就 continue,就永远等不到这组答案。
这两个方向的去重看起来只差一个下标,结果却是天壤之别。我在给朋友 review 代码时,发现十个人里至少有四个会在这里写反。
3.3 双指针获取答案之后:先跳跃,再收缩
当左指针和右指针指到的两个值恰好凑出 target 时,执行完记录之后,不能只单纯地 left++ 和 right--,还需要先把左右两侧与当前值重复的元素全部跳过。否则下一轮循环就可能再次进入 sum == target 分支,记录出重复的三元组。
这里我见过一种常见的错误写法:在记录答案后只执行一次 left++; right--;,然后在 while 条件里依赖外层循环的 nums[i] 去重来兜底。问题是,外层去重只能去掉以相同 i 值开头的重复组合,无法去掉 left、right 组合内部产生的重复。所以内层必须有自己的去重逻辑。
另一个错误是去重写成了 while (left < right && nums[left] == nums[left - 1]) left++;,这个 bug 很隐蔽,看起来只是下标方向写反了,实际上它会把当前已经记录过的合法值再次跳过去,导致左指针越过预期的位置。内层去重在第一次记录时,nums[left] 的“前一个值”并不是当前 left 指向的值,而是上一个 left 曾经指向过的值。如果你混淆了方向,指针收缩逻辑就会错乱。
要避免这种错误,最好的方式是记住一个原则:先记录,再把指针移到不重复的新位置上,然后再做最后一次收缩。也就是先跳完重,再移动。
4. 从提交失败到 AC:一次完整的 Bug 排查链路复盘
很多文章只给最终解法,不给踩坑过程。但说实话,看别人的 AC 代码只能让你“以为会了”,真正遇到奇怪的提交失败时,没有排查思路就只能干瞪眼。我复盘一次我最初提交这个题时遇到的失败,带你完整走一遍定位链路。
4.1 症状:直接报错的用例长什么样
我第一次提交用的代码,外层去重用的是 nums[i] == nums[i+1],错误输出发生在下面这个用例:
code复制输入:[-1, 0, 1, 1]
预期输出:[[-1, 0, 1]]
实际输出:[]
拿到这个失败结果时,第一反应当然是“是不是双指针内部逻辑错了”。于是我在本地上加了日志,把每一轮外层循环的 i、left、right 以及进入的分支都打印出来,得到这样的中间过程:
code复制i=0, nums[i]=-1, left=1, right=3, sum=0+1=1, 大于 target=1? 哦,target=1,sum=1,命中
记录三元组:[-1, 0, 1]
left=1 -> 跳过重复,left=2
right=3 -> 跳过重复,right=2
left=2, right=2, 循环结束
i=1, nums[i]=0, left=2, right=3, target=0, sum=1+1=2, 大于 target, right--
left=2, right=2, 循环结束
i=2, nums[i]=1, left=3, right=3, 循环不进入
奇怪,从日志看,第一轮就输出了 [-1, 0, 1],结果集应该非空啊。问题出在哪里?再仔细看外层循环的去重逻辑,发现我在去重条件里用的是 nums[i] == nums[i + 1],当 i=1,也就是 nums[1]=0 时,由于 nums[2]=1,条件不成立,循环继续执行,看起来没问题。
那问题出在哪?我猛然发现,问题根本不在去重逻辑,而在于最外层的循环边界或者输入排序后的位置。再回看这次的具体输入 [-1, 0, 1, 1],排序后不变,i=0 时正常找到了 [-1,0,1],为什么返回空集合?
如果只在脑子里模拟这段代码,确实会得出“非空”的结论,但提交结果却是空。这时候唯一的解释是:我的环境里运行的代码和我想的代码不是同一份。检查后发现,当时代码里外层剪枝条件写的不是 break 而是类似 if (nums[i] > 0) return result;,理论上不影响这个用例,所以真正原因还得继续找。
再往下排查,发现我把“输出答案三元组”这一步写成了向一个预先初始化的二维数组里 add,但由于 Java 的 Arrays.asList 返回的是一个固定长度的 List 视图,某些环境下不能直接添加到 ArrayList<ArrayList<Integer>> 类型的结果集?排查到这一步已经偏离了核心逻辑,属于类型层面的低级错误。但正是这种错误最容易让人抓狂,因为算法看起来完全正确,却怎么都 AC 不了。
4.2 通过“最小复现用例”定位真正的隐患
排查这类诡异问题,最有效的手段是不断缩小输入规模,直到找出能稳定复现的最小用例。我把输入缩减为 [-1, 0, 1],提交依然失败。这说明问题可能与重复无关,而是出在更底层的逻辑判断上。
再看代码,发现退出条件 while (left < right) 我写对了,但在 sum 小于 target 时,我只执行了 left++,没有考虑 left 指针和 right 指针是否越界。虽然 Java 的数组访问会抛出越界异常而不是静默出错,但若是某些语言(如 C++)就没有这种保护,会出现未定义行为。隐患在于:假设 target 特别大,left 一路加到了 right 的位置,此时循环退出,不会越界;但如果 while 内部又有一层指针移动的 while,比如去重的 while(left < right && nums[left] == nums[left+1]),并且这个 while 的执行顺序和左右指针更新顺序搞反了,可能导致指针越过边界后在 while 条件中访问到 nums[left]。
为了彻底规避这一类问题,我建议所有涉及指针移动的操作,都先检查 left < right 再做元素访问。正是在一次次的失败与定位中,这种“访问数组前先验证下标范围”的习惯才真正被刻进肌肉记忆里。这也是为什么我建议每个刷题的人,碰上诡异失败时不要急着看题解,而是先拿小规模输入做单步跟踪,亲眼看一次指针的运动轨迹。
4.3 把这类 Bug 归类:见一次就永远不会再犯
复盘这次排查经历,我发现三数之和里绝大多数“非典型”提交失败,根因都能归为下面三类。整理成一张表,方便你排查时对照。
| 错误类别 | 典型表现 | 根因 | 排查方向 |
|---|---|---|---|
| 去重方向写反 | 合法三元组缺失 | nums[i] == nums[i+1] 跳过合法组合 |
检查去重是和前一个比还是和后一个比 |
| 指针跳跃早退 | 解集中出现重复三元组 | 记录答案后只移动一次指针,没有跳过所有重复值 | 检查记录完成后是否连续 while 跳过重复 |
| 下标越界 | 运行时崩溃或部分语言产生未定义值 | while 条件与数组访问顺序不当 | 所有数组访问前保证下标在 [0, n-1] |
这三类问题几乎覆盖了所有无效提交场景。每当你觉得自己逻辑完全正确但系统报错时,先把代码过一遍这三关,基本都能找到病因。
5. 复杂度分析与优化上限:为什么双指针就是终局
聊完了正确性,再来看看这份代码的性能画像。分析一个算法的复杂度,不能只背结论,要能现场推演出来,因为面试官大概率会追问一句“有没有更优解”。
排序的时间复杂度为 O(n log n)。外层循环从 0 遍历到 n-3,内层双指针在最坏情况下会从两端向中间扫完整段区间,复杂度为 O(n)。因此整体复杂度为 O(n log n + n²) = O(n²)。空间复杂度方面,我们没有使用额外的哈希表,排序是在原数组上操作(Arrays.sort 的底层实现可能是双轴快排或归并,但 Java 对象数组排序使用 TimSort,多少会有些额外空间;在分析算法时空复杂度时通常只考虑算法本身额外申请的辅助数据结构),因此辅助空间是 O(1),不算存储答案所需的空间。
那问题来了:三数之和能不能像两数之和那样使用哈希表把复杂度降到 O(n log n)?很遗憾,不能。哈希表方案在解决两数之和时确实能做到 O(n),但三数之和需要记录三元组而不只是索引对,哈希表无法处理“组合去重”这个附加条件。你可以做一个固定 i、内部用哈希表找两数的方案,这样复杂度是 O(n²),空间复杂度 O(n),但比双指针方案多出额外的哈希表维护开销,还要额外处理重复值,得不偿失。
计算机科学里有一个经典结论:三数之和问题的下界就是 O(n²),除非你能突破基于比较的搜索模型。这套理论在面 Complexity 相关的追问时偶尔会被提到,你只需要记住一点:能在 O(n²) 以内解决“无序数组的三数之和”的算法目前并不存在,所以双指针方案就是这个问题的终局优化,再往深挖就是论文题了。
在工程实践中,n 在绝大多数应用场景下不会超过 10⁵,O(n²) 的算法在这个量级下会达到纳秒级的单次操作也能撑个几秒。但力扣的判题服务器会对大型数组做超时控制,所以对超过 10⁵ 的 n,三数之和这道题本身并不适合直接用这套 O(n²) 算法,而需要考虑近似算法或数据预处理策略。不过在面试及常规刷题场景下,我们讨论的 n 通常在 10³ 到 10⁴ 之间,双指针方案完全够用且最优。
6. 变体题与举一反三:三数之和的“家族”如何统一应对
一道题刷完,最好的复习方式不是再做一遍原题,而是立刻做它的变体。三数之和至少有四个近亲,分别是“两数之和 II - 输入有序数组”“最接近的三数之和”“四数之和”“三数之和的多种可能”。它们的核心思想惊人地一致,只是边界条件和剪枝逻辑稍有变化。
6.1 最接近的三数之和:双指针框架的直接搬运
“最接近的三数之和”是一道很适合作为进阶练习的变体题。题面要求找出和目标值最接近的三元组,并返回这三个数的和。它比原题少了一个“必须等于 0”的强约束,多了一个“距离最小”的量化目标。
解法依然是用双指针框架,唯一的区别在于:当 currentSum == target 时,你可以直接返回 target,因为距离为 0 已经是最优结果。当 currentSum 不等于 target 时,你需要维护一个全局最优变量 bestSum,每次计算完当前和之后,比较 abs(currentSum - target) 与 abs(bestSum - target),如果当前和更接近,就更新 bestSum。
这道题的去重没有原题严格,因为返回的是“和”而不是三元组本身,所以即便存在重复三元组,只要它们的和相同,也不影响最终输出。但面试时如果直接这么做,会被追问“如果题目要求返回所有最接近的三元组怎么办”,提前想好去重逻辑能体现你的考虑更周全。
6.2 四数之和:从 N 数之和看递归模板
四数之和就是把三数之和再套一层循环,本质思想完全一样。先排序,固定前两个数 nums[i]、nums[j],然后对剩下的区间做双指针搜索后两数。时间复杂度从 O(n²) 涨到 O(n³)。
如果你刷完三数之和立刻做四数之和,会发现根本不需要学新东西。真正值得思考的是:如何把这些公共逻辑抽象成一个“N 数之和”模板?网上有一个流传很广的递归解法,基本思路就是 K 数之和等于固定一个数 + 对剩余 K-1 个数求和,终止条件是 K=2 时用双指针。这个模板适合在面试中展示你的代码抽象能力,但平时做题时建议老老实实写循环版本,因为它更直观,也不容易出现递归深度过大的隐患。
6.3 三数之和的多种可能:Mod 边界与去重策略
“三数之和的多种可能”是力扣 923 题,它不要求返回三元组的具体值,而是要求返回所有和为 target 的三元组个数,结果需要对 10^9+7 取模。这道题的巧妙之处在于,它完全放弃了双指针的“选值”逻辑,改用统计每个值的出现次数,然后对三个值的取值情况进行分类讨论。这属于组合数学和双指针的交叉,如果只是为了准备面试,优先级不高,但它能帮你打开“去重”之外的另一种视角:当你不关心三元组长什么样、只关心数量时,很多问题可以通过频率统计直接计算,而不必枚举。
6.4 和“两数之和”的对照:为什么最优解法完全不同
把“三数之和”和它的基础版“两数之和”放在一起对比,你会发现一个耐人寻味的现象:两数之和的最优解是哈希表 O(n),三数之和的最优解却是排序 + 双指针 O(n²)。为什么哈希表在两数之和里那么香,在三数之和里就不行了?
关键在于两数之和要求返回两个数的下标,而且只要一个答案。它不关心重复组合,也不需要对结果排序,哈希表确实是最短路径。而三数之和要求返回所有不重复的三元组,组合去重的代价在哈希表方案里非常高——你必须先排序三元组内部的三项,再序列化成可哈希的字符串,再存入 HashSet。这不仅是 O(n²) 的额外时间,还让代码读起来像在写 JSON 解析器。
所以正确的选题策略是:判断一个题目该用哈希还是排序 + 双指针,就看它对“去重”和“全量结果”的敏感度。只要题目要求返回所有不重复的组合,排序基本都是必修课。这个判断标准在所有 sum 类题目里都通用。
7. 编码层面的一些边角料:Java 版本的单测与自测用例
代码写完了不代表结束,一套靠谱的自测用例能让你在上线前就避开无数坑。这里我把自己常用的测试用例集分享出来,都是这些年刷题沉淀下来的高风险输入。
java复制// 基础用例
int[] test1 = {-1, 0, 1, 2, -1, -4};
// 预期:[[-1, -1, 2], [-1, 0, 1]]
// 全部为 0 的输入
int[] test2 = {0, 0, 0};
// 预期:[[0, 0, 0]]
// 不足三个元素
int[] test3 = {1, 2};
// 预期:[]
// 重复元素极多
int[] test4 = {-1, -1, -1, -1, 0, 0, 0, 1, 1, 1};
// 预期:[[-1, 0, 1]]
// 没有可行组合
int[] test5 = {1, 2, 3};
// 预期:[]
// 全是正数
int[] test6 = {1, 2, 3, 4};
// 预期:[]
// 大数边界
int[] test7 = {-1000000000, 0, 1000000000};
// 预期:[[-1000000000, 0, 1000000000]]
// 极端重复
int[] test8 = {-1, -1, -1, -1, 2, 2, 2};
// 预期:[[-1, -1, 2]]
// 负数和正数互相抵消但组合多的输入
int[] test9 = {-4, -3, -2, 0, 2, 3, 5};
// 预期:[[-3, 0, 3]? 检查组合]
// 同时包含最小值与最大值,验证整数溢出
int[] test10 = {2147483647, -2147483647, 0};
特别注意最后一条,nums[i] + nums[left] + nums[right] 在极端情况下可能超过 int 范围。虽然这个题目的约束通常把数值限制在 [-10^5, 10^5] 之间,三个极值相加也不会溢出 int,但你一旦把解法迁移到“四数之和”或“最接近的三数之和”这类变体上,就可能在求和那一步遇到溢出问题。保险做法是用 long 接收中间和,或者在求和前先判断符号是否相同,判断是否可以安全相加。
提示:如果面试时能主动说出“当前约束下 int 不会溢出,但为了健壮性我可以改用 long 做中间计算”,面试官通常会认为你对边界条件的敏感度高于平均水平。
测试用例不是越多越好,而是需要覆盖“大类 + 边界 + 极端重复”三个维度。一旦用例覆盖了这些场景,提交被拒的概率会大幅下降;即便被拒,也能帮助我们快速定位是哪一类的边界条件没处理到位。
8. 面试应对策略:怎么讲这道题才不会显得像背答案
最后聊一点面试相关的经验。三数之和在面试中的出现频率有多高?从各大公司的面经来看,它基本是和“反转链表”“LRU 缓存”并列的 Top 10 常客。这道题写对不难,但要在面试中拿到高分,讲究的是讲解顺序和节奏。
8.1 不要一上来就甩最优解
很多人面试时喜欢直接写双指针,因为看了太多题解,已经形成条件反射。但对于面试官而言,他最想看到的是你的思考过程,而非最终结果。我建议的顺序是:
- 先说暴力解:枚举所有三元组,O(n³),同时需要借助集合去重。
- 分析暴力解的两大痛点:时间复杂度过高,去重逻辑繁琐。
- 提出改进方向:排序可以把重复元素聚拢,也让双指针成为可能。
- 过渡到双指针解:固定一个数,针对剩余区间做对撞搜索,同时按值跳过重复。
这套话术走下来,面试官会清晰地看到你的推导链,而不是觉得你在背诵标准答案。它也能帮你自己的思路保持清晰,避免在写代码时突然忘记某个细节为什么要这么做。
8.2 主动暴露一个容易踩的坑
在讲解过程中,可以主动提一句去重方向的问题。比如你可以说:“这里我要注意,跳过重复时要用 nums[i] == nums[i-1],不能写成 nums[i] == nums[i+1],否则会漏掉类似 [-1, -1, 2] 这种合法组合。”主动暴露坑点会带来两个好处:第一,证明你是真的理解这道题而不是死记模板;第二,提前打消面试官对“你可能会写反”的疑虑,为后续写代码环节减压。
8.3 时间分配的取舍
如果面试时间紧张,三数之和的标准解理应在 10 到 15 分钟内完成从思路到实现的全部过程。如果超过 20 分钟还没有写出来,大概率是对双指针的模板还不够熟。我的建议是,在真正面试前把这个解法手写过至少十遍,直到不需要大脑额外思考就能写出无 bug 的双指针跳跃逻辑。不要觉得手写十遍很枯燥,面试场上每一分钟的从容都来自背后的重复。
另外一个实用的建议是:在白板上写代码时,先定义好变量名,保持左右指针的命名统一为 left 和 right,保持跳重逻辑的 while 循环格式一致。这样即使中途写岔了,回头检查时也能更快定位。面试代码不需要花哨,需要的是稳定、清晰、无 bug。
9. 个人体会:这道题该放在刷题路线的哪个位置
如果让我给刷题路线提建议,我会把三数之和放在“数组与双指针”这个知识块的中间位置,而不是开头或结尾。它的前置知识是“两数之和”和“排序算法”,后续知识是“四数之和”“最接近的三数之和”以及“滑动窗口类双指针问题”。
刚做完两数之和就立刻来做三数之和,很多人会陷入“用哈希表能不能也 O(n²) 搞定”的惯性思维,反而忽略了这道题真正想教你的排序思维。做完几道字符串和链表类的基础题、对数据结构有一定手感之后,再回来做三数之和,理解深度是完全不同的。
如果发现自己第一次写三数之和提交了十几次才 AC,不用气馁。我见过不少基础不错的同事,第一次写这道题时都要折腾很久,反复调试错误答案。这道题真正的难点不是双指针模板本身,而是把“去重”这个隐性需求在二维搜索空间中处理得干净利落,需要你在脑袋里同时保持两个维度的指针跳跃逻辑,这对工作记忆的消耗确实比较大。
最后想分享一个私藏的练习技巧:用纸笔走查一遍代码。找一组中等长度的随机数组,手工模拟每一步的 left、right、sum 变化,记录下每轮循环进入的分支。这个看似原始的方法,比在 IDE 里打断点更能帮你建立对双指针运动的直觉。我当年就是这么把双指针类问题从“会背”变成“会推”的,此后遇到任何需要双指针对撞的题目,都能做到心中有数。
