三数之和

数组 双指针 排序

题目描述

给定一个整数数组 nums,找出数组中所有和为 0 的三元组 [nums[i], nums[j], nums[k]]

要求:

  • 返回的结果中不能包含重复的三元组。
  • 三元组的顺序可以是任意的。

思路分析

这道题的核心难点在于:如何高效地找到所有满足条件的三元组,并避免产生重复结果

暴力解法的局限

最直观的想法是三层循环暴力枚举所有可能的三元组,时间复杂度 O(n³)。但这种方法不仅效率低,而且去重逻辑复杂(需要用 Set 或者排序后判断),不适合大规模数据。

排序 + 双指针优化

通过将问题转化为"固定一个数,然后在剩余数组中寻找两数之和"的形式,可以将时间复杂度降低到 O(n²)

算法核心思想

  1. 先排序:将数组从小到大排序,这样做有三个好处:

    • 可以使用双指针技巧。
    • 方便去重(相同的数字会相邻)。
    • 可以提前终止循环(当固定的数字 > 0 时,后面都是正数,和不可能为 0)。
  2. 固定第一个数:外层循环遍历数组,固定第一个数 nums[i]

  3. 双指针寻找另外两个数:在 i 右侧的子数组中,用双指针从两端向中间收缩,寻找和为 -nums[i] 的两个数:

    • 左指针 Li + 1 开始。
    • 右指针 R 从数组末尾开始。
    • 计算 sum = nums[i] + nums[L] + nums[R]
      • sum = 0,找到一个三元组,记录结果,并同时移动 LR
      • sum < 0,说明和太小,需要增大,移动左指针 L++
      • sum > 0,说明和太大,需要减小,移动右指针 R--

去重策略

为了避免重复的三元组,需要在两个地方进行去重:

  1. 外层循环去重:如果当前数字与前一个数字相同,跳过,避免重复固定相同的第一个数。

    if (i > 0 && nums[i] === nums[i - 1]) continue;
  2. 内层循环去重:找到一组解后,跳过所有与当前 LR 相同的元素。

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

剪枝优化

由于数组已排序,当固定的第一个数 nums[i] > 0 时,后面的数字都大于 0,三个正数的和不可能为 0,可以直接退出循环。

算法步骤

  1. 如果数组长度小于 3,直接返回空数组。
  2. 对数组从小到大排序。
  3. 外层循环遍历数组(i 从 0 到 n - 2):
    • 如果 nums[i] > 0,直接退出循环(剪枝)。
    • 如果 nums[i] 与前一个数字相同,跳过(去重)。
    • 初始化双指针:L = i + 1R = n - 1
    • 内层循环,当 L < R 时:
      • 计算 sum = nums[i] + nums[L] + nums[R]
      • 如果 sum = 0,记录结果,跳过重复元素,同时移动 LR
      • 如果 sum < 0L++(需要增大和)。
      • 如果 sum > 0R--(需要减小和)。
  4. 返回结果数组。

示例代码

/**
 * 找出数组中所有和为 0 的三元组
 * 使用排序 + 双指针算法,时间复杂度 O(n²)
 *
 * @param nums - 输入的整数数组
 * @returns 所有和为 0 的三元组数组,不包含重复的三元组
 *
 * @example
 * ```ts
 * threeSum([-1, 0, 1, 2, -1, -4]) // [[-1, -1, 2], [-1, 0, 1]]
 * threeSum([0, 0, 0]) // [[0, 0, 0]]
 * threeSum([0, 1, 1]) // []
 * ```
 */
export function threeSum(nums: number[]): number[][] {
    // 边界判断:如果数组长度小于 3,无法组成三元组,直接返回空数组
    if (nums.length < 3) {
        return [];
    }

    // 用于存储所有满足条件的三元组
    const res: number[][] = [];
    // 缓存数组长度,避免重复计算
    const len = nums.length;

    // 先对数组从小到大排序
    // 排序的好处:1. 可以使用双指针 2. 方便去重 3. 可以提前终止循环
    nums.sort((a, b) => a - b);

    // 外层循环:固定第一个数 nums[i]
    // 注意:i 只需要遍历到 len - 2,因为后面至少要留两个位置给 L 和 R
    for (let i = 0; i < len; i++) {
        // 优化:如果当前数字大于 0,由于数组已排序,后面的数字都 > 0
        // 三个正数的和不可能为 0,所以直接退出循环
        if (nums[i] > 0) break;

        // 去重:如果当前数字与前一个数字相同,跳过
        // 避免产生重复的三元组,例如 [-1, -1, 2] 只需要找一次
        if (i > 0 && nums[i] === nums[i - 1]) continue;

        // 定义左右双指针
        // L 指向 i 的下一个位置(左边界)
        let L = i + 1;
        // R 指向数组的最后一个位置(右边界)
        let R = len - 1;

        // 双指针向中间收缩,寻找满足条件的三元组
        while (L < R) {
            // 计算三个数的和
            const sum = nums[i] + nums[L] + nums[R];

            // 情况 1:和为 0,找到一个满足条件的三元组
            if (sum === 0) {
                // 将这个三元组加入结果数组
                res.push([nums[i], nums[L], nums[R]]);

                // 去重:跳过所有与当前 L 相同的元素
                // 例如:[-2, 0, 0, 2, 2],找到 [-2, 0, 2] 后,跳过重复的 0 和 2
                while (L < R && nums[L] === nums[L + 1]) L++;
                // 去重:跳过所有与当前 R 相同的元素
                while (L < R && nums[R] === nums[R - 1]) R--;

                // 同时移动双指针,继续寻找下一组解
                L++;
                R--;
            }
            // 情况 2:和小于 0,说明数值太小,需要增大
            // 由于数组已排序,移动左指针可以增大和
            else if (sum < 0) {
                L++;
            }
            // 情况 3:和大于 0,说明数值太大,需要减小
            // 移动右指针可以减小和
            else {
                R--;
            }
        }
    }

    // 返回所有满足条件的三元组
    return res;
}

复杂度分析

  • 时间复杂度O(n²)

    • 排序:O(n log n)
    • 外层循环:O(n)
    • 内层双指针:每次 O(n)
    • 总体:O(n log n) + O(n²) = O(n²)
  • 空间复杂度O(log n)

    • 排序算法(如快速排序)的递归栈空间。
    • 不计算结果数组的空间。

示例演示

示例 1

输入:nums = [-1, 0, 1, 2, -1, -4]

第一步:排序

[-4, -1, -1, 0, 1, 2]

第二步:遍历 + 双指针

轮次i (固定)LRsum操作结果
1nums[0] = -415-4 + (-1) + 2 = -3L++-
2nums[0] = -425-4 + (-1) + 2 = -3L++-
3nums[0] = -435-4 + 0 + 2 = -2L++-
4nums[0] = -445-4 + 1 + 2 = -1L++-
5nums[0] = -455L >= R 结束--
6nums[1] = -125-1 + (-1) + 2 = 0找到 [-1,-1,2],跳过重复,L++, R--[-1,-1,2]
7nums[1] = -134-1 + 0 + 1 = 0找到 [-1,0,1]L++, R--[-1,0,1]
8nums[1] = -143L >= R 结束--
9nums[2] = -1--与前一个数字相同,跳过--
10nums[3] = 0450 + 1 + 2 = 3R---
11nums[3] = 044L >= R 结束--
12nums[4] = 155L >= R 结束--

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

示例 2

输入:nums = [0, 0, 0]

排序后仍为 [0, 0, 0]

轮次i (固定)LRsum操作结果
1nums[0] = 0120 + 0 + 0 = 0找到 [0,0,0][0,0,0]

输出[[0, 0, 0]]

示例 3

输入:nums = [0, 1, 1]

排序后仍为 [0, 1, 1]

轮次i (固定)LRsum操作结果
1nums[0] = 0120 + 1 + 1 = 2R---
2nums[0] = 011L >= R 结束--
3nums[1] = 1--nums[i] > 0,退出循环--

输出[]

关键要点

  1. 排序是前提:排序后才能使用双指针,并方便去重和剪枝。
  2. 双指针的移动规则:根据和的大小决定移动哪个指针,这是算法的核心。
  3. 去重要在两个地方:外层循环跳过相同的固定值,内层循环跳过相同的 LR
  4. 剪枝优化:当固定的数字 > 0 时,直接退出,避免无效计算。