三数之和题解:从暴力枚举到双指针的艺术

45 阅读5分钟

引言

在 LeetCode 的浩瀚题海中,《三数之和》(Three Sum) 15. 三数之和 - 力扣(LeetCode) 是一道看似平凡却极具教学价值的经典题。它不考复杂数据结构,也不依赖高深数学,却精准地考察了你对问题转化、时间复杂度优化、边界控制与去重逻辑的综合掌控能力。

本文将带你完成一次完整的思维跃迁:从最朴素的暴力解法出发,逐步拆解瓶颈,最终抵达那个优雅高效的 O(n²) 解法——并在此过程中,揭示“排序 + 双指针”这一组合为何能成为解决多数之和类问题的黄金范式。


一、暴力解法:为什么它注定失败?

最直觉的思路是三层嵌套循环:

for (i) for (j > i) for (k > j) if (nums[i] + nums[j] + nums[k] === 0) ...
  • 时间复杂度O(n³) —— 当 n = 1000 时,操作次数高达 10⁹,远超 1 秒时限。
  • 重复问题:即使找到所有三元组,还需用 Set 或哈希表去重,空间开销大,代码臃肿。
  • 根本缺陷:暴力法没有利用任何数组结构信息,纯粹穷举,效率低下。

💡 关键洞察:当问题涉及“多个元素组合满足某种和条件”时,若能固定部分变量,往往可转化为更简单的子问题。


二、核心思想:降维打击 —— 从“三数之和”到“两数之和”

我们重新审视目标:
寻找 a + b + c = 0 ⇨ 等价于 b + c = -a

如果我们先固定 a,问题就变成了:在剩余数组中找两个数,使其和为 -a —— 这正是经典的「两数之和」问题!

但注意:普通的「两数之和」可用哈希表 O(n) 解决,但本题要求返回所有不重复三元组,且不能使用额外索引映射(因元素可重复)。此时,排序 + 双指针 成为最优选择。


三、为什么排序是破局关键?

对数组排序后,带来两大优势:

  1. 天然支持去重:相同值连续排列,跳过重复只需比较相邻元素。
  2. 支持有序搜索:双指针可根据当前和的大小,确定性地移动以逼近目标。

🌰 举例:[-1, 0, 1, 2, -1, -4] → 排序后 [-4, -1, -1, 0, 1, 2]
此时,若固定 a = -1,只需在 [ -1, 0, 1, 2 ] 中找和为 1 的两个数。


四、双指针:如何高效搜索“两数之和”?

在已排序的子数组中:

  • 左指针 L = i+1,右指针 R = n-1

  • 计算 sum = a + nums[L] + nums[R]

    • sum == 0 → 找到答案,记录并同时移动双指针
    • sum < 0 → 和太小,需增大 → L++
    • sum > 0 → 和太大,需减小 → R--

为什么能保证不漏解?
因为数组有序,对于固定的 a,所有可能的 (b, c) 组合构成一个单调二维平面。双指针相当于从右上角开始“爬山”,每一步都排除一行或一列,覆盖全部可能性。


五、去重:三重防线,杜绝重复三元组

这是本题最容易出错的地方。必须在三个层级进行去重:

1️⃣ 基准数去重(外层循环)

if (i > 0 && nums[i] === nums[i - 1]) continue;

避免以相同值作为 a 重复启动搜索。

2️⃣ 左指针去重(找到解后)

while (L < R && nums[L] === nums[L + 1]) L++;

3️⃣ 右指针去重(找到解后)

while (L < R && nums[R] === nums[R - 1]) R--;

⚠️ 注意:去重要在记录结果之后进行!否则会跳过有效解。


六、完整代码实现(JavaScript)

var threeSum = function(nums) {
    const res = [];
    const n = nums.length;
    if (n < 3) return res;

    nums.sort((a, b) => a - b); // 关键第一步:排序

    for (let i = 0; i < n - 2; i++) {
        // 优化:最小值 > 0,后续不可能和为0
        if (nums[i] > 0) break;

        // 基准数去重
        if (i > 0 && nums[i] === nums[i - 1]) continue;

        let L = i + 1, R = n - 1;
        while (L < R) {
            const sum = nums[i] + nums[L] + nums[R];
            if (sum === 0) {
                res.push([nums[i], nums[L], nums[R]]);

                // 左右指针去重
                while (L < R && nums[L] === nums[L + 1]) L++;
                while (L < R && nums[R] === nums[R - 1]) R--;

                L++;
                R--;
            } else if (sum < 0) {
                L++;
            } else {
                R--;
            }
        }
    }

    return res;
};

七、复杂度分析

项目复杂度说明
时间O(n²)排序 O(n log n) + 外层 O(n) × 内层双指针 O(n)
空间O(log n) ~ O(n)取决于排序算法(V8 引擎通常为快排,栈空间 O(log n)

📌 对比暴力法:从 10⁹ 操作降至 10⁶,提升千倍以上!


八、示例走查:眼见为实

输入:[-1, 0, 1, 2, -1, -4]
排序后:[-4, -1, -1, 0, 1, 2]

  • i=0(-4):无解(最大和 = -4+1+2 = -1 < 0)

  • i=1(-1):

    • L=2(-1), R=5(2) → sum=0 → [-1,-1,2]
    • 去重后 L=3(0), R=4(1) → sum=0 → [-1,0,1]
  • i=2(-1):与前一个相同,跳过

  • i=3(0):0 > 0 不成立,但后续 0+1+2=3>0,无解

  • i>=3nums[i] >= 0,且 i=3 时已无法构成和为0,提前终止

✅ 输出:[[-1,-1,2], [-1,0,1]]


九、常见陷阱 & 工程建议

  1. 忘记边界检查n < 3 直接返回空。
  2. 去重顺序错误:必须先 push 再去重,否则漏解。
  3. 未利用排序性质提前终止:当 nums[i] > 0 时,后续全为正,和不可能为0。
  4. 指针越界:所有 while 去重必须包含 L < R 条件。

🔧 工程思维延伸:该模式可无缝扩展至「四数之和」——固定两个数,内层双指针;甚至「K 数之和」可用递归 + 双指针通解。


十、总结:不止于一道题

《三数之和》的价值,远不止于通过一次面试。它教会我们:

  • 问题转化:复杂问题 → 熟悉子问题(三数 → 两数)
  • 结构利用:排序不是目的,而是为高效搜索和去重服务
  • 指针艺术:双指针不仅是技巧,更是对“有序性”的极致利用
  • 边界意识:算法正确性 = 逻辑 + 边界 + 去重

当你下次面对“多数之和”、“窗口匹配”、“有序数组查找”等问题时,不妨回想这个经典模型——排序 + 双指针 + 三重去重,或许就是那把打开新世界的钥匙。


📢 互动时间:你在刷这道题时踩过哪些坑?是否尝试过其他解法(如哈希表)?欢迎在评论区分享你的思考!
👍 如果觉得有收获,别忘了点赞 + 收藏 + 转发,让更多人看到这份清晰的解题指南!