看题解的时候觉得双指针算法特别简单:左指针往右挪,右指针往左挪,或者一个走快一个走慢,来回就那几行代码。可一旦脱离题解自己上手,很多人会陷入同一种窘境——盯着题目很久,也不敢确定这题到底能不能用双指针,指针该从哪头开始。我在这个阶段挣扎了很长时间,直到把刷过的双指针题目放在一起横向对比,才真正理解它的本质:双指针不是一种需要硬记的招式,而是利用序列本身的顺序特征,把暴力解法中大量无效的搜索空间系统性地剪掉。这篇内容想用几个最经典的题目,把双指针的几类典型模式拆开讲清楚,包含每类模式的适用条件、核心代码和我在实际刷题过程中踩过的细节坑。不管你现在是刚开始刷题的入门选手,还是准备面试需要快速过一遍算法框架的开发者,这篇文章都值得耐心读完。
1. 暴力解法到底浪费在哪:双指针效率的根源
讲双指针之前,我觉得有必要先把"暴力解法为什么慢"这个问题想透。只有理解了浪费在哪里,才能明白双指针的每一步移动到底在省什么。
1.1 一个最简单的例子:有序数组的两数之和
假设现在有一个有序数组 numbers = [1, 2, 3, 4, 6, 8, 9, 11],目标值是 10,要求找出两个数,使它们的和等于 target,返回这两个数的下标。
暴力解法非常直接:两层循环枚举所有的二元组合,第一次外循环固定 1,内循环依次跟 2、3、4、6、8、9、11 相加;第二次外循环固定 2,再依次跟后面的所有元素相加。这样一共要比较 C(8, 2) = 28 次,当数组长度是 n 时,比较次数是 n*(n-1)/2,时间复杂度是 O(n²)。
如果数组长度是 1000,大概是 50 万次比较;如果长度是 10 万,那就是 50 亿次。这个增长速度在真实场景下很难接受。
关键是,这 28 次比较里有多少是"注定没结果"的?非常多。固定 1 的时候,1 + 11 = 12 已经大于 10,后面继续加 9、加 11 都只会更大,这些比较完全没有意义。换句话说,暴力解法最大的问题不是"比较慢",而是"把大量一边已经越界的组合依然拿过来硬比"。
1.2 单调性:双指针能成立的底层合同
为什么双指针能把这些无效比较直接跳过去?
因为数组是有序的,这给了我们一个非常强的信息:numbers[left] 和 numbers[right] 都沿着各自的方向单调变化。如果 numbers[left] + numbers[right] < target,说明什么?说明在维持 right 不变的情况下,把 left 往右移动,得到的和会变大;反过来,如果把 left 往左移动(已经不能了)或者把 right 往右移动(也不能了),没有意义。而更大的和才有希望接近 target,所以唯一正确的方向是 left++。
同样的逻辑,如果 numbers[left] + numbers[right] > target,说明和太大了,继续增大 left 只会更大,唯一正确的方向是 right--。
这就是双指针效率的根源——每一步指针移动都是"被数据特征逼出来的唯一选择",不会像暴力那样做无意义的尝试。可以把这种性质理解成双指针能成立的"底层合同":你必须在序列上找到某种单调性,无论是数组有序、窗口内累积和单调增加,还是链表环结构的周期性,只要这个合同存在,双指针就能把你的搜索空间从平方米级别的穷举,压缩到一条线性的路径上。
我还想特意强调一下,这里说的单调性不一定是数组有序。滑动窗口里的累积和、快慢指针在环里的相对距离变化,本质上都是某种"可预测的单调关系"。我们在后面几章会反复看到这个思想的影子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 左右对撞指针:有序搜索和三数之和的骨架
2.1 双指针移动的"唯一性"原理
基于上面的分析,左右对撞指针的实现就非常自然了。left 指向数组开头,right 指向数组末尾,每次根据两数之和与 target 的大小关系,二选一地移动一个指针,直到相遇。
python复制def twoSum(numbers, target):
left, right = 0, len(numbers) - 1
while left < right:
s = numbers[left] + numbers[right]
if s == target:
return [left + 1, right + 1] # 题目要求下标从1开始
elif s < target:
left += 1
else:
right -= 1
return [-1, -1]
这段代码在有序数组两数之和里是标准答案,但比代码更重要的是理解它为什么不会漏解。
有人会担心:你每次只移动一个指针,是不是可能把正确答案错过了?不会。想象搜索空间是一个二维矩阵,left 是行索引,right 是列索引,矩阵中每个格子代表一个候选组合。由于数组有序,这个矩阵的数值沿着行方向和列方向都是单调的。当前格子的值小于 target 时,整行(固定 left,right 左边的所有列)的值都小于 target,可以整行放弃;当前格子的值大于 target 时,整列的值都大于 target,可以整列放弃。
每移动一次指针,要么放弃一整行,要么放弃一整列,搜索空间的规模从二维矩阵缩小到一条从右上角到左下角的折线路径。这就是为什么时间复杂度是 O(n),而且不会漏解。
2.2 三数之和的去重逻辑
两数之和是对撞指针最基础的形态,接下来看三数之和(LeetCode 15)。题目要求在一个无序数组中找到所有三元组,使三数之和为 0,并且不能包含重复的三元组。
思路是排序 + 固定一个数 + 对撞双指针。先把数组排序,固定 i 作为第一个数,然后在 i+1 到 n-1 的区间里用双指针找两个数,使它们的和等于 -nums[i]。
python复制def threeSum(nums):
nums.sort()
res = []
n = len(nums)
for i in range(n - 2):
if i > 0 and nums[i] == nums[i - 1]:
continue
target = -nums[i]
left, right = i + 1, n - 1
while left < right:
s = nums[left] + nums[right]
if s == target:
res.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 s < target:
left += 1
else:
right -= 1
return res
去重是这道题最容易写错的地方。我见过不少人试图把去重逻辑放在 while 循环的一开始,也就是在移动 left、right 之前就跳过重复元素,这样写不是绝对不能过,但会引入很多额外的边界判断,稍不留神就会漏解。比较稳的做法分成两层:外层固定在处理 nums[i] 时,如果它和前一个数 nums[i-1] 相同就直接 continue,因为以这个值为首的所有三元组在上一次循环中已经完整地找过了;内层则是在找到一组合法解之后,分别跳过所有与当前 left、right 指向值相同的元素,再偏移两个指针。原因是去重去的是"已经处理过的组合",而不是"即将要处理的组合"。任何时候你想提前跳过重复元素,都要重新推一遍边界,很容易把自己绕晕。
2.3 盛最多水的容器:依赖的不是有序,而是"移动短板"的单调逻辑
对撞指针还有一个经常被误解的变体——盛最多水的容器(LeetCode 11)。给一个数组 height,height[i] 表示位置 i 处隔板的高度,选择两个隔板,求能盛水的最大面积。
这道题的数组不一定有序,为什么还能用对撞指针?因为面积 A = min(height[left], height[right]) * (right - left) 具有这样的性质:固定当前的两个边界,如果移动较高的那一端,新的 min 高度不会超过原来矮的那一端(甚至可能更矮),同时宽度一定变小,所以面积必然不会变大。移动较短的那一端,才有机会遇到更高的板,从而增大面积。
这个逻辑实际上也是一种单调性——"移动高板一定不会让答案变得更好",因此移动高板这个方向是注定无效的,可以被剪掉。对撞指针的核心并不是数组必须有序,而是你必须找到"哪条路是死路"的证据。能证明移动某个指针方向不会产生更优解,剩下的移动方向就是唯一的活路,搜索空间自然一步步收缩到线性级。
3. 快慢指针:链表里那些容易被忽略的数学细节
双指针并非只能用在数组上,链表也是一种天然适合双指针的结构。快慢指针的核心思路是:两个指针从同一起点出发,以不同的速度前进,利用相对速度差来检测结构特征或者定位特定节点。
3.1 为什么快指针走 2 步一定能追上慢指针
最经典的应用是环形链表检测(LeetCode 141)。一个指针每次走一步,另一个指针每次走两步,如果链表里有环,它们一定会在某个位置相遇;如果没有环,快指针会先走到链表末尾。
python复制def hasCycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
我一开始有一个疑问:为什么快指针每次走 2 步,而不是走 3 步、走 4 步?后来发现,2 步不是拍脑袋定的。慢指针走 1 步、快指针走 2 步时,两者的相对速度恰好是 1,也就是说,快指针相当于"一个单位一个单位"地靠近慢指针。即使它们相距一整圈,也一定会在有限步内相遇,不存在"跨过去"的跳变问题。如果快指针一次走 3 步,相对速度是 2,虽然数学上也能证明能相遇,但分析起来要绕一些,完全没有必要给自己增加理解成本。
这个知识点看着简单,但面试的时候经常有追问:如果要求返回环的入口节点呢?这就是 LeetCode 142。设链表头到环入口的距离是 a,slow 走的路程是 a+b(b 是入口到相遇点的距离),fast 走的路程是 2(a+b)。由于 fast 在环里多走了 k 圈,有 2(a+b) = a+b+kL,也就是 a+b = kL,整理得 a = kL - b。
这个式子的含义是:从链表头走 a 步到达入口,与从相遇点走 a 步到达的也是同一个入口。所以代码逻辑是:找到相遇点之后,让一个新指针 p 从 head 出发,slow 从相遇点出发,两个指针每次都走一步,它们相遇的位置就是环的入口。
python复制def detectCycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
p = head
while p != slow:
p = p.next
slow = slow.next
return p
return None
这里我踩过一个坑:第一次写的时候,p 指针走的是 while p != fast,结果正确。后来改成 while p != slow 也对,因为此时 slow 和 fast 指向同一个节点。但要小心的是,一旦你复用 fast 来移动,就不能再用快慢关系来判断了。建议单独用一个新变量 p,语义清晰,不容易把自己绕晕。
3.2 链表中点和删除倒数第N个节点
快慢指针在链表里的另外两个高频应用是找中间节点和删除倒数第 N 个节点。
找中间节点时,慢指针一次走一步,快指针一次走两步。快指针到末尾时,慢指针恰好走到中间位置:
python复制def middleNode(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
删除倒数第 N 个节点的思路稍微绕一点:先让快指针领先慢指针 N 步,然后两个指针同步前进。当快指针到达链表末尾(None)时,慢指针恰好停在倒数第 N 个节点的前一个位置,此时可以直接执行删除操作。这里的关键是引入一个哑节点 dummy,否则删除头节点时需要一个额外的 if 分支,写起来很别扭,还容易遗漏边界。
这类题目共同揭示了一个模式:快指针和慢指针之间的"距离差"是人为预设的,这个距离差可以用于环检测(是否追上)、位置定位(差值固定)和中点确定(速度 2 倍)。理解了这一点,就能把链表双指针的题串成一条线,而不是每道题孤立地去背代码。
3.3 快慢指针不只在链表里:数组原地去重
快慢指针其实也能用在数组上,最典型的是删除有序数组中的重复项(LeetCode 26)。慢指针维护"已处理结果"的末尾,快指针负责扫描整个数组:
python复制def removeDuplicates(nums):
if not nums:
return 0
slow = 0
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]:
slow += 1
nums[slow] = nums[fast]
return slow + 1
这个题让我对快慢指针有了新的认识:快慢指针本质上是一种分工——快指针负责"探查未知区域",慢指针负责"记录已确定的合法状态"。数组或者链表只是载体,这个分工思路在很多场景都能套用。
4. 滑动窗口:最长无重复子串的"扩张-收缩"循环
4.1 滑动窗口的本质就是同向双指针
滑动窗口听起来像是一个独立的知识点,但拆开看,它其实就是两个同向移动的指针:left 指向窗口左边界,right 指向窗口右边界。right 不断向右扩张,把新元素纳入窗口;当窗口不再满足题目条件时,left 不断向右收缩,直到窗口恢复合法状态。
之所以叫"滑动窗口",是因为这个 left-right 区间就像一个长度可变的窗口,在数组上从左往右滑过去。它能工作的前提条件是:窗口内的状态可以在 O(1) 时间内维护,并且合法性的判断可以随着指针移动而增量更新。
4.2 无重复字符的最长子串
以 LeetCode 3 为例,给定字符串 s,找出其中不含重复字符的最长子串长度。
我用一个集合 window 来记录当前窗口内有哪些字符。right 向右扩展时,先把新字符尝试塞进窗口;如果它已经存在于 window 中,就一直移动 left,把窗口左边的字符一个个移除,直到这个冲突字符被彻底赶出窗口:
python复制def lengthOfLongestSubstring(s):
window = set()
left = 0
max_len = 0
for right in range(len(s)):
while s[right] in window:
window.remove(s[left])
left += 1
window.add(s[right])
max_len = max(max_len, right - left + 1)
return max_len
这里有一个很容易搞错的点:加入新字符的这个动作,一定要放在 while 收缩完成之后。如果先 add 再收缩,冲突字符本身还在 window 里,left 要一直移动到什么位置才能结束?逻辑会变得非常混乱。我的习惯是每次循环只做三件事:收缩到合法、更新状态、记录答案,顺序不要乱。收缩用的 while 而不是 if,也是因为窗口内可能积累了多个重复字符,右指针碰到一个冲突时,可能需要一次性移除多个左边的元素才能恢复合法状态。
4.3 长度最小的子数组:收缩阶段才是更新答案的时机
再来看另一个高频题——长度最小的子数组(LeetCode 209)。给定一个正整数数组 nums 和一个目标值 target,找出满足和不小于 target 的连续子数组的最小长度。
这道题的滑动方向跟前一题一样,但更新答案的时机完全反过来。最长无重复子串是在"扩张完、窗口合法"之后记录最大值;这道题则是在"收缩过程中",窗口刚好从合法变为不合法之前,记录最小长度:
python复制def minSubArrayLen(target, nums):
left = 0
total = 0
min_len = float('inf')
for right in range(len(nums)):
total += nums[right]
while total >= target:
min_len = min(min_len, right - left + 1)
total -= nums[left]
left += 1
return 0 if min_len == float('inf') else min_len
我第一次写这个题的时候,把 min_len 的更新放在了 while 循环外面,导致每次窗口收缩到不合法之后才记录长度,得到的结果基本都是错的。这道题让我学到一个经验:滑动窗口题不能死记"扩张完后记录答案"这一个模板,必须先想清楚——答案的合法状态是"窗口内满足某个条件"还是"窗口内不满足某个条件"。条件变化的那一刻,才是答案最容易出现的时候。
4.4 一个可复用的滑动窗口骨架
把上面两个题抽象出来,我一般用下面这个骨架:
python复制left = 0
for right in range(len(nums)):
# 1. 把 nums[right] 加入窗口,更新相关状态
...
# 2. 当窗口不合法时,收缩 left 直到恢复合法
while not valid(window):
...
left += 1
# 3. 窗口合法后,更新答案
...
这里最需要想清楚的是两步之间存在两种关系:有的题在"恢复合法后"记录答案(求最大值),有的题在"收缩过程中窗口处于临界状态时"记录答案(求最小值)。模板可以帮你理清顺序,但具体的更新时机,还是要回到题目的定义里去推。
5. 实战中最容易踩的坑,和"什么情况别用双指针"
5.1 边界条件的几个高频bug
双指针代码虽然短,但边界 bug 出现的频率一点都不低。我列出自己在刷题过程中反复踩过的几个坑,都是真实经历。
第一个是循环条件的选取。对撞指针处理"找一对元素"时,用 while left < right 比较安全;如果用 while left <= right,可能在 left == right 时把同一个元素当成两个数。滑动窗口里则相反,窗口区间是 [left, right],left 可能大于 right,收缩时要注意判断 left <= right,避免窗口越界。
第二个是链表快慢指针的判空。正确写法是 while fast and fast.next,两个条件缺一不可。如果你只判 fast,当 fast 走到最后一个节点时,fast.next.next 会直接抛空指针异常;如果你只判 fast.next,在空链表或单节点链表上又会出问题。这个顺序(先 fast 再 fast.next)是固定的,建议直接背下来。
第三个是数组快慢指针中 slow 的初始值。原地去重的题,slow 要从 0 开始,代表着第一个元素已经作为结果的一部分保留;如果你把 slow 初始化为 -1 或者 1,可能会产生越界或者漏元素的问题。建议这类题用具体的简单样例推演一遍,再写代码,而不是凭着印象直接开写。
5.2 看起来能用双指针,实际上不该用的场景
有一种误判比写错边界条件更可怕,就是"觉得这题能用双指针然后开始硬套"。我整理了两类典型的反例。
第一类是无序数组的两数之和(LeetCode 1)。数组无序时,两数之和最稳的解法是哈希表,把已经遍历过的元素存进字典,每到一个新元素就查 target - 当前值 是否在字典里。如果你先排序再用双指针,虽然也能找到答案,但排序本身消耗了 O(n log n) 时间,而且排序后你还要返回原始下标,处理起来非常麻烦。哈希表 O(n) 一次遍历就能搞定,没必要绕路。
第二类是组合型问题,比如从数组里选三个数组成某些不连续的模式。这类问题如果用双指针强行套,会发现指针移动没有一个可靠的依据——你不知道该移动哪个指针,因为候选组合之间没有单调性的关联。组合型问题该用回溯就回溯,不要看到"n 选 k"就想到双指针。
判断"能不能用双指针",我有一个自检清单:
- 数据是有序的,或者可以排序后利用有序性吗?
- 问题涉及连续子数组/子串吗?
- 对象是链表,且需要检测环或定位特殊位置吗?
- 合并操作需要同时遍历两个有序序列吗?
- 指针移动时,能找到一个"移动方向不会产生更优解"的确定依据吗?
如果一道题对这些问题全是"否",那双指针大概率不是最合适的工具。
5.3 常见双指针类型速查
| 双指针类型 | 典型场景 | 代表题目 | 时间复杂度 |
|---|---|---|---|
| 左右对撞 | 有序数组查找、去重、回文 | 两数之和II、三数之和、盛最多水的容器、验证回文串 | O(n) 或 O(n log n) 排序 |
| 快慢指针 | 链表环检测、中点、数组原地去重 | 环形链表、链表中点、删除有序数组中的重复项 | O(n) |
| 滑动窗口 | 连续子数组/子串最值 | 无重复字符最长子串、长度最小的子数组、最小覆盖子串 | O(n) |
| 归并双指针 | 两个有序序列合并 | 合并两个有序数组、合并两个有序链表 | O(m+n) |
这张表是我在复习时自己整理的。每次拿到新题,先对照表格里的场景特征,再动手写代码,比盲目套模板要靠谱得多。
最后聊一点个人体会。双指针算法看似简单,但真正要把它"内化",光看题解是不够的,需要自己在纸上推演指针每一步移动的动机。我到现在都还保持着这个习惯:做每道双指针题,先画一遍搜索空间,标出哪些区域是注定无效的,再看指针是怎么一步步把这些区域裁掉的。这样练过几道题之后,你会发现自己对"为什么能用双指针"的判断力提升得非常明显。
这一篇主要覆盖了线性结构上的双指针模式,包括左右对撞、快慢指针、滑动窗口和归并双指针。至于双指针在其他场景里的变体,比如二维数组上的指针、字符串处理里的特殊窗口,以及更多组合题里的双指针应用,后面有机会再单独展开。
