免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

双指针算法全攻略:对撞、快慢、滑动窗口三大模板与实战总结

双指针算法全攻略:对撞、快慢、滑动窗口三大模板与实战总结 刷题刷到一定量很多人会慢慢总结出一条规律有一类题的解法特别“固定”——有序数组里找两个数凑目标值、链表中判断有没有环、字符串里找不重复的最长子串题面长得完全不一样翻开题解一看底层全是同一个思路双指针。这类算法最大的特点是四个字省时省力。时间复杂度从暴力解法常见的 O(n²) 降到 O(n)空间基本做到 O(1)而且模板化程度极高记住几个典型写法确实可以直接套完一大片题。这篇我把自己长时间刷题积累的双指针总结和模板完整整理出来适配两类读者一是刚接触算法、想快速建立题感的初学者二是已经刷了不少题、但想系统性梳理同类型考点的人。文章会集中讲三件事什么场景用哪种指针形态、三个万能模板怎么写、边界条件怎么卡。最后附上我踩过的坑和一份题型速查表方便当复习索引用。1. 双指针算法的核心思想和它为什么能替代暴力解法1.1 指针移动的本质是一次“剪枝”很多人第一次接触双指针是在“两数之和”这道经典题上。给定一个升序数组比如[1, 3, 4, 6, 8, 11]目标值target 10暴力解法是两层循环把所有数对都试一遍找到4 6 10时间复杂度 O(n²)。数据量一旦过万这个复杂度就直接崩了。双指针的做法是一个指针left指向数组头部一个指针right指向数组尾部。每一步比较nums[left] nums[right]与target的大小和太大了就把right往左收和太小了就把left往右推一次循环结束战斗时间复杂度变成 O(n)。这个思想背后的本质是“剪枝”再生活化一点叫“排除法”。因为数组是有序的最左边的数是当前范围内的最小值最右边的数是当前范围内的最大值。如果最小值加最大值已经大于target那么最大值和范围内任何一个中间数相加结果只会更大绝不可能是答案。此时把right左移相当于一次性淘汰掉一大把无效组合而不是像暴力循环那样逐对试探。这也是双指针和暴力解法最核心的差别暴力是枚举所有可能性双指针是利用数据本身的有序性、单向性等确定性特征把不可能的解集中排除。所以双指针一般只适用在线性结构数组、链表、字符串上而且往往要求数据存在某种顺序或者方向约束否则指针的移动没有依据。1.2 一道题该不该用双指针看这三个信号我在刷题快两百道之后发现判断“能不能用双指针”其实是有明确信号的不需要靠感觉。第一个信号输入是数组、链表、字符串这类线性结构。这是先决条件。双指针在树、图这种非线性结构里很难直接套用除非先转成线性序列。第二个信号题目内容涉及“找两个数、找两个位置、判断环、找中点、子串或子数组的最值”。这些都是双指针的高频应用场景。比如“两数之和”“三数之和”“接雨水”“验证回文串”“环形链表”“无重复最长子串”“最小覆盖子串”。第三个信号题目的暴力解是 O(n²)但数据范围提示你必须做到 O(n) 或 O(n log n)。很多 LeetCode 题在数据范围上其实已经给了暗示看到n 10^5这种规模两层循环基本不可能过这时候就该往双指针、滑动窗口、哈希表这类方案上想。确定能用之后再进一步细分用哪一种双指针形态。这里我总结了一个最简单的区分口诀数组有序、需要首尾配合用对撞指针也就是left和right相向而行。链表题、找环或找中点用快慢指针让两个指针以不同速度前进。子串、子数组的最值或满足条件问题用滑动窗口也就是两个同向指针围出一个窗口右边界扩展、左边界收缩。记住这个口诀做题时先给题目分类再动手效率会高很多。2. 三大双指针模板对撞指针、快慢指针、滑动窗口2.1 对撞指针模板有序数组里的标准动作对撞指针是最容易上手的一种形态核心逻辑其实就三步初始化left 0、right 数组长度 - 1在left right的循环里计算当前两数和再根据和与目标值的比较结果移动指针。以“两数之和 II”的模板为例def two_sum_sorted(nums, target): left, right 0, len(nums) - 1 while left right: total nums[left] nums[right] if total target: return [left, right] elif total target: left 1 else: right - 1 return []这段代码有两个地方很多人第一次写会卡住。一个是循环条件为什么是while left right而不是因为如果允许left right两个指针指到同一个元素就变成自己加自己了除了一些特殊允许元素重复利用的题目大部分情况下这是错的。另一个是移动方向的判断和小于target时说明当前右边最大数加上左边最小数都不够大左边这个数已经“尽力了”只能让left 1找一个更大的数和大于target时同理让right - 1找一个更小的数。对撞指针的变形也很多。比如“三数之和”先固定第一个数i在剩下的区间里继续用对撞指针找两个数整体复杂度是 O(n²)。比如“接雨水”那道题对撞指针配合左右两侧最大高度的记录也能在线性时间内完成不需要额外数组。掌握好一个模板然后反复在不同题目里套用熟练度会提升得很快。2.2 快慢指针模板用差速跑解决链表题快慢指针的经典场景是链表。初始化时两个指针都指向头节点一个每次走一步另一个每次走两步靠速度差来实现不同的功能。最常用的是链表中点问题和环形链表检测。链表中点的模板def find_middle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next return slow为什么快指针到达链表末尾时慢指针恰好在中点因为快指针速度是慢指针的两倍。快指针走完整个链表长度 L 时慢指针只走了 L/2正好落在中间位置。链表长度是偶数时这个模板返回的是后一个中间节点如果题目要前一个可以自己调整比如增加一个prev变量记录慢指针的上一个位置。环形链表检测的模板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 False这里有一个很多人问过的问题为什么快慢指针一定要移动步长差为 1也就是快指针一次走两步而不是三步四步原因有两层。第一层步长差 1 可以保证在一个周期步数内必然追上数学上最干净第二层如果快指针步长过大第二次扫描时可能会越过某些关键节点处理边界条件会更复杂而且时间复杂度并不会因此降低。完全没必要为了炫技把简单问题搞复杂。快慢指针还有一个变体是“找倒数第 k 个节点”让快指针先走 k 步然后两个指针同速前进快指针走到结尾时慢指针正好指向倒数第 k 个节点。这个思路本质上也是利用指针之间的固定距离差。2.3 滑动窗口模板同向指针的收放框架滑动窗口可以理解成一种“动态区间”的双指针left 和 right 都往一个方向移动right 负责扩展窗口让更多元素进入视野left 负责收缩窗口去掉不满足条件的部分。我把最通用的框架拆一下以“无重复字符的最长子串”为例def sliding_window(s): n len(s) left 0 counter {} ans 0 for right in range(n): # 扩展右边界把 s[right] 纳入窗口 counter[s[right]] counter.get(s[right], 0) 1 # 不满足约束时收缩左边界 while 不满足约束的条件: counter[s[left]] - 1 if counter[s[left]] 0: del counter[s[left]] left 1 # 当前窗口满足约束更新答案 ans max(ans, right - left 1) return ans这个框架的通用性是极强的很多滑动窗口题其实只是“不满足约束的条件”不同而已。比如“最小覆盖子串”是窗口内缺少目标字符“字符串的排列”是窗口内每个字符的数量必须和目标串一致“最长重复字符替换”是窗口内需要替换的字符数超过 k。为什么滑动窗口能做到 O(n)核心原因在于 left 和 right 都只往一个方向移动每个元素最多被 left 移出一次、被 right 纳入一次总操作次数在 2n 量级。对比暴力枚举所有子串 O(n²)滑动窗口实际上把大量不可能成为答案的区间全部砍掉了窗口每收缩一次就相当于放弃一批子串的检查。3. 模板落地三道高频题的完整拆解与推导3.1 两数之和 II对撞指针的完整实操LeetCode 167 题输入是一个已按升序排列的整数数组numbers和一个目标值target要求找出两个数使它们相加等于target返回这两个数的下标。直接套对撞指针模板代码写起来非常短class Solution: def twoSum(self, numbers: List[int], target: int) - List[int]: left, right 0, len(numbers) - 1 while left right: total numbers[left] numbers[right] if total target: return [left 1, right 1] elif total target: left 1 else: right - 1 return [-1, -1]这里有一个小细节题目要求返回的下标从 1 开始计数所以找到结果时要各自加 1。很多人漏了这一步提交的时候才反应过来。这个题目能套对撞指针关键前提是数组已经升序。如果数组是无序的双指针就失去了“移动方向有依据”这个基础比如[3, 1, 2, 5]里找 4left 和 right 无论如何移动都很难保证不遗漏解。遇到无序版本的两数之和正确思路是用哈希表记下已经遍历过的元素遍历一遍即可。这个对比很值得记下来因为它解释了双指针的适用边界。复杂度方面时间复杂度 O(n)空间复杂度 O(1)。相比暴力 O(n²)这个提升是巨大的。3.2 环形链表 IIFloyd 判圈背后的数学推导LeetCode 142 题要求返回链表开始入环的第一个节点。如果无环则返回null。第一步先判断有没有环直接复用快慢指针的模板。第二步是关键当快慢指针在环中相遇后把慢指针重新放回链表头然后让两个指针都以每次一步的速度前进它们最终会在环的入口处再次相遇。我第一次看到这个解法时觉得这是魔法后来认真推导了一遍才明白其实是一道数学题。假设链表头到环入口的距离是 a环入口到快慢指针第一次相遇点的距离是 b相遇点继续走到环入口的距离是 c那么环长就是 b c。慢指针从链表头走到相遇点总路程是 a b。快指针走的路程是慢指针的两倍也就是 2(a b)。但快指针可能已经在环里转了好几圈所以它的实际路程还可以写为 a b k(b c)其中 k 是快指针在环里多走的圈数。于是有2(a b) a b k(b c)把等式化简得到a b k(b c)也就是a k(b c) - b (k - 1)(b c) c这个式子的意思是从链表头走 a 步能到环入口从相遇点走 c 步能到环入口如果走不满 k 圈可能先绕了几圈再补 c 步同样能到环入口。所以当慢指针从头开始、快指针从相遇点开始都以步长 1 前进时两者必然在环入口汇合。代码可以写成class Solution: def detectCycle(self, head: Optional[ListNode]) - Optional[ListNode]: slow fast head has_cycle False while fast and fast.next: slow slow.next fast fast.next.next if slow fast: has_cycle True break if not has_cycle: return None slow head while slow ! fast: slow slow.next fast fast.next return slow这里有个小技巧判定有环之后不需要重新申请一个新的slow直接把原来的slow重置到 headfast保持原地两者同时开始同速前进就行省一个变量。这种写法在题解里很常见但理解它需要把前面那段数学推导消化掉否则第二次循环会看得一头雾水。3.3 无重复字符的最长子串窗口收放的时机把握LeetCode 3 题给定一个字符串找出其中不含有重复字符的最长子串的长度。这道题用滑动窗口最直观。窗口右边界不断往后扩展每进入一个新字符就检查这个字符在窗口内是否已经出现。如果出现就把左边界移动到上次出现位置的后一位保证窗口内没有重复字符。有一种写法是不用while反复收缩而是用字典记录每个字符最近一次出现的下标直接一次跳跃完成左边界更新class Solution: def lengthOfLongestSubstring(self, s: str) - int: left 0 seen {} ans 0 for right, ch in enumerate(s): if ch in seen and seen[ch] left: left seen[ch] 1 seen[ch] right ans max(ans, right - left 1) return ans这个写法里最容易出错的是seen[ch] left这个判断。为什么要加这个条件因为seen字典里保存的是这个字符最近一次出现的位置这个位置有可能已经落到了窗口左边界之外。比如字符串abba遍历到第三个 b 时left 已经因为前面的 a 重复跳到了 2此时seen[b]是 1 和 2 比较是1 2不成立说明这个旧 b 不在当前窗口里不应该触发左边界跳跃。如果漏掉这个条件直接无条件执行left seen[ch] 1会把一个本来正确的窗口硬生生砍掉导致答案错误。用“记录最近下标 一次跳跃”的写法时间复杂度是 O(n)空间复杂度 O(min(n, 字符集大小))。相比“计数器 while”的写法它在无重复场景下更简洁但条件判断要多思考一层。两种写法我都用过实际效果差别不大关键是你得彻底理解你自己那一种。4. 实战避坑双指针最容易踩的四个坑和一份速查清单4.1 四个高频错误每一个都有对应排查思路双指针本身不难但细节极其容易翻车。我自己刷题时踩过的坑整理下来主要就是下面四个。第一个坑是死循环。最典型的场景是对撞指针里left和right的更新方向写反或者循环结束后还在移动指针。比如有人在total target的分支里忘记return然后继续移动left结果后续循环永远找不到答案最后要么越界要么死循环。排查思路很简单每次移动指针都检查一下移动方向是否让区间在收敛而不是在发散。可以在循环里打印当前 left 和 right 的值看到区间每次都在变小基本就没问题。第二个坑是空指针访问。这个问题在快慢指针里特别常见。while fast and fast.next这个条件两个部分缺一不可。只写while fast访问fast.next.next时可能因为fast.next为 null 直接崩溃只写while fast.next链表本身就为空时连fast.next都没法访问。我见过太多提交报AttributeError: NoneType object has no attribute next一查基本都是这里。第三个坑是滑动窗口的左边界忘记收缩。有时候我们写代码只把右边界扩展写得很顺利但遇到不满足条件的情况没有及时while收缩或者收缩顺序写错。正确顺序一定是先更新计数器、再移动left反过来会导致窗口内元素统计和指针位置不同步引发各种诡异结果。排查技巧把窗口的left/right和辅助字典/计数器每一步都打印出来肉眼很容易看出统计数据和实际区间不匹配的问题。第四个坑属于方向性错误在无序数组上硬套双指针。比如无序数组找两数之和不排序直接双指针结果肯定不对。如果题目本身就要求返回原数组下标排序都不行那就要果断放弃双指针改用哈希表。这个错误不是细节问题而是思路选型问题越早发现越省时间。4.2 题型-模板速查表和我的三点经验清单双指针题型虽然多但归纳到模板层面其实非常有限。我刷题时整理过一张速查表每次复习都会过一遍题目类型双指针形态核心模板时间复杂度关键前提有序数组两数之和对撞指针左端 右端O(n)数组升序三数之和固定一层 对撞固定 i对撞 l、rO(n²)可先排序验证回文串对撞指针首尾比较O(n)无链表中点快慢指针慢一步、快两步O(n)链表非空判断环形链表检测快慢指针差速判定相交O(n)无倒数第 k 个节点快慢指针快指针先走 k 步O(n)链表非空判断无重复字符最长子串滑动窗口右扩左缩O(n)字符计数/索引字典最小覆盖子串滑动窗口右扩左缩O(n)目标字符计数字符串排列滑动窗口定长窗口比较O(n)字符计数一致最后分享三点比较私人的经验。第一双指针题的代码骨架不长真正考验你的是初始化条件、循环条件、移动条件这三个点。我现在的习惯是每道双指针题先不写代码先在草稿纸上把这三个条件写清楚再动手。看起来多了一道工序实际上反而快很多因为绝大多数 bug 都产生在这三个条件上。第二做题时一定要先确认数据的有序性。题目只要没说数组有序用对撞指针之前就必须自己先排序或者换思路。排序带来的额外复杂度 O(n log n) 虽然比 O(n²) 好很多但如果题目要求保持原数组顺序这条路就走不通。第三建议把三大模板抄下来每天做一道题就对着模板改一遍。坚持两周后你会发现很多题目拿到手就能立刻判断出用哪个模板剩下的只是改约束条件而已。刷算法题的边际收益在哪里就在这种“模式识别”的熟练度上。在我个人的体验里双指针是投入产出比很高的算法分支花了很短时间把三个模板吃透就能解决一大类看起来毫无关联的题目。特别是对面试准备来说双指针几乎是必考点但掌握成本远低于动态规划这类内容。把它练到条件反射级别性价比非常高。
返回列表