三数之和:从暴力到优雅的算法之旅
在编程的世界里,有些问题看似简单,却蕴含着深刻的算法智慧。比如15. 三数之和 - 力扣(LeetCode)——在一个整数数组中找出所有不重复的三个数,使得它们的和为0。这个问题不仅频繁出现在面试题中,更是理解排序、双指针、去重逻辑等核心思想的绝佳入口。
今天,我们就从最朴素的想法出发,一步步走进高效解法的大门。即使你是 JavaScript 小白,也能轻松跟上节奏。准备好了吗?让我们一起开启这段算法之旅!
🌱 一、问题初识:什么是“三数之和”?
假设你有一堆数字卡片:[-1, 0, 1, 2, -1, -4]。你的任务是:从中挑出三个不同的数,让它们加起来等于0,并且不能有重复的组合。
比如:
-1 + 0 + 1 = 0✅-1 + (-1) + 2 = 0✅0 + 1 + 2 = 3❌
最终答案应该是:[[-1, -1, 2], [-1, 0, 1]]
注意:顺序不同但数字相同的组合算作重复,比如 [0, -1, 1] 和 [-1, 0, 1] 是同一个解。
🔥 二、暴力解法:三重循环的“蛮力”
最直接的想法是什么?穷举所有可能的三元组!
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) {
// 找到一个解
}
}
}
}
这就像你在超市货架上,一个一个地试吃三种零食的组合,看它们合起来是不是“刚刚好”。虽然方法笨,但确实能解决问题。
然而,时间复杂度是 O(n³) —— 当数组有1000个元素时,要尝试近10亿次!显然不可行。
🧠 三、优化思路:先排序,再用“双指针”
1. 为什么要排序?
排序后,数组变得“有序”,就像把杂乱的书按字母顺序排好,找起来就快多了。
更重要的是:我们可以利用“大小关系”来跳过无效尝试。
例如,如果当前三个数的和太大了,说明右边的数太大,应该往左移;如果太小,说明左边的数太小,应该往右移。
[ ... , i , ..., left , ..., right , ... ]
↑ ↑ ↑
固定起点 左指针 右指针
这就引出了双指针技巧。
2. 双指针怎么工作?
▶ 核心思想:固定一个数,转化为“两数之和”
我们不再同时找三个数,而是:
- 先固定第一个数
nums[i](由外层循环控制); - 然后在它右边的子数组中,找两个数,使得它们的和等于
-nums[i]。
这就把“三数之和”巧妙地降维成了一个经典的“两数之和(目标值已知)”问题!
▶ 双指针初始化:从两端向中间夹逼
对于每个固定的 i,我们设置:
left = i + 1→ 子数组中最左边(最小值)right = nums.length - 1→ 子数组中最右边(最大值)
索引: 0 1 2 3 4 5
值: [-4, -1, -1, 0, 1, 2]
↑ ↑ ↑
i left right
此时,三元组是 (-1, -1, 2),和为 0 —— 找到了一个解!
▶ 指针如何移动?——根据“偏差”调整
计算当前和:
sum = nums[i] + nums[left] + nums[right]
然后分三种情况:
| 情况 | 当前和 | 问题 | 动作 | 原因 |
|---|---|---|---|---|
| ✅ 相等 | sum === 0 | 找到解 | left++, right-- | 记录后收缩窗口,探索新组合 |
| ➕ 太小 | sum < 0 | 总和不够 | left++ | 左边数太小,需换更大的 |
| ➖ 太大 | sum > 0 | 总和超了 | right-- | 右边数太大,需换更小的 |
代码实现:
nums.sort((a, b) => a - b); // 升序排序
const res = [];
for (let i = 0; i < nums.length - 2; i++) {
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--;
} else if (sum < 0) {
left++; // 太小了,左边往右走(取更大的数)
} else {
right--; // 太大了,右边往左走(取更小的数)
}
}
}
| 步骤 | 动作 | 目的 |
|---|---|---|
| 1 | 排序 | 利用有序性指导搜索 |
| 2 | 固定 i | 枚举第一个数 |
| 3 | left = i+1, right = end | 初始化双指针 |
| 4 | 计算三数之和 | 判断当前组合 |
| 5 | 和=0 → 记录并收缩窗口 | 找到解 |
| 6 | 和<0 → left++ | 增大总和 |
| 7 | 和>0 → right-- | 减小总和 |
🎯 时间复杂度:O(n²)
空间复杂度:O(1)(不计输出)
🚫 四、去重:避免重复答案
上面的代码有个问题:可能会输出重复的三元组。
比如输入 [-1, -1, 0, 1],如果不处理,可能得到两次 [-1, 0, 1]。
如何跳过重复?
关键在于:当某个位置的值和前一个一样时,直接跳过。
1. 跳过重复的“固定数”
if (i > 0 && nums[i] === nums[i - 1]) continue;
因为如果 nums[i] == nums[i-1],那么以 nums[i] 为起点的所有组合,其实在 i-1 时已经找过了。
2. 跳过重复的左右指针值
找到一个解后,继续移动指针时也要跳过相同值:
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
这样就能确保每个三元组都是唯一的。
🧩 五、完整代码:优雅高效的实现
结合以上所有思想,我们得到最终解法:
function threeSum(nums) {
nums.sort((a, b) => a - b); // 升序排序
const res = [];
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复的起点
if (i > 0 && nums[i] === nums[i - 1]) continue;
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--;
// 跳过重复的 left 和 right
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
} else if (sum < 0) {
left++; // 太小,左指针右移
} else {
right--; // 太大,右指针左移
}
}
}
return res;
}
🎯 六、总结:从混乱到秩序的思维跃迁
“三数之和”教会我们的,不仅是算法技巧,更是一种化繁为简的思维方式:
- 暴力尝试 → 发现效率瓶颈
- 引入排序 → 利用有序性加速搜索
- 双指针 → 将三层循环降为两层
- 去重逻辑 → 确保结果干净唯一
这就像整理房间:先把东西分类(排序),再用两只手从两端往中间收(双指针),遇到重复物品就跳过(去重),最终得到整洁有序的结果。
🌈 鼓励:
算法不是天赋,而是训练。
每一次“啊哈!”的顿悟,都来自对问题的反复咀嚼。
你现在看到的优雅代码,背后是无数次试错与重构。
继续写,继续想,你也能写出属于自己的“三数之和”。
愿你在代码的世界里,既有探索的勇气,也有优化的智慧。