三数之和:从暴力枚举到双指针的优雅跃迁

70 阅读6分钟

三数之和:从暴力到优雅的算法之旅

在编程的世界里,有些问题看似简单,却蕴含着深刻的算法智慧。比如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枚举第一个数
3left = 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;
}

🎯 六、总结:从混乱到秩序的思维跃迁

“三数之和”教会我们的,不仅是算法技巧,更是一种化繁为简的思维方式

  1. 暴力尝试 → 发现效率瓶颈
  2. 引入排序 → 利用有序性加速搜索
  3. 双指针 → 将三层循环降为两层
  4. 去重逻辑 → 确保结果干净唯一

这就像整理房间:先把东西分类(排序),再用两只手从两端往中间收(双指针),遇到重复物品就跳过(去重),最终得到整洁有序的结果。


🌈 鼓励
算法不是天赋,而是训练。
每一次“啊哈!”的顿悟,都来自对问题的反复咀嚼。
你现在看到的优雅代码,背后是无数次试错与重构。
继续写,继续想,你也能写出属于自己的“三数之和”。


愿你在代码的世界里,既有探索的勇气,也有优化的智慧。