力扣热题100里,三数之和这道题属于那种“看起来很简单,写起来全是坑”的典型代表。题目本身只做一件事:在一个整数数组里找出所有三个数相加等于 0 的三元组。但真等你动手写代码,去重逻辑怎么写、双指针怎么走、边界怎么卡,每一个环节都能让新手卡上半天。我自己是把这道题放在数组双指针这一类里第一个刷的,刷完以后再遇到“最接近的三数之和”“四数之和”,基本就是同一套解题框架,只是套了一层皮而已。
这道题之所以被反复拿出来考,不只是因为它出现在力扣热题100,更是因为它在面试里出现的频率高得离谱。大厂笔试和面试都爱用它来考察候选人对排序、双指针、去重和边界条件的综合处理能力。无论你是刚开始刷题准备校招,还是已经工作了想保持手感,这道题都值得认真吃透。
1. 题目到底在问什么:先读懂三数之和
1.1 原题描述与示例
题目要求是这样:给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。最后返回所有和为 0 且不重复的三元组。
注意“不重复”这三个字,意思是三元组之间不能重复,也就是说 [-1,0,1] 和 [0,1,-1] 这种顺序不同但元素相同的组合,只能算一个。
一个比较经典的示例是:
code复制输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
这里有两个三元组满足条件。注意 [-1,0,1] 用了一个 -1,而 [-1,-1,2] 用了两个 -1,数组里恰好有两个 -1 所以合法。如果把数组换成 [-1,0,1,2],那结果就只有 [-1,0,1] 这一组了。
还有一个边界示例特别容易踩坑,就是全零数组:
code复制输入:nums = [0,0,0]
输出:[[0,0,0]]
对于一个长度为 3 且全是 0 的数组,只有一个三元组可用,所以答案就是它本身。这个用例在判断去重逻辑时非常有用。
1.2 为什么这道题值得反复刷
一个很现实的问题是,面试官不会只让你把 AC 代码写出来就结束。他们会追问“为什么排序后可以用双指针”“你的去重条件为什么写在那个位置”“如果数组里有重复元素怎么办”。这些追问背后的知识点,全部浓缩在这道题里。
从考点来看,三数之和至少覆盖了四块内容:
- 排序算法与有序数组的性质
- 双指针的收敛移动思想
- 三层循环 O(n^3) 到 O(n^2) 的复杂度优化
- 去重时的边界条件处理
从面试角度来看,这道题还有一个隐藏优势:它是“两数之和 II”的扩展,又是“四数之和”的前置。也就是说,你只要吃透这一道题,等于同时预复习了三道题。我在实际刷题规划里会把这一类题排在数组专题靠前的位置,因为它们能帮你快速建立“固定外层 + 双指针内层”的通用解题模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力到最优:完整思路推演
2.1 暴力枚举为什么不行
很多人拿到题的第一反应是三层循环暴力枚举,这确实能解,但代价非常大。
假设数组长度是 n,三层循环要枚举所有三元组,组合数是 C(n,3),总时间复杂度是 O(n^3)。力扣的测试数据里数组长度经常到 3000,n^3 相当于 270 亿次运算,这在任何语言里都跑不动。而且暴力枚举还面临一个更麻烦的问题:怎么去重?
因为题目要求返回不重复的三元组,暴力解法要么先把所有结果放进一个 set 里去重,要么对数组做全排列判断。用 set 去重倒是简单,但需要把每个三元组排序后转成字符串或元组再存入,这一过程的内存开销和操作开销都很大。所以暴力法在工程上当玩具可以,作为正式解法完全不合格。
还有一个容易被忽略的问题:即使你用 set 去重了,暴力枚举依然可能超时。因为去重只是去掉了输出阶段的重复,枚举过程本身的三层循环一步都没少。
2.2 降维打击:排序 + 双指针的思路推导
既然暴力不行,我们就需要一个更聪明的办法。这里的关键动作是先排序。
排序不会改变“三个数相加是否为 0”这个布尔结果,因为加法满足交换律。但排序后的数组有几大好处:元素有序,重复元素相邻,这为后续的双指针和去重打好了基础。
思路这样展开:固定一个数 nums[i],那么在剩下的区间里,问题就变成“找出两个数,它们的和等于 -nums[i]”。这是标准的“两数之和”问题,而且因为数组已经有序,可以用双指针在线性时间内解决。
具体做法是:左指针 left 指向 i+1,右指针 right 指向数组末尾。计算 sum = nums[i] + nums[left] + nums[right]:
- 如果
sum < 0,说明整体太小了,左指针右移,让nums[left]变大; - 如果
sum > 0,说明整体太大了,右指针左移,让nums[right]变小; - 如果
sum == 0,找到一组答案,左右指针同时收缩,并跳过重复值。
为什么双指针移动是安全的?因为数组有序,当 sum < 0 时,如果左指针不动,只移动右指针让 nums[right] 变得更小,那 sum 只会更小,不可能接近 0。所以唯一正确的方向就是把左指针向右移。同理,sum > 0 时只能右指针左移。每次移动都在朝目标值单调逼近,不会遗漏可能的组合。
这样整体的时间复杂度是:排序 O(n log n),外层循环 O(n),内层双指针 O(n),总共 O(n^2),空间复杂度 O(1)(不考虑排序栈)。相比暴力 O(n^3),这是质的飞跃。
3. 核心实现:手把手写出不超时的代码
3.1 先看一个可以 AC 的 Python 版本
这里给出最标准的双指针解法,代码我逐行加上注释:
python复制def threeSum(nums):
# 第一步:排序,这是双指针的基础
nums.sort()
n = len(nums)
res = []
# 外层循环固定第一个数
for i in range(n - 2):
# 剪枝:如果 nums[i] > 0,后面的数都大于0,三数之和不可能为0
if nums[i] > 0:
break
# 去重:i 位置的数已经处理过,跳过
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:
# 太小了,左指针右移
left += 1
elif total > 0:
# 太大了,右指针左移
right -= 1
else:
# 找到一组答案
res.append([nums[i], nums[left], nums[right]])
# 左指针先移动一步,再跳过重复值
left += 1
right -= 1
# 跳过左侧重复元素
while left < right and nums[left] == nums[left - 1]:
left += 1
# 跳过右侧重复元素
while left < right and nums[right] == nums[right + 1]:
right -= 1
return res
这段代码有三处关键点:排序、剪枝、去重。排序保证双指针可用,剪枝减少无意义循环,去重保证结果不重复。任何一个环节写错,要么超时,要么结果不正确。
3.2 去重的三种写法与常见错误
去重是整个题最容易写错的地方。先说外层循环的去重。
新手容易写成这样:
python复制if nums[i] == nums[i + 1]:
continue
这种写法是错的。因为跳过 nums[i+1] 会直接把合法组合丢掉。举个例子,数组是 [-1, -1, 2],i 指向第一个 -1,i+1 指向第二个 -1,这两个数相等。如果按照 nums[i] == nums[i+1] 就 continue,那么表达式 (-1) + (-1) + 2 = 0 这一组合法答案就被漏掉了。
正确的写法是:
python复制if i > 0 and nums[i] == nums[i - 1]:
continue
意思是“如果当前数和上一个数相同,说明以当前数为第一个元素的所有三元组,在上一次循环里已经全部找过了”。比如数组 [-1, -1, -1, 2],第一个 -1 作为固定数时找到了 [-1, -1, 2],那么第二个和第三个 -1 作为固定数时,结果肯定还是 [-1, -1, 2],属于重复答案,直接跳过即可。
内层去重也容易出错。找到答案后,我习惯先让左右指针各自走一步,再跳过重复值。这样做的目的是保证不遗漏也不重复。跳过左侧重复的代码是 while left < right and nums[left] == nums[left - 1]: left += 1,注意这里的 left - 1 是左边已经处理过的位置,而不是 left + 1。如果写成 nums[left] == nums[left + 1],同样会漏掉合法的重复元素组合。
3.3 复杂度分析
时间复杂度分两部分:
- 排序:O(n log n)
- 双指针:外层循环 n 次,内层双指针最多遍历 n 次,O(n^2)
所以整体是 O(n^2)。当 n=3000 时,大约 900 万次操作,任意主流语言都能在 1 秒内跑完,这也是力扣能接受的上限附近。
空间复杂度:不考虑排序递归栈的话是 O(1)。实际提交时,nums.sort() 在 Python 里是原地排序,不需要额外开数组。主要内存消耗是返回结果 res,这个属于输出占用的空间。
如果拿 O(n^3) 和 O(n^2) 做个对比,感受直接拉满:
| 方案 | 时间复杂度 | n=1000 时操作次数 | n=3000 时操作次数 |
|---|---|---|---|
| 暴力三层循环 | O(n^3) | 约 1.67 亿 | 约 45 亿 |
| 排序 + 双指针 | O(n^2) | 约 100 万 | 约 900 万 |
这个差距就是刷题时“能过”和“超时”的分水岭。
4. 多语言版本实现与性能实测
4.1 Java 实现要点
Java 版本的思路和 Python 完全一样,但要注意几个语言特有的细节:
java复制class Solution {
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
int n = nums.length;
List<List<Integer>> res = new ArrayList<>();
for (int i = 0; i < n - 2; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1;
int right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
left++;
right--;
while (left < right && nums[left] == nums[left - 1]) left++;
while (left < right && nums[right] == nums[right + 1]) right--;
}
}
}
return res;
}
}
Java 里有个小坑是 Arrays.asList 生成的列表不能修改,虽然这道题不需要修改,但如果你后面要对结果做 set 操作就会报 UnsupportedOperationException。这里只是作为返回值所以没问题。
另外,Java 的 Arrays.sort(int[]) 是对基本类型数组的双轴快速排序,时间复杂度 O(n log n),表现很稳定。LeetCode 实测时,3000 个元素的用例基本在 30ms 以内。
4.2 C++ 实现要点
C++ 版本和 Java 结构几乎一致,这里直接给完整代码:
cpp复制class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
int n = nums.size();
vector<vector<int>> res;
for (int i = 0; i < n - 2; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1, right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
res.push_back({nums[i], nums[left], nums[right]});
left++;
right--;
while (left < right && nums[left] == nums[left - 1]) left++;
while (left < right && nums[right] == nums[right + 1]) right--;
}
}
}
return res;
}
};
C++ 需要注意的一点是 sort 是原地排序,且使用的是混合排序策略,对基本类型序列性能很好。还有一个细节是 nums[i] > 0 的剪枝在 C++ 中同样有效,因为排序是从小到大,第一个数都大于 0 了,后面两个必然大于 0,相加不可能为 0。
4.3 实测数据与语言选型建议
我本地用随机生成的 3000 元素数组分别跑了三种语言版本,大致数据如下:
| 语言 | 排序耗时 | 双指针耗时 | 总计 |
|---|---|---|---|
| Python | 约 0.02s | 约 0.1s | 约 0.12s |
| Java | 约 0.01s | 约 0.03s | 约 0.04s |
| C++ | 约 0.005s | 约 0.015s | 约 0.02s |
这个数据只是参考,不同机器不同版本会有差异。但结论很稳定:三种语言都能轻松通过力扣的测试用例,选哪种主要看你平时刷题用什么语言。我自己建议把 Python 作为刷题首选,因为写起来快,逻辑直观,面试时口头讲解也方便。但如果你求职的岗位是后端,Java 或 C++ 的熟练度也要有,面试官可能会让你用指定语言写。
5. 面试进阶:一道题变成一类题
5.1 变形题:“最接近的三数之和”
力扣第 16 题,题目是给定一个数组和一个目标值 target,在数组中找出和与 target 最接近的三元组,并返回这三个数的和。
这题的解题框架和三数之和一模一样,还是“排序 + 固定外层 + 双指针”。差异点在于:
- 不是找
sum == 0,而是维护一个最小差值diff - 每次计算
sum与target的差距,如果差距更小就更新答案 - 如果
sum < target,左指针右移;如果sum > target,右指针左移;如果sum == target,直接返回,因为不可能有更接近的
可以说,只要吃透了三数之和,这题 10 分钟就能写出来。
5.2 变形题:“四数之和”
力扣第 18 题,要求找四个数相加等于 target 的所有不重复四元组。这题的核心思路是“固定两个数 + 双指针”。
外层循环固定 nums[i] 和 nums[j],其中 j 从 i+1 开始,内层双指针找 nums[left] + nums[right] == target - nums[i] - nums[j]。时间复杂度 O(n^3),在 n=200 时可以接受。
去重逻辑比三数之和多了一层:外层两个数都要做去重,逻辑一模一样,都是“如果当前数和上一个数相同就跳过”。所以有人说三数之和是 N 数之和的基础,真不是客套话。
5.3 双指针套路总结
刷完三数之和,你应该在脑子里建立一个通用模板:
- 数组先排序
- 固定第一个数(四数之和就固定前两个数)
- 剩余区间用双指针向中间收敛
- 根据
sum与目标值的大小关系决定移动哪个指针 - 每个外层循环都要做去重,找到结果后内层也要跳过重复值
这套模板能解决的不只是“三数之和”这一道题。凡是“在一个数组里找若干个数,满足某种和或差的关系”的题,基本都能套进去。区别只在固定几个数、目标值怎么算、去重写几层。
6. 常见问题与排查技巧实录
6.1 高频错误对照表
我整理了自己和身边朋友刷这道题时经常遇到的几个问题,直接列成表格方便自查:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 输出结果包含重复三元组 | 外层或内层去重漏写 | 检查 i 层和双指针层的去重条件 |
| 结果缺少合法组合 | 去重写成 nums[i] == nums[i+1] |
改为 i > 0 && nums[i] == nums[i-1] |
| 运行超时 | 用暴力三层循环 | 改为排序 + 双指针 O(n^2) |
| 内层死循环 | 找到答案后没有先移动指针就跳过重复 | 先 left++ 和 right--,再跳过重复值 |
| 数组越界 | 跳过重复时没判断 left < right |
所有内层 while 循环都加上 left < right 条件 |
| 返回值顺序不稳定 | 数组未排序就处理 | 第一步必须先排序 |
第六个问题值得多说一句。有些人觉得去重用 set 就行,不需要先排序。这个思路在“两数之和”里可行,但在“三数之和”里直接暴露出两个问题:一是 set 去重需要额外的哈希存储,内存开销大;二是如果不排序,双指针根本没法用,退回到暴力枚举又超时。所以先排序,是这道题最关键的决策。
6.2 测试用例自查清单
提交代码前,最好把下面这些用例在自己本地跑一遍:
[0,0,0],结果应该是[[0,0,0]][0,0,0,0],结果应该是[[0,0,0]][-1,0,1,2,-1,-4],结果应该是[[-1,-1,2],[-1,0,1]][-2,0,1,1,2],结果应该是[[-2,0,2],[-2,1,1]][-1,-1,-1,2],结果应该是[[-1,-1,2]][1,2,3,4],结果应该是[][]或[1],结果应该是[]
把上面这些用例过一遍,基本能发现 90% 的逻辑错误。特别是全零数组和重复元素数组,去重逻辑写错的话一测就露馅。
6.3 我自己踩过的一个印象最深的坑
最后分享一个我自己的实际经历。第一次写这道题时,我把内层去重写成了:
python复制while left < right and nums[left] == nums[left + 1]:
left += 1
当时觉得“跳过重复元素”嘛,怎么跳都行。结果在 [-1,0,1,2,-1,-4] 这个用例上,输出的结果从两组变成了一组,丢掉的是 [-1,0,1]。我调试了很久才意识到,nums[left] == nums[left + 1] 会把 left 一次性跳到重复段的末尾,但跳之前那个位置本身已经可能是正确答案的一部分。
换成先 left += 1 再比较 nums[left] == nums[left - 1] 之后,问题立刻消失。这个细节也教会我一个道理:双指针去重时,“当前指针移动后的位置”要和“已经处理过的位置”比较,而不是和“还没处理的位置”比较。这个经验不止适用于这道题,后面做四数之和、盛最多水的容器时都受用。
力扣 100 题不是刷一遍就结束的,每个阶段回头刷都会有新的理解。三数之和作为数组双指针专题的敲门砖,值得你多花点时间把它彻底吃透。
