
看到这个标题我猜你也是被这几道题轮番折磨过的人。两数之和、三数之和、四数之和外加一道看着像亲戚的“四数相加II”是很多算法学习者绕不过去的四座小山峰。尤其可怕的是有人先把它们当成四道独立的题去背背完发现题目稍微变一变又不会了有人则觉得“两数之和”简单暗藏玄机但后面三道越做越迷糊。其实这四道题背后是同一套底层逻辑给你一批数让你找若干个元素让它们的和等于某个目标值 target。区别只在于“从几个数组里找”“要不要去重”“返回的是下标还是个数还是具体组合”。今天我就按自己刷题和讲课的经验把这四道题彻底串联起来讲明白。你不需要背四套不相干的模板只需要掌握两种核心手段——哈希表互补查缺失、排序后双指针收缩——再解决一个最让人头疼的细节“去重”就能把四道题全部拿下。这套路径是我在带新人刷题时反复验证过的先总结框架再拆解每道题的具体写法和边界最后整理一份避坑清单。不管你是刚刷 LeetCode 的新手还是想系统梳理求和问题的选手都可以照着这份思路走一遍。1. 求和问题的统一视角从暴力枚举到降维打击1.1 表面四道题底层同一个模型先说结论这四道题的本质是同一个模型。给定数据集挑选出 k 个元素使得它们的总和等于目标值 target。这个模型在不同题目里的变体有两数之和一个数组取两个元素返回它们的下标。下标就是位置的唯一标识所以天然不要求“去重”。四数相加II四个数组每个数组各取一个元素统计所有和为 target 的元组个数。因为四个元素来自不同数组下标组合天然不同也不需要去重。三数之和一个数组取三个元素要求返回所有不重复的三元组且元素值相同的组合只能出现一次。这里的“去重”成为主要难点。四数之和一个数组取四个元素同样要求返回不重复的四元组。如果你用暴力法硬解四道题的复杂度依次是 O(n²)、O(n⁴)、O(n³)、O(n⁴)。其中两数之和 O(n²) 还能忍四数之和的 O(n⁴) 在大数据量下基本就是“跑不起来”的代名词。所以整个解题史其实就是一部“降维史”用哈希表把找补数的时间从 O(n) 降到 O(1)用排序让双指针可以利用单调性把一层内层循环合并成一个 O(n) 的扫描。这也是我想强调的第一点不要把这四道题看成孤立的题目它们本质上是同一道“k 数之和”在不同约束下的特例。理解这一点之后你会发现很多看起来很难的变体比如“最接近的三数之和”“有效三角形的个数”都只是在这个模型上做了一点修饰。1.2 为什么要背框架而不是背题很多人的错误做法是“背代码”看到“三数之和”就默写一段“排序 双指针 去重”的代码看到“四数之和”再默写一段“双循环 双指针 去重”的代码。但面试官问的不一定是原题常见的变异包括把“等于 target”改成“最接近 target”把“一个数组”改成“两个数组各取一个元素”把“返回组合”改成“统计个数”把“不重复”约束去掉增加“数字可以重复使用”的条件。这些变体完全可以用同一套思维框架去推导而不是靠记忆。所以我建议你花时间建立两套工具哈希表解法和双指针解法并且明确它们的适用边界。我用一个表格先给你整体印象题目数据来源返回值是否需要去重核心方法时间复杂度两数之和单数组下标不需要哈希表O(n)四数相加II四数组组合个数不需要哈希表O(n²)三数之和单数组三元组列表需要排序 双指针O(n²)四数之和单数组四元组列表需要排序 双指针O(n³)当你看到这个表应该已经发现一个规律需要去重的题目出自同一个数组不需要去重的要么返回下标要么来自不同数组。这是因为同一个数组里不同的下标组合可能产生相同的数值组合如果不加控制就会输出重复结果“下标对”本身是唯一的所以返回下标不需要去重来自不同数组的元组天然不会重复因为它们的位置来自不同的“箱子”。2. 哈希表解法两数之和与四数相加II2.1 两数之和经典的“补数”思想题目是这样给定一个整数数组 nums 和一个目标值 target找出数组中和为目标值的两个数返回它们的下标。假设每种输入只会对应一个答案且不能重复使用同一个元素。最简单的做法是双重循环def two_sum_brute(nums, target): n len(nums) for i in range(n): for j in range(i 1, n): if nums[i] nums[j] target: return [i, j] return []这份代码的复杂度是 O(n²)。在数组规模小的时候没问题但如果数据量到 10 万级别O(n²) 就是 100 亿次操作明显不可接受。核心痛点在于对于每个 i你都在做一次“扫描剩下的数找 target - nums[i]”的动作这个动作每一个 i 都要重复一遍。哈希表的解法就是把“找补数”的扫描变成 O(1) 的查询。思路是遍历数组把当前元素的值和下标存进哈希表在存入之前先查一下 target - nums[i] 是否已经出现在哈希表里。这个“补数”思想很像你在超市买东西凑单你要凑满 100 元手里已经拿了一瓶 38 元的果汁你真正需要找的是 62 元的东西而不是把整个货架重新扫一遍。def two_sum(nums, target): seen {} for i, num in enumerate(nums): complement target - num if complement in seen: return [seen[complement], i] seen[num] i return []这里有一个非常关键的细节为什么是“边遍历边存”而不是“先全部存进哈希表再遍历查”因为题目要求一个元素不能重复使用。如果先把所有数都存进去那你可能查到同一个元素本身比如 nums [3, 2, 4]target 6。先存完再查遍历到 3 时complement 3你会发现哈希表里有 3你会返回 [0, 0]这就是同一个元素用了两次答案错误。边遍历边存存入的是“这个元素之前已经出现过的数”天然规避了这个问题。另一个细节是如果数组里有重复元素比如 nums [3, 3]target 6边遍历边存的逻辑是这样的i0 时complement3 不在哈希表存 nums[0]3 对应下标 0i1 时complement3 在哈希表返回 [0, 1]正确。这也是先存后查容易出错的场景。这段代码实际上代表了哈希表解法在求和问题中的通用姿势用空间换时间用“查补数”代替“扫描剩余元素”。你只需要记住一个词complement补数。2.2 四数相加II哈希表的进阶用法四数相加II 的题目是给定四个长度相同的整数数组 nums1、nums2、nums3、nums4计算有多少个元组 (i, j, k, l) 满足 nums1[i] nums2[j] nums3[k] nums4[l] 0。注意这里要求返回元组个数不是具体组合。如果你暴力四重循环复杂度是 O(n⁴)。在 n 稍微大一点的时候基本就不能用了。但如果你把这个式子做个变形nums1[i] nums2[j] -(nums3[k] nums4[l])问题就变成了“前半段所有两两之和”和“后半段所有两两之和”是否互补。这一步降维把四数问题变成了两个两数问题的拼接。具体的做法分成两半遍历 nums1 和 nums2 的所有组合把和存进哈希表value 统计这个和出现的次数遍历 nums3 和 nums4 的所有组合把它们的和取负去哈希表里查有多少个匹配的补数累加进答案。from collections import Counter def four_sum_count(nums1, nums2, nums3, nums4): sum_map Counter() for a in nums1: for b in nums2: sum_map[a b] 1 result 0 for c in nums3: for d in nums4: target -(c d) if target in sum_map: result sum_map[target] return result这段代码的时间复杂度是 O(n²)空间复杂度也是 O(n²)。为什么这个做法不需要去重因为这四个数来自四个不同的数组下标四元组 (i, j, k, l) 天然是唯一的。哪怕 nums1[0] 和 nums1[1] 的值都等于 2它们在元组里仍然是不同的位置所以计数时不会产生重复元组。这就是和“三数之和”“四数之和”最本质的区别。我见过很多人在这一步绕进了一个误区觉得四数相加II 也应该去重于是在哈希表里只存“不重复的和”或者最后用集合去统计元组。这完全是多此一举而且会直接改变题目的答案。记住去重只针对“同一个数组里取多个元素”的场景。2.3 快速判断什么时候用哈希表我自己的判断标准很简单背下来也不亏如果题目只要求返回下标或统计个数不要求“具体组合”优先考虑哈希表如果数组是无序的或者数组来自多个独立数组哈希表通常是首选如果题目要求“不重复组合”“不重复三元组”并且数据只来自一个数组哈希表做起来会很别扭因为去重逻辑非常繁琐这种时候建议排序加双指针。这个判断不是绝对的但至少在四道题里完全适用。两数之和返回下标哈希表一上就完了四数相加II 统计个数哈希表分组互补三数之和要求去重组合排序双指针四数之和同理。3. 双指针解法三数之和与四数之和3.1 三数之和排序 双指针三数之和的题目描述是给定一个包含 n 个整数的数组 nums判断 nums 中是否存在三个元素 a、b、c使得 a b c 0请找出所有满足条件且不重复的三元组。注意题目的两个关键词所有、不重复。这意味着你要把全部符合条件的三元组找出来并且去掉因为位置不同导致的数值重复。比如 nums [-1, 0, 1, 2, -1, -4] 里[-1, 0, 1] 和 [1, 0, -1] 算是重复的只能保留一个。暴力的三重循环是 O(n³)显然不可接受。排序 双指针的思路非常巧妙分三步走第一先对数组排序。为什么排序因为排序之后一个有序数组具备单调性我们可以在左右两端放两个指针根据当前和与 target 的大小关系决定指针往哪边移动。第二固定第一个元素 nums[i]把问题降维成“在这个元素后面的区间里找两个数它们的和等于 target - nums[i]”这就变成了一个两数之和。第三用 left 和 right 两个指针从区间的两端往中间扫遇到合适的组合就记录。伪代码大概是这样的def three_sum(nums): nums.sort() result [] n len(nums) for i in range(n): if nums[i] 0: break if i 0 and nums[i] nums[i - 1]: continue left, right i 1, n - 1 while left right: total nums[i] nums[left] nums[right] if total 0: result.append([nums[i], 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 0: left 1 else: right - 1 return result这段代码里有几个点我需要特意解释一下因为它们是三数之和的核心难点。第一个点是外层循环的去重if i 0 and nums[i] nums[i - 1] 时 continue。为什么不是 nums[i] nums[i 1]我待会会在第 4 章专门讲这个问题这里先记住结论和“前一个”比较而不是和“后一个”比较否则会漏掉一些有效组合。第二个点是内层找到一组后的去重while left right and nums[left] nums[left 1] 时 left 右移nums[right] nums[right - 1] 时 right 左移。这个去重必须放在“找到一组”之后而不是在进入循环时就无脑去重。原因也很简单如果一开始就去重可能把和恰好等于 0 的两个相邻相同元素给漏掉。第三个点是剪枝if nums[i] 0: break。因为数组已排序如果第一个数就大于 0那么后两个数必然也都大于 0三数之和必然大于 0后面不需要继续了。这个剪枝针对的是 target 0 的题目如果 target 不是 0这个剪枝条件要相应修改。3.2 四数之和三数之和的模板套用四数之和的题目给定一个数组 nums 和一个目标值 target找出所有不重复的四元组使得它们的和等于 target。注意这里的 target 可以是任意整数不一定为 0。如果你完全理解了三数之和四数之和就是“在外面再套一层循环”。三数之和是“固定一个数然后双指针找两个数”四数之和就是“固定两个数然后双指针找另两个数”。逻辑没有任何新东西唯一要小心的就是去重和边界。def four_sum(nums, target): nums.sort() n len(nums) result [] for i in range(n): if i 0 and nums[i] nums[i - 1]: continue for j in range(i 1, n): if j i 1 and nums[j] nums[j - 1]: continue left, right j 1, 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代码看着长但其实只是把三数之和的循环体复制了一遍在外面多套了一个 j 循环。这里有一个非常经典的坑target 不是 0所以你不能用三数之和里 nums[i] 0 就 break 的剪枝方法。比如 nums [-4, -3, -2, -1, 0, 1, 2, 3, 4]target -5。这时候 nums[i] 可能是 -4它已经小于 target 了但四数之和却可能等于 -5。所以要剪枝的话必须针对“当前固定数 最小可能组合”来算比如 nums[i] nums[i1] nums[i2] nums[i3] target 时可以 break但这个条件并不是必须的初学者很容易在这里犯错我建议先老老实实不剪枝或者只在安全条件下剪枝。另一个必须提的是整数溢出问题。在 C 或者 Java 里四数相加的结果可能超过 int 范围所以要用 long long 或者 long。Python 因为自带大整数没有这个问题但如果你用其他语言刷题记得类型转换。3.3 双指针解法的复杂度与边界双指针解法的时间复杂度非常固定三数之和是 O(n²)四数之和是 O(n³)。更一般化地说在一个数组里找 k 个数的组合排序加双指针可以把复杂度做到 O(n^(k-1))。这个复杂度在 k 比较小的时候完全够用但如果 k 增加到 5、6双指针就不再适用需要考虑其他办法。写双指针解法时还要注意几个边界问题数组长度不足 k 时直接返回空列表left 必须从 i 1 或 j 1 开始保证下标不重复循环结束条件永远是 left right不能写成 left right否则会出现同一个数被用两次的情况双指针对有序数组有效所以第一件事永远是排序。很多新手在写三数之和时会漏掉排序这个步骤。不排序双指针的单调收缩逻辑完全不成立。你可能会觉得“我固定 i 之后直接 left i1, right n-1 也能算呀”但那只是把所有组合都看了一遍和暴力法没有区别而且因为左右指针移动规则失效还会漏解。排序是整个方案的基石。4. 去重是这两道题的关键三个容易踩的坑4.1 去重到底去的是什么很多人对“去重”两个字理解得模模糊糊以为是把数组里的重复元素删掉。完全不是这回事。我们说的去重去掉的是“重复的结果组合”而不是“重复的元素”。举个例子nums [-1, 0, 1, 2, -1, -4]排序后变成 [-4, -1, -1, 0, 1, 2]。当 i 指向第一个 -1 时可以找到 [-1, 0, 1]当 i 指向第二个 -1 时如果你不去重又会找到一个 [-1, 0, 1]。从“下标组合”的角度看这是两个不同的下标组合但元素值完全相同题目要求返回不重复的三元组所以这个组合只能出现一次。去重就是干这件事把“下标不同但数值相同”的结果过滤掉。如果你理解了这一层就会明白为什么四数相加II 不需要去重——因为四个数组的下标元组天然不会产生“数值完全相同但下标不同”的重复结果。去重问题和数据来源的结构紧密相关。4.2 去重的时机先找后去还是先去后找这个问题错误率极高。以三数之和为例内层双指针在找到一组结果之后我会写while left right and nums[left] nums[left 1]: left 1 while left right and nums[right] nums[right - 1]: right - 1为什么是找到一组之后去重假设 nums [-2, 0, 0, 2, 2]target 0。固定 i -2 之后left 指向第一个 0right 指向最后一个 2。此时 total 0找到一组 [ -2, 0, 2 ]。如果你在进入 while 循环之前就去重把 left 移动到最后一个 0把 right 移动到第一个 2那么你会错失这个正确答案。内层去重必须在找到一组之后立刻做。它的作用是找到一组之后如果 left 右边的数和 left 相同那后面再以这个 left 位置去组合结果必然也是重复的直接跳过right 同理。做完这一步left 和 right 再各自向中间移动一步进入下一轮判断。很多人纠结要不要在 total 0 之后先 left 再去重再 right--。我推荐的做法是只移动 left 和 right 其中一个时不要去重只有在找到一组后才去重。这样代码逻辑最清晰也最不容易出错。4.3 一个经典误区i 去重用 nums[i] nums[i-1] 而不是 nums[i] nums[i1]外层循环的去重写法有两种候选# 正确 if i 0 and nums[i] nums[i - 1]: continue # 错误 if i n - 1 and nums[i] nums[i 1]: continue看看错误版本会带来什么问题。nums [-1, -1, 0, 1]target 0。假设 i 0nums[0] -1。此时 nums[i 1] -1两者相等错误版本会直接 continue把第一个 -1 跳过。结果后面两个数是 0 和 1它们之和是 1凑不成 -1于是整个数组没有任何答案输出。但实际上 [-1, 0, 1] 是一个合法答案。这段逻辑的解释是这样的nums[i] nums[i 1] 判断的是“当前数是否和下一个数相同”如果相同就跳过那么第一个出现的数可能根本就没被使用过直接把所有以它开头的组合全部跳过了。而 nums[i] nums[i - 1] 判断的是“当前数是否和前一个数相同”如果相同就跳过意思是“这个值我已经处理过了再处理一遍会得到重复结果”。这个语义完全正确。简单记法去重时永远和已经处理过的“前一个”比较而不是和“后一个”比较。前一个比较是“重复就跳过”后一个比较是“自杀式跳过第一个数”。5. 四题串联一份可背的解题模板加延伸变体5.1 模板总结k 数之和的统一框架学到这里你已经完全掌握了两套工具。我把它们总结成一个可以“抄作业”的决策框架遇到任何求和类问题先按这个流程走一遍第一步判断返回类型。如果只是返回下标或者统计个数并且数据来源明确且不需要去重优先哈希表。做法是边遍历边存补数或者分组存储后查补数。第二步如果要求返回具体组合并且要求不重复那么基本逃不开排序加双指针。做法是先排序用 k-2 层循环固定前 k-2 个数最后一层用双指针找末尾两个数。第三步写代码时强制自己回答三个问题是否需要去重从哪个维度去重去重应该放在哪个时机这套框架不仅仅是四道题而是一个可以不断往外延展的母版。5.2 高频变体最接近的三数之和、有效三角形的个数、两数之和变体你可以用这套框架去套很多变体题。比如“最接近的三数之和”题目说要找到和最接近 target 的三元组。你依然排序加双指针只是判断条件从 total target 变成更新最小差值这类变体往往不需要考虑去重因为只返回一个最接近的和所以代码反而更好写。再比如“有效三角形的个数”给定一个数组统计能组成三角形的三元组个数。背景是三角形边长关系任意两边之和大于第三边。如果你先排序固定最长边 c然后在前面的区间里用双指针找两个数 a 和 b使得 a b c这就是一个典型的“和大于目标值”的双指针应用。本质上还是同一套模板只是把等号判断改成了大于判断。两数之和的变体也很常见。比如给定一个有序数组返回两个数的下标同样可以用双指针左右两端往中间收缩比哈希表更省空间。所以不要觉得“两数之和”一定得用哈希表排序数组上双指针也是标准解法。5.3 实操建议刷题顺序与刻意练习我给新人的建议是这样不要只按题目编号刷而是按“同源题组”刷。求和系列的正确顺序应该是第一遍两数之和。先用暴力再用哈希表。掌握补数思想和“边遍历边存”的奥义。第二遍三数之和。先排序然后固定一个数加双指针。重点练去重。第三遍四数之和。不要看题解尝试自己用三数之和的模板推导。写不出来的话再看代码然后用自己的话把逻辑重写一遍。第四遍四数相加II。这道题建议放到最后因为它和前三道形成鲜明对比。你需要理解为什么它不需要去重为什么用哈希表分组效率最高。每一道题都建议做三遍以上第一遍照着题解复现第二遍不看题解独立完成第三遍隔几天再写一遍并且尝试用不同的语言或者不同的写法。记忆最强的时刻往往是你把代码重新推导出来的那个时刻而不是你看完答案的瞬间。6. 常见问题速查与避坑手册6.1 高频 BUG 清单我把这几年带人刷题时见过频率最高的几个 bug 整理成了表格每一行都是血泪教训错误现象根本原因正确做法两数之和返回 [0, 0]先把所有元素存入哈希表再查补数边遍历边存查到补数后返回三数之和结果里有重复三元组外层循环去重用错或内层找到一组后没跳过相同值外层用 nums[i] nums[i-1]内层找到后连续跳过相同值三数之和漏解外层用 nums[i] nums[i1] 提前跳过改为和 nums[i-1] 比较四数之和 target 为负数时提前 break沿用三数之和 nums[i] 0 的剪枝不要轻易 break除非能证明后续不可能四数相加II 结果偏少或偏多错误地去重或使用了集合统计出现次数即可不需要去重C 整数溢出int 相加时超出范围使用 long long 或进行类型转换还有两个小细节一个是循环里 left 的初始值三数之和是 i 1四数之和的内层 j 从 i 1 开始left 从 j 1 开始。不要写成 0。另一个是数组长度如果本身小于目标个数直接返回空结果不用进入循环。6.2 一个小技巧把 target 变成“负数”的统一思维我在讲两数之和时提到过 complement 这个概念它在四数相加II 里同样适用甚至可以把思维统一起来。所谓找补数本质上就是“从当前和出发看还需要多少”。我们可以用一句话概括所有求和问题的操作在处理每一层递归或循环时把问题变成“在剩余元素中寻找 target - 当前已选元素之和”。两数之和的 target 是外部给定的四数相加II 中两组和的 target 是 0 的补数三数之和里双指针的目标是 -nums[i]。本质上所有问题都在做同一件事需求匹配。理解这一点之后你看待这些题的目光会完全不一样。6.3 我的个人心得最后分享一点我自己的体会。我第一次刷三数之和的时候花了两天时间才把去重搞明白经常写完代码提交一跑测试用例就发现要么多解要么漏解看别人的题解总觉得他们写得云淡风轻自己写起来却处处是坑。后来我发现问题出在“没有完整的流程框架”上。我把去重当成一个孤立的技巧去背而不理解它背后的数据来源和去重时机。直到我把四道题并排放在一起看画出各自的“数据来源表”才彻底理解为什么有的题需要去重有的不需要。从那以后每逢求和类型我都能直接套框架很少再出边界错误。你如果也在这些题上挣扎我建议你别急着刷题量先把这四道题当成一个整体研究一遍做好今天这套笔记动手写三遍绝对比浑沦吞枣地过二十道题更有效。这套思路能帮你省下的时间远比想象中多。