ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

相向双指针详解:从两数之和到接雨水的经典模板

相向双指针详解:从两数之和到接雨水的经典模板 相向双指针也叫左右指针、对撞指针是力扣刷题里最容易被低估的一类技巧。我第一次刷到这类题时直接翻车拿到两数之和就两层for硬怼结果要么超时要么边界写错后来把“相向双指针”单独拎出来复盘才发现那些看起来吓人的 medium、hard 题比如三数之和、盛最多水的容器、接雨水核心逻辑其实就那么几行判断。这篇文章把我自己对这类题的理解、踩过的坑、以及一套可以直接照抄的模板梳理出来适合正在刷力扣、准备笔试面试或者想建立算法思维的朋友。1. 相向双指针的本质为什么它能把 O(n²) 降到 O(n)1.1 什么是对撞指针它长什么样相向双指针是双指针技巧里最直观的一种一个指针 left 从数组最左边出发一个指针 right 从数组最右边出发在 while (left right) 的条件下不断向中间收敛每次根据当前两个指针指向的值决定是移动 left 还是移动 right。用人话说就是两个人从队伍两端往中间走每走一步都根据当前的条件判断“哪一端可以放弃了”然后把那一端往里缩一格。它的使用前提非常明确数组有序或者我们可以先对数组排序答案要找的是两个元素的某种组合而不是连续子数组判定条件具备单调性也就是说当前指针状态能告诉我们下一步该舍去哪一侧。最常见的判断逻辑是这样的如果当前两个数的和小于目标值说明 left 指向的数太小了于是 left如果和大于目标值说明 right 指向的数太大了于是 right--。这里有一个很多新手不理解的地方——为什么“小了就 left大了就 right--”是安全的为什么不担心漏掉正确答案这正是相向双指针能剪枝的核心原因。1.2 有序数组带来的“排除一行”能力假设数组已经从小到大排好序当前 left 指向 aright 指向 b且 a b target。因为数组是有序的b 已经是当前区间内最大的数了a 和最大的数相加都小于 target那 a 和区间内任何其他数相加一定更小更不可能等于 target。于是 a 这个位置就可以安心排除left 不会有任何损失。反过来如果 a b target说明 b 太大a 已经是当前区间最小的数b 和最小的数相加都超过 target那 b 和区间内任何其他数相加一定更大b 也可以排除right-- 是安全的。这个操作每次排除的不是一个数而是一整行或一整列的搜索空间。暴力两重循环是在一个 n×n 的表格里逐个找答案双指针则每走一步就划掉一行或者一列整个过程从左上角到右下角是一条斜线所以总复杂度是 O(n) 而不是 O(n²)。举个具体例子数组 [1, 2, 3, 4, 5, 6, 7, 8]目标值 10。left0 指向 1right7 指向 8189 小于 10。此时 8 是数组里最大的数1 跟最大的数加起来都不够 10那么 1 跟 2、3、4、5、6、7 组合也一定不够所以 left 直接跳到 2。这一步排除了 7 种组合而不是一个组合。用这种方式理解“剪枝”就不会再觉得双指针是什么玄学了。2. 从两数之和到接雨水四道经典题的完整复盘2.1 两数之和 II双指针最朴素的落地力扣第 167 题“两数之和 II - 输入有序数组”是相向双指针的入门题。题目给了一个已经按升序排列的数组要求找出两个数使它们的和等于目标值返回下标加一。我一开始的暴力解法是双重循环测小数组没问题一上大数组就超时。后来改成双指针代码短到让我有点恍惚class Solution { public: vectorint twoSum(vectorint numbers, int target) { int left 0, right numbers.size() - 1; while (left right) { int sum numbers[left] numbers[right]; if (sum target) { return {left 1, right 1}; } else if (sum target) { left; } else { right--; } } return {-1, -1}; } };这里的加一操作是题目要求返回的下标是从 1 开始的。如果以后遇到从 0 开始计数的版本去掉加一就行。这个解法的时间复杂度是 O(n)空间复杂度 O(1)在有序数组的场景下比哈希表方案更省空间。需要注意这道题能用双指针的前提是数组有序如果数组无序就不能直接套这个模板否则会漏解。力扣第 1 题“两数之和”就是无序数组那道题的正确解法是哈希表很多新手把 1 题和 167 题的解法搞混原因就是没搞清楚双指针依赖有序这个前提。2.2 三数之和双指针 去重才是重头戏力扣第 15 题“三数之和”是刷题路上绕不开的一道题。它要求找出所有三个数之和为 0 的组合并且不能包含重复的三元组。思路是先把数组排序然后固定第一个数 nums[i]在 i 后面的区间里用双指针找两个数使它们的和等于 -nums[i]。class Solution { public: vectorvectorint threeSum(vectorint nums) { vectorvectorint ans; int n nums.size(); sort(nums.begin(), nums.end()); for (int i 0; i n - 2; i) { // 外层去重跳过重复的第一个数 if (i 0 nums[i] nums[i - 1]) continue; // 剪枝优化最小的三个数加起来都大于0后面不可能有解 if (nums[i] nums[i 1] nums[i 2] 0) break; // 剪枝优化当前数和最大的两个数加起来都小于0这个i直接跳过 if (nums[i] nums[n - 1] nums[n - 2] 0) continue; int left i 1, right n - 1; while (left right) { int sum nums[i] nums[left] nums[right]; if (sum 0) { ans.push_back({nums[i], 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--; } else if (sum 0) { left; } else { right--; } } } return ans; } };这道题最折磨人的不是双指针移动而是去重。我第一版代码写完跑测试用例发现输出了大量重复三元组比如 [-1, 0, 1] 出现两三次原因就是去重逻辑写错了地方。外层去重必须判断 nums[i] 和 nums[i-1] 是否相等而不是判断 nums[i] 和 nums[i1] 是否相等。如果你写成if (i 0 nums[i] nums[i 1]) continue当数组里有三个连续 -1 时比如 [-1, -1, -1, 2]固定的第一个数被跳过合法的组合 [-1, -1, 2] 就直接被丢掉了。这个坑我后来用纸笔推了一遍才发现。内层去重的正确时机是只有在 sum 0已经记录了一个答案之后才需要跳过重复元素。如果在找答案之前就跳过可能把边界上可能构成答案的重复值给过滤掉。而且去重之后记得再 left、right-- 一次否则指针会停在最后一个重复值上进入死循环。2.3 盛最多水的容器移动较矮一侧的贪心证明力扣第 11 题“盛最多水的容器”题目描述很简单给一个数组 height每个值代表柱子的高度选择两根柱子与 x 轴构成一个容器求最多能装多少水。面积公式是min(height[left], height[right]) * (right - left)。这道题的解法比两数之和还简单但难在理解“为什么移动较矮的一侧是正确的”。class Solution { public: int maxArea(vectorint height) { int left 0, right height.size() - 1; int ans 0; while (left right) { int area min(height[left], height[right]) * (right - left); ans max(ans, area); if (height[left] height[right]) { left; } else { right--; } } return ans; } };核心证明只有一句话容器的容积由较矮的那根柱子和宽度决定。如果当前左边矮那么移动左边这根矮柱子虽然宽度减少了一点但容器的高度有可能变大所以面积有上升的潜力反之如果移动右边那根高柱子宽度同样减少但高度永远不可能超过左边那根矮柱子所以面积最多持平大概率变小移动高侧没有任何收益。当两根柱子高度相等时移动哪边都可以。因为此时当前面积已经是在这个高度下能达到的最大值宽度再收缩就算后面出现更高的柱子另一边还是当前这根矮柱子高度不会变面积只会更小所以不会漏掉最优解。这道题用到的“谁矮移动谁”的判断逻辑和接雨水的双指针写法有很强的关联我建议把 11 题和 42 题放在一起刷对比着看能加深对双指针剪枝的理解。2.4 接雨水双指针法的贪心本质说到“三维接雨水”很多人会先被吓到但其实力扣第 42 题“接雨水”这个二维版本本身就是一道很经典的 hard。它的核心计算方式是对于每个位置它能接的水量等于min(左侧最大高度, 右侧最大高度) - 当前位置高度如果结果是负数就按 0 算。动态规划的思路很好理解从左往右算一遍 leftMax 数组从右往左算一遍 rightMax 数组然后逐列累加。但双指针法更漂亮可以做到 O(1) 额外空间只是需要想清楚它到底在贪什么。class Solution { public: int trap(vectorint height) { int n height.size(); if (n 3) return 0; int left 0, right n - 1; int leftMax 0, rightMax 0; int ans 0; while (left right) { leftMax max(leftMax, height[left]); rightMax max(rightMax, height[right]); if (leftMax rightMax) { ans leftMax - height[left]; left; } else { ans rightMax - height[right]; right--; } } return ans; } };这里最绕的地方是对位置 left 来说它右侧的最大值明明还没完全确定为什么可以用 rightMax 来判断关键在于当 leftMax rightMax 时位置 left 右侧至少已经存在一个高度为 rightMax 的柱子所以位置 left 真正的右侧最大值一定大于等于 rightMax也一定大于 leftMax。那么min(左侧最大值, 右侧最大值)就一定是 leftMax不管中间还没扫描的部分有多高都不会影响位置 left 的积水高度。于是可以直接用leftMax - height[left]算出当前位置的积水量然后 left。右侧对称同理。这个结论我第一次看题解也没懂后来拿一个具体数组手动推了一遍才明白。你如果也卡在这里强烈建议自己模拟一遍[0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]这个官方用例把每一步的 left、right、leftMax、rightMax 写下来推完就会豁然开朗。作为扩展三维接雨水力扣 407思路是二维版本的进阶把最外层一圈柱子放进最小堆每次弹出最矮的边界柱子从它向内扩展用“边界围栏”的思路维护水位。核心思想仍然是用“最矮的边界”来决定能蓄多少水只是数据结构从双指针换成了堆和 visited 标记。建议先把二维版本彻底吃透再碰三维。3. 实操中的踩坑记录与调试技巧3.1 三数之和去重用 nums[i] nums[i-1] 而不是 nums[i1]这个坑我前面提过一次但值得单独拿出来再说一遍因为它是三数之和出错率最高的点。错误写法if (nums[i] nums[i 1]) continue;这会导致所有“第一个数后面还跟着相同元素”的合法组合全被跳过。比如数组 [-1, -1, 2]排序后是 [-1, -1, 2]i0 时 nums[0] nums[1]按错误写法直接 continue但 [-1, -1, 2] 的和正好是 0是合法答案。正确写法if (i 0 nums[i] nums[i - 1]) continue;这样只有当前元素和它前一个已经处理过的元素相同时才跳过也就是说同一个数值只做一次“固定第一个数”的尝试但内部的双指针仍然可以自由组合不会漏掉包含重复元素的合法三元组。这个细节我会建议每个刷三数之和的人都手写一遍 Debug 一下因为看答案永远比自己踩坑记得牢。3.2 while 的边界条件如何选left right 还是 left right相向双指针的循环条件绝大多数题目都应该写while (left right)原因很简单两个指针指向同一个元素时意味着用同一个位置的数当两个不同元素使用这在两数之和、三数之和、盛水容器这些题里都是不合法的。只有一种情况我会考虑left right那就是在二分查找里找单个目标值。二分查找和相向双指针虽然都有左右指针但解决的问题模型完全不同不要混用。实际编码时还有一个隐蔽问题是内层去重 while 里如果漏掉left right的条件在极端情况下会数组越界。比如三数之和内层去重while (left right nums[left] nums[left 1]) left;如果去掉前面的left right当数组里全是重复元素时left 会一路加到越界。这个错误编译器不报错但运行时会出大问题。3.3 数组无序时不要硬套双指针相向双指针好用但它不是万能的。力扣第 1 题“两数之和”就是无序数组很多人在我评论区问“为什么不能用双指针排序后不就行了吗”答案是可以排序后使用双指针但这道题要求返回两个数的原始下标排序后下标就全丢了。如果你用 pair 保存原下标再排序代码会变得比哈希表方案复杂而且哈希表的 O(n) 时间和 O(n) 空间完全够用不需要绕这一圈。所以我的建议是看到“找两个元素”的题目先问自己三个问题数组是不是有序的如果无序排序会破坏我要返回的索引吗排序后还能保持问题的原始语义吗只有这三个问题的答案都合适才放心用相向双指针。3.4 调试双指针题目的通用手段我自己调试双指针题目时最常用的方法是在 while 循环里加一行输出打印当前 left、right、sum、以及指针下一轮要移动的方向。以两数之和为例临时加打印while (left right) { int sum numbers[left] numbers[right]; cout left left right right sum sum endl; // ... }跑一个简单的用例比如 [1, 2, 7, 11] target9看输出left0, right3, sum12 - right--left0, right2, sum8 - leftleft1, right2, sum9 - 找到这样一眼就能看出来指针移动是否符合预期。如果发现输出里 left 和 right 在某两行之间完全没变那就是死循环的征兆重点检查是不是某个分支少写了指针移动。4. 常见问题与排查技巧实录4.1 为什么我的双指针会死循环死循环的常见原因有三个第一找到答案后没有移动指针。比如三数之和里 sum 0 的分支如果只记录结果但不 left、不 right--下一次循环还停在原位置于是死循环。第二内层去重 while 写成了死循环。比如while (nums[left] nums[left 1]) left;如果没有用 left right 作为约束并且数组里全是重复值left 会一直加到自己都不等于自己为止——不对实际上数组可能越界但在此之前可能已经陷入无限循环。第三相等时两个指针都没有移动。两数之和里 sum target 直接 return所以没问题但在其他题目里如果条件相等时没有明确的指针移动逻辑就会卡死。排查死循环最快的方式就是加打印。我在刷题群里看到很多人问“为什么我这个代码超时”十有八九是死循环打印一开就真相大白。4.2 如何快速判断该移动 left 还是 right这个问题我总结了一个口诀对大多数题都好用和太小left两数之和、三数之和都是这个逻辑和太大right--同理和相等记录结果后双指针同时向中间收。盛水容器、接雨水这类题则是“谁矮移动谁”。“谁矮移动谁”其实是相向双指针在求容量类问题里的通用直觉因为容量和积水的高度都受限于较矮的那一侧移动较高的一侧只会让宽度变小但高度不会增加收益为负。4.3 顺着刷题顺序和复盘模板如果你刚开始练相向双指针我建议按这个顺序来题目难度核心考点建议完成时间344 反转字符串简单双指针交换字符10分钟125 验证回文串简单双指针 字符过滤15分钟167 两数之和 II简单基础对撞模型15分钟11 盛最多水的容器中等贪心 移动矮边20分钟15 三数之和中等去重 双指针30分钟18 四数之和中等三数之和的扩展30分钟42 接雨水困难双指针贪心40分钟如果这些题都能独立写出来相向双指针这块就算是过关了。我自己复盘时给每个题目建了一个简单的表格字段包括日期、题号、标签、是否一次 AC、核心思路总结、踩过的坑、同类题链接。坚持了一个多月后最明显的变化是再看到数组相关的题第一反应不再是“暴力能不能过”而是“能不能排序”、“能不能用双指针剪枝”、“能不能从两端往中间收敛”。这种条件反射比你刷十道题但从不复盘有用得多。最后一句话送给正在刷题的读者双指针本质上是通过利用了数据的顺序信息来减少无效比较它的优雅程度和代码长度往往不成正比——判断越短想明白背后的“为什么”就越值钱。
返回列表