刷LeetCode永远绕不开的题——三数之和(一生之敌)。说它经典,是因为几乎所有大厂面试都会考;说它「坑多」,是因为暴力解法会超时,双指针看似简单但去重逻辑能让人挠破头。
今天就用我刷这道题的真实经历,从暴力解法的痛点讲到双指针的核心思路,重点拆解让无数人崩溃的「去重逻辑」。看完这篇,你不仅能彻底搞懂三数之和,还能举一反三解决四数之和、五数之和等变种题。
题目描述:找三个数,和为0且不重复
题目是这样的:给定一个整数数组 nums,判断是否存在三个元素 a, b, c ,使得 a + b + c = 0 ?要求找出所有不重复的三元组。
举个例子,输入 nums = [-1,0,1,2,-1,-4],正确输出应该是 [[-1,-1,2],[-1,0,1]]。注意,像 [-1,0,1] 和 [0,-1,1] 这种元素相同但顺序不同的组合,算重复,只能保留一个。
暴力解法:能过样例,但过不了面试
刚学算法时,我第一反应是暴力枚举:三层循环直接套,简单暴力又美妙。代码大概长这样:
function threeSum(nums) {
const res = [];
const n = nums.length;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
for (let k = j + 1; k < n; k++) {
if (nums[i] + nums[j] + nums[k] === 0) {
res.push([nums[i], nums[j], nums[k]]);
}
}
}
}
// 去重:这里还要对res去重,复杂度爆炸
return unique(res);
}
但这种方法有两个致命问题:
- 时间复杂度太高:三层循环的时间复杂度是 (O(n^3)),当数组长度是1000时,计算次数是十亿级,直接超时;
- 去重困难:即使找到所有和为0的三元组,还要对结果去重(比如
[-1,0,1]和[0,-1,1]算重复)。用JSON.stringify转字符串再去重?这会额外增加 (O(k)) 的时间复杂度(k是结果数量),面试肯定不会给过。
双指针解法:排序+双指针,时间复杂度降为 (O(n^2))
ps:其实我看两遍了再写还是记不住,就只能换方法了,就去b站看up视频我看的是代码随想录,讲解十分清晰,上完一遍就手感火热啊,正好看到四数之和,一家人一起解决了。
暴力解法行不通,就得想优化。这时候双指针法登场了。它的核心思路是:先排序,再固定一个数,用双指针找另外两个数。
第一步:排序——双指针的前提
为什么要先排序?因为排序后可以:
- 利用有序数组的特性,通过指针移动快速缩小范围;
- 方便后续去重(重复元素会相邻,容易跳过)。
比如原数组是 [-1,0,1,2,-1,-4],排序后变成 [-4,-1,-1,0,1,2]。这时候,相同的元素(比如两个 -1)会挨在一起,为后续去重打下基础。
第二步:固定一个数,双指针找另外两个数
排序后,我们固定第一个数 nums[i],然后用左指针 left 指向 i+1,右指针 right 指向数组末尾。三个数的和 sum = nums[i] + nums[left] + nums[right]:
- 如果
sum < 0:说明需要更大的数,左指针右移(left++); - 如果
sum > 0:说明需要更小的数,右指针左移(right--); - 如果
sum = 0:找到一个有效三元组,记录结果。
第三步:去重——这才是真正的难点
找到三元组后,如何避免重复?关键是跳过相同的元素。具体分三种情况:
1. 固定数 nums[i] 重复
比如排序后的数组是 [-4,-1,-1,0,1,2],当 i=1(nums[i]=-1)时,和 i=2(nums[i]=-1)时的情况是一样的。这时候需要跳过重复的 nums[i]。
判断条件:如果 i > 0 且 nums[i] === nums[i-1],说明当前 nums[i] 和前一个数重复,直接跳过。
2. 左指针 nums[left] 重复
假设已经找到 i=0(nums[i]=-4),left=1(nums[left]=-1),right=5(nums[right]=2),此时和为 -4 + (-1) + 2 = -3,不满足条件。左指针右移到 left=2(nums[left]=-1),这时候 nums[left] 和前一个 left 位置的数重复,需要跳过。
判断条件:当找到和为0的三元组后,需要循环判断 nums[left] === nums[left+1],如果是,左指针右移,直到遇到不同的数。
3. 右指针 nums[right] 重复
同样,找到和为0的三元组后,如果 nums[right] 和前一个 right 位置的数重复(比如 nums[right]=1 和 nums[right-1]=1),需要跳过。
判断条件:循环判断 nums[right] === nums[right-1],如果是,右指针左移,直到遇到不同的数。
代码逐行解析:看我如何把思路写成代码
结合上面的思路,来看我写的三数之和代码:
function threeSum(nums) {
nums.sort((a, b) => a - b); // 先排序
const result = [];
const n = nums.length;
for (let i = 0; i < n - 2; i++) { // 固定第一个数 nums[i]
// 跳过重复的 nums[i](i>0时才判断)
if (i > 0 && nums[i] === nums[i - 1]) {
continue;
}
let left = i + 1; // 左指针初始化为 i+1
let right = n - 1; // 右指针初始化为数组末尾
while (left < right) { // 左右指针相遇时停止
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) { // 找到有效三元组
result.push([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 < 0) { // 和太小,左指针右移
left++;
} else { // 和太大,右指针左移
right--;
}
}
}
return result;
}
关键步骤说明:
- 排序:
nums.sort((a, b) => a - b)将数组升序排列,是双指针的基础; - 固定第一个数:外层循环
i遍历数组,固定nums[i]为第一个数; - 跳过
nums[i]重复:if (i > 0 && nums[i] === nums[i - 1]) continue避免重复的三元组; - 双指针移动:通过
left和right的移动,在i固定的情况下,找到所有可能的nums[left]和nums[right]; - 跳过
left和right重复:找到和为0的三元组后,跳过相邻的重复元素,避免结果重复。
四数之和:三数之和的「套娃版」
理解了三数之和,四数之和(LeetCode 18)就简单了。思路是:固定前两个数,用双指针找后两个数,同样需要处理重复问题。
看我写的四数之和代码:
var fourSum = function(nums, target) {
nums.sort((a, b) => a - b); // 先排序
const res = [];
const n = nums.length;
for (let i = 0; i < n - 3; i++) { // 固定第一个数 nums[i]
// 跳过 nums[i] 重复
if (i > 0 && nums[i] === nums[i - 1]) continue;
for (let j = i + 1; j < n - 2; j++) { // 固定第二个数 nums[j]
// 跳过 nums[j] 重复
if (j > i + 1 && nums[j] === nums[j - 1]) continue;
let left = j + 1; // 左指针
let right = n - 1; // 右指针
while (left < right) {
const sum = nums[i] + nums[j] + nums[left] + nums[right];
if (sum === target) { // 找到有效四元组
res.push([nums[i], nums[j], nums[left], nums[right]]);
// 跳过 left 重复
while (left < right && nums[left] === nums[left + 1]) left++;
// 跳过 right 重复
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
} else if (sum < target) { // 和太小,左指针右移
left++;
} else { // 和太大,右指针左移
right--;
}
}
}
}
return res;
};
四数之和与三数之和的区别:
- 多了一层循环固定第二个数
nums[j]; - 去重时需要同时处理
nums[i]和nums[j]的重复; - 目标和从0变成了任意
target,但逻辑完全一致。
总结:双指针+去重的核心逻辑
三数之和这道题,表面考的是算法,实际考的是「如何用排序和双指针降低时间复杂度」「如何通过跳过重复元素避免结果重复」。掌握这两个核心,不仅能解决三数之和、四数之和,还能解决类似的N数之和问题。
最后,给刚开始刷算法的同学一个建议:遇到类似问题,先想暴力解法,再想如何优化。双指针不是凭空想出来的,而是在分析暴力解法的痛点(时间复杂度高、重复元素难处理)后,结合排序的特性推导出来的。
下次面试遇到三数之和,记得把这篇的思路讲给面试官听——从暴力到双指针的优化过程,比直接写代码更能体现你的算法思维。