【LC 15】三数之和:难点从来不是双指针,而是「只有两处」的去重
适用读者:已经会写"两数之和"、也觉得"两数之和 II"很简单,但每次写三数之和都会在去重上翻车的人
技术栈:Python 为主,附 Java 版
难度:中等 —— 算法骨架十分钟就能背下来,但去重那两处,背下来不算真会
收获:排序在这题里真正的作用(不是"查找更快")、去重为什么只有两处且形态还不一样、双指针单调性的正确性论证,以及 LC 16 / 18 / 259 / 611 / 923 一整条演化路线
题目
给你一个整数数组
nums,判断是否存在三元组
[nums[i], nums[j], nums[k]] 满足
i != j、
i != k 且
j != k,同时还满足
nums[i] + nums[j] + nums[k] == 0。请你返回所有和为
0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
输入:nums = [-1, 0, 1, 2, -1, -4]
输出:[[-1, -1, 2], [-1, 0, 1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0
不同的三元组是 [-1, 0, 1] 和 [-1, -1, 2]。
注意输出的顺序和三元组的顺序并不重要。
输入:nums = [0, 1, 1]
输出:[]
输入:nums = [0, 0, 0]
输出:[[0, 0, 0]]
从"两数之和"到"三数之和",只多了一个数,但复杂度跳了一级、去重难度跳了两级。很多人第一次写这题是这样的:
- 想起两数之和用哈希表,于是套过来;
- 跑官方样例,答案对了;
- 交上去 —— 答案重复。
然后加一个
set 去重,再交 —— 还是错,因为
set 装不了
list。改成
set of tuple,终于过了,但时间和空间都不好看。
这题的"正确姿势"其实只有一条路,而且它反直觉地
在做完排序之后就不再使用哈希了。
一、先说清楚这题在考什么
1.1 暴力:三重循环
最直白的写法,枚举三个下标:
def threeSum(nums):
res = []
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
if nums[i] + nums[j] + nums[k] == 0:
res.append([nums[i], nums[j], nums[k]])
return res
时间
O(n³)。数据量一上去就废。
但比"慢"更致命的是:
这个写法连去重都不会做。上面这段代码跑官方样例会输出 3 组(其中两组是同一个三元组的排列),而题目要求 2 组。
关于"用值去重"还是"用下标去重",还有个特别容易搞混的地方:
- 题目要求 下标互不相同(
i != j != k)—— 这是"不能重复使用同一个元素";
- 题目要求 答案里没有重复的三元组 —— 这是"
[-1, 0, 1] 和 [0, 1, -1] 算同一个答案"。
这两个约束是
独立的,暴力循环的
j = i + 1、
k = j + 1 保证了第一条,但完全没管第二条。
1.2 固定一个数,退化成"两数之和"
nums[i] + nums[j] + nums[k] == 0 可以改写成:
nums[j] + nums[k] == -nums[i]
也就是说:
枚举一个 i,问题就退化成"在剩下的元素里找两个数,使它们的和等于 -nums[i]" —— 这正是"两数之和"。
那能不能直接把 LC 1 的哈希表搬过来?
能,但很快就会发现它不划算。
# 哈希版:外层枚举 i,内层再做一次"两数之和"
def threeSumHash(nums):
n = len(nums)
seen_triples = set() # ← 逃不掉的去重
for i in range(n):
target = -nums[i]
seen = {}
for j in range(i + 1, n):
if target - nums[j] in seen:
triple = tuple(sorted([nums[i], nums[j], target - nums[j]]))
seen_triples.add(triple) # ← 只能在这里补去重
seen[nums[j]] = j
return [list(t) for t in seen_triples]
问题出在两处:
- 哈希表回答不了"这组是不是已经出现过了"。 它只知道"某个值有没有见过、在哪",不知道"某一个三元组有没有被记录过"。所以你被迫在最后加一个
set of tuple —— 而那一步的代价是 O(n²) 个元组的哈希与拼接。
sorted([...]) 又出现了一次。 为了让 [-1, 0, 1] 和 [0, 1, -1] 变成同一个键,你得给每个三元组排序。既然横竖都躲不开排序,为什么不一开始就把整个数组排一次?
1.3 正确的路线:先排序,再双指针
① 把整个数组升序排好
② 外层枚举 i(固定三元组里最小的那个数)
③ 内层用双指针 l = i + 1、r = n - 1,夹逼出剩下两个数
内层的判据只有三行:
nums[i] + nums[l] + nums[r] < 0 → 和太小,把 l 往右挪(换更大的数);
nums[i] + nums[l] + nums[r] > 0 → 和太大,把 r 往左挪(换更小的数);
- 等于
0 → 记下这一组。
为什么这么挪不会漏解? 这题的正确答案论证就在这里,别把它当成"显然":
固定 i,目标是让 nums[l] + nums[r] 等于 -nums[i]。
当 r 停在最右端时,nums[l] + nums[r] 是以当前 l 为左端时能取到的最大和(因为 r 是剩下元素里最大的那个)。
如果连这个最大和都小于目标,那么以 l 为左端的任何一对都不可能达标 —— 所以 l 可以放心右移一格,且这一格以后再也不用回头。
对称地,当 l 停在最左端时,nums[l] + nums[r] 是以当前 r 为右端时能取到的最小和;如果它都大于目标,r 就可以放心左移。
这段论证还顺带证明了另一件事:
l 只增、r 只减,两个指针加起来最多走 n 步 —— 所以内层是严格的
O(n),不是"外层枚举
l、内层枚举
r"的
O(n²)。
1.4 排序的真正收益:让相同的数挨在一起
这是全篇最值得划重点的一句话:
排序在这题里的作用不是"查找更快"(这题压根没有查找),而是让相同的数挨在一起。
有了"相同值相邻"这个性质,去重才从"和一堆组合打交道"退化成"
跟前一个值比一比":
整题的去重,就压缩在这
两处 O(1) 的比较里。
二、完整代码
def threeSum(nums):
nums.sort() # ① 排序:让相同的数挨在一起
res = []
n = len(nums)
for i in range(n - 2): # 留两个位置给 l 和 r
if nums[i] > 0: # ② 剪枝:最小的都 > 0,后面没戏
break
if i > 0 and nums[i] == nums[i - 1]: # ③ 第一处去重:起点重复,整轮跳过
continue
l, r = i + 1, n - 1
while l < r:
total = nums[i] + nums[l] + nums[r]
if total < 0:
l += 1 # 和太小 → 换个更大的
elif total > 0:
r -= 1 # 和太大 → 换个更小的
else:
res.append([nums[i], nums[l], nums[r]])
while l < r and nums[l] == nums[l + 1]: # ④ 第二处去重:跳
l += 1
while l < r and nums[r] == nums[r - 1]:
r -= 1
l += 1
r -= 1
return res
短短二十行,但有三行是"去重 / 剪枝"性质的(②③④),也就是说:
这题的代码量有三分之一在解决"重复"问题。
三、动画怎么看
画面里有五层信息:
- 顶部小字:原数组 → 已排序。指针的全部坐标都以排序后的数组为准;
- 数组格子:橙色边框是外层锚点
i,绿色是左指针 L,蓝紫是右指针 R;
- 金色格子 + 外环:本帧命中的三个数;灰色虚线格子:被去重跳过、不再参与的位置;
- 中间的横幅:当前三数之和与判定结果(
< 0 左指针右移 / > 0 右指针左移 / = 0 命中);
- 底部:已经收集到的三元组。
有两个地方是
刻意设计的,看的时候留意一下:
L 和 R 的标签分两行放。 它们分别是 l 和 r 两根指针,一定会靠得越来越近。放在同一行的话,当 r = l + 1 时两个标签会直接叠在一起。
- 动画里那唯一一次"跳过"发生在第 12 帧。
i 走到下标 2 时,发现 nums[2] 和上一轮的 nums[1] 都是 -1 —— 于是整个内层循环被跳过,对应代码里的 continue。这是全片唯一能"看见"去重动作的地方,因为它恰好触发了第一处去重。
四、完整推演表(15 帧逐帧)
输入
nums = [-1, 0, 1, 2, -1, -4],排序后
[-4, -1, -1, 0, 1, 2]。
几个值得单独拎出来的观察:
第一轮(i = 0,值 -4)跑了四次 squeeze 一无所获。 它把
l 从 1 一路推到 5,每次和都是负的。这说明
-4 太小了 —— 剩下的数里最大的两个是
1 和
2,加起来才
3,够不到需要的
4。这类"注定没结果的起点"是剪枝优化的空间(第七节)。
第二轮(i = 1)连中两元。 注意第 8 帧和第 10 帧的
dedup 都显示"两侧都无重复值" —— 官方例里恰好没触发第二处去重。但这两帧
每次命中之后都必然要跑,它们是算法的固定步骤,只是这次没有可跳的东西。
第 11 帧是全场唯一一次真去重。 i = 2 的时候,
nums[2] 和
nums[1] 都是
-1。假如不跳过,它会用同一个起点把
[-1, 0, 1] 和
[-1, -1, 2] 原样再找一遍 —— 答案就会出现两组重复。
最后 i = 3(值 0)那一轮会不必要地跑一次。 0 + 1 + 2 = 3 > 0,不可能等于 0。理论上可以在进入内层之前就发现"这个起点加上最大的两个数还是正的",直接跳过 —— 这就是第七节要说的优化。
五、去重:只有两处,形态还不一样
这是全篇最核心的一节。
把这两处的触发条件、位置、形态都搞清楚,这题才算真会。
5.1 第一处:外层 i 的"事前跳过"
if i > 0 and nums[i] == nums[i - 1]:
continue
含义:这一轮的起点,和上一轮的起点是同一个值。
为什么整轮都可以跳过:三元组里
nums[i] 是
最小的那个数(因为数组升序,
l 和
r 都在
i 右边)。既然最小值相同、可选的剩余区间也只是"右边那些",那这一轮能找出来的三元组,上一轮必然已经全找过了 ——
整轮跳过,不用进内层。
形态:
continue,
事前跳过。还没开始跑就知道它没意义。
i > 0 不能省。 这不只是"防止第一个元素越界"的问题 —— 在 Python 里
nums[-1] 是
合法的,它会绕到数组
最后一个元素。也就是说漏掉
i > 0 的话,
i = 0 时比的是
nums[0] 和
nums[n - 1] —— 一个完全是偶然的判断,会随机地跳过或触发第一轮。这种 bug 比直接报错更难查。
5.2 第二处:命中后双指针的"事后跳过"
res.append([nums[i], nums[l], nums[r]])
while l < r and nums[l] == nums[l + 1]:
l += 1
while l < r and nums[r] == nums[r - 1]:
r -= 1
l += 1
r -= 1
含义:刚刚记下的这一组是
[nums[i], nums[l], nums[r]]。如果
l 右边还贴着和
nums[l] 相同的值,那么
换一个位置当下标、值还是一样,产出的三元组长得一模一样。
为什么必须"事后":因为你在命中之前
不知道该从哪里跳 —— 只有拿到一组解,才知道"这一对值已经用过了"。所以形态是
while,而不是
continue。
为什么去重完还要再各走一步:两个
while 结束时,
l 停在
最后一个相同值上、
r 停在
第一个相同值上。它们本身仍然是刚刚用过的那两个值,所以必须再各向内走一格,才算真正离开这一对。
while 里的 l < r 不能省,它同时管两件事:
- 防越界:
nums[l + 1] 在 l 已经是最后一个下标时会越界;
- 防跑过头:
l 有可能被一路推到 r 上甚至越过 r,那时剩下的是"只有两个元素"的区间,再去跳就会把本该考虑的组合跳掉。
5.3 两处对照
5.4 漏掉任何一处的后果
两处都会导致"答案重复",但
触发条件完全不同,所以只用官方样例测,很可能一个都发现不了。
漏掉第一处(
i 去重):需要一个"最小数出现多次"的输入。
nums = [-1, -1, 2] 排序后还是 [-1, -1, 2]
i = 0:l=1, r=2,和 = (-1) + (-1) + 2 = 0 → 记录 [-1, -1, 2]
i = 1:l=2, r=2,l == r 直接结束
呃,这个例子里
i = 1 时
l = 2、
r = 2 马上就退出了,所以没暴露。换一个:
nums = [3, 0, -2, -1, 1, 2] 排序后 [-2, -1, 0, 1, 2, 3]
i = 0(值 -2):找到 [-2, 0, 2]、[-2, -1, 3]
i = 1(值 -1):找到 [-1, 0, 1]
还是没暴露 —— 因为
-2 和
-1 不相等。
要暴露第一处的漏判,必须有"真正重复的最小值"并且它右边还有足够多的元素:
nums = [-1, -1, 0, 1] 排序后 [-1, -1, 0, 1]
✅ 有 i 去重:i = 0 找到 [-1, 0, 1];i = 1 被 continue 跳过 → 共 1 组
❌ 漏 i 去重:i = 1 时 l=2, r=3,(-1) + 0 + 1 = 0 → 又记录一次 [-1, 0, 1]
答案变成 [[-1,0,1], [-1,0,1]] —— 重复
漏掉第二处(双指针去重):需要一个"配对数发生值碰撞"的输入。
nums = [-2, 0, 0, 2, 2] 排序后 [-2, 0, 0, 2, 2]
✅ 有第二处去重:
i = 0,l=1, r=4,( -2) + 0 + 2 = 0 → 记录 [-2, 0, 2]
L 侧:nums[1] == nums[2](都是 0)→ l 移到 2
R 侧:nums[4] == nums[3](都是 2)→ r 移到 3
各走一步 → l = 3, r = 2,交叉,结束 → 共 1 组
❌ 漏第二处去重:
命中后只做 l += 1、r -= 1 → l = 2, r = 3
再算:(-2) + 0 + 2 = 0 → 又记录一次 [-2, 0, 2]!答案重复
注意这两组输入长什么样:
[-1, -1, 0, 1] 和
[-2, 0, 0, 2, 2]。它们的共同点是"重复值恰好落在指针会经过的位置上"。而官方样例
[-1, 0, 1, 2, -1, -4] 排序后是
[-4, -1, -1, 0, 1, 2] —— 两个
-1 是相邻的,但
它们恰好一个当 i、一个当 l,两个去重条件都不会被真正触发。
所以:跑通官方样例,完全不能证明去重写对了。 这两组反例应该直接进你的自测用例。
六、复杂度
两点补充:
- 排序的
O(n log n) 被 O(n²) 吸收了,它根本不是瓶颈。所以"为了去重而多排一次序"是白赚的 —— 这也是 1.2 节里"既然横竖要排序,不如一开始就排"那句话的底气。
- 严格说"空间
O(1)"是不计排序开销的说法。Python 的 list.sort() 是 Timsort,需要 O(n) 辅助空间;Java 的 Arrays.sort(int[]) 是双轴快排、O(log n) 栈空间。面试时可以主动补一句:"不计结果和排序开销是 O(1)。"
把三种写法放在一起看:
注意哈希版和双指针版的时间量级是一样的 —— 双指针胜在常数更小、空间更省、去重逻辑更清晰,而不是"快了一个量级"。面试时说清楚这一点,比笼统地讲"双指针更快"准确得多。
七、两个能省时间的剪枝
6.1 nums[i] > 0 就 break
数组已经升序。如果作为
最小值的
nums[i] 都已经是正数,那后面两个只会更大,三个正数之和不可能等于
0。
注意这里是
break 而不是
continue —— 因为
nums 递增,后面所有的
nums[i] 也都会是正数,整个外层循环都可以结束了。
官方样例里这一条不会触发(
i 只有 0、1、2、3,对应的值是
-4、
-1、
-1、
0,全都不是正数)。但像
[1, 2, 3, 4, 5] 这种输入,
i = 0 就能直接结束,省掉整个
O(n²)。
6.2 两个"提前看最小值 / 最大值"的剪枝
在内层循环开始之前,可以先算一下这一轮
可能的和的范围:
minimum = nums[i] + nums[i + 1] + nums[i + 2] # 这一轮能凑出的最小和
if minimum > 0:
break # 最小和都 > 0,后面更没戏
maximum = nums[i] + nums[-1] + nums[-2] # 这一轮能凑出的最大和
if maximum < 0:
continue # 最大和都 < 0,这个 i 太小了,换下一个
这两句在"数组很长、正负数分布极端"的时候能省掉大量无效的双指针扫描。
回到第四节的推演表:第 12 帧(
i = 3,值
0)那一轮就是被第二句剪掉的典型 ——
0 + 1 + 2 = 3 > 0,最大和都是正的、最小值也不可能是负的,这一轮注定找不到
0。
不过要注意:这两句都是
可选优化。面试时先把主逻辑写对、去重写对,再补剪枝。
为了剪枝写错边界,得不偿失。
八、变体一:LC 16 最接近的三数之和 / LC 18 四数之和
7.1 LC 16:把 == 0 换成 abs(s - target) 最小
LC 16 要求"找出三个数,使它们的和最接近
target"。骨架几乎一模一样,只有命中判定变了:
def threeSumClosest(nums, target):
nums.sort()
best = nums[0] + nums[1] + nums[2] # 用一个真实的和初始化,别用 inf
n = len(nums)
for i in range(n - 2):
if i > 0 and nums[i] == nums[i - 1]:
continue
l, r = i + 1, n - 1
while l < r:
total = nums[i] + nums[l] + nums[r]
if abs(total - target) < abs(best - target):
best = total
if total < target:
l += 1
elif total > target:
r -= 1
else:
return target # 差为 0,已经不可能更好了
return best
三点差异值得注意:
- 不再需要第二处去重。因为返回的是"一个和",重复的组合只会算出同一个和,不影响结果。去重只在"返回所有解"的场景下才是必需品。
- 可以提前
return。一旦 total == target,差值已经是理论最优的 0,再找也不会有更好的。
best 的初始化要用一个真实存在的和,或者用 float('inf')。用 0 初始化会错,因为 abs(0 - target) 在某些输入下比真实答案更小。
7.2 LC 18:四数之和 = 外面再套一层
def fourSum(nums, target):
nums.sort()
res = []
n = len(nums)
for a in range(n - 3):
if a > 0 and nums[a] == nums[a - 1]:
continue # 第一层去重
for b in range(a + 1, n - 2):
if b > a + 1 and nums[b] == nums[b - 1]:
continue # 第二层去重
l, r = b + 1, n - 1
while l < r:
total = nums[a] + nums[b] + nums[l] + nums[r]
if total < target:
l += 1
elif total > target:
r -= 1
else:
res.append([nums[a], nums[b], nums[l], nums[r]])
while l < r and nums[l] == nums[l + 1]:
l += 1
while l < r and nums[r] == nums[r - 1]:
r -= 1
l += 1
r -= 1
return res
复杂度
O(n³)。
去重的模式完全一致:外层每多套一层,就多一句"和外层前一个比一比";最内层仍然是"命中后跳重复"。
注意第二层的判断是
b > a + 1 而不是
b > 0 —— 因为
b 的起点是
a + 1,要在
同一个 a 内部做去重,所以第一个
b(等于
a + 1)不需要检查前一个。
这个细节很能说明问题:
去重语句的边界条件,取决于"这一段循环的起点在哪",不能无脑照抄
> 0。
九、变体二:从"和为 0"到"小于 target"—— 259 / 611 / 923
同一套"排序 + 固定 + 双指针",换个判定条件就是新题:
LC 259 是这一组里最有意思的变体 —— 它把双指针的单调性用到了极致:
def threeSumSmaller(nums, target):
nums.sort()
count = 0
n = len(nums)
for i in range(n - 2):
l, r = i + 1, n - 1
while l < r:
if nums[i] + nums[l] + nums[r] < target:
# ⭐ 关键:l 固定时,从 l+1 到 r 的全都能配上 nums[l]
count += r - l
l += 1
else:
r -= 1
return count
当
nums[i] + nums[l] + nums[r] < target 成立时,因为数组升序,
nums[l] 配上
l+1 到
r 之间的
任何一个下标,和都只会更小 —— 也就是说
r - l 组解一次性成立,不用逐个枚举。
这才是"有序"真正的威力:不只是"能移动指针",而是"能一句话数出一整段"。LC 3 滑动窗口里的"一步跳"、LC 259 这里的"一次数一段",本质上是同一件事:
利用单调性把 O(n) 的枚举压缩成 O(1) 的结论。
十、Java 版
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> res = new ArrayList<>();
for (int i = 0; i < nums.length - 2; i++) {
if (nums[i] > 0) break; // 剪枝
if (i > 0 && nums[i] == nums[i - 1]) continue; // 第一处去重
int l = i + 1, r = nums.length - 1;
while (l < r) {
int total = nums[i] + nums[l] + nums[r];
if (total < 0) {
l++;
} else if (total > 0) {
r--;
} else {
res.add(Arrays.asList(nums[i], nums[l], nums[r]));
while (l < r && nums[l] == nums[l + 1]) l++; // 第二处去重
while (l < r && nums[r] == nums[r - 1]) r--;
l++;
r--;
}
}
}
return res;
}
}
Java 版有个额外注意点:
Arrays.asList(nums[i], nums[l], nums[r]) 里的三个
int 会被自动装箱成
Integer,
这是合法的(
Arrays.asList(T...) 会按
Integer 处理)。但如果写成
Arrays.asList(nums)(传整个
int[]),因为
int[] 是一个
Object 而不是
T[],会得到一个
只有一个元素的 List<int[]> —— 这是 Java 里非常经典的一个坑。
同理,
Arrays.sort(int[]) 用的是双轴快速排序(
O(log n) 栈空间),而
Arrays.sort(T[]) 用的是 Timsort(
O(n) 空间)。这些细节在讨论"空间复杂度到底是 O(1) 还是 O(n)"的时候会用到。
十一、边界情况与常见坑
边界情况
常见坑
坑 1:在 for i 里漏掉 i > 0
if nums[i] == nums[i - 1]: # ❌ i = 0 时,Python 会取到 nums[-1](最后一个元素)
Python 里
nums[-1] 不会报错,它会绕回数组末尾。所以这个 bug 表现为"随机的第一轮被跳过或不被跳过",比直接崩溃更难查。
坑 2:第二处去重的 while 漏掉 l < r
while nums[l] == nums[l + 1]: # ❌ l 是最后一个下标时越界
l += 1
而且即使没越界,
l 也可能被推到
r 之外,把本该保留的组合跳掉。
坑 3:去重完忘了各走一步
while l < r and nums[l] == nums[l + 1]: l += 1
while l < r and nums[r] == nums[r - 1]: r -= 1
# ❌ 少了 l += 1 和 r -= 1
两个
while 结束时,
l 停在
最后一个相同值上 —— 那还是刚刚用过的值。不各走一步的话,下一轮算出来还是同一组,
死循环。
坑 4:JavaScript 里 Array.prototype.sort 默认按字符串排序
这一条是语言特有的,但在 JS 里写这题一定会撞上:
[-1, 0, 1, 2, -1, -4].sort() // → [-1, -1, -4, 0, 1, 2] ❌ 字符串序
[10, 2, 1].sort() // → [1, 10, 2] ❌ "-1" < "-4"
[-1, 0, 1, 2, -1, -4].sort((a, b) => a - b) // → [-4, -1, -1, 0, 1, 2] ✅
默认比较器会把元素转成字符串再比。于是
-4 会排在
-1 后面(因为字符
'4' > '1'),整个双指针的单调性假设直接失效 ——
代码不报错,只是答案悄悄错了。
还有一点:
sort() 是
原地排序,会改掉调用方传进来的数组。写工具函数时要记得先拷贝。
坑 5:用哈希做,然后在最后补 set 去重
技术上能过,但要额外付出
O(n²) 个元组的构造与哈希。既然排序早晚要做(见 1.2 节),不如一开始就排序,让去重退化成两次
O(1) 的比较。
坑 6:把"下标互不相同"和"三元组不重复"混为一谈
- 下标互不相同 → 靠
l = i + 1、r = n - 1 和 l < r 保证,这部分代码本来就对;
- 三元组不重复 → 靠两处去重保证,这才是要额外写的地方。
如果你发现答案里有
[0, 0, 0] 这种"看起来下标重复"的结果 —— 那不是 bug,只要能找到三个不同下标的
0,它就是一组合法答案。
判据是下标,不是值。
十二、面试怎么答
被问到"三数之和"时,一个能拿满分的回答顺序大致是:
-
先说暴力与它的两个缺陷。"三重循环
O(n³);而且它连去重都不会做 —— 保证下标不同靠的是 j = i + 1,但保证答案不重复需要额外逻辑。"
-
说清为什么不用哈希。 "可以用,外层固定一个数之后内层就是两数之和。但哈希回答不了'这个三元组出现过没有',最后还得拿
set 去重;而且为了让 [-1,0,1] 和 [0,1,-1] 变成同一个键,每个答案都要排序 —— 既然横竖要排序,不如一开始就排一次。"
-
说双指针的正确性论证(这段能明显区分"背过"和"想过"):"
r 在最右端时 nums[l] + nums[r] 是以当前 l 为左端能取到的最大和;如果它都小于目标,那以 l 为左端的任何一对都不行,所以 l 可以放心右移。对称地,l 在最左端时是最小和,太大就让 r 左移。这也顺带证明了两个指针合计最多走 n 步,内层是严格 O(n)。"
-
主动点出去重的两处,并说清为什么形态不同:"一处在外层、事前跳过,用
continue;一处在内层、命中后跳过,用 while。前者挡'起点重复',后者挡'配对数重复'。"
-
提边界:"
i > 0 不能省,否则 Python 会取到 nums[-1];while 里的 l < r 不能省,防越界也防跳过头。"
-
最后给剪枝:"
nums[i] > 0 就直接 break;更进一步可以提前算这一轮的最小三数和与最大三数和,> 0 就 break、< 0 就 continue。"
-
如果面试官继续追问,顺势把变体串起来:"16 是换成最近的和,不需要去重还能提前返回;18 是外面多套一层,去重模式完全一样;259 是把'和等于'换成'和小于',这时单调性能一次数出一整段 ——
count += r - l。"
结语
三数之和是一道"骨架简单、细节致命"的题。
它的骨架(排序 + 固定一个数 + 双指针)十分钟就能背下来,但它的难点(去重)
背下来不算会 —— 因为去重的两处,形态不同、时机不同、边界陷阱也不同,而且
官方样例恰好一个都不会触发。
写这篇文章时我反复想的是同一件事:
排序在这题里的价值,不在"查找更快",而在"让相同的数挨在一起"。
一旦相同的数挨在一起,"去重"就退化成了"跟前一个值比一比" —— 两处
O(1) 的比较,解决了这题 80% 的难度。
这个视角还能往前推一步:
滑动窗口、双指针、二分,本质都是"利用有序性把枚举换成结论"。LC 3 的"一步跳"是这样,LC 259 的"一次数一段"也是这样。
有序性本身不产生答案,它产生的是"可以跳过的空间"。
自测一下,别只跑官方样例:
assert sorted(sorted(t) for t in threeSum([-1, 0, 1, 2, -1, -4])) == [[-1, -1, 2], [-1, 0, 1]]
assert threeSum([0, 0, 0, 0]) == [[0, 0, 0]] # 第一处去重
assert threeSum([-2, 0, 0, 2, 2]) == [[-2, 0, 2]] # 第二处去重
assert threeSum([-1, -1, 0, 1]) == [[-1, 0, 1]] # 第一处去重(另一个角度)
assert threeSum([1, 2, 3]) == [] # 剪枝
这五行比跑十遍官方样例有用。