1. 先来拆题:三数之和到底在考什么
看到“三数之和”这个题目,很多人第一反应是:不就是三个数加起来等于0吗?两层循环先固定两个数,再遍历找第三个数,有什么难的。真去写了才发现,题目的完整要求是“找出所有不重复的三元组”,这个“不重复”三个字才是整道题的魂。
力扣15题三数之和的原题描述是:给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。请你返回所有和为 0 且不重复的三元组。
“不重复”包含两层意思:第一,同一个索引不能重复使用,这个好理解;第二,最终的结果集合里不能出现重复的三元组,比如 [-1, 0, 1] 和 [1, 0, -1] 算同一个三元组,[0, 0, 0] 这种极端情况如果数组里有三个0,只能出一个 [0, 0, 0],不能出多份。
如果把这道题当成“找三个数使其和为目标值”的查值问题,暴力循环 O(n^3) 肯定能过小数据,但 LeetCode 的测试用例给的数组长度上限是 3000,O(n^3) 意味着 270 亿次操作,铁定超时。所以这道题真正考察的是:如何把“三重循环的查值问题”降维成“单次遍历 + 双指针收缩”的 O(n^2) 问题,同时把去重逻辑做得滴水不漏。
三数之和算是双指针类题目的敲门砖。刷明白它,四数之和就是套一层壳;刷不明白,后面接踵而至的最接近三数之和、四数之和II、有序数组的Two Sum,都会踩同样的坑。所以这篇笔记我打算从暴力解法的痛点开始,一步步推演到标准双指针写法,再展开四数之和的细节差异,最后把我们刷题群里经常争论的几个细节一次性说透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针的演进过程:从暴力三重循环到排序收缩
2.1 暴力法的核心痛点:重复三元组比超时更头疼
先看最无脑的解法:三重循环枚举三元组,然后去重。伪代码大概是这样的:
java复制List<List<Integer>> result = new ArrayList<>();
Set<List<Integer>> set = new HashSet<>();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
for (int k = j + 1; k < n; k++) {
if (nums[i] + nums[j] + nums[k] == 0) {
List<Integer> list = Arrays.asList(nums[i], nums[j], nums[k]);
Collections.sort(list);
set.add(list);
}
}
}
}
result.addAll(set);
这个写法有两个致命问题:一是时间复杂度 O(n^3) 在 n=3000 时直接打爆;二是为了去重,每个三元组还要排序再放进Set,排序本身又增加了常数时间。更麻烦的是,LeetCode的判题器会严格校验返回顺序,就算是集合去重,结果集里的三元组顺序不统一也会被判错。
暴力法告诉我们一个朴素事实:单纯枚举解决不了“去重”这个语义问题,必须从生成三元组的源头就避免重复。怎么避免?核心思路是“顺序化”:先把数组排序,让所有相同元素聚在一起,然后让三元组的枚举满足一种人为约定的顺序关系,比如 a <= b <= c 或者按下标 i < j < k 来约束。一旦元素顺序固定,重复三元组就只能以一种形态出现,去重就变成了“跳过相同的枚举起点”。
2.2 排序的价值:既方便去重,又给双指针铺路
给数组排序就是 O(n log n) 的代价,很多人觉得这个排序是额外开销,其实它是整道题的灵魂。排序之后,数组变成递增序列,我们枚举第一个数 a,如果 a 本身已经大于 0,那么后面所有数都大于 0,三数之和必然大于 0,整个循环可以直接终止。这个剪枝能让很多测试用例跑得飞快。
更重要的是,排序让双指针的收缩有了依据。假设我们已经固定了第一个数 nums[i],需要在 i 后面的区间 [i+1, n-1] 里找两个数 b 和 c,让 nums[i] + b + c = 0。此时区间内的数从左到右是递增的,我们设置左指针 L = i+1,右指针 R = n-1,当 sum > 0 时说明太大,右指针左移让和变小;当 sum < 0 时说明太小,左指针右移让和变大。这个过程本质上是“有序数组上的双指针夹逼”,时间复杂度 O(n) 就能处理完以 nums[i] 开头的所有二元组合。
2.3 双指针的正确姿势:固定一个数,剩下两个数夹逼
标准写法如下:
java复制public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
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 L = i + 1, R = n - 1;
while (L < R) {
int sum = nums[i] + nums[L] + nums[R];
if (sum == 0) {
result.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--;
} else if (sum < 0) {
L++;
} else {
R--;
}
}
}
return result;
}
这段代码看起来简单,里面的细节密度其实非常高。第一个 if (nums[i] > 0) break; 利用了数组有序性做全局剪枝;第二个 if (i > 0 && nums[i] == nums[i-1]) continue; 是为了让重复的第一个数只被枚举一次。注意我这里判断的是 nums[i] == nums[i-1],而不是 nums[i] == nums[i+1],很多新手在这里写反,结果把合法三元组漏掉了。
为什么是 nums[i-1] 而不是 nums[i+1]?因为我们的枚举顺序是从左到右,判断 nums[i] 是否和“上一个已经处理过的元素”相同,才能确保当前这个数不是第一次出现。如果写 nums[i] == nums[i+1],那是在判断“下一个数是否和当前数相同”,这会把相同值的第一个元素也跳过去,导致类似 [-1, -1, 2] 这个合法三元组直接消失。这套逻辑在四数之和里同样成立,所以一定要形成肌肉记忆。
找到一组解之后,左指针和右指针都要移动,因为只移动一个指针必然导致和不再等于0。移动之前要完成一轮“跳过重复元素”的操作,避免把相同值组合反复加入到结果集。这里的 while (L < R && nums[L] == nums[L+1]) L++; 是在找到解之后把左指针跳过所有相同的值,注意跳过之后还要再额外执行一次 L++,才能真正指向和之前不同的元素。右指针同理,跳过重复后还要再 R--。
3. 三数之和的两个隐藏考点:溢出与候选集边界
3.1 int溢出的可能性
力扣的题目没说数组里数值的取值范围,实际上数值范围是 -10^5 <= nums[i] <= 10^5,三数之和最大是 3 * 10^5,int够用。但如果把同样的代码逻辑迁移到其他平台或变种题,三个 int 相加可能溢出。比如三数之和的目标值改成 10^9,数组里全是接近 10^9 的数,nums[i] + nums[L] + nums[R] 就可能超过 Integer.MAX_VALUE,变成负数,导致比较逻辑混乱。
稳妥的做法是用 long 来接收和值,或者在做加法前先判断一下符号。刷题阶段我的习惯是统一用 long sum = (long) nums[i] + nums[L] + nums[R];,多敲几个字符换来的是代码的健壮性。面试的时候如果在白板上写出这个细节,面试官通常会认为你踩过数值边界问题的坑。
3.2 n-2边界的解读
外层循环的条件是 i < n - 2,意思是第一个数至少要留出两个位置给后两个数。很多人会写成 i < n,然后内部再判断 j < k,写起来也能跑,但多了一次无效循环。i < n - 2 这个边界其实在做两件事:一是保证三元组的下标 i < j < k 必然成立,天然避免了下标重复;二是剪掉后面不可能形成完整三元组的枚举。
对于最后一个可行下标,应该是 n - 3,因为 i = n - 3 时,左指针取 n-2,右指针取 n-1,刚好凑齐三个数。所以循环条件写成 i < n - 2 等价于 i <= n - 3,恰好覆盖最后一个可行起点。
4. 四数之和:不是三数之和套一层循环那么简单
力扣18题四数之和,题目要求是找所有不重复的四元组,满足 nums[a] + nums[b] + nums[c] + nums[d] == target。这里的 target 是任意整数,不是固定为0。很多刷题同学觉得四数之和就是三数之和加一层外层循环,直接写下:
java复制for (int i = 0; i < n - 3; i++) {
// 外层去重
for (int j = i + 1; j < n - 2; j++) {
// 内层去重
// 然后双指针找两数
}
}
这个框架没有错,但直接平移会掉进好几个坑。
4.1 第一个坑:剪枝条件不能照搬三数之和
三数之和里有个漂亮的剪枝:if (nums[i] > 0) break;。到了四数之和,如果你照抄 if (nums[i] > target) break; 就出问题了。
为什么?因为 target 是任意整数,可能是负数。比如 target = -10,nums[i] = -3,此时 nums[i] 虽然大于 -10,但后面如果再配两个更小的负数,四数之和完全可以等于 -10。所以四数之和的剪枝必须做两层判断:
- 如果
nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target,说明以 nums[i] 为起点时,后续连续四个数已经超过 target,再往后更不可能,直接 break。 - 如果
nums[i] + nums[n-1] + nums[n-2] + nums[n-3] < target,说明 nums[i] 和数组中最大的三个数相加都小于 target,那以 nums[i] 为起点的任何四元组都不可能等于 target,continue 到下一个 i。
这两个剪枝分别利用了有序数组的“最小组合”和“最大组合”的性质,一个管整体偏大,一个管整体偏小。对应到内层循环的 j,也需要类似的判断,只是把固定数从 i 扩展到 i 和 j,分别判断 nums[i] + nums[j] + nums[j+1] + nums[j+2] 与 target 的关系,以及 nums[i] + nums[j] + nums[n-1] + nums[n-2] 与 target 的关系。
4.2 第二个坑:去重逻辑的对称性
四数之和的去重分三个层次:第一个数 i 去重、第二个数 j 去重、双指针区间的左右指针去重。i 和 j 的去重逻辑都遵循同一个原则:if (i > 0 && nums[i] == nums[i-1]) continue; 和 if (j > i+1 && nums[j] == nums[j-1]) continue;。
注意 j 的去重条件是 j > i+1,不是 j > 0。因为 j 的起点是 i+1,当 j 等于 i+1 时,它是这个 i 下的第一个合法 j,即使它和 nums[i] 的值相同,也不能跳过。比如数组 [0, 0, 0, 0],i=0 固定第一个0,j=1 时值是0,此时 j == i+1,不能跳过,否则整个结果集就空了。
这个细微差别非常容易出 bug。很多人在三数之和里记住了 i > 0 的条件,到了四数之和直接平移成 j > 0,结果 j 在第一次循环时就把自己跳过了。我刷题群里有位同学在这里卡了两个小时,最后 debug 出来发现就是去重条件写错了位置。
4.3 完整参考实现
java复制public List<List<Integer>> fourSum(int[] nums, int target) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(nums);
int n = nums.length;
for (int i = 0; i < n - 3; i++) {
// 整体剪枝:连续最小四数之和已大于target
if ((long) nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target) break;
// 当前i与最大三数之和仍小于target,跳过
if ((long) nums[i] + nums[n-1] + nums[n-2] + nums[n-3] < target) continue;
// 去重
if (i > 0 && nums[i] == nums[i-1]) continue;
for (int j = i + 1; j < n - 2; j++) {
if ((long) nums[i] + nums[j] + nums[j+1] + nums[j+2] > target) break;
if ((long) nums[i] + nums[j] + nums[n-1] + nums[n-2] < target) continue;
if (j > i + 1 && nums[j] == nums[j-1]) continue;
int L = j + 1, R = n - 1;
while (L < R) {
long sum = (long) nums[i] + nums[j] + nums[L] + nums[R];
if (sum == target) {
result.add(Arrays.asList(nums[i], nums[j], nums[L], nums[R]));
while (L < R && nums[L] == nums[L+1]) L++;
while (L < R && nums[R] == nums[R-1]) R--;
L++;
R--;
} else if (sum < target) {
L++;
} else {
R--;
}
}
}
}
return result;
}
注意这里我把所有求和都转成了 long,因为 target 的取值范围是 -10^9 <= target <= 10^9,nums 的取值范围是 -10^9 <= nums[i] <= 10^9。四数之和的最大值能到 4 * 10^9,int 直接溢出。这也是四数之和和三数之和非常不同的一点:三数之和你用 int 可能侥幸没事,四数之和用 int 就是必炸。
4.4 从三数到四数的规律提炼
总结一下,从三数之和到四数之和,本质上就是“固定数的数量”增加了一个。三数之和固定一个数,剩下两个数用双指针;四数之和固定两个数,剩下两个数用双指针。如果再扩展成五数之和、六数之和,就是固定 k-2 个数,最后两个数用双指针。
这类题目的算法复杂度是 O(n^(k-1)),k 是元组大小。但工程实现上有个递归模板可以统一处理,先把 k 数之和问题降维成 (k-1) 数之和问题,直到 k == 2 时用左右指针求解。递归函数需要维护当前固定的前缀、起始下标、剩余目标值等参数。我建议有余力的同学把这个递归版本写一遍,写完之后对这类题型的理解会从“背模板”变成“懂原理”。
java复制public List<List<Integer>> kSum(int[] nums, long target, int start, int k) {
List<List<Integer>> res = new ArrayList<>();
if (start == nums.length) return res;
long avg = target / k;
if (nums[start] > avg || nums[nums.length - 1] < avg) return res;
if (k == 2) {
int L = start, R = nums.length - 1;
while (L < R) {
long sum = (long) nums[L] + nums[R];
if (sum == target) {
res.add(Arrays.asList(nums[L], nums[R]));
while (L < R && nums[L] == nums[L+1]) L++;
while (L < R && nums[R] == nums[R-1]) R--;
L++; R--;
} else if (sum < target) {
L++;
} else {
R--;
}
}
return res;
}
for (int i = start; i < nums.length - k + 1; i++) {
if (i > start && nums[i] == nums[i-1]) continue;
List<List<Integer>> sub = kSum(nums, target - nums[i], i + 1, k - 1);
for (List<Integer> list : sub) {
List<Integer> temp = new ArrayList<>();
temp.add(nums[i]);
temp.addAll(list);
res.add(temp);
}
}
return res;
}
递归版本里的平均剪枝是个很巧妙的设计:target / k 得到的是平均值,如果当前区间的最小值大于平均值或最大值小于平均值,说明无论如何凑不齐 target,可以直接剪掉。这个剪枝在数据量大时能省下大量递归分支。不过递归版本在面试时写起来容易出错,建议先用迭代版保底,递归版作为进阶理解。
5. 实战中那些反复出现的细节坑与排查方法
5.1 数组元素全为0的极端用例
输入 [0, 0, 0, 0],target 为 0,四数之和应该输出一个 [0, 0, 0, 0] 还是空集?正确答案是输出一个四元组。很多人的代码在这里出问题,本质上是去重条件写错了。
用上面的实现跑一遍:i=0 时 nums[0]=0,i > 0 不成立,不跳过;j=1 时 j > i+1 不成立,不跳过;双指针 L=2, R=3,sum 正好等于 target,加入结果;之后双指针的去重循环把 L 从 2 移到 3,R 保持在 3,然后 L++ 变成 4,循环退出。j 循环继续,j=2 时 j > i+1 成立且 nums[2]==nums[1],跳过;j=3 同理跳过。i 循环继续,i=1 时 i > 0 成立且 nums[1]==nums[0],跳过。所以最终只输出一个三元组,正确。
如果去重条件错写成了 j > 0 && nums[j] == nums[j-1],在 j=1 时就会因为 nums[1]==nums[0] 把第二个0跳过,导致 i=0 下没有任何 j 可用,最终结果为空集。这个边界值得反复验证。
5.2 双指针去重后是否还要再执行一次 L++ 和 R--
很多初学者会问:找到一组解之后,while 循环已经把 L 跳到了最后一个重复元素的位置,还需要 L++; R-- 吗?
必须执行。因为去重 while 循环结束后,L 指向的是“重复区间的最后一个元素”,这个元素在上一轮已经加入结果了,不执行 L++ 就会进入死循环——又用相同的 L 值计算一次 sum,又等于 target,又加入一次重复结果。R 同理。所以去重循环负责“跳过重复”,额外的 L++; R-- 负责“指向新区间”。
5.3 target 为负数时的剪枝陷阱
数组 [-4, -3, -2, -1, 0, 1, 2, 3, 4],target = -5。外层 i 固定 -4,nums[i] = -4,如果按 nums[i] > target 剪枝,-4 > -5,直接 break,实际上 [-4, -3, 0, 2]、[-4, -2, -1, 2] 等一堆组合加起来都能等于 -5,全被剪掉了。
所以再次强调:剪枝条件是“当前最小四数之和大于 target”而不是“当前值大于 target”。有序数组的剪枝必须依赖于“组合值”,不能简单依赖“单元素值”。这是把三数之和思路硬搬到四数之和时最典型的翻车现场。
5.4 时间复杂度与空间复杂度的平衡
三数之和的 O(n^2) 在 n=3000 时大约是 900 万次指针移动,LeetCode 实测大概 20ms 左右。四数之和的 O(n^3) 在最坏情况下是 27 亿次操作,但因为剪枝的存在,平均耗时会远低于理论值。如果不想写剪枝,也可以在进入双指针前先跳过所有重复元素,但整体收益不如四个方向的剪枝大。
空间复杂度方面,三数之和与四数之和如果不算存储结果的空间,辅助空间是 O(1);排序用的是 Java 的 Arrays.sort(),底层是双轴快排,平均空间复杂度 O(log n),这个通常不纳入讨论。
5.5 面试现场最常见的追问方向
面试官喜欢在这道题后面追加追问,我这里总结几个常被追问的点:
- “如果数组是流式的,不能一次性排序怎么办?” 答案是用哈希表记录两数之和,时间复杂度不变但空间复杂度变成 O(n^2)。这种变体在“四数之和II”(力扣454题)里有体现,它给了四个数组,不要求元素来自同一个数组,所以可以用分组哈希。
- “如果要求找出所有和为目标值的四元组,且不可以使用排序呢?” 这就是纯哈希法的场景,但去重会非常麻烦。实际工程中一般还是先排序再加指针,因为排序 O(n log n) 的代价远比去重的维护成本低。
- “能否扩展到 k 数之和?” 这就是我上面给的递归模板,时间复杂度 O(n^(k-1)),空间复杂度 O(k)。
6. 一道同源题的迁移:最近三数之和与四数之和II
三数之和的衍生题里,比较有代表性的是力扣16题“最接近的三数之和”和力扣454题“四数之和II”。前者要求找和与 target 最接近的三元组,后者要求从四个数组中各取一个数使和为0,计算组合数。
6.1 最接近的三数之和:双指针框架不变,比较逻辑换成差值绝对值
最接近的三数之和和原题唯一的区别是:不要求 sum 精确等于 target,而是要求 abs(sum - target) 最小。所以循环框架完全一样,只需要维护一个 best 变量,每轮计算 abs(sum - target),比 best 小就更新。如果恰好等于 target,直接剪枝返回,因为不可能有更接近的组合了。
内层双指针的移动逻辑也可以微调:如果当前 sum 小于 target,L 右移让和变大;否则 R 左移让和变小。这里的逻辑和三数之和完全一致,因为数组有序,移动指针是唯一有序调整方向的手段。
6.2 四数之和II:哈希表计数替代双指针去重
四数之和II给的是四个独立数组 A、B、C、D,要求统计满足 A[i] + B[j] + C[k] + D[l] == 0 的四元组个数。因为四个数组相互独立,同一元素可能被多次使用,所以不存在“去重”的约束,反而可以用哈希表高效求解。
思路是把 A 和 B 的所有两数之和存入哈希表,key 是两数和,value 是出现次数。然后遍历 C 和 D 的所有两数之和,查哈希表里有没有 -(C[k] + D[l]) 这个值,有就把对应次数累加到答案里。时间复杂度 O(n^2),空间复杂度 O(n^2)。
这道题和标准四数之和放在一起看很有意思:标准四数之和需要去重,所以走“排序+双指针”路线;四数之和II不需要去重,所以走“哈希计数”路线。这两条路线没有优劣之分,完全取决于题目是否要求结果唯一。这也是算法题中非常典型的一个分叉点:同一类型的问题,约束条件不同,最优解法可能完全不同。
7. 我的刷题心得与一个实用的模板化写法
三数之和和四数之和这类题目,刷多了之后你会发现它们是有“套路”的。我个人的习惯是背一个统一的模板,遇到 k 数之和先套模板,再根据题目细节调整。这个模板就是递归降维的 kSum 函数。虽然迭代版更直观,但递归版能帮助你在面对五数之和、六数之和甚至更复杂的变体时心里有底。
在实际刷题过程中,我总结出几个值得反复默念的要点:
第一,排序永远在前。不管后续用什么策略,先排序永远是对的。排序不仅让双指针有收缩方向,还天然把所有相同元素聚在一起,去重逻辑才写得干净。
第二,去重永远参考“前一个元素”,而不是“后一个元素”。即 nums[i] == nums[i-1] 这种写法,配合“当前元素是否等于上一个已处理元素”的语义。
第三,剪枝的方向是“当前最小 k 数之和”和“当前最大 k 数之和”这两个极端值,不是当前起始元素本身的比较。
第四,涉及 target 或数据范围较大的求和,一律用 long,不要舍不得那一个类型转换。
第五,四数之和的 j 去重条件必须写成 j > i+1,这是和三数之和的 i 去重条件 i > 0 唯一的差异点,值得单独记忆。
我在刷题群里见过太多人在“去重条件”上反复出错,有一个同学甚至总结出了一句口诀:“外层看前驱,内层看起点后的前驱。”虽然有点绕,但确实能帮他记住 i > 0 和 j > i+1 的区别。我个人更推荐直接理解为什么这样写:因为你只在当前元素不是第一个候选时才需要检查“是否和上一位相同”,而第一个候选对应的下标分别是 i=0 和 j=i+1。
三数之和与四数之和这类题,本质上是用双指针把 O(n^3) 或 O(n^4) 的暴力降了一个维度。把这个降维过程吃透,再去看合并 K 个有序链表、盛最多水的容器、接雨水这些题目,你会发现双指针的思路贯穿始终。算法能力就是这样一道题一道题垒起来的,每一道经典题都不白刷。
