四数之和 —— 剪枝思想深度剖析

0 阅读9分钟

在 LeetCode 的“N 数之和”系列中,四数之和(18. 四数之和)  是一道经典的中等难度题目。它既考察双指针的灵活运用,更考验你对剪枝去重的细致处理。很多初学者能写出 O(n³) 的暴力解法,但往往因忽略剪枝而在极端数据下超时,或因去重不当而输出重复结果。

本文将带你从零开始,不仅给出一个高效的 JavaScript 实现,更重点剖析其中的剪枝思想——为什么需要剪枝?如何设计安全且高效的剪枝条件?以及本题剪枝的局限与拓展。


题目回顾

给定一个包含 n 个整数的数组 nums 和一个目标值 target,找出所有 不重复 的四元组 [nums[a], nums[b], nums[c], nums[d]],满足:

  • 下标 a, b, c, d 互不相同;
  • nums[a] + nums[b] + nums[c] + nums[d] == target
  • 返回顺序任意。

示例:

text

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

text

输入:nums = [2,2,2,2,2], target = 8
输出:[[2,2,2,2]]

基础思路:排序 + 双指针

解决四数之和,最通用的方法是将其转化为“三数之和”的子问题,而三数之和又基于“两数之和”的双指针法。具体流程:

  1. 排序:将数组升序排列,这是双指针能够正确移动的前提。
  2. 固定前两个数:使用两层循环分别枚举第一个数 nums[k] 和第二个数 nums[i]i > k)。
  3. 双指针扫描:在剩余区间 [i+1, n-1] 内,用 left 和 right 指针寻找两个数,使得四数之和等于 target
  4. 去重:在每一层循环以及双指针内部,跳过重复元素,保证结果唯一。

时间复杂度 O(n³),空间复杂度 O(1)(不计结果存储)。对于 n ≤ 200,O(n³) 约为 8e6 次运算,尚可接受,但若不加以剪枝,在大量数据或特殊构造下仍可能效率低下。


剪枝:算法优化的灵魂

剪枝(Pruning)是指在搜索过程中,提前判断某些分支不可能产生有效解,从而直接跳过,减少不必要的计算。在四数之和中,剪枝主要发生在两层循环的入口处,目的是尽早跳过不可能的目标。

为什么需要剪枝?

  • 避免无效迭代:当数组很大时,即便 O(n³) 也可能达到千万级操作,加上 JavaScript 的执行效率,剪枝能显著提升运行速度。
  • 处理特殊数据:例如全部为正数且 target 较小,或全部为负数且 target 较大,若不剪枝,内层循环会大量空跑。
  • 笔试/面试加分:写出带剪枝的代码,体现你对算法效率的思考。

常见的剪枝策略

  1. 最小可能和剪枝:如果当前固定的前两个数(或第一个数)之后,最小的四数和已经大于 target,且后续数只会更大,则可以直接跳出循环。
  2. 最大可能和剪枝:如果当前最大的四数和(即最后四个数)仍小于 target,则可以跳过当前固定的数,尝试下一个更大的数。
  3. 符号剪枝:基于 target 的正负和当前数的大小关系,快速判断是否可能达到目标。

本题实现中主要采用了符号剪枝,但我们也将在后文讨论如何综合运用最小/最大和剪枝。


代码中的剪枝详解

让我们先看完整代码,然后再逐条分析剪枝条件:

var fourSum = function(nums, target) {
    nums.sort((a, b) => a - b);
    const res = [];
    const n = nums.length;

    for (let k = 0; k < n - 3; k++) {
        // 剪枝 ①
        if (nums[k] > target && nums[k] > 0 && target > 0) break;
        // 去重
        if (k > 0 && nums[k] === nums[k - 1]) continue;

        for (let i = k + 1; i < n - 2; i++) {
            // 剪枝 ②
            if (nums[i] > target && nums[i] > 0 && target > 0) break;
            // 去重
            if (i > k + 1 && nums[i] === nums[i - 1]) continue;

            let left = i + 1, right = n - 1;
            while (left < right) {
                const sum = nums[k] + nums[i] + nums[left] + nums[right];
                if (sum < target) left++;
                else if (sum > target) right--;
                else {
                    res.push([nums[k], nums[i], nums[left], nums[right]]);
                    // 去重
                    while (left < right && nums[left] === nums[left + 1]) left++;
                    left++;
                    while (left < right && nums[right] === nums[right - 1]) right--;
                    right--;
                }
            }
        }
    }
    return res;
};

剪枝 ①:外层循环

if (nums[k] > target && nums[k] > 0 && target > 0) break;

逻辑解析

  • 条件成立时,直接跳出整个外层循环(break),而不是 continue

  • 三个子条件同时满足:

    1. nums[k] > target:当前数已经比目标值大。
    2. nums[k] > 0:当前数是正数。
    3. target > 0:目标值为正数。

为什么安全?

  • 因为数组已排序,当 k 递增时,nums[k] 只会越来越大。
  • 如果 nums[k] 本身大于正数 target,那么加上后面任何三个数(它们都 ≥ nums[k])后,四数和必然 > target,不可能等于 target
  • 所以可以终止整个外层循环。

为什么需要 target > 0 和 nums[k] > 0

  • 若 target 为负数,可能出现 nums[k] > target 但后续负数相加后反而更小的情况。例如 nums = [-5, -4, -3]target = -10nums[0] = -5 大于 -10,但 -5 + (-4) + (-3) + ... 可以等于 -10(尽管这里长度不够,但概念上)。因此,只有在 target > 0 且 nums[k] > 0 时,上述单调性才成立。

注意:这个剪枝条件并不完备。例如 nums = [1, 2, 3, 4, 5]target = 6nums[0] = 1 不大于 6,不会触发;但 k=1 时 nums[1]=2 也不触发;直到 k=2nums[2]=3,仍然 3 > 6 为 false,所以会一直循环到 k=n-4,虽然这些循环可能因为 sum 始终大于 target 而很快被双指针剪掉,但外层仍会执行。因此该剪枝仅针对 target 为正且当前数已大于 target 的特殊情况。

剪枝 ②:内层循环

if (nums[i] > target && nums[i] > 0 && target > 0) break;

逻辑:同理,当固定了第一个数 nums[k] 后,第二个数 nums[i] 若大于正数 target,则后续四数和必然更大,可以提前结束内层循环。

注意:这里用的是 break 而不是 continue,因为 i 递增时 nums[i] 只会增大,所以一旦满足条件,后续的 i 也都满足,可以直接跳出内层循环。


剪枝的局限与更优方案

上述剪枝条件虽然简单有效,但只覆盖了部分情形。更通用的做法是结合当前最小四数和当前最大四数和

  • 最小四数和:固定 k 后,最小的四数和为 nums[k] + nums[k+1] + nums[k+2] + nums[k+3]。若该值 > target,则对于当前 k 及更大的 k,四数和只会更大(因为数组升序),因此可以 break 外层循环。
  • 最大四数和:固定 k 后,最大的四数和为 nums[k] + nums[n-3] + nums[n-2] + nums[n-1]。若该值 < target,则当前 k 太小,无法达到目标,可以 continue 到下一个 k(因为更大的 k 可能增大和)。

将这两个条件加入,可以更精确地剪枝,特别是处理混合正负数的情况。然而,它们需要额外的数组索引计算,但开销很小。

改进后的代码片段(仅外层循环示例):

for (let k = 0; k < n - 3; k++) {
    // 最小和剪枝
    const minSum = nums[k] + nums[k+1] + nums[k+2] + nums[k+3];
    if (minSum > target) break;
    // 最大和剪枝
    const maxSum = nums[k] + nums[n-3] + nums[n-2] + nums[n-1];
    if (maxSum < target) continue;
    // ... 后续去重和内层循环
}

这种方法更加稳健,无论 target 正负都能工作,且能显著减少循环次数。但需要额外注意数组边界(n-3 等索引必须有效,因为 k < n-3,所以 k+3 <= n-1 成立)。


剪枝 vs 去重:不要混淆

剪枝和去重是两码事:

  • 去重:保证结果中不出现相同的四元组,通过跳过相同数值的枚举来实现。
  • 剪枝:提高效率,不改变结果的正确性。

两者在代码中位置不同,但常常写在一起。务必分清各自的作用,避免误将剪枝条件当作去重使用(例如用 continue 代替 break 可能导致遗漏某些情况)。


性能对比(直观感受)

假设 nums 为 [1, 2, 3, ..., 200]target = 1000。无剪枝时,外层循环约 197 次,内层循环约 196 次,每次双指针扫描约 O(200),总计近 8e6 次操作。有最小/最大和剪枝后,当 k 增大到一定程度,最小四数和会迅速超过 1000,此时外层循环直接 break,可能只执行前几十次,大大提速。

对于本题的给定约束(n ≤ 200),即使没有剪枝,JavaScript 也能在几十毫秒内完成,但剪枝体现了对算法细节的追求,也是面试官欣赏的亮点。


总结

四数之和的剪枝核心在于利用数组排序后的单调性,提前终止不可能产生解的分支。本文实现的剪枝条件 nums[k] > target && nums[k] > 0 && target > 0 是一种简单而有效的启发式剪枝,适用于正数目标场景。更通用且更强力的做法是引入当前最小四数和最大四数和的剪枝,能覆盖所有情况。

无论采用哪种剪枝,都必须保证安全性——即剪枝后不会漏掉任何有效解。理解这一点,你就能在类似问题(如三数之和、N 数之和)中灵活设计自己的剪枝策略。

希望这篇文章能帮助你吃透四数之和的剪枝思想,并在今后的算法实践中举一反三。


附:带更全面剪枝的完整代码(推荐使用):

var fourSum = function(nums, target) {
    nums.sort((a, b) => a - b);
    const res = [];
    const n = nums.length;
    if (n < 4) return res;

    for (let k = 0; k < n - 3; k++) {
        // 最小和剪枝
        if (nums[k] + nums[k+1] + nums[k+2] + nums[k+3] > target) break;
        // 最大和剪枝
        if (nums[k] + nums[n-3] + nums[n-2] + nums[n-1] < target) continue;
        // 去重
        if (k > 0 && nums[k] === nums[k-1]) continue;

        for (let i = k + 1; i < n - 2; i++) {
            // 最小和剪枝(固定k后)
            if (nums[k] + nums[i] + nums[i+1] + nums[i+2] > target) break;
            // 最大和剪枝(固定k后)
            if (nums[k] + nums[i] + nums[n-2] + nums[n-1] < target) continue;
            // 去重
            if (i > k + 1 && nums[i] === nums[i-1]) continue;

            let left = i + 1, right = n - 1;
            while (left < right) {
                const sum = nums[k] + nums[i] + nums[left] + nums[right];
                if (sum < target) left++;
                else if (sum > target) right--;
                else {
                    res.push([nums[k], nums[i], nums[left], nums[right]]);
                    while (left < right && nums[left] === nums[left+1]) left++;
                    left++;
                    while (left < right && nums[right] === nums[right-1]) right--;
                    right--;
                }
            }
        }
    }
    return res;
};

这个版本在 LeetCode 上同样 AC,且剪枝更彻底,推荐作为标准实现。