LEETCODE 015Medium

三数之和

排序把“找组合”变成“找有序对”,而去重不靠哈希集合,靠的是在三个位置各跳过一次重复值。

问题拆解

在数组里找出所有和为 0 的三元组,要求结果不含重复的三元组。

三重循环枚举是 O(n³),且去重要么排序每个三元组塞进集合,要么逐个比对,都很笨重。突破口是先把数组排序:固定第一个数 nums[i] 后,问题退化成“在 i 右侧的有序区间里找两数之和等于 -nums[i]”——有序数组上的两数之和用左右双指针 O(n) 就能解决:和太小左指针右移,和太大右指针左移。

排序换来两件事:双指针能按大小关系单向移动,重复元素相邻排列——去重只需比较邻居。

去重恰恰是这道题的主要难点,它分散在三个位置:固定的 i、左指针 left、右指针 right,漏掉任何一处都会产出重复三元组。

排序 + 固定一数 + 双指针

public List<List<Integer>> threeSum(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> res = new ArrayList<>();
    int n = nums.length;
    for (int i = 0; i < n - 2; i++) {
        if (nums[i] > 0) break; // 最小的数已大于 0,后面不可能凑出 0
        if (i > 0 && nums[i] == nums[i - 1]) continue; // 去重一:跳过重复的固定数
        int left = i + 1, right = n - 1;
        while (left < right) {
            int sum = nums[i] + nums[left] + nums[right];
            if (sum < 0) {
                left++;
            } else if (sum > 0) {
                right--;
            } else {
                res.add(List.of(nums[i], nums[left], nums[right]));
                left++;
                right--;
                // 去重二、三:命中后跳过两侧的重复值
                while (left < right && nums[left] == nums[left - 1]) left++;
                while (left < right && nums[right] == nums[right + 1]) right--;
            }
        }
    }
    return res;
}
def threeSum(nums: list[int]) -> list[list[int]]:
    nums.sort()
    res = []
    n = len(nums)
    for i in range(n - 2):
        if nums[i] > 0:
            break  # 最小的数已大于 0,后面不可能凑出 0
        if i > 0 and nums[i] == nums[i - 1]:
            continue  # 去重一:跳过重复的固定数
        left, right = i + 1, n - 1
        while left < right:
            s = nums[i] + nums[left] + nums[right]
            if s < 0:
                left += 1
            elif s > 0:
                right -= 1
            else:
                res.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                # 去重二、三:命中后跳过两侧的重复值
                while left < right and nums[left] == nums[left - 1]:
                    left += 1
                while left < right and nums[right] == nums[right + 1]:
                    right -= 1
    return res
func threeSum(nums []int) [][]int {
    slices.Sort(nums)
    res := [][]int{}
    n := len(nums)
    for i := 0; i < n-2; i++ {
        if nums[i] > 0 {
            break // 最小的数已大于 0,后面不可能凑出 0
        }
        if i > 0 && nums[i] == nums[i-1] {
            continue // 去重一:跳过重复的固定数
        }
        left, right := i+1, n-1
        for left < right {
            sum := nums[i] + nums[left] + nums[right]
            switch {
            case sum < 0:
                left++
            case sum > 0:
                right--
            default:
                res = append(res, []int{nums[i], nums[left], nums[right]})
                left++
                right--
                // 去重二、三:命中后跳过两侧的重复值
                for left < right && nums[left] == nums[left-1] {
                    left++
                }
                for left < right && nums[right] == nums[right+1] {
                    right--
                }
            }
        }
    }
    return res
}
pub fn three_sum(nums: Vec<i32>) -> Vec<Vec<i32>> {
    let mut nums = nums;
    nums.sort_unstable();
    let mut res = Vec::new();
    let n = nums.len();
    if n < 3 {
        return res;
    }
    for i in 0..n - 2 {
        if nums[i] > 0 {
            break; // 最小的数已大于 0,后面不可能凑出 0
        }
        if i > 0 && nums[i] == nums[i - 1] {
            continue; // 去重一:跳过重复的固定数
        }
        let (mut left, mut right) = (i + 1, n - 1);
        while left < right {
            let sum = nums[i] + nums[left] + nums[right];
            if sum < 0 {
                left += 1;
            } else if sum > 0 {
                right -= 1;
            } else {
                res.push(vec![nums[i], nums[left], nums[right]]);
                left += 1;
                right -= 1;
                // 去重二、三:命中后跳过两侧的重复值
                while left < right && nums[left] == nums[left - 1] {
                    left += 1;
                }
                while left < right && nums[right] == nums[right + 1] {
                    right -= 1;
                }
            }
        }
    }
    res
}

三处去重各有讲究。去重一比较的是 nums[i]nums[i-1](前一个位置),而不是 nums[i+1]——写成后者会把 [-1, -1, 2] 这类“固定数自身重复但三元组合法”的答案错杀。去重二、三放在命中之后执行:先把这组答案收下、指针各走一步,再跳过与刚才相同的值;若把跳过逻辑放在移动之前,leftleft - 1 比较的还是同一组答案里的数,边界很容易乱。另外 nums[i] > 0 的剪枝依赖排序后固定数是三者最小:连最小的都是正数,三数之和必大于 0,可以直接收工。

复杂度

指标 复杂度 原因
时间 O(n²) 排序 O(n log n),外层固定一数 O(n),内层双指针合计 O(n)
空间 O(log n) 排序的栈开销,结果数组不计

可以迁移的模式

  • k 数之和的通用降维法:排序后固定前 k-2 个数,最内层交给双指针,四数之和(18)同款;
  • 有序数组上的去重不需要哈希集合,跳过相邻重复值就够,还是 O(1) 空间;
  • “固定一端 + 双指针夹逼”要求单调性支撑指针的单向移动,这正是排序买来的性质。

把“去重”从事后过滤变成枚举过程中的跳过,是这道题从会做到写对的分水岭。