Hot 100 --- 三数之和

0 阅读7分钟

本文概览:本文以LeetCode经典题目"三数之和"为例,系统讲解排序+双指针的解题思路,深入剖析三重去重机制和剪枝优化的实现细节,并详细解释为何固定最小元素是最优策略


一、题目

三数之和题目.png

二、题目分析

给定一个整数数组 nums,找出所有满足条件的三元组 [nums[i], nums[j], nums[k]],使得 nums[i] + nums[j] + nums[k] == 0,且 i != j、i != k、j != k

目标:返回所有和为 0 且不重复的三元组

思路概览

Java实现代码如下

public List<List<Integer>> threeSum(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    if (nums == null || nums.length < 3) {
        return res;
    }
    Arrays.sort(nums);
    for (int i = 0; i < nums.length - 2; i++) {
        if (nums[i] > 0)
            break;
        if (i > 0 && nums[i] == nums[i - 1])
            continue;
        int left = i + 1;
        int right = nums.length - 1;
        while (left < right) {
            int sum = nums[i] + nums[left] + nums[right];
            if (sum == 0) {
                res.add(List.of(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)
                left++;
            else
                right--;
        }
    }
    return res;
}

思路简要说明

  1. 排序是前提:将数组排序后,我们才能利用有序性使用双指针,同时也为去重提供了便利

  2. 固定一个,双指针找另外两个:外层循环固定 nums[i],内层用 leftright 双指针在 i 右侧的区间内寻找和为 -nums[i] 的两个数

  3. 双指针的移动逻辑

    • sum < 0,说明三数之和偏小,left++ 让和增大
    • sum > 0,说明三数之和偏大,right-- 让和减小
    • sum == 0,找到了一组解,同时需要跳过重复元素避免重复解

三、思路详解

暴力解法入手

最自然的想法是使用三重循环枚举所有可能的三元组 (i, j, k),判断三数之和是否为 0

显然可以得出以下结论

  • 时间复杂度:O(n³),效率极低
  • 核心瓶颈:做了大量无效计算,很多组合在计算之前就可以被逻辑排除
  • 关键思考:能否利用有序性,将三重循环优化为更低的时间复杂度?

排序 + 双指针解法

思路分析

由于我们需要找到三个数使它们的和为 0,如果数组是无序的,我们只能暴力枚举。但如果先将数组排序,就可以利用单调性来优化搜索过程

排序之后,我们采用固定一个数 + 双指针的策略:

  1. 外层循环固定 nums[i] :遍历数组,每次固定一个数作为三元组的第一个元素(即最小的元素)
  2. 内层双指针寻找另外两个数:在 i 右侧的区间 [left, right] 中,用左右指针向中间收缩,寻找和为 -nums[i] 的两个数

为什么固定的是三元组的第一个(最小)元素,而不是中间或右边的元素?

因为数组已经排序,三元组中的三个元素天然满足 nums[i] ≤ nums[left] ≤ nums[right]。如果我们将 nums[i] 固定为最小的那个元素,那么另外两个元素一定在 i 的右侧,搜索范围就是一个连续的区间 [i+1, n-1] ,双指针可以在这个区间上从两端向中间收缩

反过来想,如果我们固定的是中间元素 nums[left],那么比它小的元素在左边、比它大的元素在右边,搜索空间被拆成了两段,双指针就无法在一个连续区间上工作了;如果固定的是最大的元素 nums[right],同理,另外两个元素都在它左边,虽然可以工作,但这样外层循环从右往左遍历,剪枝逻辑(nums[i] > 0 直接 break)就无法使用了,因为最小的元素在最后才被固定,无法提前终止

所以,固定最小元素是最自然的选择:搜索空间连续、双指针可行、剪枝高效

为什么双指针有效?

因为数组是有序的,所以:

  • nums[i] + nums[left] + nums[right] < 0 时,说明和偏小。由于 left 右侧的数更大,所以 left++ 可以让和增大
  • nums[i] + nums[left] + nums[right] > 0 时,说明和偏大。由于 right 左侧的数更小,所以 right-- 可以让和减小

这样,内层循环就不再需要 O(n²) 的双重遍历,而是 O(n) 的双指针扫描

举例说明

nums = [-4, -1, -1, 0, 1, 2] 为例(已排序)

  • i = 0nums[i] = -4,需要找两个数之和为 4

    • left = 1, right = 5-1 + 2 = 1 < 4left++
    • left = 2, right = 5-1 + 2 = 1 < 4left++
    • left = 3, right = 50 + 2 = 2 < 4left++
    • left = 4, right = 51 + 2 = 3 < 4left++
    • left >= right,结束
  • i = 1nums[i] = -1,需要找两个数之和为 1

    • left = 2, right = 5-1 + 2 = 1,找到一组 [-1, -1, 2]
    • 跳过重复后继续...
    • left = 3, right = 40 + 1 = 1,找到一组 [-1, 0, 1]
  • i = 2nums[i] = -1,与 i = 1 时的值相同,跳过(去重)

  • i = 3nums[i] = 0,需要找两个数之和为 0

    • left = 4, right = 51 + 2 = 3 > 0right--
    • left >= right,结束
  • i = 4nums[i] = 1 > 0直接 break(剪枝)

最终结果为 [[-1, -1, 2], [-1, 0, 1]]

时间复杂度:排序 O(n log n) + 双指针遍历 O(n²) = O(n²)

四、代码细节

这道题的难点不仅在于思路,更在于去重(筛重) 的处理。如果不做去重,会产生大量重复的三元组。代码中共有三处去重一处剪枝,每一处都至关重要

1. 外层循环:跳过重复的 nums[i]

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

为什么需要?nums[i]nums[i - 1] 相同时,以 nums[i] 为固定值搜索到的所有三元组,必然已经在前一轮 nums[i - 1] 时全部搜索过了。如果不跳过,就会产生重复解

为什么判断 i > 0i == 0 时,nums[i - 1] 会越界,所以必须加 i > 0 的保护

示例nums = [-1, -1, 0, 1],当 i = 1nums[1] = -1 == nums[0],直接跳过,否则会再次找到 [-1, 0, 1]

2. 找到解后:跳过重复的 nums[left]

while (left < right && nums[left] == nums[left + 1]) left++;

为什么需要? 当找到一组解后,如果 nums[left] 与下一个 nums[left + 1] 相同,那么继续 left++ 后找到的仍然是相同的组合,会产生重复解。所以需要提前跳过所有与当前 nums[left] 相同的元素

示例nums = [-2, 0, 0, 2, 2],当 i = 0, left = 1, right = 4 时找到 [-2, 0, 2]。此时 nums[1] == nums[2],如果不跳过,left++left = 2,又会找到 [-2, 0, 2],重复!

3. 找到解后:跳过重复的 nums[right]

while (left < right && nums[right] == nums[right - 1]) right--;

为什么需要? 与跳过重复 nums[left] 同理,如果 nums[right] 与前一个 nums[right - 1] 相同,继续 right-- 后找到的仍然是相同的组合

示例:同样 nums = [-2, 0, 0, 2, 2]nums[3] == nums[4],需要跳过

4. 找到解后:最终移动指针

left++;
right--;

为什么跳过重复之后还要再移动一次? 上面的 while 循环只是把指针移到了最后一个重复元素的位置,还没有真正移动到下一个不同的元素。所以需要额外 left++right-- 来跳到新的候选值

注意顺序:必须先跳过重复,再移动指针。如果先移动指针再跳过重复,逻辑会出错,因为跳过重复的 while 循环是基于当前位置与下一个位置的比较

5. 剪枝优化:nums[i] > 0 直接 break

if (nums[i] > 0)
    break;

为什么可以? 数组已经排序,如果 nums[i] > 0,那么 nums[left]nums[right] 必然也大于 0(因为它们在 nums[i] 右边),三数之和不可能为 0。所以后续的所有 i 都无需再检查,直接 break 即可

这是 break 而不是 continue:因为数组有序,当前 nums[i] > 0 时,后面所有的 nums[i] 都更大,不可能再找到解,所以直接终止整个外层循环