相向双指针详解:从两数之和到接雨水的经典模板
2026/9/7 18:38:01 网站建设 项目流程

相向双指针,也叫左右指针、对撞指针,是力扣刷题里最容易被低估的一类技巧。我第一次刷到这类题时直接翻车:拿到两数之和就两层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 指向 a,right 指向 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。

left=0 指向 1,right=7 指向 8,1+8=9 小于 10。此时 8 是数组里最大的数,1 跟最大的数加起来都不够 10,那么 1 跟 2、3、4、5、6、7 组合也一定不够,所以 left 直接跳到 2。这一步排除了 7 种组合,而不是一个组合。用这种方式理解“剪枝”,就不会再觉得双指针是什么玄学了。

2. 从两数之和到接雨水:四道经典题的完整复盘

2.1 两数之和 II:双指针最朴素的落地

力扣第 167 题“两数之和 II - 输入有序数组”是相向双指针的入门题。题目给了一个已经按升序排列的数组,要求找出两个数,使它们的和等于目标值,返回下标加一。

我一开始的暴力解法是双重循环,测小数组没问题,一上大数组就超时。后来改成双指针,代码短到让我有点恍惚:

class Solution { public: vector<int> twoSum(vector<int>& 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: vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> 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[i+1] 是否相等。如果你写成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(vector<int>& 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(vector<int>& 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[i+1]

这个坑我前面提过一次,但值得单独拿出来再说一遍,因为它是三数之和出错率最高的点。

错误写法:

if (nums[i] == nums[i + 1]) continue;

这会导致所有“第一个数后面还跟着相同元素”的合法组合全被跳过。比如数组 [-1, -1, 2],排序后是 [-1, -1, 2],i=0 时 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] target=9,看输出:

  • left=0, right=3, sum=12 -> right--
  • left=0, right=2, sum=8 -> left++
  • left=1, right=2, sum=9 -> 找到

这样一眼就能看出来指针移动是否符合预期。如果发现输出里 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、核心思路总结、踩过的坑、同类题链接。坚持了一个多月后,最明显的变化是再看到数组相关的题,第一反应不再是“暴力能不能过”,而是“能不能排序”、“能不能用双指针剪枝”、“能不能从两端往中间收敛”。这种条件反射,比你刷十道题但从不复盘有用得多。

最后一句话送给正在刷题的读者:双指针本质上是通过利用了数据的顺序信息来减少无效比较,它的优雅程度和代码长度往往不成正比——判断越短,想明白背后的“为什么”就越值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询