嘿,各位算法探险家们!今天咱们要聊聊一个在 LeetCode 上出镜率极高、同时也是面试官们最爱的经典问题——三数之和 (Three Sum) 。
这个问题看似简单:给你一个整数数组 nums,找出所有不重复的三元组 [a, b, c],使得它们的和为零,即 。
如果你上来就想用三层循环(暴力解法),那可要小心了,时间复杂度直接飙到 ,对于稍微大一点的输入,你的程序可能就要原地“爆炸”了💥。
但是别慌!聪明的程序员总有优雅的解决方案。我们将采用一种结合了排序和双指针的技巧,将时间复杂度优化到 。
💡 从“两数之和”到“三数之和”
在解决三数之和之前,咱们先快速回顾一下它的老弟——两数之和 (Two Sum) 。
- 两数之和(暴力) : 双层循环,。
- 两数之和(哈希表) : 遍历一次数组,用 的时间去查找 是否存在于哈希表中,将时间复杂度优化到****。
然而,三数之和的约束条件是“不重复的三元组”,如果直接套用哈希表解法,处理去重会变得非常麻烦。所以,咱们需要一种更结构化的方法。
🛠️ 三数之和:排序 + 双指针 方案详解
我们的核心思路是:降维打击!
将 拆解为:固定一个数 ,然后寻找两个数 和 ,使得 。
1. 预处理:排序(Sort)
这是整个解法的基石,也是优化复杂度的关键一步。
JavaScript
nums.sort((a, b) => a - b);
为什么一定要排序?
- 方便跳过重复元素(去重) :如果数组是有序的,相同的元素会相邻。我们只需要检查当前元素是否与其前一个元素相同,就可以轻松跳过重复项。
- 方便使用双指针:排序后,数组具备单调性。当我们知道当前三数之和是偏大还是偏小时,可以确定性地移动指针来缩小查找范围。
🔑 关于 JavaScript 的 sort 方法:
很多初学者在这里会遇到一个“坑”。JS 内置的 sort() 默认是按字符串字典顺序排序的,例如 [1, 10, 2] 会被排成 [1, 10, 2](因为它把 '10' 看作 '1' 后面跟了个 '0')。
为了实现数字升序排序,我们必须传入一个比较函数:
-
nums.sort((a, b) => a - b):- 当 时,表示 ,
sort认为 应该在 前面,不交换,结果是升序(从小到大)。
- 当 时,表示 ,
排序本身需要 的时间,但这只执行一次,所以不会影响我们主体 的复杂度。
2. 主体:固定 + 双指针
数组排序完成后,我们开始主体循环。
JavaScript
for (let i = 0; i < nums.length - 2; i++) {
// ... 固定 i 的逻辑 ...
}
A. 固定第一个数 并去重
我们用一个外层循环 来固定第一个数 。循环条件是 ,因为我们至少需要三个数,所以 至少要留出两个位置给 left 和 right 指针。
JavaScript
// **关键点 1:跳过重复的 i**
// i > 0 确保 i 不是第一个元素
// nums[i] === nums[i - 1] 确保当前元素和上一个固定元素相同
if (i > 0 && nums[i] === nums[i - 1]) continue;
B. 初始化双指针
对于固定的 ,我们的目标是在剩余的子数组中找到 和 使得 。
- 左指针
left: 初始化为 。 - 右指针
right: 初始化为数组的末尾 。
JavaScript
let left = i + 1;
let right = nums.length - 1;
C. 移动双指针
在 while (left < right) 的循环中,我们计算当前的和 sum:
JavaScript
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 和 right**
// left < right 检查循环是否结束
// nums[left] === nums[left - 1] 检查 left 是否和上一个元素重复
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
} else if (sum < 0) {
// **和太小了 (sum < 0)**:
// 需要一个更大的数,因为数组已排序,所以**左指针 right++**
left++;
} else { // sum > 0
// **和太大了 (sum > 0)**:
// 需要一个更小的数,所以**右指针 left--**
right--;
}
}
总结时间复杂度:
- 排序:
- 外层循环 :
- 内层双指针 和 :
- 总时间复杂度:。
完整代码示例(技术交流平台版)
JavaScript
/**
* @param {number[]} nums
* @return {number[][]}
*/
function threeSum(nums) {
// O(n log n):排序是优化解法的基石
// 使用 (a, b) => a - b 确保数字升序排序
nums.sort((a, b) => a - b);
const res = [];
// O(n):外层循环 i,固定第一个数 a
for(let i = 0; i < nums.length - 2; i++) {
// **关键点 1:对 i 进行去重**
// 确保本次固定的 nums[i] 与上一次不同
if(i > 0 && nums[i] === nums[i - 1]) continue;
// i > 0 且 nums[i] > 0,因为数组已排序,后续的数肯定大于零,和不可能等于 0,可以提前退出
if (nums[i] > 0) break;
// O(n):双指针,用于在剩余数组中找 b 和 c
let left = i + 1;
let right = nums.length - 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 和 right 进行去重**
// 找到一个解后,要跳过所有和当前 nums[left] 重复的元素
while(left < right && nums[left] === nums[left - 1]) left++;
// 找到一个解后,要跳过所有和当前 nums[right] 重复的元素
while(left < right && nums[right] === nums[right + 1]) right--;
} else if(sum < 0) {
// 和太小了,需要增大和 -> left 右移
left++;
} else { // sum > 0
// 和太大了,需要减小和 -> right 左移
right--;
}
}
}
return res;
}
// 示例运行
const testNums = [-1, 0, 1, 2, -1, -4];
console.log(`原数组: ${testNums}`);
console.log(`结果: ${threeSum(testNums)}`); // 结果: [ [ -1, -1, 2 ], [ -1, 0, 1 ] ]
const sortedTestNums = [2, 1, 6, 3, 4, 5];
// 验证 sort
sortedTestNums.sort((a, b) => a - b);
console.log(`验证排序结果: ${sortedTestNums}`); // 结果: [ 1, 2, 3, 4, 5, 6 ]
总结陈词:算法之美 💖
三数之和这个题目,完美地展示了算法设计中的降维和结构化思维。从 的暴力解到 的优雅解,我们只多做了一步排序。
- 排序:为后续的去重和双指针操作奠定基础。
- 固定 :将三数之和问题降维成两数之和问题。
- 双指针:利用排序后的数组特性,以 的时间复杂度完成查找和去重。
掌握了这套“排序 + 固定 + 双指针”的组合拳,你不仅能轻松拿下三数之和,还能解决四数之和、K 数之和等一系列类似问题!
算法的世界充满乐趣,让我们在下一个挑战中再见!👋