刷算法题刷到一定量,你会发现“双指针”这三个字几乎无处不在。数组里找两数之和、链表里判断有没有环、字符串里找最长不重复子串,表面上看是完全不同的题型,底层用的却是同一套思想:用两个指针代替多重循环,把暴力枚举那个 O(n²) 变成一趟 O(n) 扫描。这篇文章我会把双指针拆成左右指针、快慢指针、滑动窗口三个形态,给出可以直接套用的模板,再用几道高频题走一遍完整的思考过程。不管你是刚开始刷题,还是准备面试前的冲刺,照着这个框架去分类,应该能少走不少弯路。
1. 双指针的本质:把 O(n²) 暴力循环压缩成一遍扫描
1.1 从两数之和的暴力解说起
先看最经典的入门题:给定一个整数数组和一个目标值,找出数组中两个数,使它们的和等于目标值。
最直觉的解法是两层循环,枚举所有数对:
def two_sum_brute(nums, target): n = len(nums) for i in range(n): for j in range(i + 1, n): if nums[i] + nums[j] == target: return [i, j] return []这段代码逻辑没错,但 n 到 10^5 级别就会超时。面试官要的不是“能跑”,而是“在数据规模约束下能跑”。这时候双指针登场:如果数组是有序的,我们可以让一个指针指向开头、一个指针指向结尾,根据当前和与目标值的大小关系,每次只移动一个指针。
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: return [left, right] elif cur < target: left += 1 else: right -= 1 return []同样是找数对,暴力解在最坏情况下要比较 n(n-1)/2 次,双指针最多走 n 步。这个差距在数据量大的时候是质变。很多初学者会问:那无序数组怎么办?排序。排序本身 O(nlogn),再加上一趟 O(n) 扫描,整体还是远优于暴力。
1.2 双指针省时间的关键:单调性
双指针不是“快”就完了,你得理解它为什么快。以有序数组的两数之和为例,假设当前nums[left] + nums[right] < target,这意味着什么?
nums[right]已经是整个数组里能和nums[left]配对的最大值,它俩加起来都小于目标值,说明nums[left]和当前右边这一整段里的任何元素配对,都不可能达到目标值。所以nums[left]这个位置的使命结束了,只能left += 1。反过来,如果和大于目标值,说明nums[right]和左边任何元素配对都超了,只能right -= 1。
这就是所谓的单调性:数组有序时,指针移动方向能排除掉一大批不可能的解,而不是一个一个去试。每次移动都砍掉一个区间的可能性,整体下来只需要线性时间。没有这个单调性,双指针就退化成无头苍蝇,这也是为什么很多双指针题目都要求数组有序,或者要求题目本身满足某种单调条件。
1.3 双指针的三个常见形态
刷题多了你会发现,双指针其实是三个长得不太像的套路,被大家统称在一个名字下面:
- 左右指针:从数组两端向中间走,解决有序数组、回文判断这类问题。
- 快慢指针:一个走两步、一个走一步,解决链表环检测、链表中点、数组原地去重这类问题。
- 滑动窗口:两个指针同向移动,维护一段连续的区间,解决子串、子数组问题。
这三个形态的模板完全不同,识别错了会越写越乱。我后面每个形态单独讲,配模板和例题。
2. 左右指针:有序数组上最经典的“夹逼”打法
2.1 左右指针通用模板
左右指针的骨架非常固定,背下来就行:
def two_pointers(nums): left, right = 0, len(nums) - 1 while left < right: # 根据具体情况计算当前状态 # 判断是移动 left 还是移动 right if 满足某个条件: # 记录答案 left += 1 # 或 right -= 1 elif 条件 A: left += 1 else: right -= 1 return 答案注意 while 条件。找“一对元素”时用的是left < right,因为 left 和 right 指向同一个元素时没有意义;但如果题目语义是“在区间里找一个位置”,那就可能是left <= right。这个细节我在第六部分会专门讲。
2.2 例题走读:三数之和的模板改造
两数之和太基础,我们直接看它的升级版三数之和:找出数组中所有和为 0 的三元组,且不能重复。
思路是固定一个数,剩下两个数用左右指针去找。模版的改造点有两个:外层先排序,内层对每个nums[i]在i+1到n-1区间内做双指针。
def three_sum(nums): nums.sort() n = len(nums) res = [] for i in range(n): # 跳过重复的固定数 if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: s = nums[i] + nums[left] + nums[right] if s == 0: 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 < 0: left += 1 else: right -= 1 return res这里我想重点说“去重”这个坑。第一处去重是外层跳过重复的nums[i],第二处去重是在找到一个三元组之后,把左右指针跳过相同的值。很多人在第二处写错,常见错误是先用left += 1再判断,导致漏掉一组。正确顺序是先跳过重复、再移动指针。这道题是面试常客,建议直接背透。
2.3 例题走读:盛最多水的容器与回文判断
三数之和是“求和”类,盛最多水的容器是另一类——没有明确的目标值,靠左右指针维护一个“当前最优”。
def max_area(height): left, right = 0, len(height) - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans为什么矮的那边要移动?因为容器高度由矮边决定,如果移动高的那边,宽度变小、高度不变或变小,面积只会更差;而移动矮的那边,有可能遇到更高的边,面积才有改善的可能。这又是一个单调性的应用。
回文判断就更简单了,两个指针从两端往中间走,逐字符比较:
def is_palindrome(s): left, right = 0, len(s) - 1 while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True这类题目没什么技巧,但它是左右指针的“体检题”,能帮你确认对指针边界条件的手感。
3. 快慢指针:链表环检测与数组原地操作的通用套路
3.1 龟兔赛跑判环的原理与代码
快慢指针最有名的应用就是环形链表检测。慢指针每次走一步,快指针每次走两步,如果链表里有环,两者最终会相遇。这里有个常见的疑问:为什么快指针走两步而不是三步、四步?
关键在“相对速度”。快指针每次比慢指针多走一步,相当于慢指针不动、快指针以每次一步的速度逼近它。在环上,这个追赶过程最多走完一圈就能相遇。如果快指针走三步,相对速度是两步,在某些环长和入环位置组合下,可能会跳过慢指针导致要多走几圈,逻辑上还是能相遇,但分析起来更绕,代码里还要额外处理空指针。所以两步是业界标准,没必要为了花哨改成三步。
def has_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return Falsewhile 条件里fast and fast.next这两个判断缺一不可,因为 fast 一次走两步,如果不检查 fast.next,第二次访问 fast.next.next 就可能对 None 取 next,直接抛异常。这个边界条件在链表题里出现频率极高。
3.2 链表中点与删除倒数第 N 个节点
快慢指针还有个很实用的场景:找链表中点。慢指针走一步、快指针走两步,快指针到末尾时,慢指针正好在中点。这个技巧常用于归并排序链表、判断回文链表第一步。
def middle_node(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow删除倒数第 N 个节点也可以用快慢指针:快指针先走 N 步,然后快慢一起走,快指针到末尾时,慢指针正好在倒数第 N 个节点的前一个位置。这类题的共同点都是“让两个指针之间拉开固定距离”,本质上是制造一个长度已知的窗口。
3.3 数组原地去重的快慢指针写法
快慢指针不止用在链表上,数组原地操作也经常用它。最经典的是有序数组去重:要求不开新数组,原地修改,返回去重后长度。
def remove_duplicates(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这里的思路是:slow指向“已处理区域的最后一个位置”,fast负责向前扫描。每当 fast 遇到一个新值,就把它搬到 slow 的下一个位置。维护的不变量是:[0, slow]这个前缀里的元素永远不重复。这个写法在很多类似题目里都能复用,比如把 0 移动到数组末尾、移除指定元素,都是同一套“快指针扫描、慢指针落位”的模式。
4. 滑动窗口:连续子串子数组问题的统一模板
4.1 窗口的本质与模板骨架
滑动窗口是双指针里最需要“理解”而不是“背”的一个形态。它的核心思想是:用一个左指针和一个右指针维护一个连续区间,右指针负责扩展窗口,左指针负责在窗口不满足条件时收缩窗口。
def sliding_window(nums): left = 0 ans = 0 window = {} # 或 collections.Counter() for right in range(len(nums)): # 1. 把 nums[right] 加入窗口 # window[nums[right]] += 1 # 2. 当窗口不满足题目要求时,移动 left 收缩 while 窗口不合法: # 把 nums[left] 移出窗口 # window[nums[left]] -= 1 left += 1 # 3. 窗口合法后,更新答案 ans = max(ans, right - left + 1) return ans这个骨架看着简单,真正难的是判断“窗口是否合法”,以及“在什么时候更新答案”。我建议你把这两个决策点单独想清楚再动手写代码,否则很容易陷入“改一行就出 bug,越改越乱”的窘境。
4.2 收缩时机的选择:最长还是最短
我把滑动窗口题目分成两类:最长类和最短类,它们的收缩时机和答案更新位置完全不同,这个点很容易翻车。
最长类题目,比如“无重复字符的最长子串”,语义是窗口可以尽量长,所以右指针不断扩展,当窗口里出现重复字符时,才收缩左指针。答案在窗口恢复合法之后更新。
def length_of_longest_substring(s): left = 0 ans = 0 seen = set() for right in range(len(s)): while s[right] in seen: seen.remove(s[left]) left += 1 seen.add(s[right]) ans = max(ans, right - left + 1) return ans最短类题目,比如“长度最小的子数组”,要求的是满足条件的窗口尽可能短。右指针扩展到一个窗口合法时,答案要在收缩过程中的每一个合法状态都尝试更新,因为最短答案往往出现在收缩到临界点的那个时刻。
def min_subarray_len(target, nums): left = 0 cur_sum = 0 ans = float('inf') for right in range(len(nums)): cur_sum += nums[right] while cur_sum >= target: ans = min(ans, right - left + 1) cur_sum -= nums[left] left += 1 return 0 if ans == float('inf') else ans对比这两个代码你会发现:最长类先收缩再更新答案,最短类先更新答案再收缩。这个先后顺序一旦搞反,结果要么漏解要么错解。我自己曾经在这个点上栽过跟头,所以建议你刷题时把这两类题各挑两三道,专门对比它们的结构。
4.3 用计数器判断窗口合法性:最小覆盖子串走读
如果窗口合法性的判断比较复杂,比如“窗口内必须包含某个集合的所有字符”时,就需要用到计数器来统计。
拿最小覆盖子串举例:给一个字符串 s 和一个模式串 t,要求在 s 中找到包含 t 全部字符的最短子串。这类题的窗口合法性判断不能只看某个字符在不在,得看每个字符的出现次数是否都达标。
def min_window(s, t): from collections import Counter need = Counter(t) left = 0 formed = 0 # 已经达标的字符种类数 ans = (float('inf'), 0, 0) window = {} for right in range(len(s)): ch = s[right] window[ch] = window.get(ch, 0) + 1 if ch in need and window[ch] == need[ch]: formed += 1 while formed == len(need): if right - left + 1 < ans[0]: ans = (right - left + 1, left, right) left_ch = s[left] window[left_ch] -= 1 if left_ch in need and window[left_ch] < need[left_ch]: formed -= 1 left += 1 return "" if ans[0] == float('inf') else s[ans[1]:ans[2] + 1]这里的formed变量是滑动窗口题里很常用的优化技巧:没有必要每次检查所有字符是否达标,只需要记录“有多少种字符已经满足数量要求”。每当右指针加入一个字符使它刚好达标,formed加一;左指针移走字符使它不再达标,formed减一。当formed == len(need)时窗口就是合法的。
5. 拿到题目先分类:三种双指针的判别框架
5.1 一句话判别法
很多读者问我:怎么快速判断一道题该用哪种双指针?我总结了一个很粗糙但好用的判别表,先对号入座再动手:
| 题目特征 | 倾向使用的类型 | 典型信号 |
|---|---|---|
| 有序数组、回文、找数对 | 左右指针 | 题面出现排序数组、“两数”“三数”、两端向中间 |
| 链表、原地修改数组 | 快慢指针 | 出现链表、环、中点、去重、移动零 |
| 连续子串、子数组 | 滑动窗口 | 出现“连续”“子串”“子数组”“最短”“最长” |
这个表格不是绝对真理,但能覆盖大概八成的题目。剩下的两成要靠实战经验去补,比如两个数组各用一个指针做合并,本质上也是双指针,但它不属于上面三种的任一种,我在下一节单独讲。
5.2 实战走读:合并两个有序数组的逆向双指针
合并两个有序数组,要求把第二个数组合并进第一个数组,第一个数组预留了足够的空间。正向合并需要一个额外数组存储结果,但如果从后往前填充,就能原地完成。
def merge(nums1, m, nums2, n): p1, p2 = m - 1, n - 1 tail = m + n - 1 while p2 >= 0: if p1 >= 0 and nums1[p1] > nums2[p2]: nums1[tail] = nums1[p1] p1 -= 1 else: nums1[tail] = nums2[p2] p2 -= 1 tail -= 1这里的双指针是两个数组各一个,方向是逆向的。为什么要从后往前?因为 nums1 后面是空的,从后往前填充不会覆盖还没处理的元素。这个思路用一句话记就是“给结果留出位置,从没有冲突的方向走”。这类“两个指针各管一个数组”的形态,在归并排序、找两个数组的交集等题目里很常见,值得单独练。
5.3 实战走读:移动零到数组末尾
移动零这道题要求把数组里所有 0 移到末尾,同时保持非零元素的相对顺序。它用快慢指针的思路来解:
def move_zeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1slow 指向“下一个非零元素应该放的位置”,fast 扫描整个数组。遇到非零就交换到 slow 的位置。这个代码很短,但它和前面原地去重的模板是同构的,区别只是去重用赋值、移动零用交换。刷题时把这类题归到“快慢指针的数组形态”里,你的知识树就清晰了。
6. 模板之外:边界条件、复杂度与常见翻车点
6.1 时间复杂度与空间复杂度
双指针类题目的复杂度非常统一:左右指针和滑动窗口是 O(n) 时间、O(1) 空间,快慢指针在链表上是 O(n) 时间、O(1) 空间。这个复杂度看起来简单,但面试被问到“为什么是 O(n)”时,很多人答得含含糊糊。
关键在于两个指针虽然在一个循环里移动,但每个指针从头到尾最多移动 n 次。你担心的“会不会出现左指针来回移动导致多次扫描”的问题不会发生:左右指针只往一个方向走,快慢指针也是单向运动,滑动窗口的 left 只增不减。循环里的 while 收缩看起来是嵌套的,但 left 总的移动次数不会超过 n,所以整体摊还下来是 O(n)。这是双指针题最常考的复杂度论证方式,建议背下来。
6.2 五个最容易踩的坑
刷了这么多双指针题,我把最常见的翻车点列一下:
**第一,while 条件的等号问题。**左右指针找数对用left < right,因为左右指针指向同一个位置时不可能构成合法数对;但二分查找找单个目标时要用left <= right。这两个混用是新手最常犯的错,建议写完先检查条件再检查逻辑。
**第二,快指针移动两步时的空指针。**链表题的while fast and fast.next少写一个都会在运行时崩。凡是快指针一次走两步,必须同时检查当前节点和下一个节点是否为空。
**第三,三数之和的去重顺序。**找到一组解后,要先跳过重复元素、再移动指针,而不是移动之后再判断。前者能保证不重不漏,后者会漏掉紧挨着的重复解。
**第四,滑动窗口的更新位置。**最长类题目在收缩结束后更新答案,最短类题目在收缩过程中更新答案。如果题目要求“最短”,你却只在循环末尾更新一次,拿到的往往是窗口合法后第一次出现的状态,而不是最短的那个。
**第五,窗口数据结构的同步。**滑动窗口里无论用哈希表还是计数器,left 移出元素时一定要同步更新对应计数,否则窗口状态和实际区间不一致,后续判断全乱。这个错误隐蔽在代码里很难一眼看出来,我建议把“移除元素时同步更新计数”当作写代码时的固定动作。
6.3 双指针与二分、哈希表的取舍
最后聊一个实战中经常纠结的问题:同样是找数对,什么时候用双指针,什么时候用哈希表,什么时候用二分?
哈希表的优势是找“是否存在某个值”能在 O(1) 时间内完成,比如无序数组的两数之和,哈希表解法 O(n) 时间且不需要排序,比先排序再双指针更直接。但哈希表会占用 O(n) 的额外空间,而且在需要输出所有不重复结果、或者结果本身依赖“有序性”的题目里(比如三数之和要求去重),双指针往往更好写。
二分适合在单个数组里找某个值,而双指针解决的是“两个位置之间的关系问题”。举个例子,找数组中两个数差值最接近 target 的数对,用二分需要枚举每个数再二分,O(nlogn);用双指针 O(n) 就能解决,前提是数组有序。所以我的个人习惯是:不需要去重、不依赖有序、空间敏感时优先双指针;无序且只需判断存在性时用哈希表。
回头再说一个细节:我刚整理这套模板的时候,也走过一段弯路。当时我把滑动窗口和左右指针混在一起练,看到“子数组”三个字就套左右指针,结果好几个题都卡在边界条件上。后来我把三种形态的模板各抄了三遍,每道题先分类、后套模板、再改逻辑,才慢慢建立起题感。如果让我给一个建议,就是别急着刷题量,先把这套分类框架刻在脑子里,遇到题目先问自己一句“这是左右、快慢还是滑动窗口”,这一步想清楚了,后面的代码往往十几分钟就写完。