1. 题目本质与解法选型思路
1.1 四数之和到底在考什么
先把这个题目拆开看。四数之和,说白了就是给你一个数组nums和一个目标值target,让你找出所有不重复的四元组,满足四个数加起来等于target。看起来只是比三数之和多了一个数,但实际动起手来,复杂度一下子从O(n^2)升到了O(n^3),而且去重的坑也比三数之和多了一倍。
我为什么说这个题值得单独拎出来写一篇?因为大多数人第一次做四数之和,都是直接用四层循环暴力解,然后天真地以为用Set去重就完事了。结果一提交,要么超时,要么一堆重复答案,要么边界条件写错导致漏解。而双指针法正是解决这类“固定数量元素求和”问题的最经典套路,它把暴力里的四层循环压成了两层循环加一层双指针,时间复杂度从O(n^4)降到了O(n^3),空间复杂度基本可以做到O(1)(不计排序和答案存储的话)。这个优化幅度,在算法题里是非常可观的。
另外要注意,四数之和的target不再是固定为0的三数之和了,它是一个输入参数,可正可负可零。这一点直接导致了一些“剪枝”写法在三数之和里能用,在四数之和里不能乱用——后面我会专门讲这个坑,这也是很多题解互相矛盾的地方。
1.2 为什么必须是双指针,而不是哈希表或二分
有人会问:两数之和能用哈希表,三数之和也能用哈希表,那四数之和是不是也可以用哈希表?技术上说可以,但实际操作起来非常难受。哈希表方案的一个典型做法是:先把任意两个数的和以及对应的下标对存进哈希表,然后再找另外两个数,使得两组和加起来等于target。这里面的去重逻辑极其容易写错,因为你不仅要对数字去重,还要对下标去重,而且答案的排序问题也很麻烦——最终你往往还是得靠Set来兜底,那性能优势就完全没了,代码反而比双指针复杂一个量级。
再说二分。四数之和即使排序后,内层再套一个二分查找,也就是把后两个数的枚举用二分优化,但枚举前两个数本身已经是O(n^2)了,二分后两个数的组合无论如何也绕不开“找出所有可行组合”这个输出规模问题。最坏情况下答案数量本身就是O(n^2)级别的,所以二分在这里优化不了最坏复杂度,反而把去重和下标关系搞得更乱。
所以最终公认的写法就是:排序 + 两层固定 + 双指针内缩。这个思路的核心价值在于:通过排序让数组有序,从而让双指针可以根据“和与目标值的大小关系”来智能地决定移动方向,每次移动都能排除掉大量无效组合。
我打个比方,这就像你在一排按身高排好队的人里找两个身高加起来等于某个值的人。因为队伍有序,你从两头往中间走,左边的人太矮了就往右走一步,右边的人太高了就往左走一步,每一步都排除了一整条线上的无效组合。如果是无序队伍,你只能挨个配对试,那效率就天差地别了。
1.3 这类题的通用解法套路总结
做多了会发现,两数之和、三数之和、四数之和、甚至四数之和II,本质上都是同一个套路家族。我总结了一个通用心法,后面写代码的时候你直接套就行:
- 第一步,排序。这是所有双指针求和题的基石,没有排序就没有双指针的移动逻辑。
- 第二步,确定固定层数。找几个数,就用几层循环固定前几个数。四数之和就是两层循环固定前两个数,剩下的两个数交给双指针。
- 第三步,双指针内缩。左右指针根据当前和与
target的大小关系移动,相等时记录答案。 - 第四步,去重。每一层固定值去重 + 双指针移动时去重,这是最容易出事的地方。
这个套路你一旦吃透,处理五数之和、六数之和也就是多加两层循环的事(当然复杂度会指数上涨,面试一般不会考到超过四数)。后面第 5 节我会专门讲如何从两数之和到k数之和做抽象,这一节先聚焦到题目本身。
2. 双指针核心逻辑与去重细节拆解
2.1 主循环框架:两层固定的写法与边界控制
先上一份可以直接跑的 Java 参考代码,后面所有的讲解都围绕它展开,也方便你逐行对照理解:
public List<List<Integer>> fourSum(int[] nums, int target) { List<List<Integer>> result = new ArrayList<>(); if (nums == null || nums.length < 4) { return result; } Arrays.sort(nums); int n = nums.length; for (int i = 0; i < n - 3; i++) { // 固定值去重:跳过与上一次相同的第一个数 if (i > 0 && nums[i] == nums[i - 1]) { continue; } for (int j = i + 1; j < n - 2; j++) { // 固定值去重:跳过与上一次相同的第二个数 if (j > i + 1 && nums[j] == nums[j - 1]) { continue; } int left = j + 1; int right = n - 1; while (left < right) { int sum = nums[i] + nums[j] + nums[left] + nums[right]; if (sum == target) { result.add(Arrays.asList(nums[i], nums[j], 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 < target) { left++; } else { right--; } } } } return result; }这份代码里有两个边界条件需要你特别留心。第一个是外层for循环的终止条件:i < n - 3,因为i定了之后,后面至少还要留 3 个数(j、left、right)才能凑出四元组。同理内层j < n - 2。很多新手会把这里的边界写成n,结果数组越界,或者读到已经被覆盖的脏数据。第二个是内层去重的起始判断:j > i + 1而不是j > 0,这个我踩过一次坑——你想想,如果j在i+1这个起始位置时去和nums[j - 1]也就是nums[i]比较,那万一nums[i] == nums[j],就会把这个本应合法的四元组直接跳过了。这是典型的“去重去过头”问题。
2.2 “和”的累加方式与类型溢出隐患
继续说代码里的int sum = nums[i] + nums[j] + nums[left] + nums[right];。很多题解为了省事,会写成nums[i] + nums[j] + nums[left] + nums[right]直接和target比较,这在绝大多数情况下没问题,但有一种极端情况会让你挂在测试用例上:数组里的数字是int范围内的任意值,四个int相加是会溢出的。
比如nums[i] = 1000000000,其他三个数也都是1000000000,四个加起来是4000000000,已经超过了int的最大值2147483647。溢出之后,相加的结果会变成一个负数,然后你拿它和target比较,结果自然全错了——你会以为这个组合的和小于目标值,于是left++,把正确答案活活跳过。
我个人的习惯是:如果题目没有明确说数组元素都在小范围内,求和这一步就用long类型做临时变量,比如long sum = (long) nums[i] + nums[j] + nums[left] + nums[right];。别看只是多写了一个(long),这个习惯能帮你省掉很多摸不着头脑的Wrong Answer。这个题目如果数据范围不给,保守处理永远是对的。
2.3 双指针的移动策略:什么时候动左,什么时候动右
双指针的移动策略是整个算法的灵魂。当前sum和target之间有下面三种关系:
sum == target:记录答案,然后左右指针同时收缩。收缩之前要先把左右两边重复的数字都跳过,避免下一轮组合出相同的四元组。sum < target:说明整体偏小,需要把和变大。因为数组是升序排列的,右边已经是最大的数了,不能动;只能把左指针往右移,也就是left++,选一个更大一点的数。sum > target:说明整体偏大,需要把和变小。同理只能把右指针往左移,也就是right--,选一个更小一点的数。
这里有个初学者容易迷惑的点:为什么不能用left++和right--同时进行?因为你不知道到底是差了一个数还是差了三个数,一次动两个指针很容易跳过正确答案。双指针的核心思想是每次只“微调”一个维度,让搜索空间逐步收敛。这就像你调淋浴水温,太烫了就稍微拧一点冷水,太凉了再稍微拧一点热水,一次拧太多不是烫伤就是冻着。
另外,while (left < right)这个循环条件务必时刻保证,因为一旦left == right,就说明这个区间内没有可选的两个数了,循环必须终止。我在代码里会在去重跳过的过程中反复检查left < right,就是为了防止指针越界或交叉。
2.4 恰好卡住target时的去重坑位
当sum == target时,很多同学直接result.add(...),然后left++; right--;就完事了。表面看没问题,实际跑出来答案里会出现大量重复四元组。原因很简单:假设当前nums[left] = 2, nums[right] = 5,记录完一个答案后你只把left挪到下一个位置,如果下一个位置的数还是2,那组合依然相同,只是right变了而已,这不就又产生一个重复答案了吗?
所以标准的做法是:记录答案之后,先用两个嵌套while把左指针右侧连续的相同值全部跳过,把右指针左侧连续的相同值也全部跳过,最后才left++和right--落到新值上。这个过程看起来像是在“扫雷”,其实就是在临时固定区间内跳过所有与当前组合相同的候选值,确保下一个组合至少有一个数不同。
我在第 3 节会提供一份带详细注释的“完整版”代码,你会看到去重逻辑是最长的一部分代码。这不是代码啰嗦,而是这个题目的核心难点本来就集中在去重上。很多人题解能看懂,一写就错,基本都是栽在这里。
3. 完整代码落地与剪枝优化实录
3.1 带详细注释的可运行版本
下面是我在实际刷题时会写出来的最终版本,注释写得很全,你可以直接照着这个思路敲,或者拿去做 review 对照。语言我用 Python 写一版,因为 Python 的切片和列表操作让答案展示更直观,逻辑上跟上面的 Java 版本完全等价。
from typing import List def fourSum(nums: List[int], target: int) -> List[List[int]]: result = [] n = len(nums) if n < 4: return result # 排序是双指针方案的前提 nums.sort() for i in range(n - 3): # 跳过第一个数的重复值 if i > 0 and nums[i] == nums[i - 1]: continue # 最小的四个数加起来已经大于 target,后面不用看了(只对正数) # 这里先不写,后面我会解释为什么不能直接照搬三数之和的写法 for j in range(i + 1, n - 2): # 跳过第二个数的重复值 if j > i + 1 and nums[j] == nums[j - 1]: continue left = j + 1 right = n - 1 while left < right: total = nums[i] + nums[j] + nums[left] + nums[right] if total == target: result.append([nums[i], nums[j], nums[left], nums[right]]) # 左指针跳过重复 while left < right and nums[left] == nums[left + 1]: left += 1 # 右指针跳过重复 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif total < target: left += 1 else: right -= 1 return result这段代码我已经在 LeetCode 第 18 题上跑通过了。从实际的执行效果看,时间复杂度大约在O(n^3),排序的部分是O(n log n),在n = 200左右的测试数据下,耗时在几十毫秒级别,完全不会卡超时。
3.2 三数之和的“剪枝”为什么不能照搬到四数之和
很多题解在三数之和里会加这么一段剪枝:
# 三数之和的常见剪枝 if nums[i] > 0 and nums[i] + nums[i + 1] + nums[i + 2] > 0: break if nums[i] + nums[-1] + nums[-2] < 0: continue这个写法在三数之和里是安全的,因为target固定为0。但在四数之和里,target是输入参数,不一定为0,你要是直接抄这个写法就翻车了。
举例来说:nums = [-5, -3, -1, 0, 2, 4],target = -2。排序后nums[0] = -5,nums[0] + nums[1] + nums[2] + nums[3] = -5 + (-3) + (-1) + 0 = -9,小于target,但其实存在-5 + -3 + 2 + 4 = -2这个合法答案。如果你用“当前最小四数之和大于 target 就 break”的逻辑,虽然 -9 不大于 -2,你不会 break,但如果你写出“当前最小四数之和小于 target 就 continue”这种反向剪枝,那就直接把i=0给跳过了——这恰恰是错误的,因为以-5开头的四元组里明明有合法解。
所以四数之和的正确剪枝思路应该是:
- 如果
nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target,那么无论后面怎么组合,以nums[i]开头的所有四元组之和都不可能更小了,直接break。 - 如果
nums[i] + nums[n-1] + nums[n-2] + nums[n-3] < target,那么以nums[i]开头的最大四元组都小于target,说明这个i太小了,直接continue到下一个i。
这两个剪枝在三数之和里也被广泛使用,但它们对 target 的正负没有假设,所以四数之和是可以用的。关键就是别把“最小”和“最大”的方向搞反了,也别直接把“和0比较”的版本抄过来。
3.3 一次完整的 debug 演示:从错误到正确
我拿一个真实例子带你走一遍调试过程。假设输入是:
nums = [1, 0, -1, 0, -2, 2] target = 0排序后变成[-2, -1, 0, 0, 1, 2]。
正确的预期结果是四个不重复的四元组:
[-2, -1, 1, 2] [-2, 0, 0, 2] [-1, 0, 0, 1]现在假设你在去重时漏掉了内层双指针的重复跳过,代码长这样:
if total == target: result.append([...]) left += 1 right -= 1跑出来的结果会变成什么?在i=1(nums[1] = -1)、j=2(nums[2] = 0)这一组里,left=3(nums[3]=0),right=5(nums[5]=2)。此时sum = -1 + 0 + 0 + 2 = 1,比target=0大,right -= 1,变成right=4(nums[4]=1)。现在sum = -1 + 0 + 0 + 1 = 0,命中一次答案,记录下[-1, 0, 0, 1]。然后left++变成4,right--变成3,此时left > right,内层循环退出。
看起来没问题,但其实你丢了一个答案:在i=0(nums[0]=-2)、j=1(nums[1]=-1)这组里,left=2(nums[2]=0),right=5(nums[5]=2)。sum = -2 + -1 + 0 + 2 = -1,比target小,left++变成3(nums[3]=0)。sum = -2 + -1 + 0 + 2 = -1,还是小,left++变 4,nums[4]=1,sum = -2 + -1 + 1 + 2 = 0,命中答案,记录[-2, -1, 1, 2]。然后若不跳过重复的left或right,你会发现left++后nums[5]=2,right--后nums[3]=0,此时sum = -2 + -1 + 2 + 0 = -1,然后就会发生什么?继续走,你会发现有可能出现重复的组合或者漏解交错出现。这种问题用肉眼很难看出来,最好的方式就是加print打印每一层循环的i, j, left, right和当前sum。
我的调试诀窍是:先把result用一个Set转一下看有多少重复,如果去重后的数量等于预期数量,而带Set的去重前数量大于预期,那你几乎可以断定是内层双指针去重没写干净。反过来,如果Set之后的答案还比预期少,那说明你的left/right移动逻辑有问题,或者外层固定值去重把合法答案跳过了。
3.4 复杂度与性能实测分析
最后说下性能和复杂度,面试官基本必问。
- 时间复杂度:外层
i循环O(n),内层j循环O(n),双指针收缩最坏情况下也是O(n),所以总时间复杂度O(n^3)。排序O(n log n)可以忽略不计。对比暴力四层循环的O(n^4),这是一次非常可观的降维。 - 空间复杂度:如果不算存储答案的空间,只需要常量级别的指针变量,
O(1)。但如果算上result的存储,那答案数量在最坏情况下是O(n^2)级别的(比如数组全是同一个数,target 是其四倍,所有四元组都相等,去重后其实答案只有一个;但更一般的情况下,答案数量可以到O(n^2)),所以总体空间复杂度可以记为O(n^2)的答案存储,辅助空间O(1)。
我用一个n=1000的随机数组测试过,双指针版本在本地跑大概几十毫秒到一百多毫秒。面试官如果追问“能不能再优化”,你可以提一句:对答案本身数量已经达到O(n^2)级别的输入,任何算法都不可能低于输出规模。这个回答能体现出你真的理解了问题的瓶颈。
4. 常见问题与排查技巧实录
4.1 问题速查表
我在日常刷题和技术讨论里,把四数之和最常见的报错和异常整理成了一个速查表,直接对照定位就行。
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| 答案里有大量重复四元组 | 内层双指针命中后没有跳过重复值 | 在left++和right--前用while跳过相同元素 |
| 答案少了某几个预期组合 | 外层固定值去重时判断条件写成了j > 0 | 改成j > i + 1,避免把初始位置误判为重复 |
| 数组越界异常 | 循环边界写成了i < n | 分别设为i < n - 3和j < n - 2 |
| 特定测试用例下结果全错 | 四个int相加溢出 | 求和时先把任意一个数强转成long |
| 明明排序了但结果还是无序 | 没理解答案顺序要求 | 因为固定循环本身有序,只要保证i < j < left < right就自动满足升序 |
| 超时 | 用了哈希表方案或没有去重的暴力枚举 | 换双指针方案,且每层都做去重提前减支 |
4.2 一个隐蔽的剪枝陷阱:负数场景下的反向剪枝
这个问题值得单独拿出来讲,因为我见过不止一个同学在讨论区问“为什么我抄的三数之和剪枝在四数之和上挂掉了”。
三数之和的经典剪枝写法是排序后,如果nums[i] > 0就break。这个逻辑成立的前提是 target = 0,且数组升序,一旦nums[i] > 0,后面所有数都大于 0,三数之和不可能再回到 0。但如果四数之和的target是 -5,nums[i] = -3,后面可能组合出-3 + (-1) + 0 + (-1) = -5,此时nums[i]小于 0,你不能只靠正负来判断。
所以正确写法是:如果当前固定i后,哪怕选最小的四个数,和还是大于target,那就可以break;如果哪怕选最大的四个数,和还是小于target,那就continue。这里的关键词是“当前固定值之后的最小/最大四数组合”,不是全局的,也不能拿0当参照物。
# 四数之和可用的剪枝 if nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target: break if nums[i] + nums[-1] + nums[-2] + nums[-3] < target: continue # 内层 j 也同理 if nums[i] + nums[j] + nums[j + 1] + nums[j + 2] > target: break if nums[i] + nums[j] + nums[-1] + nums[-2] < target: continue注意第二行里,nums[-1]、nums[-2]是数组末尾最大的几个数。这是利用了排序后数组的性质,不需要额外排序。这种剪枝在最坏情况下不能改变复杂度,但在普遍数据上能省不少时间,尤其当数组很大且 target 比较极端的时候。
4.3 调试四数之和的通用三板斧
如果你是在 LeetCode 或类似平台上报错,我建议你调试时按这三步走。
第一步,构造极简测试用例。比如nums = [0, 0, 0, 0], target = 0,这种全相等数组最能暴露去重问题。预期答案只有[0, 0, 0, 0]一个,如果你的代码输出多个,说明去重根本没生效。
第二步,构造负数混合用例。比如nums = [-2, -1, 0, 0, 1, 2], target = 0,这个用例能暴露排序后负数参与组合时的剪枝错误和固定值去重的边界问题。
第三步,用Set<List<Integer>>做临时辅助去重,把去重前后的结果数量打出来对比。如果去重后数量和预期相等,但去重前数量远超预期,问题一定在去重逻辑;如果去重后数量都比预期少,说明主逻辑有漏解。
我自己刷题时还会在total == target的那个分支里打印i, j, left, right, total五个值。很多肉眼看不到的错位,一打印就全明白了。
5. 从四数之和到 k 数之和的通用套路
5.1 两数之和“双指针版”是这一切的起点
很多人提起两数之和,第一反应是哈希表。但其实双指针也可以解两数之和——只要数组排序过。排序后,left指向最小,right指向最大,每次比较nums[left] + nums[right]和target的关系,小了就left++,大了就right--,相等就记录一组。这个基本框架就是一切双指针求和题的底层内核。
两数之和的双指针版本时间复杂度是O(n),哈希表也是O(n),但双指针版多了一个O(n log n)的排序前置步骤。如果题目要求返回下标且不让你排序,那哈希表仍然是两数之和的最优解。但如果你追求的是“找所有组合”,那双指针配合去重更自然。
我建议你先手写几遍两数之和双指针版,找找“左右指针想象成两个人从两端往中间走”的感觉。这个手感建立起来之后,三数之和只是在外层套一个for,四数之和只是再套一个for,本质上你并没有学新东西。
5.2 用递归把固定层数抽象成通用函数
有了四数之和的经验,我们可以做个更高层的抽象。其实三数之和、四数之和都可以统一成一个递归函数:每次固定一个数,然后递归处理剩下k-1个数的问题。递归终止条件是k == 2,也就是双指针处理两数之和。
这种写法的核心价值在于,它帮你把“固定前两层”和“双指针”两个阶段解耦了。面试比你写五数之和的时候,你不需要现场再推导五层循环怎么写,直接递归套就行。
def kSum(nums, target, k, start, cur, result): n = len(nums) if k == 2: left, right = start, n - 1 while left < right: total = nums[left] + nums[right] if total == target: result.append(cur + [nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif total < target: left += 1 else: right -= 1 return for i in range(start, n - k + 1): # 去重 if i > start and nums[i] == nums[i - 1]: continue kSum(nums, target - nums[i], k - 1, i + 1, cur + [nums[i]], result)调用方式就是kSum(sorted_nums, target, 4, 0, [], result)。这个写法有个额外的好处:剪枝逻辑可以统一写进递归函数开头,避免每一层都复制粘贴。当然递归层数多了会稍微慢一点点,但绝大多数场景下完全够用,而且代码可读性远比五层循环嵌套要好。
5.3 掌握套路之后,刷题效率会明显提升
我刚学双指针的时候,三数之和写了整整一个下午,四数之和又写了半天。后来把两数之和、三数之和、四数之和放在一起对比,发现它们的骨架居然高度相似,就把这个套路抽了出来。从那以后,再遇到五数之和或者是最接近的三数之和、四数之和II这类变体,基本都能在十分钟之内理清思路。
这其实印证了一件事:算法题表面上成千上万,但核心题型就是有限的那么几十种。双指针求和题就是最典型的一类,它考察的“排序预处理 + 指针逼近 + 去重细节”几乎是所有数组类题目的基本功。四数之和作为这个套路里的最高频考点之一,你把它吃透,后面看什么求和变体都会很轻松。
我个人在实际操作中的体会是:双指针系列的题目,一定不要只看题解,必须亲手在编辑器里跑一遍,把每个while的条件都改一改试试,看看报错长什么样。踩过几次边界和去重的坑之后,你对“指针移动”和“去重时机”的理解会变得非常扎实。这套东西,光靠看是永远看不会的。
最后再分享一个小技巧:面试现场如果遇到四数之和,你可以先问面试官一句“数组里有重复元素吗?答案需要去重吗?返回值有顺序要求吗?”这三个问题问完,很多边界情况就直接浮出水面了。这比你闷头写五分钟再返工要高效得多。