三数之和:从暴力破解到优雅双指针

59 阅读6分钟

三数之和:从暴力破解到优雅双指针

作为一个算法爱好者,我曾经在深夜的咖啡馆里,面对三数之和问题,差点把咖啡洒在键盘上。但经过一番思考,我发现排序+双指针的组合,就像给算法装上了火箭推进器,瞬间起飞!今天,让我们一起探索这个经典算法题,看看如何从暴力破解的泥潭中跳出来,走向优雅的双指针世界。

一、暴力破解:效率低到让人想哭

想象一下,你有1000个数字,要找出所有三个数之和等于目标值的组合。最直接的思路是用三个嵌套循环:

for (let i = 0; i < nums.length; i++) {
  for (let j = i + 1; j < nums.length; j++) {
    for (let k = j + 1; k < nums.length; k++) {
      if (nums[i] + nums[j] + nums[k] === target) {
        // 找到结果
      }
    }
  }
}

这种暴力方法的时间复杂度是 O(n³) 。对于1000个数字,我们需要尝试大约 166,167,000 次操作(C(1000,3))。

效率有多低? 让我们用一个生动的比喻:

你有1000个朋友,想找出所有生日加起来是1000的三人组。如果用暴力方法,你得尝试 C(1000,3) = 166,167,000 种组合。这相当于在1000个朋友中,尝试所有可能的三人组,直到找到满足条件的组合。你可能会想:"这比我在餐厅点餐还要麻烦!"

更糟糕的是,如果输入规模是10,000,暴力方法需要尝试约 166,670,000,000 次操作。即使你的电脑每秒能处理10亿次操作,也需要166秒。而如果输入是100万,那需要 166,666,666,666,666 次操作,相当于 5270年!这可不是我们想要的算法。

二、优化思路:先排序,再用双指针

核心思想: 先对数组排序,然后固定一个数字,用两个指针分别从两端向中间移动,高效地查找满足条件的组合。

为什么这个方法有效?因为排序后,我们可以利用数组的有序性,通过移动指针来高效地找到满足条件的组合。

为什么排序能提高效率?

排序后,数组是升序排列的。固定一个数字后,我们可以:

  1. 从固定数字的下一个位置开始,用 left 指针
  2. 从数组末尾开始,用 right 指针
  3. 根据当前和与目标值的大小关系,调整指针位置

关键点:

  • 如果当前和 sum > 0,说明需要减小和,right 指针左移
  • 如果当前和 sum < 0,说明需要增大和,left 指针右移
  • 如果当前和 sum = 0,找到一组解,加入结果集,并跳过重复数字

代码实现详解

function threeSum(nums) {
  // 先排序,时间复杂度 O(nlogn)
  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]]);
        
        // 跳过重复的数字
        while (left < right && nums[left] === nums[left + 1]) {
          left++;
        }
        while (left < right && nums[right] === nums[right - 1]) {
          right--;
        }
        
        left++;
        right--;
      } else if (sum > 0) {
        right--;
      } else {
        left++;
      }
    }
  }
  return res;
}

执行过程演示:

假设输入 nums = [2, 1, 6, 3, 4, 5],目标值为 0。

  1. 先排序:[1, 2, 3, 4, 5, 6]

  2. 固定第一个数字 1left 指向 2right 指向 6

    • 1 + 2 + 6 = 9 > 0 → right 左移
    • 1 + 2 + 5 = 8 > 0 → right 左移
    • ...
    • 1 + 2 + 3 = 6 > 0 → right 左移
    • 没有找到和为 0 的组合
  3. 固定 2left 指向 3right 指向 6

    • 2 + 3 + 6 = 11 > 0 → right 左移
    • 2 + 3 + 5 = 10 > 0 → right 左移
    • ...
    • 2 + 3 + 4 = 9 > 0 → right 左移
    • 没有找到和为 0 的组合
  4. 固定 3left 指向 4right 指向 6

    • 3 + 4 + 6 = 13 > 0 → right 左移
    • 3 + 4 + 5 = 12 > 0 → right 左移
    • 没有找到和为 0 的组合
  5. 固定 4left 指向 5right 指向 6

    • 4 + 5 + 6 = 15 > 0 → right 左移
    • 没有找到和为 0 的组合

最终,对于这个输入,没有三数之和为 0 的组合。

三、sort方法:排序的艺术

在JavaScript中,sort() 方法用于对数组进行排序。默认情况下,sort() 方法会将元素转换为字符串,然后按字符串的 Unicode 顺序进行排序。

const nums = [2, 1, 6, 3, 4, 5];
nums.sort((a, b) => { console.log(a, b); return a - b; });

比较函数解释:

  • a - b < 0a 应该排在 b 前面(升序)
  • a - b > 0a 应该排在 b 后面(降序)
  • a - b = 0a 和 b 顺序不变

在我们的三数之和问题中,我们使用 nums.sort((a, b) => a - b) 来对数组进行升序排序,这样我们可以方便地使用双指针。

排序的效率:

  • JavaScript 的 sort() 方法通常使用快速排序或归并排序,时间复杂度为 O(nlogn)
  • 这比暴力方法的 O(n³)  低得多,是优化的关键

四、为什么这个方法优雅?

  1. 效率高:  时间复杂度为 O(n²) (排序 O(nlogn) + 双指针 O(n²))
  2. 避免重复:  通过排序和指针移动,自然避免了重复的三元组
  3. 空间效率好:  只需要常数空间来存储指针和结果

优化对比:

方法时间复杂度空间复杂度适用场景
暴力破解O(n³)O(1)小规模输入
排序+双指针O(n²)O(1)大规模输入

五、幽默小结

三数之和问题告诉我们:排序不是为了好看,而是为了效率。 就像在超市购物,如果你先按商品类别排序,找东西会快很多,而不是在货架上乱翻。

有一次,我问一个朋友:"你为什么总是用暴力方法解题?" 他回答:"因为我想试试运气。" 我说:"那下次试试排序+双指针吧,保证让你的代码跑得比你的咖啡还快!"

三数之和问题的优雅之处,在于它教会我们:有时候,把问题'排序'一下,答案就自然浮现了。 这就像把散落的乐高积木按颜色和形状分类,很快就能找到匹配的组合。

六、实际应用

三数之和问题在现实世界中有许多应用,例如:

  1. 金融领域:  找出三个股票的组合,使得它们的总回报率等于目标值
  2. 推荐系统:  找出三个商品的组合,使得它们的推荐度之和达到某个阈值
  3. 生物信息学:  找出三个基因的组合,使得它们的表达量之和满足某种条件

七、结语

三数之和问题是一个经典的算法题,它告诉我们:在面对复杂问题时,不要急于动手,先想一想有没有更优雅的解决方案。

排序+双指针的思路,就像给算法装上了火箭推进器,从 O(n³) 的泥潭中跳出来,飞向 O(n²) 的高效世界。

下次当你面对一个看起来很复杂的问题时,先想想:能不能先排序?也许你会发现一个更优雅的解决方案。

记住:在算法的世界里,排序常常是解决复杂问题的钥匙,而双指针则是打开这把钥匙的完美工具。

现在,去试试看吧!也许你的下一个算法题,会因为一个简单的排序,而变得无比优雅!