在 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]]
基础思路:排序 + 双指针
解决四数之和,最通用的方法是将其转化为“三数之和”的子问题,而三数之和又基于“两数之和”的双指针法。具体流程:
- 排序:将数组升序排列,这是双指针能够正确移动的前提。
- 固定前两个数:使用两层循环分别枚举第一个数
nums[k]和第二个数nums[i](i > k)。 - 双指针扫描:在剩余区间
[i+1, n-1]内,用left和right指针寻找两个数,使得四数之和等于target。 - 去重:在每一层循环以及双指针内部,跳过重复元素,保证结果唯一。
时间复杂度 O(n³),空间复杂度 O(1)(不计结果存储)。对于 n ≤ 200,O(n³) 约为 8e6 次运算,尚可接受,但若不加以剪枝,在大量数据或特殊构造下仍可能效率低下。
剪枝:算法优化的灵魂
剪枝(Pruning)是指在搜索过程中,提前判断某些分支不可能产生有效解,从而直接跳过,减少不必要的计算。在四数之和中,剪枝主要发生在两层循环的入口处,目的是尽早跳过不可能的目标。
为什么需要剪枝?
- 避免无效迭代:当数组很大时,即便 O(n³) 也可能达到千万级操作,加上 JavaScript 的执行效率,剪枝能显著提升运行速度。
- 处理特殊数据:例如全部为正数且
target较小,或全部为负数且target较大,若不剪枝,内层循环会大量空跑。 - 笔试/面试加分:写出带剪枝的代码,体现你对算法效率的思考。
常见的剪枝策略
- 最小可能和剪枝:如果当前固定的前两个数(或第一个数)之后,最小的四数和已经大于
target,且后续数只会更大,则可以直接跳出循环。 - 最大可能和剪枝:如果当前最大的四数和(即最后四个数)仍小于
target,则可以跳过当前固定的数,尝试下一个更大的数。 - 符号剪枝:基于
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。 -
三个子条件同时满足:
nums[k] > target:当前数已经比目标值大。nums[k] > 0:当前数是正数。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 = -10,nums[0] = -5大于-10,但-5 + (-4) + (-3) + ...可以等于-10(尽管这里长度不够,但概念上)。因此,只有在target > 0且nums[k] > 0时,上述单调性才成立。
注意:这个剪枝条件并不完备。例如 nums = [1, 2, 3, 4, 5],target = 6,nums[0] = 1 不大于 6,不会触发;但 k=1 时 nums[1]=2 也不触发;直到 k=2,nums[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,且剪枝更彻底,推荐作为标准实现。