双指针这个技巧,说实话,是我刷 LeetCode 这么久以来觉得性价比最高的算法之一。它不像动态规划那样需要极强的抽象思维,也不像图论那样有一堆复杂的模板,核心思想往往几句话就能讲明白,但一旦用对地方,能把 O(n²) 甚至 O(n³) 的时间复杂度直接砍到 O(n),这种「降维打击」的快感,用过一次就上瘾。这篇文章不打算堆砌一堆抽象的概念,而是从原理入手,配合 LeetCode 上几道最经典的例题,一步步拆解双指针的几种核心模式和背后的设计动机。无论你是刚开始刷题的萌新,还是被某些中等题卡住想要突破的进阶选手,这篇文章都值得你花十几分钟读完,之后遇到「有序数组」、「链表环检测」、「最长回文子串」这类问题,你会自然想到双指针这个利器。
我最早接触双指针,其实并不是在 LeetCode 上,而是在一次排查线上性能问题时,发现一段合并两个有序列表的代码跑得非常慢。当时那代码是两层循环硬怼,数据量一上来直接超时。后来用双指针重写了一遍,逻辑不仅清晰了,速度也快了几个数量级。从那以后我就意识到:双指针不只是一个刷题技巧,它更是一种优化的思维习惯,帮你敏锐地发现数据之间的「位置关系」,用空间换时间,或者干脆连空间都不换,只靠两个游标的移动就解决问题。
1. 双指针算法核心原理与设计思路
1.1 双指针到底在解决什么问题
要理解双指针,先得理解暴力解法为什么慢。拿「两数之和 II - 输入有序数组」这道题来说,如果暴力枚举两个下标,需要两层循环,时间复杂度 O(n²)。之所以慢,是因为它枚举了大量明显不可能成立的组合。比如数组已经排好序了,nums[0] + nums[5] 已经大于目标值,那 nums[0] + nums[6]、nums[0] + nums[7] 这些组合根本不需要再看。但暴力循环不知道这一点,它还是会傻傻地全走一遍。
双指针的本质,就是利用数据本身的结构特征(最常见的是有序性),让两个指针按照一定的规则移动,从而跳过那些「不可能的分支」,把枚举空间从二维压成一维。你可以想象成两个人配合在一排有序的书架上找两本书,让它们的页码加起来等于目标值:一个从最薄的书开始看,一个从最厚的书开始看,如果两本加起来太厚,就让厚的那边往左挪一挪;如果太薄,就让薄的那边往右挪一挪。整个过程不需要回头,最多走一趟就能找到答案。
这种「夹逼」的思想,就是双指针最核心、最基础的应用。它不依赖额外的数据结构,不需要哈希表,不需要排序(因为题目已经帮你排好了),只需要维护两个下标,在循环里根据条件判断移动左边还是右边。很多初学者容易把双指针和二分查找搞混,其实两者有本质区别:二分查找关注的是「一个点」在有序空间中的位置,双指针关注的是「两个点」的关系,这两个点之间往往是相互制约、相互影响的。
1.2 双指针的三大常见模式与选型
根据使用场景的不同,双指针在实战中演化出了几种固定模式,选对模式是解题的关键第一步。
第一种是「对撞指针」,也叫左右指针。两个指针分别指向数组的首尾,然后根据条件向中间移动,直到两个指针相遇。这种模式最常见的应用场景就是有序数组、回文串判断、反转数组等。它的时间复杂度是 O(n),空间复杂度 O(1)。对撞指针的移动方向是相反的,一个往右,一个往左,像两台推土机对着开,所以叫「对撞」。
第二种是「快慢指针」。两个指针从同一起点出发,但移动速度不一样,一个每次走两步,一个每次走一步。这种模式最经典的应用就是链表环检测(Floyd 判圈算法),以及寻找链表中点、寻找链表倒数第 K 个节点等场景。快慢指针不依赖数据的有序性,它利用的是「追赶」的逻辑——如果链表有环,快指针最终一定会追上慢指针,就像在环形跑道上,跑得快的人终将套圈跑得慢的人。
第三种是「滑动窗口」(其实也属于双指针的一个变体)。两个指针同向移动,一前一后维护一个窗口区间。这种模式专门用来解决「连续子数组 / 连续子串」类问题,比如「最长无重复子串」、「最小覆盖子串」。滑动窗口的核心是维护窗口的语义——什么时候扩大右边界,什么时候收缩左边界,这个「窗口的合法性」是解题的关键。很多人会把滑动窗口当作一个独立算法来学,但本质上它就是双指针的另一种形态,只是两个指针同向且距离不固定而已。
我在实际刷题时,拿到一道题会先问自己三个问题:这个数组是否有序?我关注的是两个元素的关系还是连续区间?两个指针是否需要从同一个起点出发?这三个问题基本能帮你确定该用哪种双指针模式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LeetCode 经典例题实战拆解
理论讲再多,不如动手做一道题。这一章节我选了四道最经典、最能代表双指针不同应用方向的 LeetCode 题目,从题目分析到代码实现,再到复杂度分析,一步步带你走完整套解题流程。
2.1 两数之和 II - 输入有序数组(LeetCode 167)
这道题是双指针入门的必修课。题目给你一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数。
最直接的暴力解法就是两层循环,把每一对组合都试一遍。但题目给了一个至关重要的条件——「已按非递减顺序排列」。这就是在暗示你:不需要两层循环,有更优的解法。我自己的思考路径是这样的:如果我用两个指针,一个指向最左边的数(最小值),一个指向最右边的数(最大值),它们的和无非三种情况:
- 和等于
target,直接返回结果; - 和小于
target,说明太小了,需要更大一点的数,所以左指针往右移动一位; - 和大于
target,说明太大了,需要更小一点的数,所以右指针往左移动一位。
这个过程就像是在调整天平的两端,每次移动都排除掉了一整批不可能的组合。为什么?因为左指针往右移动,意味着放弃了当前左指针和右指针左侧所有数的组合(它们加起来更小);右指针往左移动,意味着放弃了当前右指针和左指针右侧所有数的组合(它们加起来更大)。这些被跳过的组合,在数学上已经证明不可能等于 target,所以跳过它们是完全安全的。
python复制def twoSum(numbers, target):
left, right = 0, len(numbers) - 1
while left < right:
current_sum = numbers[left] + numbers[right]
if current_sum == target:
return [left + 1, right + 1] # 题目要求下标从1开始
elif current_sum < target:
left += 1
else:
right -= 1
return []
这段代码的精华就在 while 循环里的三行判断。最坏情况下,左指针和右指针向中间移动,总共移动 n 步就结束了,时间复杂度 O(n)。空间上只用了两个变量,O(1)。从 O(n²) 到 O(n),这就是双指针带来的巨大提升。这道题还有另一个解法是用哈希表,时间复杂度同样是 O(n),但空间复杂度是 O(n)。双指针解法最大的优势在于:不需要额外空间,而且代码更简洁。
2.2 最长回文子串(LeetCode 5)
如果说上一道题是双指针的「入门机制」,那这道题就是双指针的「进阶考验」。题目要求在一个字符串中找出最长的回文子串。暴力解法是枚举所有子串(O(n²) 个),再对每个子串判断是否为回文(O(n)),总复杂度 O(n³),这显然是无法接受的。
这道题用双指针的「中心扩展法」来解决。核心思想是:回文串的特点是对称的,所以我们可以把每个字符(或每两个相邻字符的中间位置)当作回文的中心,然后用两个指针从中心向外扩展,直到不满足回文条件为止。注意这里有坑:回文串的长度可能是奇数(中心是一个字符),也可能是偶数(中心是两个字符之间)。所以中心可能有 n 个(单字符中心)加上 n-1 个(双字符中心),一共 2n-1 个中心,每个中心向外扩展最多需要 O(n) 的时间,总复杂度 O(n²)。
python复制def longestPalindrome(s):
if not s or len(s) < 1:
return ""
start, end = 0, 0
def expand_around_center(left, right):
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return left + 1, right - 1
for i in range(len(s)):
l1, r1 = expand_around_center(i, i) # 奇数长度中心
l2, r2 = expand_around_center(i, i + 1) # 偶数长度中心
if r1 - l1 > end - start:
start, end = l1, r1
if r2 - l2 > end - start:
start, end = l2, r2
return s[start:end + 1]
我当初做这道题的时候,踩过一个非常隐蔽的坑:中心扩展函数的返回值。如果扩展超出了边界或者遇到不相等字符,循环停止时 left 和 right 已经越界或者指向了不匹配的字符,需要把它们回退到合法范围内。所以返回的是 left + 1 和 right - 1,而不是直接的 left, right。这个细节很容易出错,建议你写的时候画个图模拟一下。
另外,这道题还有一种更优的解法叫 Manacher 算法,可以在 O(n) 时间内解决,但那个算法理解门槛较高,涉及动态规划的思想和巧妙的对称性复用。我个人的建议是:如果你面试时遇到最长回文子串,先用中心扩展法讲清楚思路(O(n²) 一般够用),如果面试官追问能否更快,再展开 Manacher 算法。双指针的中心扩展法虽然时间复杂度不是最优,但胜在思路直观、代码简单、不容易写错。
2.3 合并两个有序数组(LeetCode 88)
这道题出现的频率极高,而且特别能体现双指针的另一个应用方向——从后往前遍历。题目要求把两个非递减排列的数组 nums1 和 nums2 合并到 nums1 中,nums1 的长度是 m + n,前 m 个位置是有效元素,后面 n 个位置是空的(用 0 占位)。
大部分人的第一反应是从前往后合并:用两个指针分别指向 nums1 的有效起始位和 nums2 的起始位,比较大小后放入结果数组。但这样做有一个致命问题:nums1 的有效元素会被覆盖。你如果把小的元素放到前面,原本在 nums1 里的元素还没被比较就被覆盖了。解决办法是多开一个临时数组存结果,但这需要 O(m+n) 的额外空间。
其实我们完全可以不额外开空间,只要换一个方向——从后往前填充。为什么从后往前就不会覆盖?因为 nums1 的后面 m 位是空的,合并后总长度是 m+n,我们从最后一个位置开始填,填的一定是当前所有还未放置元素中最大的那个。这个最大的元素放进去之后,它所在的位置一定是最终位置,不会影响还没处理的元素。两个指针,一个指向 nums1 的有效末尾(m-1),一个指向 nums2 的末尾(n-1),再加上一个指针指向合并后数组的末尾(m+n-1),每次比较两个指针指向的元素,把更大的放进尾部位置。
python复制def merge(nums1, m, nums2, n):
p1, p2 = m - 1, n - 1
p = m + n - 1
while p1 >= 0 and p2 >= 0:
if nums1[p1] > nums2[p2]:
nums1[p] = nums1[p1]
p1 -= 1
else:
nums1[p] = nums2[p2]
p2 -= 1
p -= 1
# 如果 nums2 还有剩余,直接拷贝到 nums1 前面
while p2 >= 0:
nums1[p] = nums2[p2]
p2 -= 1
p -= 1
注意最后的那个 while p2 >= 0,它处理的是 nums2 中还有剩余元素的情况。为什么不需要处理 p1 >= 0 的情况?因为 nums1 的前 m 个位置本来就在最终位置上了,如果 p1 还有剩余,说明它们已经就位,不需要额外处理。这个细节很多初学者会忽略,或者干脆把两个 while 都写上,其实多余的 while 也不影响正确性,但理解了原理后你就会觉得代码异常简洁。
这道题从后往前合并的思路,在面试中特别受面试官喜欢,因为它考察了你对「覆盖风险」的敏感性,以及能否通过改变遍历方向来规避额外空间。实际工程中,如果内存敏感(比如嵌入式环境下合并两个很大的数组),这个技巧非常实用。
2.4 盛最多水的容器(LeetCode 11)
这道题完美诠释了对撞指针的精髓——如何通过「贪心收缩」来剪枝。题目是给一个整数数组 height,每个元素代表一个竖线的高度,你需要找到两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
暴力解法又是两层循环,枚举所有两根柱子的组合,求面积,取最大值。但仔细分析就会发现,很多组合是根本没有必要比较的。容器的面积由两个因素决定:两根柱子之间的距离,以及较短那根柱子的高度。面积 = 距离 × 较短高度。
用对撞指针的思路:一开始左指针指向第一根柱子,右指针指向最后一根柱子,此时距离最大。接下来,关键的一步来了:如果左指针的高度小于右指针的高度,那么以左指针为边界的所有容器,高度都不可能超过左指针的高度(因为容器高度由短边决定),而距离又只会随着指针收缩而减小。换句话说,在当前的右指针位置下,左指针已经达到了它的「最优解」,以左指针为左边界的组合不需要再考虑了,直接让左指针右移。同理,如果右指针更矮,就让右指针左移。这样每一轮都能排除掉一批不可能成为最大值的组合。
python复制def maxArea(height):
left, right = 0, len(height) - 1
max_water = 0
while left < right:
current_water = min(height[left], height[right]) * (right - left)
max_water = max(max_water, current_water)
if height[left] < height[right]:
left += 1
else:
right -= 1
return max_water
用一句话总结这个策略就是:每次移动较矮的那一边。这个决策背后的数学逻辑是,如果我们移动较高的那一边,新的面积不可能比当前更大,因为距离减少了,而高度最多也不会超过原本较矮的那一边的高度。移动较矮的那一边,才有可能遇到更高的柱子,从而提升面积的上限。
这道题我在面试中遇到过不只一次,面试官考察的重点不是你背下了代码,而是你能不能清晰地解释「为什么要移动较矮的那一边」以及「这样为什么不会错过最优解」。如果你能用反证法说清楚,基本就稳了。
2.5 链表中的双指针:环形链表与链表中点
前面几道题处理的对象都是数组,双指针在链表场景下同样大放异彩。最经典的就是「环形链表」(LeetCode 141)。题目给一个链表,判断链表中是否有环。常规的想法是用哈希表记录访问过的节点,空间复杂度 O(n)。但用快慢指针,可以在 O(1) 空间内解决。
快指针每次走两步,慢指针每次走一步。如果链表没有环,快指针会先到达尾部(null),遍历结束。如果链表有环,快指针会在环内一直转圈,慢指针也会进入环中。由于快指针比慢指针快一步,两个指针一定会在环中相遇。这个结论可以用相对速度来理解:把慢指针视为静止,快指针相对慢指针的速度是每步 1 个节点,所以快指针一定能追上慢指针(在环上)。
python复制def hasCycle(head):
if not head or not head.next:
return False
slow, fast = head, head.next
while slow != fast:
if not fast or not fast.next:
return False
slow = slow.next
fast = fast.next.next
return True
除了判断环,「寻找链表中点」也是快慢指针的高频应用。快指针走两步,慢指针走一步,当快指针到达链表末尾时,慢指针正好指向中间节点。如果要找「第 K 个节点」或「倒数第 K 个节点」,可以让快指针先走 K 步,然后两个指针同速前进,快指针走到终点时,慢指针就在倒数第 K 个位置。这套组合拳在链表题里非常实用,建议把「快慢指针」和「前后指针」这两种形态都练熟。
3. 滑动窗口与双指针的组合应用
3.1 无重复字符的最长子串(LeetCode 3)
前面提到过,滑动窗口本质上是双指针的一个变体。这道题是滑动窗口最典型的应用:给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
暴力解法是枚举所有子串,然后判断子串里有没有重复字符,时间复杂度 O(n³)。即使优化一下,用哈希集合判断重复,也需要 O(n²)。滑动窗口的思路是:维护两个指针 left 和 right 作为一个窗口的边界,窗口内始终没有重复字符。右指针不断向右扩展,把新字符纳入窗口。如果新字符和窗口内的某个字符冲突了,就把左指针向右收缩,直到窗口内重新合法。
这个过程中,左指针和右指针始终同向移动,窗口就像一条在字符串上爬行的毛毛虫,一伸一缩地前进。
python复制def lengthOfLongestSubstring(s):
char_set = set()
left = 0
max_len = 0
for right in range(len(s)):
while s[right] in char_set:
char_set.remove(s[left])
left += 1
char_set.add(s[right])
max_len = max(max_len, right - left + 1)
return max_len
这道题的关键就在于,怎么高效地判断「窗口内是否存在重复字符」。我用的是 set,但有个细节需要注意:当发现重复时,不能直接把重复字符从 set 里删掉就完事,而是一步步把左指针移过去,同时把沿途的字符全部从 set 中移除。因为窗口是一个连续区间,左指针收缩时,离开窗口的每一个字符都要从 set 里删掉,否则 set 就会残留窗口外的字符,导致误判。
更高效的做法是用一个哈希表记录每个字符最后出现的位置,左指针可以直接跳到 max(left, 上次出现位置 + 1),省去了一步步收缩的过程。代码会更复杂一些,但也值得掌握。
3.2 滑动窗口与双指针的区别和联系
很多人分不清滑动窗口和对撞指针,我在这里用一个表格把它们区分开:
| 维度 | 对撞指针 | 滑动窗口 |
|---|---|---|
| 指针移动方向 | 相向移动(左右往中间) | 同向移动(都往右) |
| 典型应用 | 有序数组两数之和、回文判断 | 最长无重复子串、最小覆盖子串 |
| 数据要求 | 通常要求有序 | 不要求有序,但需要维护窗口语义 |
| 核心问题 | 什么时候移动左指针、什么时候移动右指针 | 什么时候扩大窗口、什么时候收缩窗口 |
不过它们本质上是一家人——都是通过两个指针维护某种「状态」,从而避免重复计算,将时间复杂度从高阶降到线性。你在刷 LeetCode 时会发现,有些题目既可以理解为对撞指针,也可以理解为滑动窗口,关键看你从哪个角度切入。比如「长度最小的子数组」(LeetCode 209)就是一个同向双指针问题,因为要找的是连续子数组,窗口满足某种条件时收缩左边界。
我在做滑动窗口题时有一个心得:一定要先明确窗口的「语义」,也就是窗口内的元素满足什么条件。是「无重复字符」?还是「和大于等于 target」?还是「包含目标串的所有字符」?把这个语义写清楚,代码逻辑就清晰了。另一个常用技巧是:外层循环移动右指针,内层循环(用 while)移动左指针,直到窗口重新合法,然后更新结果。这个套路能覆盖大部分滑动窗口题目。
4. 常见问题与排查技巧实录
4.1 死循环与边界条件
双指针代码写起来简单,但真正跑起来出 bug 的也不少见。最常见的 bug 就是死循环。比如对撞指针中,你移动指针的时候忘记检查 left < right 条件,两个指针就可能交错而过,甚至无限循环。还有快慢指针中,如果快指针移动时没有判断 fast.next 是否为空,访问 fast.next.next 就会抛空指针异常。
我的习惯是:在写循环体之前,先把循环条件写清楚。对撞指针的循环条件是 while (left < right),快慢指针的循环条件是 while (fast != null && fast.next != null),滑动窗口的循环条件是 while (right < n) 加上内部的 while 收缩条件。边界条件不是靠记,而是靠每次写完后在心里模拟一遍边界情况:数组长度为 0、长度为 1、两个指针相邻、指针相遇、指针越界,这些情况全部走一遍,代码基本就稳了。
4.2 从 O(n²) 到 O(n) 的思考路径
很多读者问我:我拿到一道题,怎么知道该用双指针?我怎么看出来题目可以用双指针优化?我的回答是:先写暴力解,再看「单调性」。
暴力解通常是两层循环,外层枚举一个变量,内层枚举另一个变量。如果你发现内层循环的某些枚举是不必要的——比如因为数组有序,当前枚举的组合已经不满足条件了,那么后面的组合也一定不满足——这时候就有优化的空间。双指针能够成立背后恰恰依赖的就是这种「单调性」:左指针往右移动会让某个值单调递增(比如两数之和的和),或者让窗口变长、变短。一旦你意识到数据存在这种单调性,就可以用双指针来剪枝。
举个直观的例子:在「盛最多水的容器」里,左指针固定时,右指针越往左,距离越小;如果你把较矮的柱子固定住,无论右指针怎么移动,面积都不会超过当前值。这种「无论如何都不可能更好」的判断,就是剪枝的依据。多练几道双指针题后,你会培养出一种做题直觉:看到一个序列,会下意识地问自己——这个序列上的某个量,是否随着指针移动而单调变化?
4.3 常见错误速查表
| 错误类型 | 具体场景 | 解决方法 |
|---|---|---|
| 指针越界 | 快指针移动时访问 fast.next.next |
循环条件加上 fast != null && fast.next != null |
| 死循环 | 对撞指针移动后没有更新指针 | 每次判断后确保 left 或 right 至少移动一步 |
| 覆盖数据 | 从前往后合并数组时覆盖了未处理的元素 | 改用从后往前遍历 |
| 返回值搞错 | 中心扩展法返回的边界包含不合法字符 | 返回 left + 1, right - 1 |
| 忘记了窗口语义 | 滑动窗口收缩左指针时没有同步更新辅助数据结构 | 每次 left++ 后同步删除/更新对应的记录 |
4.4 双指针结合其它算法的进阶玩法
双指针虽然强大,但它往往不是一道题的终点,而是解题过程中的一环。我在刷 LeetCode 热门 100 题时发现,很多「困难」题都是多种算法的组合。比如「三数之和」(LeetCode 15),固定一个数之后,剩下两个数就是一个双指针问题;「接雨水」(LeetCode 42)既可以用双指针,也可以用单调栈;「最小覆盖子串」(LeetCode 76)是滑动窗口 + 哈希表计数的经典组合。
所以学习双指针,不应该只背模板,而应该把它当作一把「瑞士军刀」——它小巧、灵活,能嵌入到各种复杂算法中。当你面对一道题很复杂、不知道从何下手时,可以先固定住某个变量,看剩下的子问题能不能用双指针解决。这种「化繁为简」的思维方式,其实正是算法工程师在日常工作中最需要的核心能力。
在我自己带队做代码评审时,会特别关注那些能用双指针却写了一堆复杂逻辑的代码。不是说复杂逻辑一定不好,但双指针通常意味着更低的资源消耗和更高的可读性。尤其在处理大量实时数据流的场景(比如合并多个有序日志流、滑动窗口统计在线用户数),双指针几乎是最优雅的解决方案。
如果你正在准备面试,我建议你把本文提到的这几道题全部自己手写一遍,不要看答案,写完再对照优化。双指针的「手感」是练出来的,不是看出来的。当你能不假思索地说出「这道题为什么能双指针、移动哪个指针、如何证明不会错过最优解」时,这一关你就真正过了。
