题目刷到"四数之和"的时候,很多人的第一反应是:三数之和刚弄明白,怎么又来个四数?其实这道题在LeetCode上编号是18,属于那种"看起来只比三数多了一个数,但实际写起来处处是坑"的典型题目。我第一次做的时候,照着三数之和的思路往上套,结果去重逻辑写了一堆if,一提交还是超时加重复答案,折腾了一晚上才搞明白问题出在哪。
这篇文章就把四数之和这件事彻底讲透。我会从题目的本质出发,讲清楚它和三数之和、两数之和之间的递进关系,再一步步拆解双指针解法的完整思路,把每一层去重逻辑掰开揉碎,最后额外补充剪枝优化、整数溢出这类面试高频追问点,以及如何把四数之和推广到N数之和的通用解法。不管你是刚开始刷题的初学者,还是准备面试想系统过一遍双指针题型的求职者,这篇都能给你一套可以直接复用的方法论。
1. 从两数之和平移到四数之和:为什么这道题能卡住不少人
先看题目本身:给定一个包含n个整数的数组nums,判断是否存在四个元素a、b、c、d,使得a+b+c+d等于目标值target,找出所有满足条件且不重复的四元组。
乍一看,这跟两数之和、三数之和就是一个家族的东西。两数之和用哈希表能O(n)解决,三数之和因为要去重并且找出所有组合,哈希表反而不方便,双指针成了主流方案。到了四数之和,逻辑上就是"固定两个数,剩下的两个数用双指针去找",听起来很顺理成章。但实际动手写就会发现,真正难的从来不是找数,而是"去重"这件事。
为什么去重在四数之和里特别容易翻车?因为三数之和只需要处理一层外层循环的去重和一层双指针的去重,到四数之和这里,外层变成了两层循环。每多一层固定循环,就多一层去重判断,而且每一层的去重逻辑都有细微差别。我见过很多人的代码,前两层循环的去重判断写得跟双指针的去重判断一模一样,结果要么漏掉答案,要么出现重复四元组,或者在数组全是重复元素时性能退化到无法接受。
另一个容易忽视的点是边界情况。四数之和的数组长度要求至少是4,那么在长度刚好等于4时,整个数组就是唯一候选。这个case看着简单,实际写代码时如果循环边界没控制好,很容易漏掉或者重复判断。还有当target是负数或者数组里有负数时,剪枝条件不能简单写成"当前最小值大于target就break",这些都是这道题独有的陷阱。
还有一个让很多人困惑的点:为什么四数之和偏偏要用双指针,不能像两数之和那样用哈希表吗?理论上可以用哈希表存储两数之和,再配对另外两数之和,但问题在于去重极其麻烦。哈希表方案在处理"所有不重复组合"时,需要对结果做排序、哈希、去重一整套操作,代码量翻倍不说,性能也不一定比双指针好。所以面试中公认的标准解法就是排序加双指针,这也是这道题被归为"经典双指针题"的原因。
从知识点覆盖的角度看,四数之和几乎把一个合格工程师需要的基本功都考了一遍:排序、循环嵌套、指针收缩、边界控制、去重策略、复杂度分析,再加上可能的整数溢出处理。这也是为什么大厂面试爱出这道题或者它的变体,因为一道题就能看出候选人写代码的严谨程度。
2. 双指针解法为什么是四数之和的标准答案
2.1 核心思路:固定两个数,收缩两个指针
四数之和的双指针解法可以拆成一句话:排序之后,用两层循环固定前两个数,然后在剩余区间里用双指针找后两个数,使四数之和等于target。
具体流程是这样:
- 对数组排序,这一步是整个方案的地基。排序让数组有了顺序性,才能让双指针根据和与target的大小关系决定往哪边移动。
- 第一层循环固定第一个数nums[i],i从0遍历到n-4。
- 第二层循环固定第二个数nums[j],j从i+1遍历到n-3。
- 双指针left指向j+1,right指向n-1,计算四个数的和。
- 如果和等于target,记录结果,然后left右移、right左移,同时跳过重复值。
- 如果和小于target,说明需要更大的数,left右移;如果和大于target,说明需要更小的数,right左移。
关键在于双指针的收缩逻辑:因为数组已经有序,left右移意味着和变大,right左移意味着和变小。这样每次移动都朝着target的方向逼近,不会漏掉任何一种组合。
用一个具体例子走一遍。假设nums = [1, 0, -1, 0, -2, 2],target = 0。排序后是[-2, -1, 0, 0, 1, 2]。
- i=0指向-2,j=1指向-1,left=2指向0,right=5指向2,四数和为-1,小于0,left右移。
- left=3指向0,四数和还是-1,继续右移。
- left=4指向1,四数和为0,记录[-2, -1, 1, 2]。left右移,right左移,相遇,本轮结束。
- 回到j,j=2指向0,此时固定的是[-2, 0]。left=3指向0,right=5指向2,四数和为0,记录[-2, 0, 0, 2]。然后left右移跳过重复的0,right左移,结束。
- 继续i=1固定-1,j=2固定0,left=3指向0,right=5指向2,四数和为1,大于0,right左移。right=4指向1,四数和为0,记录[-1, 0, 0, 1]。全部结束。
最后结果就是[[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]],没有重复,也没有遗漏。
2.2 为什么排序+双指针比哈希表方案更优
很多人会问:排序不是引入了O(n log n)的开销吗?哈希表方案理论上可以做到O(n^2)甚至接近O(n^2),为什么不用?
答案在"找出所有不重复的组合"这个要求上。哈希表方案的核心思路是先把所有两数之和存进哈希表,key是和,value是下标对的集合,然后再遍历找另外两个数。但问题来了:你找到一个四元组之后,怎么判断它是不是跟已经找到的重复?最粗暴的办法是把四元组排序后作为key再存一次,这就在时间和空间上双重浪费。更麻烦的是,同一个两数之和可能对应多个下标对,去重的判断逻辑会变得非常复杂。
双指针方案在排序之后天然规避了这个问题。因为指针是从左往右、从右往左有序移动的,每一轮的i和j都是唯一的,left和right的去重只需要在找到答案后跳过相邻的重复元素即可。整个去重过程是"流程性"的,不需要额外的哈希结构来记录。
时间复杂度上,双指针方案是两层循环加一次双指针扫描,也就是O(n^3)再乘上排序的O(n log n),整体是O(n^3)。哈希表方案看起来是两层循环存哈希加两层循环查哈希,也是O(n^2)级别的枚举,但实际因为有大量的去重开销,往往达不到理论性能。我在LeetCode上实测过,哈希表方案在数组长度几百的量级还能跑,到上千之后就会明显变慢,而双指针方案即便在n=3000的规模下也能稳定通过。
2.3 复杂度分析的完整推导
排序:O(n log n)。
外层循环i从0到n-4,一共n-3次;内层循环j从i+1到n-3,平均n/2次。每次内层循环里,双指针最多扫描从j+1到n-1的区间,长度接近n。总操作次数大约是n乘以n乘以n,也就是O(n^3)。
空间复杂度:除了排序可能用到的栈空间(一般算O(log n))以及存储结果的数组,没有额外的大块内存分配,所以是O(1)级别的辅助空间。如果把结果数组算进去,那就是O(k),k是结果个数,但通常不把它计入算法本身的复杂度。
这个复杂度放在LeetCode的题目里不算优秀,但它是这类"找出所有不重复组合"问题的下限附近,因为组合数量本身就可能达到O(n^3)量级。也就是说,就算给你一个完美的算法,结果集的规模决定了你至少要输出O(n^3)个数组,所以O(n^3)的时间其实是合理的。
3. 代码实现与去重逻辑全拆解
3.1 完整代码:注释版
这里给出Python实现,因为Python写双指针最直观,也最适合理解逻辑。面试时用这个版本讲思路完全没问题。
def fourSum(nums, target): nums.sort() n = len(nums) res = [] # 第一层循环,固定第一个数 for i in range(n - 3): # 去重:第一个数不能重复 if i > 0 and nums[i] == nums[i - 1]: continue # 第二层循环,固定第二个数 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: res.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 res3.2 三层去重的各自含义
这个代码里总共有三处去重,每处的作用完全不同,混用就会出问题。
第一层去重针对nums[i]。当i指向的值和上一个i指向的值相同时,直接跳过。逻辑是:既然前一个i已经把所有以它开头的四元组找完了,再以同样的值开头,必然产生重复结果。这里有个细节,判断条件是nums[i] == nums[i - 1]而不是nums[i] == nums[i + 1],因为后者会误跳:假设数组是[1, 1, 2, 3, 4, 5],i=0时nums[0]=1,i=1时nums[1]和nums[1+1]比,如果相等就跳,但i=1作为第一个数的位置本来就应该尝试,跳过就漏掉了以第二个1开头的组合。
第二层去重针对nums[j]。思路和第一层一样,但边界条件不同:j的起点是i+1,所以要和j-1比较,同时判断条件是j > i + 1,确保j不在初始位置时就跳过。为什么不能直接写j > 0?因为j的最小值是i+1,如果i+1位置的值恰好和i相同,那j完全应该继续,因为这里nums[j]是第二个数,不是第一个数。
第三层去重针对left和right。这两处的去重时机是"找到一组答案之后",而不是"移动之前"。原因很简单:如果还没找到答案,left和right会随着total和target的比较关系自然移动,根本不需要额外跳重复。只有在找到一组答案后,如果不跳过重复值,下一轮循环还会以相同left和right进入,又算出同样的total,产生重复结果。所以这里一定是两条while循环,把left右侧连续的相同值全部跳过,把right左侧连续的相同值全部跳过。
3.3 典型错误与修正
我见过的错误主要集中在两处。
第一处是把双指针的去重写在了while循环的开头,而不是找到答案后。比如有人这样写:
while left < right: while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 total = ...看起来好像也没什么问题,但实际上是"跳过重复值"和"移动指针"混在了一起。当total不等于target时,这个去重会提前把一些合法的left或right位置略过,导致漏解。比如某个left位置的值尽管和前面的重复,但它在当前组合里可能是唯一能让total等于target的位置,提前跳过就错过了。正确做法是:第一次进入这一轮时不做去重,left和right完全由total决定怎么移动;只有命中target之后才需要清理。
第二处是外层的continue条件写错。比如第一层循环写成:
if nums[i] == nums[i + 1]: continue当数组是[1, 1, 2, 3, 4]时,i=0时nums[0]==nums[1],跳过了i=0,但i=1时它又是一个全新的开始,结果以1开头的组合完全没有被枚举。正确写法应该是和前一个比较:if i > 0 and nums[i] == nums[i - 1]。
4. 三个容易翻车的细节:整数溢出、剪枝与小优化
4.1 整数溢出:C++里必须处理的问题
在Python里,整数是不限长的,这道题怎么算都不会溢出。但如果你用C++或者Java写,int类型的四数之和就可能溢出。比如nums = [1000000000, 1000000000, 1000000000, 1000000000],target = 0,这四数之和远远超过int最大值。
处理方式有两种。第一种是把total声明成long long再计算,这是最直接的做法。第二种是调整判断顺序,比如先判断nums[i] + nums[j]是否已经大于target减去right的值,用减法代替加法来避免溢出,但这样写代码可读性很差,而且容易出错。我的建议是能声明成long long就声明成long long,性能差别可以忽略不计,但安全性提升巨大。
long long total = (long long)nums[i] + nums[j] + nums[left] + nums[right];这里要注意,一定要把第一个数先转成long long再做加法,否则如果nums[i]是int,四个int相加还是int,转long long的动作发生得太晚就白转了。
4.2 两级剪枝:提前终止无效循环
剪枝是"四数之和"里能明显提升性能的手段,也是面试官喜欢追问的优化点。核心思想是:数组排序后,如果当前固定的数已经让可能的和的最小值都大于target,或者让可能的最大值还小于target,那么后续就不会有答案了,可以提前退出。
第一级剪枝针对i的循环。在固定nums[i]之后,计算当前最小值:nums[i] + nums[i+1] + nums[i+2] + nums[i+3]。如果这个最小值已经大于target,说明就算取剩下的最小的三个数,和也超过target了,那i继续往后走只会让和更大,直接break跳出整个循环。
第二级剪枝同样在i的循环里,但针对的是"最大值太小的场景"。当前最大值是nums[i] + nums[n-3] + nums[n-2] + nums[n-1]。如果这个最大值小于target,说明就算取当前i下最大的三个数,和也够不到target,那i这个位置没有希望,continue到下一个i。
特别注意:第一个剪枝必须写成break,因为排序后的数组,i越大nums[i]越大,最小值只会更大,所以直接退出整个循环;而第二个剪枝只能continue,因为i变大后nums[i]变大,最大值可能够到target,需要继续尝试。这两个方向一旦搞反,要么答案漏光,要么性能没有任何提升。
在j的循环里也可以做类似的剪枝,但由于j的循环在i的循环内部,条件会更复杂一些,实际写的时候我通常会只做i层剪枝,已经能覆盖大部分无效枚举,j层剪枝的边际收益不大。当然你想做到极致也可以加上。
4.3 其他值得做的小优化
循环边界的收缩能省不少时间。第一层循环的上限不是n-1,而是n-3,因为要留出至少三个位置给j、left、right。第二层循环的上限是n-2。很多人在暴力枚举时习惯写for i in range(n)然后在循环体内判断剩余元素够不够,多了一大堆无效迭代。直接用n-3和n-2作为边界,代码更短,逻辑更清晰。
还有人会在进入双指针之前做个快速判断:如果nums[i] + nums[j]已经比target - nums[right]大,说明当前j的循环里,就算right取最大值也凑不够,可以直接break出j循环。这个优化放在j层很有效,能减少不少无效的双指针扫描。
实测下来,加了剪枝和边界收缩的版本,在LeetCode的随机数据上大约能比朴素版本快20%到30%。如果数组里大量重复元素,去重逻辑到位的话,性能提升会更明显,因为重复值被跳过,枚举的组合数大幅减少。
5. 从四数之和到N数之和:一套通用的递归解法
5.1 递归模板:kSum问题的通用写法
面试中有个高频追问:四数之和会了,那五数之和、六数之和怎么办?其实四数之和只是三数之和的扩展,而三数之和又是两数之和的扩展,这个递推关系可以抽象成一个通用函数kSum,用递归逐层固定一个数,直到剩下两个数时用双指针处理。
def fourSum(nums, target): nums.sort() n = len(nums) def kSum(start, k, target): res = [] if k == 2: # 双指针找两数之和 left, right = start, n - 1 while left < right: total = nums[left] + nums[right] if total == target: res.append([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 res for i in range(start, n - k + 1): # 剪枝 if k > 2: if nums[i] * k > target: break if nums[i] + nums[n - 1] * (k - 1) < target: continue # 去重 if i > start and nums[i] == nums[i - 1]: continue # 递归固定当前数 sub_results = kSum(i + 1, k - 1, target - nums[i]) for sub in sub_results: res.append([nums[i]] + sub) return res return kSum(0, 4, target)这个模板的核心在于:每一层递归只负责固定一个数,然后把target减去这个数,传给下一层;当k等于2时,就直接用双指针解决两数之和。去重逻辑和剪枝逻辑都放在每一层的for循环里,思路和四数之和完全一致。
5.2 递归和双循环的取舍
既然有了通用递归模板,是不是以后都写递归就行了?我的建议是分场景看。四数之和这种固定k的问题,直接写两层循环加双指针最简单直接,递归反而绕了一层,代码也长一些。但如果你要处理变体题,比如"找出所有和等于target的k数组合,k由参数传入",那递归就是最优解,因为循环层数没法动态确定。
递归还有一个隐含的成本:每层递归都要开辟新的函数栈,而且每层的子结果都要做一层拼接。实测数据表明,在k=4时,递归版比双循环版慢10%左右,但差距在可接受范围。面试时如果面试官问"你会不会写kSum",直接把递归模板递上去,能明显加分。
5.3 常见变体与应对方式
四数之和的变体题在LeetCode上还有几道,最有名的是"四数之和为0且去重"的变体,以及"在四个独立数组中找四数和为0"的题目(LeetCode 454)。后者看起来像是四数之和,但本质完全不同:它要求在四个数组里各取一个数,不需要去重,也不需要返回组合,只需要统计数量。这种题反而更适合用哈希表,因为不需要枚举所有组合,只要计数。所以拿到题目先看清楚要求,是"找所有组合"还是"统计数量",两者对应的解法天差地别。
另外一个面试常问的变体是:如果数组里有大量负数,target也是负数,怎么做剪枝?比如nums = [-100, -50, -30, -10, 0, 1, 2],target = -200。这种情况下,四数之和等于-200必须全是负数。刚才说的剪枝条件如nums[i] * k > target在这种场景下其实就不太有效,需要额外判断。我的习惯是:如果在循环中发现当前最小可能和已经小于target并且继续增大也无法到达,就改用"从后往前固定"的方式,或者调整target的判断方向。虽然这种细节面试中不一定会被问到,但面试官一旦追问,能答上来就是加分项。
最后,如果你是在本地练习,强烈建议把四数之和和三数之和放在同一天做,做完之后再用统一的递归模板把两数之和、三数之和、四数之和全部重写一遍。我自己当时就是这么练的,把三题放在一起对比之后,去重的逻辑就彻底长在脑子里了,之后再遇到N数之和的变体,写起来基本不用过脑子。这个练习方法亲测有效,比单刷十道同类型的题都管用。