【面试必问】手撕 LeetCode “三数之和”:双指针+去重,这一篇图解给你讲透!
前言:为什么你总倒在“三数之和”?
太多面试者在 LeetCode 第1题 “两数之和” 上重拳出击,用 HashMap 秒杀全场;然而一旦题目变成 第15题 “三数之和” ,由于无法直接套用 Hash 策略,很多人就开始支支吾吾,最后写出了一个
O(n3)
的暴力三层循环。
兄弟们,面试写
O(n3)
,基本上就是“回家等通知”的节奏了,还能打包一份"凉面"。
其实,这道题是考察 “排序 + 双指针” 的经典范例。它的难点不在于找数字,而在于 如何优雅地去重。今天,就带你一步步拆解这道大厂必考题,保证你下次遇到能手撕得明明白白!
一、核心思路:降维打击(排序 + 双指针)
解决“三数之和”的核心在于将 三维问题降低到二维。
如果我们固定其中一个数字(假设为 nums[i]),那么问题就变成了:在剩下的数组中,找到两个数 left 和 right,使得 nums[left] + nums[right] = -nums[i] 。
看!这不就变回我们熟悉的“两数之和”了吗?
但是,为了让双指针能跑起来,我们需要一个前提:数组必须是有序的。
1. 为什么要排序?
这就要我们深刻理解 sort 的意义:
JavaScript
// a - b < 0 => a 在前 b 在后 (升序)
nums.sort((a, b) => a - b);
排序有两个巨大的好处:
- 单调性:数组有序后,如果三数之和偏大,我们只能通过左移右指针来减小总和;反之亦然。这是双指针能生效的物理基础。
- 方便去重:重复的元素会挨在一起,我们只需要判断 nums[i] === nums[i-1] 就能轻松跳过重复项。
2. 双指针布局
- 一层 for 循环,索引 i 从 0 到 length-2,这是我们的固定桩。
- left 指针指向 i + 1(桩子的下一位)。
- right 指针指向 length - 1(数组末尾)。
二、动图级流程解析:指针怎么动?
一旦 i 固定了,left 和 right 就开始向中间靠拢。在这个过程中,我们计算 sum = nums[i] + nums[left] + nums[right]。
这里有三种情况,逻辑非常清晰:
-
sum > 0(和太大了)
- 原因:数组是升序的,右边的数太大。
- 动作:right--(右指针左移,找个小点的数)。
-
sum < 0(和太小了)
- 原因:左边的数太小。
- 动作:left++ (左指针右移,找个大点的数)。
-
sum == 0(中奖了!)
- 动作:把 [nums[i], nums[left], nums[right]] 加入结果集 res。
- 关键点:不仅要记录,还要同时收缩 left++ 和 right--,继续寻找下一组可能的解。
三、地狱级细节:如何优雅地去重?💀
这道题 80% 的挂科率都出在去重上。题目要求结果集中不能包含重复的三元组(例如不能出现两个 [-1, 0, 1])。
我们在两个维度进行去重:
1. 外层循环去重(固定桩去重)
代码:
JavaScript
if (i > 0 && nums[i] == nums[i-1]) {
continue;
}
面试官追问: 为什么是 nums[i] == nums[i-1] 而不是 nums[i] == nums[i+1]?
解析:
- 如果是 nums[i] == nums[i+1],对于数组 [-1, -1, 2],当 i 指向第一个 -1 时,你就把它跳过了。那你就会漏掉 [-1, -1, 2] 这个有效解(因为这两个 -1 是不同位置的,可以共存)。
- 我们用 nums[i] == nums[i-1] 的意思是: “如果当前的数字和上一个数字一样,说明上一个数字已经把所有可能的组合都找过了,我就不用再找一遍了” 。
2. 内层双指针去重
找到一个答案后,还没完!left 和 right 移动后的新位置可能还是和刚才一样的数字。
代码:
JavaScript
while(left < right && nums[left] == nums[left-1]) { left++; }
while(left < right && nums[right] == nums[right+1]) { right--; }
这步操作必须在 res.push 并且常规移动 left++/right-- 之后进行,确保彻底跳过重复段。
四、完整代码展示 (可以直接背诵版)
优化版本,加上了详细的注释:
JavaScript
function threeSum(nums) {
const res = [];
// 1. 必须先排序!这是双指针生效的前提
// sort 是 JS 内置排序,a-b < 0 表示升序
nums.sort((a, b) => a - b);
const len = nums.length;
// 2. 遍历每一个数字作为“固定桩” i
for (let i = 0; i < len - 2; i++) {
// 【核心去重1】:跳过重复的起点
// 注意是 i > 0 且和 i-1 比,不是和 i+1 比
if (i > 0 && nums[i] === nums[i-1]) {
continue;
}
let left = i + 1;
let right = len - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
// 找到一组解
res.push([nums[i], nums[left], nums[right]]);
// 无论如何都要移动指针
left++;
right--;
// 【核心去重2】:跳过重复的 left
while (left < right && nums[left] === nums[left-1]) {
left++;
}
// 【核心去重3】:跳过重复的 right
while (left < right && nums[right] === nums[right+1]) {
right--;
}
} else if (sum < 0) {
// 和太小,左指针右移让和变大
left++;
} else {
// 和太大,右指针左移让和变小
right--;
}
}
}
return res;
}
五、复杂度分析
-
时间复杂度:
O(n2)-
数组排序通常是快排,复杂度
O(nlogn)。
-
双指针遍历过程:外层循环
O(n),内层双指针
O(n),乘积是
O(n2)。
-
总体:
O(n2),远优于暴力的
O(n3)。
-
-
空间复杂度:
O(1)或
O(logn)-
如果你不计算存储结果的 res 数组,额外的空间主要是排序算法栈的空间(取决于语言底层实现),通常认为是
O(logn)或
O(1)。
-
六、总结
做这道题,心里要默念这句四步口诀:
- 一排序:无序没法玩,sort 走在前。
- 二定桩:for 循环定 i,去重要判前(i-1)。
- 三双指:left、right 两头堵,大了左移小右顾。
- 四去重:找到答案别停步,while 循环跳重复。
学会了吗?别光看,赶紧打开编辑器 手撕 一遍吧!觉得有用的兄弟,点个赞再走呗!