从两数之和到四数之和:双指针算法详解与C++实现
1. 项目概述从“两数之和”到“四数之和”的算法进化如果你在力扣上刷过“两数之和”和“三数之和”那么看到“四数之和”这道题时第一反应可能是“又来”。没错这道编号为18的题目正是经典双指针算法在多维求和问题上的又一次进化与考验。它不再是简单的哈希表映射也不是三层循环的暴力破解而是要求你在一个可能包含重复元素的数组中找出所有和为特定目标值的四元组并且不能包含重复的组合。这听起来就像是在一个混乱的仓库里用一套精密的规则找出四件重量加起来恰好等于某个标准值的货物而且每次找到的货物组合还不能重样。这道题的核心价值在于它逼迫你跳出舒适区将解决“三数之和”时习得的排序、双指针、去重技巧进行系统性升级和组合。很多朋友卡在这里不是因为算法思想不懂而是败在了细节处理上——去重的边界条件、指针移动的时机、提前剪枝的判断任何一个环节的疏忽都会导致结果错误或超时。今天我们就来彻底拆解这道题不仅给出能通过的C代码更要讲清楚每一个循环、每一个判断背后的“为什么”以及如何将时间复杂度从最暴力的O(N⁴)优化到O(N³)。无论你是正在准备面试还是想深化对双指针和减治思想的理解这篇从一线实战中总结的笔记都能让你有所收获。2. 核心思路拆解双指针如何从二维扩展到四维2.1 问题重述与暴力解法的不可行性题目“四数之和”的要求很明确给定一个包含n个整数的数组nums和一个目标值target找出数组中所有满足以下条件的不重复四元组[nums[a], nums[b], nums[c], nums[d]]0 a, b, c, d na, b, c, d互不相同nums[a] nums[b] nums[c] nums[d] target最直观的想法是四层嵌套循环枚举所有可能的四元组检查其和是否等于target并用一个集合如setvectorint来去重。这种方法的时间复杂度是O(N⁴)在力扣的测试用例下n最大为200计算量级高达1.6亿次运算必然超时。这迫使我们寻找更优的解法。2.2 算法进化路径排序、固定、双指针解决此类“K数之和”问题的通用思路是“降维打击”。对于“四数之和”我们可以将其转化为“三数之和”进而再转化为“两数之和”来解决。具体进化路径如下排序首先对数组进行排序。这是后续所有优化双指针移动、去重、剪枝的基础。排序的时间复杂度为O(N log N)在整体复杂度中是可以接受的。固定两层循环我们通过两层循环来固定前两个数nums[i]和nums[j]。这样问题就简化为在j之后的子数组中寻找两个数nums[left]和nums[right]使得它们的和等于target - nums[i] - nums[j]。这正是“两数之和”问题。双指针解决剩余两数对于已固定的i和j我们使用两个指针left初始为j1和right初始为n-1。根据当前四数之和与target的比较来移动指针如果和小于target说明需要更大的数则left右移。如果和大于target说明需要更小的数则right左移。如果和等于target则找到一组解记录后同时移动left和right继续寻找。通过这种方式我们将寻找后两个数的复杂度从O(N²)降低到了O(N)。因此总的时间复杂度为排序O(N log N) 两层循环O(N²) * 双指针O(N) ≈O(N³)。这是一个质的飞跃。2.3 为何双指针在此有效背后的数学原理双指针之所以能工作核心前提是数组已排序。在有序数组中left指针从左向右移动指向的值单调递增right指针从右向左移动指向的值单调递减。当我们固定i和j后target - nums[i] - nums[j]就是一个确定的值remain。我们在[j1, n-1]的区间内寻找nums[left] nums[right] remain。如果当前和小于remain那么为了增大和唯一的办法就是让left右移因为right左移只会让和更小。反之亦然。这种单调性保证了我们不会漏掉任何可能的组合同时通过指针的相向移动可以在一次线性扫描内完成对所有可能(left, right)配对的检查而不是嵌套循环。注意这里有一个非常关键的细节双指针法解决的是“两数之和”问题并且是在排序后的数组上。它无法直接应用于原版的“两数之和”力扣第1题因为那道题要求返回下标排序会打乱下标。而“四数之和”这类题目只要求返回数值组合不关心原始下标因此排序是可行的第一步。3. 关键细节与避坑指南理解了核心思路代码实现起来似乎不难。但实际动手你会发现到处都是“坑”。下面这些细节处理正是区分“通过”和“优雅通过”的关键。3.1 去重操作不止一种方法但逻辑必须清晰去重是本题最大的难点之一。重复可能发生在两个层面外层循环中固定的第一个数nums[i]重复。内层循环中固定的第二个数nums[j]重复。内层双指针找到解后nums[left]和nums[right]重复。错误的去重方式在找到一组解后仅使用while (left right nums[left] nums[left1]) left;和while (left right nums[right] nums[right-1]) right--;。这只解决了双指针找到解时的重复忽略了i和j的重复。正确的去重逻辑对i去重如果当前nums[i]与前一个元素nums[i-1]相同则跳过本次循环。但必须注意这个判断不能是if (i 0 nums[i] nums[i-1]) continue;这么简单。因为当i0时没有前一个元素不会跳过。更重要的是我们要允许i和j选取相同的数值如果数组中有多个相同值只要它们的下标不同。例如nums [2,2,2,2,2], target8解是[2,2,2,2]。这里的i和j可以指向不同的‘2’。因此i的去重应该是if (i 0 nums[i] nums[i-1]) continue;。这确保了当i不是第一个元素且其值与前一个相同时我们跳过因为以这个值作为第一个数的所有组合已经在上一轮i循环中被搜索过了。对j去重逻辑与i类似但范围是在i之后。if (j i 1 nums[j] nums[j-1]) continue;。这里j i 1确保了j不是i之后的第一个位置。如果j是i1即使nums[j]等于nums[j-1]即nums[i]这也是一个新的、合法的起点不应该跳过。对left和right去重在找到一组解(i, j, left, right)后我们需要移动left和right跳过所有与当前值相同的元素以避免记录重复的四元组。代码通常为while (left right nums[left] nums[left1]) left; while (left right nums[right] nums[right-1]) right--; left; right--;3.2 剪枝优化大幅提升效率的关键在两层循环中我们可以加入提前判断避免无谓的遍历这能显著提升算法在实际情况下的运行速度。最小和剪枝在固定i和j后我们能得到当前情况下可能的最小四数之和。即取j之后最小的两个数nums[j1]和nums[j2]与nums[i]、nums[j]相加。如果这个最小和已经大于target那么无论left和right怎么选和都会更大。因此可以break出内层j循环吗不行因为i是固定的j增大可能会使和变小。所以这里应该用continue跳过当前j尝试下一个更大的j因为数组有序j增大会使nums[j]增大可能使总和增大等等这里逻辑需要仔细推敲。实际上正确的逻辑是如果nums[i] nums[j] nums[j1] nums[j2] target那么对于当前的i以及当前和未来的j因为j增大会使nums[j]增大四数之和只会更大。所以应该直接break出内层j循环。但更严谨的写法是放在j循环内判断if ((long)nums[i] nums[j] nums[j1] nums[j2] target) break;。最大和剪枝同理我们可以计算当前情况下的最大四数之和即取j之后最大的两个数nums[n-1]和nums[n-2]。如果这个最大和仍然小于target那么对于当前的i和j无论如何也凑不到target应该用continue跳过当前j让j增大再去尝试。判断条件为if ((long)nums[i] nums[j] nums[n-1] nums[n-2] target) continue;。实操心得剪枝条件中的下标访问如j2,n-1必须确保不越界。因此这些剪枝操作通常放在j循环内部并且在执行前需要判断j 2 n等条件。虽然增加了代码量但对于大数据集性能提升非常明显。3.3 整数溢出一个容易被忽略的陷阱题目中nums[i]的范围是[-10^9, 10^9]。四个这样的数相加结果可能超出32位有符号整数int的范围大约±21亿。在C中这会导致溢出产生错误的结果。例如两个很大的正数相加可能变成负数。解决方案在计算四数之和特别是在剪枝判断时使用范围更大的数据类型如long long。在代码中可以在关键的计算处进行强制类型转换long long sum (long long)nums[i] nums[j] nums[left] nums[right]; if (sum target) { ... } else if (sum target) { ... } else { // 找到解 }在剪枝判断中也应如此if ((long long)nums[i] nums[j] nums[j1] nums[j2] target) break;4. 完整C代码实现与逐行解析下面给出整合了所有上述优化点排序、双指针、去重、剪枝、防溢出的完整C题解。我们将代码分为几个逻辑块并附上详细注释。#include vector #include algorithm using namespace std; class Solution { public: vectorvectorint fourSum(vectorint nums, int target) { vectorvectorint result; int n nums.size(); if (n 4) return result; // 元素不足4个直接返回空结果 // 1. 排序 sort(nums.begin(), nums.end()); // 2. 外层循环固定第一个数 i for (int i 0; i n - 3; i) { // 对 i 去重 if (i 0 nums[i] nums[i - 1]) { continue; } // 提前剪枝如果当前i能组成的最小和都大于target后面i更大和也更大直接结束 if ((long long)nums[i] nums[i1] nums[i2] nums[i3] target) { break; // 注意是break因为i增大nums[i]也增大和只会更大 } // 提前剪枝如果当前i能组成的最大和都小于target说明这个i太小尝试下一个更大的i if ((long long)nums[i] nums[n-3] nums[n-2] nums[n-1] target) { continue; } // 3. 内层循环固定第二个数 j for (int j i 1; j n - 2; j) { // 对 j 去重 if (j i 1 nums[j] nums[j - 1]) { continue; } // 针对当前i和j的剪枝 if ((long long)nums[i] nums[j] nums[j1] nums[j2] target) { break; // j再增大和只会更大所以跳出j循环 } if ((long long)nums[i] nums[j] nums[n-2] nums[n-1] target) { continue; // 当前j太小尝试下一个更大的j } // 4. 使用双指针寻找剩下的两个数 left 和 right int left j 1; int right n - 1; while (left right) { // 使用long long防止溢出 long long sum (long long)nums[i] nums[j] nums[left] nums[right]; if (sum target) { left; // 和太小左指针右移 } else if (sum target) { --right; // 和太大右指针左移 } else { // 找到一组解 result.push_back({nums[i], nums[j], nums[left], nums[right]}); // 对left和right去重 while (left right nums[left] nums[left 1]) { left; } while (left right nums[right] nums[right - 1]) { --right; } // 移动指针继续寻找下一组可能的解 left; --right; } } } } return result; } };代码关键点解析排序sort(nums.begin(), nums.end());是后续所有操作的基础。外层循环边界i n - 3因为需要留出至少3个位置给j,left,right。i的剪枝if ((long long)nums[i] nums[i1] nums[i2] nums[i3] target) break;计算了以当前nums[i]开头能形成的最小四数之和取i后面三个最小的数。如果这个最小值都大于target那么i增大值变大后最小值会更大更不可能等于target所以直接break整个i循环。if ((long long)nums[i] nums[n-3] nums[n-2] nums[n-1] target) continue;计算了以当前nums[i]开头能形成的最大四数之和取数组最后三个最大的数。如果这个最大值都小于target说明当前nums[i]太小即使配上最大的三个数也达不到目标应该continue尝试下一个更大的nums[i]。内层循环边界与剪枝逻辑与i的剪枝类似但范围限定在j之后。注意j循环内的break和continue只影响j循环本身。双指针部分这是核心逻辑。通过比较sum和target来移动指针。找到解后先移动left和right跳过重复值再同时向中间移动一位进入下一轮查找。5. 时间复杂度分析与优化对比让我们定量地分析一下优化带来的收益。暴力解法四层循环时间复杂度为O(N⁴)。空间复杂度O(1)不考虑存储结果的空间。当N200时操作数约为1.6e9完全不可接受。排序双指针解法排序O(N log N)两层循环最坏情况下i从0到n-4j从i1到n-3循环次数约为 (N²)/2。内层双指针对于每一对(i, j)left和right相向移动总共最多扫描O(N)次。因此最坏总时间复杂度为O(N³)。平均情况下由于剪枝的存在实际运行会快很多。空间复杂度主要取决于存储结果的空间最坏情况下为O(N²)当所有四元组都是解时。算法本身只使用了常数个额外变量空间复杂度为O(1)不考虑排序的栈空间和结果集。剪枝的效果剪枝操作不会改变最坏时间复杂度的大O表示但它能极大地减少常数因子。在随机数据或数据范围较大的情况下很多i和j的组合在早期就被剪枝跳过避免了进入内层的双指针循环实际运行时间可能接近O(N²)甚至更好。6. 常见问题与调试技巧实录即使理解了算法自己实现时还是会遇到各种问题。下面是我在刷题和教学过程中总结的几个高频问题。6.1 结果集中出现重复的四元组问题现象代码逻辑看起来没错但输出结果里包含了[a, b, c, d]和[a, b, c, d]这样的重复项。排查步骤检查i的去重确保是if (i 0 nums[i] nums[i-1]) continue;。而不是if (nums[i] nums[i1])后者会错误地跳过本应被使用的、作为起点的重复元素。检查j的去重确保是if (j i 1 nums[j] nums[j-1]) continue;。j i 1这个条件至关重要它保证了当j是i之后第一个元素时即使它的值和nums[i]相同也会被作为j的起点考虑进去。检查双指针去重的顺序在找到解后必须先执行while循环跳过所有与当前nums[left]、nums[right]相同的元素然后再执行left; right--;。如果顺序反了会漏掉去重操作。调试技巧用一个简单的、包含重复元素的数组测试例如nums [1,1,1,1,1], target4。正确答案应该是[[1,1,1,1]]。单步调试观察i,j的去重逻辑是否正确执行。6.2 遇到大数测试用例时结果错误或崩溃问题现象代码在小规模测试上通过但提交后在某些包含10^9级别数字的测试用例上失败。原因分析几乎可以肯定是整数溢出。四个10^9相加是4e9超过了int型上限约2.15e9计算过程中发生溢出导致比较和判断逻辑完全混乱。解决方案在代码中所有可能发生四数相加的地方使用long long类型。特别注意剪枝判断中的加法也要进行类型转换。例如将if (nums[i] nums[j] nums[j1] nums[j2] target)改为if ((long long)nums[i] nums[j] nums[j1] nums[j2] target)。一个良好的习惯是在定义sum变量时就直接使用long long。6.3 剪枝条件导致漏解问题现象加入了剪枝代码后有些原本应该被找到的解消失了。原因分析剪枝条件写得太“激进”或者边界条件没处理好。最常见的是下标越界访问。排查与修正最小和剪枝if ((long long)nums[i] nums[j] nums[j1] nums[j2] target) break;这里访问了nums[j2]必须确保j2 n。由于j的循环条件是j n - 2所以j2的最大值是nnums[j2]是合法的。但为了更安全可以在循环内部先判断if (j 2 n) break;或者将剪枝放在j循环内靠后的位置。最大和剪枝if ((long long)nums[i] nums[j] nums[n-2] nums[n-1] target) continue;这里访问了nums[n-2]和nums[n-1]只要n4就是合法的而我们在函数开头已经判断过n4的情况。i层的剪枝同样需要注意下标。nums[i3]要求i3 n即i n-3这正好是外层循环的条件。建议在编写剪枝代码时在旁边用注释写明该剪枝条件的数学含义如“当前最小和已大于target”并仔细核对所有数组访问的下标是否在有效范围内。对于不确定的情况可以暂时注释掉剪枝代码先保证算法正确性再逐步添加和测试剪枝条件。6.4 双指针移动逻辑混乱问题现象代码陷入死循环或者找不到某些明显的解。标准双指针移动逻辑while (left right) { long long sum (long long)nums[i] nums[j] nums[left] nums[right]; if (sum target) { left; // 和太小需要增大左指针右移 } else if (sum target) { right--; // 和太大需要减小右指针左移 } else { // sum target // 记录结果 result.push_back({nums[i], nums[j], nums[left], nums[right]}); // 去重 while (left right nums[left] nums[left1]) left; while (left right nums[right] nums[right-1]) right--; // 移动指针继续寻找 left; right--; } }关键点在sum target的分支里去重和移动指针必须按顺序进行。先去重再移动。如果先left; right--;再去重就会漏掉对当前解的去重操作可能导致记录重复解或者去重循环的起点不对。这道“四数之和”的题目就像算法学习路上的一个综合训练场。它考察的不仅仅是对双指针的掌握更是对边界条件、去重逻辑、溢出处理和剪枝优化的综合把控能力。我个人的体会是与其死记硬背代码模板不如亲手推导一遍每个循环的边界、每个判断条件的由来。比如为什么j的去重要判断j i 1多问几个为什么在纸上画一画数组下标跑几个边缘用例理解会深刻得多。当你能够不参考任何资料从零推导并写出这段代码时你对双指针和减治思想的理解就真正过关了。
