力扣的题库里,三数之和(LeetCode 15)对我来说是一道分水岭级别的题目。刷题刚起步的时候,我在这道题上卡了整整一个周末,甚至一度怀疑自己的智商是不是不适合写代码。后来真正把它吃透之后才意识到,这道题覆盖了排序预处理、双指针扫描、去重边界控制三大核心考点,而且面试出现的频率高得离谱,几乎可以算作大厂算法面试的“送分题”和“拉分题”的分界点。这篇文章我把三数之和从暴力解法到最优解法的完整推导过程、实现细节、去重原理、常见坑点全部拆开来讲,希望能帮正在刷力扣的你少走一点弯路。
1. 题目解读与核心思路
1.1 题目到底在问什么
先看清楚题目本身。给定一个包含 n 个整数的数组 nums,要求判断 nums 中是否存在三个元素 a、b、c,使得它们的和为 0,并且返回所有满足条件且不重复的三元组。注意两个关键词:第一是“所有”,意思是不能找到一组就停,必须把符合条件的三元组全部找出来;第二是“不重复”,比如同时出现 [-1, 0, 1] 和 [1, 0, -1],它们本质上是同一个三元组,只能保留一个。
举个例子,输入 nums = [-1, 0, 1, 2, -1, -4],符合条件的三元组是 [-1, -1, 2] 和 [-1, 0, 1],而 [0, 1, -1] 因为和 [-1, 0, 1] 重复,不能出现在结果里。
不重复这个限制,正是这道题和“两数之和”最大的区别。两数之和只需要返回一组下标,重复与否无所谓;三数之和要求返回所有组合,而每个组合里的元素值可能重复出现,这就引出了整套去重的设计哲学。很多人在力扣上提交三数之和被判 Wrong Answer,十有八九就是去重没写对,要么答案里混进了重复三元组,要么把本应存在的三元组误杀了。
1.2 为什么排序是解题的钥匙
我第一次做这道题的时候,本能地往哈希表方向想:先固定一个数,再用“两数之和”的思路找另外两个数。但很快就发现一个尴尬的问题——哈希表能快速判断“存不存在”,却很难优雅地保证结果不重复。因为同一个值可能对应多个位置,而三元组的顺序又不固定,去重逻辑会变得极其繁琐。
排序之所以能成为解题的钥匙,是因为它带来了三个关键红利。
第一,排序之后数组天然有序,我们可以利用单调性来快速定位。当我们需要两个数的和等于某个 target 时,可以用双指针从两端向中间逼近,一次遍历就能找到所有组合。这是时间复杂度从 O(n^3) 下降到 O(n^2) 的根本原因。
第二,排序之后所有相同的值都挤在一起,去重变得非常简单——只需要在遍历时跳过和前一个位置相同的值即可。如果不排序,去重要么用 set 套 set,要么排序结果集,效率都很差。
第三,排序带来了剪枝空间。比如三数之和要求 a + b + c = 0,如果当前固定的最小数 a 已经大于 0,那么后面两个数无论怎么选,总和都大于 0,直接 break 退出循环即可,这个优化在最坏情况下能把耗时砍掉将近一半。
我个人的习惯是:凡是遇到“找组合”“求子集”“判断条件成立”这类题目,先问自己一句能不能排序。只要题目不要求返回下标(注意,三数之和返回的是元素值而不是下标),排序几乎总是划算的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针解法的完整推导
2.1 从暴力解法到双指针的优化过程
三数之和的暴力解法很简单,三层循环枚举所有 i < j < k 的组合,判断 nums[i] + nums[j] + nums[k] == 0,然后去重。时间复杂度 O(n^3),n 是 3000 时就是 270 亿次运算,力扣的测试数据显然不会让你这么轻松过关。
优化的突破口在于:固定一个数之后,剩下的问题变成了“在有序数组中找到两个数,它们的和等于某个定值”。这个问题有个经典解法,双指针。
具体想法是这样的:先对整个数组从小到大排序。然后外层循环从左到右固定第一个数 nums[i],它作为三元组中最小的元素。接下来在区间 [i+1, n-1] 内设置两个指针,left 指向区间左端,right 指向区间右端。现在我们需要让 nums[left] + nums[right] 等于一个目标值 target,而 target 其实就是 -nums[i]。
此时数组有序带来的性质就体现出来了:如果当前 nums[i] + nums[left] + nums[right] 等于 0,那就找到了一个解;如果大于 0,说明 nums[right] 太大了,右指针左移;如果小于 0,说明 nums[left] 太小了,左指针右移。
你可能会问,为什么指针移动是安全的,不会漏掉解?这本质上利用了有序数组的单调性。因为整个区间已经排好序,当三数之和大于 0 时,说明最大的数(right 指向的)过大,所有比它小的组合都不可能让总和变大,只能把 right 往左移。反之亦然。这种排除法的推理和二分查找的思路如出一辙,都是利用有序性缩小搜索范围。
2.2 指针移动的底层逻辑
再往深一层想,双指针为什么能做到 O(n) 而不是 O(n^2)?关键在于 left 和 right 的移动方向永远是相反的——left 向右、right 向左,每次两个指针至少有一个发生移动,最多移动 n 次就相遇了。所以固定一个外层 i 之后,内部寻找两数之和的过程是线性的。总时间复杂度就是 O(n^2)。
这个复杂度在整个力扣中等难度题目里算是非常理想的。一般来说,n = 3000 时 O(n^2) 是几百万次运算,现代机器跑起来是毫秒级。如果我们硬要用哈希表实现两数之和的查找,虽然单次查找是 O(1),但需要两层循环,复杂度同样是 O(n^2),空间复杂度反而更高,还不好去重。所以双指针在这个场景下是真正的工程最优解。
但这里有一个容易忽略的前提:必须是“有序数组”。如果你在写代码时把排序忘了,双指针在无序数组里移动就是瞎撞,完全错误。建议在写代码前先在注释里写下三行话:固定一个数,剩余区间内双指针找两数,排序是这一切的前提。
3. 代码实现与去重细节
3.1 标准实现与关键代码
说了这么多理论,直接上代码。我用 Java 写一版最标准的实现,几乎每次面试我都是默写这一版:
java复制public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
int n = nums.length;
// 数组长度不足3,直接返回空结果
if (n < 3) {
return result;
}
// 排序是双指针的前提
Arrays.sort(nums);
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;
}
如果习惯用 Python,实现也几乎一样:
python复制def threeSum(nums):
nums.sort()
n = len(nums)
res = []
for i in range(n - 2):
if nums[i] > 0:
break
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 == 0:
res.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
elif s < 0:
left += 1
else:
right -= 1
return res
这段代码我建议你背到肌肉记忆的程度。面试的时候如果能一边写一边讲清楚每一步在干什么,这道题基本就能拿下。
3.2 去重的三个关键位置
去重是三数之和最容易翻车的地方,也是我当初卡了一整个周末的罪魁祸首。整个代码里有三处去重,缺一不可。
第一处是外层循环去重。注意判断条件是 i > 0 && nums[i] == nums[i - 1],而不是 nums[i] == nums[i + 1]。这里我用一个真实例子说明为什么。假设数组排序后是 [-1, -1, 2],我们想要的答案是 [-1, -1, 2] 本身。如果用 nums[i] == nums[i + 1] 判断,在 i = 0 时发现 nums[0] == nums[1],直接 continue,这个解就被跳过,答案缺失。而 nums[i] == nums[i - 1] 判断的是当前这个位置是否和上一个已经处理过的数相同,如果相同,说明以这个数开头的所有组合,在上一轮已经全部枚举过了,必须跳过。这就是为什么去重要“往前看”而不是“往后看”。
第二处是左指针去重。在找到一个可行解后,如果 nums[left] 和 nums[left + 1] 相同,说明下一个位置的数会导致重复三元组,直接跳过。第三处对称,右指针去重同理。这两处去重其实可以省略,因为最终结果集里也可能混进重复项,但为了性能和代码严谨,强烈建议加上。
3.3 剪枝优化:什么时候能提前退出
剪枝的关键判断是 nums[i] > 0,直接 break。因为数组已经从小到大排序,当前固定的 nums[i] 是最小的数,如果它都已经大于 0,后面两个数只可能更大,三数之和无论如何都不可能等于 0。
另一个常见优化是:如果 nums[i] + nums[i+1] + nums[i+2] > 0,也可以直接 break,因为即使取区间内最小的两个数和 nums[i] 相加都已经大于 0,后面不可能有解。同理,如果 nums[i] + nums[n-2] + nums[n-1] < 0,说明当前 i 太小,即使取最大的两个数也凑不到 0,可以直接 continue 到下一个 i。这两个优化在数据量极大时能显著减少循环次数,我在实测中,3000 个元素的随机数组,添加这两项剪枝后耗时能减少大概 30% 到 40%。
4. 常见坑点与排查技巧实录
4.1 去重写反导致的离奇 Bug
我在刷题论坛上看到过很多次这类提问:“为什么我的三数之和答案少了 [-1, -1, 2]?”十有八九是把外层去重写成了 nums[i] == nums[i + 1]。这个 bug 极其隐蔽,因为对某些测试用例,结果可能是对的,换了用例就出错。
排查技巧是,如果发现某组输出总是少一些包含相同元素的组合,先把去重逻辑注释掉,看看暴力枚举的结果是否包含这组解。如果包含,那问题一定出在去重误杀;如果不包含,说明双指针移动逻辑有误。
另外注意,去重在找到解之后,需要同时移动左右指针。很多人只移动一个指针,下一轮循环可能又碰到同样的一对值,导致重复结果或死循环。正确做法是在 sum == target 的分支里,先把重复元素跳过,再 left++、right-- 各走一步。
4.2 指针边界与循环条件
另一个高频坑是 while 条件写成 left <= right。这会带来什么后果?假设 left 和 right 相遇在同一个位置,按照三数之和逻辑,这相当于同一个元素被用了两次,明显不符合题意,可能产生 [1, 1, 2] 这样的错误答案(如果 nums[i] = -2 的话)。所以循环条件必须是 left < right。
还有人在内层 while 去重的时候没有判断 left < right,比如:
java复制while (nums[left] == nums[left + 1]) {
left++;
}
当数组末尾全是重复元素时,left 会一路增加到越界,抛出 ArrayIndexOutOfBoundsException。所有内部 while 条件都要额外加上 left < right 或 left < right - 1 之类的边界约束,这一点非常容易在紧张面试时漏掉。
4.3 性能陷阱与耗时排查
如果发现代码能跑通但耗时很高,优先检查这些位置:第一,是否创建了过多的中间对象,比如在循环里频繁使用 new ArrayList 和 Arrays.asList 可以接受,但不必要的 HashSet 存储结果会拖慢速度;第二,去重写得太早,比如在外层 i 还没判断剪枝前就做了重复判断,没有实际作用但增加了分支;第三,待定目标值 target 每次重新计算 -nums[i] 可以提前到 while 外面,这点小优化虽然只有毫秒级差距,但在较差的解法下会成为压垮超时的稻草。
我实测过 n = 3000 的极端用例,标准双指针解法耗时大约 20ms 到 40ms,而加上全部剪枝后可以压到 10ms 以内。如果你提交后时间接近超时边缘,优先看看剪枝有没有写全。
4.4 为什么哈希表在这里不是最优解
前文提到过哈希表思路,这里展开讲一下。用两数之和的思路扩展:外层固定 nums[i],内层固定 nums[j],然后在哈希表里查找是否存在 -nums[i] - nums[j]。这个思路能得出部分答案,但有两个绕不开的问题。第一,会重复存储同一个三元组的多种排列,比如 [-1, 0, 1] 和 [1, 0, -1] 都会被记录,最终必须排序去重,代价很高;第二,哈希表需要额外 O(n) 的空间,在大数据量下比双指针劣势明显。所以即使能用哈希表,也不是三数之和的推荐解法。
如果面试官追问“能不能用哈希表”,可以回答:理论上可行,但需要额外的 set 去重且空间复杂度更高,工程上双指针更优雅。这个回答能展示你对解法权衡的思考。
5. 面试变形与延伸思考
5.1 最接近的三数之和:同框架小改动
力扣第 16 题“最接近的三数之和”是这道题最直接的变形。题目要求找到与 target 最接近的三元组之和,而不是等于 0。解法框架几乎一模一样:排序 + 外层固定 + 双指针。唯一区别是维护一个全局的 closestSum,每次算完三数之和后,比较 abs(sum - target) 是否小于当前最小值,并选择性移动指针。因为如果 sum == target 直接返回即可,如果大于 target 则右指针左移,小于则左指针右移。
很多面试官会先考三数之和,然后立刻追问这一题考察你是否理解框架而不仅仅是背答案。如果你能流畅地说出“只需要把等于 0 的判断改成距离比较”,这轮基本稳了。
5.2 四数之和与 N 数之和
力扣第 18 题“四数之和”则是再套一层循环:固定 i、固定 j,双指针在剩余区间找两个数,使得总和等于 target。复杂度从 O(n^2) 上升为 O(n^3)。去重的逻辑逐层类推,每一层固定数都需要和上一轮相同值比较跳过。理解了三数之和的去重原理,四数之和其实就是照葫芦画瓢。这里提醒一个细节:在 Java/C++ 中计算四数之和时,nums[a] + nums[b] + nums[c] + nums[d] 可能溢出 int 范围,建议用 long 类型保存,或者提前用 target 减去已知数来判断。
再进一步,N 数之和的通用解法是递归 + 双指针,每层递归固定一个数,最终收敛到两数之和。力扣热题 100 里这类题目往往串联出现,刷题顺序上我建议:两数之和 -> 三数之和 -> 最接近的三数之和 -> 四数之和,由浅入深,一套框架吃到底。
5.3 力扣刷题的顺序与心态
聊回“力扣刷题攻略”这个热门话题。如果你是为进大厂做准备,我个人的经验是:不要盲目追求刷题数量,先把题型体系建立起来。数组、链表、哈希表、双指针、二分、滑动窗口、二叉树、回溯、动态规划,每个专题挑几道经典题吃透,比如双指针类就把三数之和、接雨水、最长无重复子串放在一起对比理解。力扣热题 100 就是很好的起点,但不要只“看题解”而不“动手写”。你看着觉得懂了,和独立 AC 是两码事。
以三数之和为例,我建议至少独立手写三遍:第一遍不看任何提示,允许思路卡壳;第二遍对照题解修正,理解每一次去重和剪枝的原因;第三遍面试前在纸上盲写,模拟白板环境。三遍下来,这道题基本可以形成条件反射。
我在实际中踩过几个坑,现在当面试官时也喜欢看候选人是否重视这些细节:比如当 nums[i] 大于 0 时能不能直接 break,比如去重到底写在哪个位置,比如找到一个解之后指针如何移动。这些小地方恰恰是区分背题侠和真正理解的人的关键。
如果你正在为笔试或面试焦虑,我的建议是,把三数之和当作一块试金石。能把这道题从头到尾讲清楚,说明你掌握了排序预处理、双指针扫描、边界控制和复杂度分析的基本功。这套功夫不仅在算法题里有用,跳到工程里处理有序数据、搜索优化、去重逻辑等场景时,思路也是相通的。说到底,刷题重要的不是那道题本身,而是解题过程中沉淀下来的思维范式。
