我的第15题三数之和,算上那次牛客模拟,前前后后碰过不下四次。每次看到"三数之和"四个字我都有心理阴影,倒不是双指针想不到,而是去重逻辑每次都写得别别扭扭,要么答案重复,要么该有的组合被跳没了,气得我在编辑器里反复删了又写。后来我特意花了一整晚把去重这件事彻底捋明白,才真正敢说这道题我拿下了。
如果你也正在做LeetCode每日一题,并且刚被15题的去重折磨过,这篇就写给你。我会把从暴力到双指针的推导过程、三个指针各自在什么时机去重、以及为什么网上很多题解把去重条件写在那个位置,全部摊开讲清楚。最后我还会把这道题的去重思路延伸到日常开发里的数组去重、对象数组去重和SQL去重查询,你会发现它们的底层逻辑是同一个——定义好"什么算重复",比记住任何一行代码都重要。
1. 先别急着背题解,搞清楚这题到底在考什么
1.1 题面简单,但"不重不漏"四个字才是真正的门槛
题目要求很直白:给你一个整数数组,找出所有和为0且不重复的三元组。很多人看到第一反应是三层循环暴力枚举,这个思路本身没错,但接着就会撞上两堵墙。
第一堵墙是性能。数组长度动不动几百几千,三层循环是O(n³)级别,提交上去大概率超时。第二堵墙就是去重。就算你辛辛苦苦把三层循环写对了,如果不对结果做去重,输出里会出现大量 [-1, 0, 1]、[1, 0, -1] 这种只是顺序不同的重复三元组。这时候你去网上搜题解,发现大家都在说"排序+双指针",于是一顿照抄,但真正让你怀疑人生的,并不是双指针本身,而是代码里那几个看起来莫名其妙的去重判断。
我想先说一个反直觉的结论:去重问题是先于性能问题存在的。哪怕给你一个长度只有十几的数组,三层循环能在几毫秒内跑完,你依然要处理重复三元组。而"排序+双指针"这个方案之所以成为标准解法,是因为它同时解决了性能和去重两个问题。不理解这一点,你背再多代码也没用。
1.2 排序的价值:让相同的数字排在一起,重复才有规律可循
很多人不理解,为什么所有解法第一步都是排序。排序在算法题里很常见,但它在这里的价值容易被低估。
如果不排序,[-1, 0, 1] 和 [0, 1, -1] 在结果里是两组不同的三元组,可它们实际是同一组数。你只能用Set去重,通过把三元组排序后转成字符串或者元组来去重。这个办法能用,但有个问题:每次找到答案都要做一次排序和哈希,额外开销不小。更重要的是,它把"去重"这个本来应该在生成答案过程中就解决的问题,拖到了最后。
排序之后,相同的数字会相邻排列。这意味着当你遍历数组时,只要发现当前数字和前一个数字相等,就说明这一步前面已经做过一遍了,应当跳过。这是后续所有去重逻辑的地基。
1.3 双指针的思路:把第三层循环优化成指针移动
排序之后,"三数之和"可以转化为一个更直观的问题:固定一个数,剩下的两个数用双指针在它右侧的区间里找。
为什么双指针能找到?因为数组已经有序。左指针从左边开始,右指针从右边开始。如果三者之和小于0,说明左指针指向的数太小,需要往右移动;如果大于0,说明右指针指向的数太大,需要往左移动;如果等于0,就找到了一组答案。
这个思路本质上把一个 O(n³) 问题降成了 O(n²):外层循环固定一个数需要O(n),内层双指针扫描右侧区间只需要O(n)。两层循环总共是 O(n²)。空间复杂度在排序时是O(log n)(部分语言),当然更常见的说法是忽略排序空间、额外空间为O(1)。
在进入代码细节前,我想让大家带着一个问题往下看:既然排序把重复数排到一起了,为什么我不能简单地"跳过所有重复值"就完事? 这个问题想通了,你就是真懂了去重,而不是在背条件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个不去重的双指针版本,暴露重复的根源
2.1 先把主框架写出来:固定一个数,再用双指针找剩下两个
很多教程一上来就把最终版代码甩给你,注释写满"去重",结果读者根本分不清哪一步在去重、为什么在这里去重。我这里故意写一个完全不去重的双指针版本,先让你看到重复是怎么产生的,后面再来收拾它。
python复制def threeSum(nums):
nums.sort()
res = []
n = len(nums)
for i in range(n - 2):
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
return res
这段代码逻辑很纯粹:外层逐个固定 nums[i],内层把 nums[left] 和 nums[right] 当成一组双指针,在 i 的右侧区间找两数之和等于 -nums[i]。
整体框架没问题,但它会返回重复结果。我们来实际跑一个例子。
2.2 用手推一遍:重复答案是怎么冒出来的
以 [-1, 0, 1, 2, -1, -4] 为例,排序后变成 [-4, -1, -1, 0, 1, 2]。
固定 i = 0,当前 nums[0] = -4。
left = 1,right = 5,三数之和为-4 + (-1) + 2 = -3 < 0,左指针右移。left = 2,nums[2] = -1,三数之和为-4 + (-1) + 2 = -3 < 0,再右移。left = 3,nums[3] = 0,三数之和为-4 + 0 + 2 = -2 < 0,继续右移。- 后面
left越移越右,直到和right相遇,这一轮结束,没有找到答案。
固定 i = 1,当前 nums[1] = -1。
left = 2,nums[2] = -1,right = 5,nums[5] = 2,三数之和为-1 + (-1) + 2 = 0,收获答案[-1, -1, 2]。这本身没问题。- 继续移动,
left = 3,nums[3] = 0,right = 5,和大于0,right左移。 right = 4,nums[4] = 1,三数之和为-1 + 0 + 1 = 0,收获答案[-1, 0, 1]。- 继续移动后左右指针相遇,结束。
固定 i = 2,当前 nums[2] = -1。
- 注意,这里
nums[2]和nums[1]都是-1。上一轮固定nums[1] = -1时,已经完整扫描了它右侧的所有可能组合。这一轮把nums[2]固定成-1,它右侧的区间是[0, 1],能得到的组合是[0, 1],于是又会找到[-1, 0, 1]。
看到问题了吗?[-1, 0, 1] 在结果里出现了两次。第一次是外层 i = 1 时找到的,第二次是外层 i = 2 时找到的。
这就是重复三元组的第一个来源:外层固定的数重复了。同一个数作为三元组的最小值被固定了多次,每次都会把右侧相同范围内的组合重新搜一遍,自然产生重复。
2.3 内层指针也会制造重复,这是第二层坑
不要以为只处理外层重复就够了。我们再看另一个例子:[-2, 0, 0, 2, 2],排序后不变。
固定 i = 0,nums[0] = -2。
left = 1,nums[1] = 0,right = 4,nums[4] = 2。三数之和为-2 + 0 + 2 = 0,找到答案[-2, 0, 2]。- 按照刚才不去重的代码,
left += 1,right -= 1。现在left = 2,nums[2] = 0;right = 3,nums[3] = 2。 - 三数之和又等于 0,又找到
[-2, 0, 2]。
同样一组数字,因为内层指针碰到了重复值,结果又重复了一次。这个例子同时还说明了一件事:数组里有重复值并不一定是坏事。如果左边和右边各有两个相同数字,第一次匹配已经把它用掉了,但不去重的话,第二次匹配会原样再来一次,造成重复。
所以去重说起来简单,实际要管住三个位置:外层固定值、左指针值、右指针值。三者都有可能导致同一个三元组再次出现。这也是"被去重整麻了"的核心原因。
3. 错误去重姿势复盘:我曾经踩过的三个版本
3.1 方案一:先压缩数组去重,再跑双指针
这是我最早想到的野路子。既然重复数组会产生重复答案,那我先对数组做一次去重,把所有重复值删掉,再跑双指针不就行了?听起来很合理,实际一跑就翻车。
比如 [0, 0, 0],正确答案应该是 [[0, 0, 0]]。如果你把数组去重成 [0],长度直接不够三个数,答案变成空。再比如 [-1, -1, 2],去重后只剩 [-1, 2],同样找不到。
这个问题的本质是:数组中的重复元素并不是没有价值的。同一个值可能同时出现在一个三元组的不同位置上。去重数组会让数组丢失重复值带来的组合可能,三元组的总数就凑不齐了。
正确的做法不是删除重复值,而是在遍历过程中,让每个值只被"当作最小值的那个位置"完整处理一遍。排序不改动原始重复信息,只是让重复元素相邻,方便判断什么时候该跳过。
3.2 方案二:只在外层跳过重复,结果内层还有重复
后来我学会在外层加 if i > 0 and nums[i] == nums[i-1]: continue,心想这样总该消停了吧?
结果一测 [-2, 0, 0, 2, 2],输出变成了 [[-2, 0, 2], [-2, 0, 2]]。外层 i 只固定了一次 -2,并没有重复,但内层 left 和 right 在碰到连续 0 和连续 2 的时候,又把同一个匹配找到了两遍。
这个案例说明,外层去重只能解决"固定值的重复",不能解决"左右指针的重复"。很多人卡就卡在这里:明明已经加了一段网上看来的去重代码,怎么还会重复?因为每种去重,只针对一种产生重复的源头。
还有另一个反面案例我没有在文章里写出来,但值得提醒大家:如果你在内层无脑把所有重复值跳过,像下面这样写在匹配判断之前:
python复制# 错误示范
while left < right:
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
total = nums[i] + nums[left] + nums[right]
# ... 后续判断
这个写法会造成漏解。原因在于,你没等到 total == 0 就把指针跳走了,很可能会跳过本该参与匹配的那个数。左边跳过了一个值,等于把一个可行的 nums[left] 位置从搜索区间里抹掉了,那这个数原本有机会和右边某个数凑成和为 -nums[i] 的场景,就直接不存在了。
去重的正确时机非常讲究:在最终匹配成功之前,你没有资格跳过任何数;匹配成功之后,你才有资格跳过和它相同的数。
3.3 方案三:盲目用set记录三元组,绕开了本质
第三个方案是躺平流。我见过很多人干脆在双指针找到答案后,把三元组排序转成元组扔进Set,最后再转回列表输出。
python复制# 某种可行但不够优雅的方案
seen = set()
...
res.append(tuple(sorted([nums[i], nums[left], nums[right]])))
这个方案确实能通过,而且不容易漏解。但它的问题是把"生成答案时去重"退化成"生成答案后去重"。虽然最终复杂度依然是O(n²),但每次匹配都要额外做排序和哈希,代码也不够干净,而且如果你在面试里这样写,面试官大概率会追问一句:"能不能在生成答案的过程中就去重?"
所以我还是建议,老老实实理解清楚三个指针各自的去重规则,比写一个万能Set更本质。下面我们进入正题。
4. 一套可以直接抄的最终答案,以及它的每个细节
4.1 完整代码与注释版
下面是我最终沉淀下来的版本。我用Python写,但在Java、C++、Go里结构完全一样,只是语法不同:
python复制def threeSum(nums):
nums.sort()
res = []
n = len(nums)
for i in range(n - 2):
# 剪枝:固定值都大于0了,后面不可能凑出和为0
if nums[i] > 0:
break
# 第1处去重:固定值去重。重复的值只处理一次
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]])
# 第2处去重:找到答案后,跳过左侧重复值
while left < right and nums[left] == nums[left + 1]:
left += 1
# 第3处去重:找到答案后,跳过右侧重复值
while left < right and nums[right] == nums[right - 1]:
right -= 1
# 跳过重复后,再正常向内收缩一次
left += 1
right -= 1
return res
这段代码是网上大多数题解的最终形态,但光看是看不会的,你得理解为什么去重条件写在这里。
4.2 逐处拆解三个去重位置
第一处,if i > 0 and nums[i] == nums[i - 1]:
这是在处理"外层固定值重复"。当 nums[i] 和前一个数相等时,意味着当前这个值作为三元组最小值的情况,已经在 i - 1 那一轮完整遍历过了。因为排序后,nums[i - 1] 和 nums[i] 相等,它们在右侧面对的搜索区间几乎一样(只是少了最左边一个元素),如果此时再做完整搜索,得到的三元组必然和上一轮完全重复。
注意这里有个初学者特别容易写歪的地方——我见过有人写 if nums[i] == nums[i + 1]: continue,这是错的。i + 1 位置的数根本没被处理过,它可能是三元组的第二个成员。你直接跳过,很可能漏解。去重永远要跟已经处理过的前一个比,不能跟还没处理的比。
第二处和第三处,匹配成功之后的两个while:
进入 else 分支意味着已经找到一组答案,此时左指针和右指针指向的数之和正好凑成了 -nums[i]。如果左指针右边还有和当前值相等的数,那么这个相同的数作为三元组的第二个成员,只会得到完全相同的三元组。同样,右指针左边如果有和当前值相等的数,作为三元组的第三个成员,结果也一定重复。
这就是为什么必须在匹配成功后,分别把左右重复值都跳过去。跳过之后,还要再执行一次 left += 1 和 right -= 1,因为左右两端的指针在循环条件里向前又走了一步。如果你只写 while 循环里的跳跃,不写最终的两行移动,指针永远不会离开当前这个重复区间,会卡死在原地。
4.3 一个特殊场景帮你彻底理解:三个零
用 [0, 0, 0] 来走一遍最终代码,你会立刻明白while和最后两行之间是什么关系。
排序后还是 [0, 0, 0],长度3。
i = 0,nums[0] = 0,不剪枝,不跳过。left = 1,right = 2,total = 0,进入else分支,追加[0, 0, 0]。- 进入第一个while:
nums[left]是nums[1] = 0,nums[left + 1]是nums[2] = 0,相等,于是执行left += 1,left变成2。 - 此时
left < right为假,因为left = 2,right = 2,循环退出。 - 第二个while同理,因为
left < right为假,不会执行。 - 执行
left += 1,right -= 1,left = 3,right = 1,内层循环结束。
最终得到 [[0, 0, 0]],结果正确。
如果把最后两行 left += 1; right -= 1 去掉,而把while写成了 while left < right and nums[left] == nums[left + 1]: left += 1,left会一直走到2然后停止。接着程序跳回while循环判断,left < right 已经为假,外层进入下一个 i。看起来这组case也能过,但如果你多遇到几个需要二次收缩的场景,就会漏答案。所以请务必保留最后的统一移动。
4.4 常见边界用例自查表
我在本地测试时,至少会把这组用例全部过一遍,这里直接分享给你:
| 输入 | 预期输出 | 补充说明 |
|---|---|---|
[0,0,0] |
[[0,0,0]] |
三个相同数可以成一组 |
[-1,0,1,2,-1,-4] |
[[-1,-1,2],[-1,0,1]] |
官方示例,注意顺序无所谓 |
[-2,0,0,2,2] |
[[-2,0,2]] |
左边0重复、右边2重复的去重 |
[1,2,-2,-1] |
[] |
没有和为0的组合 |
[-1,-1,-1,2,2,2] |
[[-1,-1,2]] |
重复数很多但不能丢答案 |
[0,0,0,0,0] |
[[0,0,0]] |
只有一组,不是多组 |
[-4,-2,-2,-2,0,1,2,2,2,3,3] |
见题解 | 综合压力测试 |
如果你最终的代码在这些用例上全部输出符合预期,基本可以放心提交了。
4.5 为什么说"不漏"比"不重"更难保证
我第一次写出一个看似正确的去重版本后,反复验证重复已经没了,但总担心会不会把某些有效组合也跳掉了。这个担心很合理。
保证"不漏"的核心依据是:排序后,每个三元组都有一个唯一的最小值位置。我们外层遍历时,跳过的是"最小值重复"的那些位置,但保留每种最小值第一次出现的位置。只要最小值第一次出现的那一轮,完整扫描了它右侧的所有可能组合,那么这种最小值对应的三元组就全被覆盖到了。
内层指针同理。当你找到一个匹配后,你跳过了所有和当前左右指针值相同的重复位置,但没有跳过不同值的有效位置。所以每一个不同的 (nums[left], nums[right]) 组合,都只被处理了一次。既没有重复,也没有遗漏。
到这里,这道题你算是真正拿下了。
5. 把三数之和的去重思维,用到日常的数组去重和SQL去重
很多人在刷LeetCode时觉得算法和日常开发是两个世界。但三数之和这道题对整个"去重"领域的启发,非常通用。想明白它,你以后碰到的数组去重、对象数组去重、SQL数据清洗去重,都会有更清晰的思路。
5.1 普通数组去重和对象数组去重的本质差异
普通数组去重很简单,因为它重复的判定维度只有一个,就是元素本身的值。
python复制# 普通数组去重
arr = [1, 2, 2, 3, 3, 3]
unique = list(dict.fromkeys(arr)) # 保持顺序去重
对象数组去重就复杂了。你没法直接判断两个对象是否相等,得指定一个或多个属性作为"唯一键"。这和三数之和里把 [a, b, c] 当成一组、只要a和b和c都一样就算重复的逻辑一模一样。
python复制# 对象数组去重:以id为唯一键
people = [
{"id": 1, "name": "A"},
{"id": 1, "name": "B"},
{"id": 2, "name": "C"},
]
seen = set()
result = []
for p in people:
key = p["id"]
if key not in seen:
seen.add(key)
result.append(p)
这里的 seen 记录的是唯一键,不是对象本身。如果你天真地直接拿整个对象去比较,当对象内部字段顺序不一致时,明明相同的数据也会被判成不同。正确做法是,把对象转化为一个稳定的、可哈希的唯一键,比如 json.dumps(obj, sort_keys=True),这样字段顺序就不会影响去重结果。
这就像三数之和里,如果直接把三元组丢进Set而不先排序,[-1,0,1] 和 [0,1,-1] 会变成两个不同元素。解决方式无非两种:要么在生成时就保证顺序一致(排序+双指针去重),要么在最后统一排序再哈希(Set方案)。它们的本质都是先定唯一键。
5.2 SQL去重查询和清洗数据时的同一个原则
SQL去重查询也是个典型场景。很多人写 SELECT DISTINCT * FROM table,以为能去重,但实际业务里你常常只需要对某些列去重,比如一个用户可能有多个订单记录,你想找出有哪些用户下了单,就不能对整行去重,而是要对关键列去重。
sql复制-- 只对 user_id 去重
SELECT DISTINCT user_id FROM orders;
-- 或者用窗口函数取每组最新一条,这才是真正的"按业务键去重"
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn = 1;
数据清洗里的 sql语句去重 也是一样。比如原始数据中同一个主键ID出现了多次,你想保留最新的一条,就要明确"以ID为唯一键,以时间戳为排序基准"。
你会发现,所有去重问题的共同套路就一句话:先想清楚"什么算重复",也就是确定唯一键,再决定用哪种方式去重。 三数之和里,唯一键是同时满足"数值相同,且由固定的最小值、左指针值、右指针值组成"的三元组;SQL里,唯一键是你指定的一个或几个业务列。唯一键一旦定义清楚,去重方案自然就出来了。
所以这道题刷完,别急着划走。你可以顺手把LeetCode里的"数组去重"和"SQL去重"相关题目拿来做一遍,你会有一种强烈的既视感:它们考的根本是同一个东西。
6. 再聊几个能让你代码更稳的小细节
6.1 求和溢出问题
在Python里你基本不用担心整数溢出,因为Python的int是无限精度的。但在Java、C++里,nums[i] + nums[left] + nums[right] 有可能超过int范围。虽然这道题的输入范围通常不会让你溢出,但严谨一点的做法是先把三数和转成long再比较,或者在计算时用减法换一种写法:
java复制// Java 中把和转成 long
long sum = (long) nums[i] + nums[left] + nums[right];
这种小细节面试时偶尔会被追问,你得会解释为什么需要转类型。
6.2 提前结束循环的顺序问题
我习惯先把 nums[i] > 0 的判断放在最前面,因为数组排序后,如果当前固定的最小数已经大于0,那么它和右侧任意两个正数相加都不可能等于0,后面更大的数更不可能,所以可以直接break。
有个容易搞错的地方:这个剪枝只能放在外层循环,不能放在内层。因为内层虽然数组也是递增的,但左指针右侧至少还有一个更小的负数候选,局部区间里 nums[left] > 0 不能说明什么。
6.3 从固定模板转向理解后,这道题成了我的"sorted array twin pointer"母题
搞懂三数之和后,后续很多题都好做了。比如四数之和、最接近的三数之和、和为s的连续正数序列、判断是否存在三个数使条件成立等等。
看到这些题,你会在脑子里自动归类:这题是不是又要在排序数组上双指针?重复条件是什么?唯一键是什么?什么时候可以break?一旦思维到了这一步,刷题速度就会快很多。
这里我把LeetCode几道同族题的差异列出来:
| 题目 | 与三数之和的关系 | 核心差异 |
|---|---|---|
| 16. 最接近的三数之和 | 双指针框架一样 | 不用严格等于0,不用去重,只要更新最小差值 |
| 18. 四数之和 | 外层多套一层循环 | 去重位置从三处变成四处,剪枝条件需要多考虑一层 |
| 1. 两数之和 | 不同路的经典题 | 不排序也能用哈希表在O(n)内解决 |
| 15. 三数之和 | 本题 | 排序加双指针,并处理三层去重 |
四数之和就是在外层再固定一个数,然后对剩下的三个数调用三数之和的思路。去重会变得更繁琐,但底层逻辑完全一致。你如果能把三数之和的去重彻底吃透,四数之和不过就是在四个指针上分别做同样的判断而已。
6.4 最后分享一个排错技巧:不要盯着代码发呆,去打印中间三元组
在调试去重逻辑的时候,我用的最多的方法,是在 res.append 之前把当前 i、left、right 和对应的数值打出来。只要看到同一组三元组被append了两次,就能立刻判断是哪一层导致的重复。很多时候你觉得逻辑对,但一打印就发现,原来左右指针在匹配成功后没有正确移动,卡在重复值上了。
这种以"中间状态"来定位问题的方式,比靠直觉修改代码快得多。如果你现在也被某道题的去重折磨,与其反复重写,不如加几行print,把决策过程摊在眼前。数据量一旦小,重复模式的产生原因会非常清楚。
我自己在经历了这道题之后,养成了一个习惯:以后凡是要求"枚举所有不重复组合"的题,都会先问自己三个问题——结果用不用排序?哪些位置可能产生重复?去重应该发生在这个组合被接受之前还是之后?这三个问题想清楚,几乎等于把答案写出来了。
