1. 为什么学了双指针还是不会做题:先分清指针在往哪边跑
我估计不少人跟我一样,一开始看到"双指针"这三个字,觉得不就是两个下标的事嘛。但实际做题的时候,什么快慢指针、左右指针、滑动窗口,一会儿一个花样,代码写出来不是超时就是越界,或者逻辑全对但漏了边界,心态直接崩掉。
先说我自己的体会。双指针这个技巧,本质上是在用"两个游标"去扫描数据结构,把原本需要嵌套循环的 O(n2) 暴力解,压到 O(n) 或者 O(n log n)。但它的难点从来不是"用两个变量遍历",而是搞清楚三个问题:两个指针分别承担什么职责?指针移动的触发条件是什么?循环终止时指针停在哪里?
这三个问题搞不清楚,背再多的模板也没用。我最早就是背模板,看到"有序数组"就套对撞指针,看到"子串"就套滑动窗口,结果题目稍微变一下——比如数组里有重复元素、链表有环、窗口要求恰好覆盖某些字符——模板就失灵了。
这篇笔记我按实际做题的顺序重新梳理了一遍双指针,核心是三种最常见的形态:同向快慢指针、对撞指针、滑动窗口。这三种形态覆盖了力扣和面试里绝大多数高频题,比如删除有序数组重复项、链表环检测、两数之和、三数之和、盛最多水的容器、无重复字符的最长子串、最小覆盖子串。每道题我都不只给代码,还会写清楚"指针为什么这么移"和"我踩过的坑"。
如果你也处于"能看懂题解、但自己写不出来"的阶段,这篇笔记应该能帮你把双指针从"背套路"变成"想清楚再写"。
2. 同向快慢指针:一个负责探路,一个负责占位
2.1 原地删除有序数组重复项的完整思路
先看最经典的一道:LeetCode 26题,删除有序数组中的重复项,要求原地修改,返回新长度。
很多人的第一反应是用一个循环遍历,发现重复就调用删除操作。Python 里del nums[i]确实能删,但删除是 O(n) 的操作,整体复杂度直接到 O(n2),而且一边删一边遍历,下标还会错位。这时候快慢指针就是标准解法:
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这里的一个关键理解是:slow维护的是一个"不重复序列的右边界",而fast负责从前往后扫描所有元素。每次发现nums[fast]和nums[slow]不同,说明遇到了新值,就把这个新值搬到 slow 的下一个位置。
这样做的本质是:快指针负责探索,慢指针负责记录"有效区域的终点",两者互不干扰。
我最初写这道题的时候,犯过一个蠢错:我把if nums[fast] != nums[slow]写成了if nums[fast] != nums[fast - 1]。看似差不多,但两者逻辑完全不同——nums[fast] != nums[fast - 1]确实也能判断"当前值是否是新的",但这写法的前提是数组有序且重复项相邻。一旦换成无序数组,或者要求"保留最多 K 个重复项"这种变体,这种写法就废了。而slow版本的判断天然适应这类变体。
2.2 快慢指针检测链表环:为什么慢指针走一步、快指针走两步一定相遇
链表的环检测是快慢指针的另一个经典场景。力扣141题,判断链表是否有环。
思路很简单:slow一次走一步,fast一次走两步。如果链表中存在环,那么 fast 最终会追上 slow,两者在环内相遇;如果无环,fast 会先到达链表末尾。
def hasCycle(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False这里有一个值得琢磨的问题:为什么 fast 每次走两步,而不是走三步、四步?
关键原因是:在环内,fast 相对于 slow 的速度是"每次一步"(因为 slow 也在前进)。步长差为 1 时,fast 不会跳过 slow;如果步长差大于 1,理论上存在"跨越而不相遇"的可能性。
我在学习这一块的时候做了一个小实验,模拟了环长和步长差的关系。以环长 L 为例,fast 和 slow 进入环之后,两者的距离差会在每次迭代中减少(步长差)的绝对值。只有当步长差与环长 L 互质或至少满足"差值能整除 L 的某个倍数"时,才能保证一定相遇,否则可能会出现永远追不上的情况。
实际工程里统一约定 fast 每次走两步,不仅因为数学上最稳妥,还有一个现实因素:链表操作本身开销不大,两步足够快,没必要用更激进的三步四步,反而增加判断fast.next是否为空时的复杂度。
2.3 快慢指针踩坑记录:口头禅式编码的代价
快慢指针常见的陷阱有两个:
第一个是遍历终止条件的边界。数组类题目通常用for fast in range(len(nums))配合slow += 1来写,这样终止条件由 for 循环天然管理。但链表类题目必须自己判断fast和fast.next是否为空,漏掉任何一个都会导致空指针异常。我见过一个很典型的错误写法:
while fast.next: # 少了 fast 本身的判空 slow = slow.next fast = fast.next.next如果fast已经指向空节点了,fast.next就抛异常。更隐蔽的做法是只写了while fast而漏掉fast.next,当 fast 恰好停在最后一个节点时,fast.next.next直接报错。正确写法必须同时判断fast和fast.next。
第二个陷阱是把快慢指针和普通"双下标"混淆。快慢指针在数组去重场景中更像是"双下标覆盖",在链表环检测中才是真正意义上一快一慢的移动。写代码前先确认自己用的是哪个场景,代码风格和边界判断完全不同。
3. 对撞指针:缩小区间的正确姿势与两个经典问题
3.1 两数之和的对撞解法:从暴力到 O(n) 的关键一跃
LeetCode 167题,在一个有序数组中找到两个数,使它们的和等于目标值。
暴力解法是双重循环,O(n2)。但一旦抓住了"有序"这个特性,就可以用对撞指针:左指针left指向数组开头,右指针right指向数组结尾,每次计算nums[left] + nums[right]:
- 和等于 target:直接返回
- 和小于 target:说明左边数太小,
left += 1 - 和大于 target:说明右边数太大,
right -= 1
def twoSum(numbers, target): left, right = 0, len(numbers) - 1 while left < right: cur = numbers[left] + numbers[right] if cur == target: return [left + 1, right + 1] # 题目要求从1开始计数 elif cur < target: left += 1 else: right -= 1 return [-1, -1]这个算法的正确性论证非常有意思,我来说说为什么可以放心地移动指针,而不会漏掉解。
初始时指针覆盖整个区间。如果cur < target,说明当前左端元素太小。如果右移left,相当于舍弃了当前left这个元素与所有剩余元素配对的可能性;但因为我们确保left之前的元素都已经被扫过,而且它们与任何位置的元素配对都不可能等于目标值(因为此时连最大的右端元素配上都不够 target),所以可以安全舍弃。
判断"是否安全舍弃"就是对撞指针的核心思维。每次移动指针,其实都是在做一次"排除法",而且排除的依据是数组有序性和当前区间边界。
我在笔记里画了一个区间收缩的例子:数组[2, 7, 11, 15],target=9。初始 left=0, right=3,和是17,大于9,所以右指针左移到11,此时和是13,继续左移到7,此时和是9,直接返回。整个过程只走了三次判断。
3.2 三数之和的完整去重流程:对撞指针的进阶形态
LeetCode 15题,三数之和,要求找出所有三元组,且不允许重复。
三数之和对两数之和做了一层改造:先固定一个数nums[i],然后在剩余区间里用对撞指针找两个数,使nums[left] + nums[right] == -nums[i]。这样就把三数之和拆成了"固定一个 + 两数之和"。
但仅仅这样做会得到大量重复三元组。比如数组[-1, 0, 1, 2, -1, -4],如果不在外层循环里去重,你会得到两个[-1, 0, 1],一个从i=0出发,一个从i=4出发——这显然不对。
去重的标准做法是在两个地方做:
- 外层固定数的循环里,如果
nums[i] == nums[i - 1],直接跳过,避免同一首个数字重复处理。 - 内部对撞指针找到一组解后,把
left和right都移动到不重复的位置,确保同一对指针不会输出重复组合。
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 if nums[i] > 0: break # 因为数组已排序,第一个数大于0就说明后续更不可能 left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total == 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 total < 0: left += 1 else: right -= 1 return res写这道题时我掉进过一个非常经典的坑:忘了排序。
如果数组不是有序的,对撞指针靠着"区间端点的大小关系"来做排除法这个前提就完全失效。我第一次刷三数之和时直接拿原始数组去跑,指针移动逻辑完全错乱,输出结果乱七八糟。后来才意识到,对撞指针只适用于有序序列或本身就有大小顺序结构(比如 BST 的中序序列),任何用到"左边界小、右边界大"这个前提的题目,先排序永远是第一步。
3.3 盛最多水的容器:移动指针的直觉反直觉推理
再来一道对撞指针的应用题:LeetCode 11题,盛最多水的容器。给你一个高度数组,找到两条线构成的容器能装最多水的面积。
暴力解法是枚举所有下标对,计算min(height[left], height[right]) * (right - left),O(n2) 显然会超时。对撞指针的做法如下:
def maxArea(height): left, right = 0, len(height) - 1 res = 0 while left < right: cur = min(height[left], height[right]) * (right - left) res = max(res, cur) if height[left] < height[right]: left += 1 else: right -= 1 return res为什么可以移动较矮的那一边?
直觉推理是这样:当前容器的容量受限于较矮的那根柱子。如果移动较高的一边,新的容器高度无论如何都不会超过原来较矮的那个高度(因为决定高度的总是矮的一端),同时宽度还在缩小,容量只可能变小或不变。反过来,移动较矮的一边,虽然宽度也缩小了,但高度可能因为新柱子变高而增加,所以存在让面积更大的机会。
这个推理有一个非常反直觉的地方:移动较矮的一边到下一个位置时,我们是在"丢弃"这个矮柱子与所有右侧柱子配对的可能。但为什么是安全的?因为假设当前右边柱子很高,这个矮柱子与任何右侧柱子配对的容量上限,都以这个矮柱子高度为准,而当前这个配对已经取到了最大宽度(右指针在最远端)。所以只要当前矮柱子的高度不变,宽度在缩小,后面所有和它配对的容量都不会超过当前值。因此可以放心地把矮的那端往中间移动。
这种"反直觉但严密"的推理在双指针题里非常多见,我强烈建议不要只靠感觉来接受,最好自己拿几组测试数据验证一遍。
4. 滑动窗口:同向指针进化版,处理连续子序列的利器
4.1 无重复字符的最长子串:窗口扩张与收缩的节奏
说到滑动窗口,力和扣上最典型的是 LeetCode 3题,无重复字符的最长子串。
之前我总觉得滑动窗口和快慢指针很像,都是两个同向指针,但它们的核心逻辑并不一样。快慢指针更关注"覆盖"和"占位",滑动窗口关注的是"维护一段满足条件的连续区间"。
用双指针实现滑动窗口时,left是窗口左边界,right是窗口右边界。我用一个字典记录窗口内每个字符最后一次出现的位置。当右指针遇到一个重复字符时,就把左指针直接跳到重复字符上一次出现位置的下一个位置,这样窗口内就不会有重复字符了。
def lengthOfLongestSubstring(s): pos = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in pos and pos[ch] >= left: left = pos[ch] + 1 pos[ch] = right max_len = max(max_len, right - left + 1) return max_len这段代码里最关键的是pos[ch] >= left这个判断。我看过很多版本没有这个条件,直接写if ch in pos,但那样会出错——因为pos里存的是字符历史出现过的位置,有些位置可能在当前左边界之外,已经不属于当前窗口了。举个例子,字符串"abba",当 right 走到最后一个 'a' 时,pos['a']还是 0,但当前的窗口左边界已经因为 'b' 的重复移动到了 2,此时 'a' 的下标 0 根本不在窗口内,不能作为重复依据。
这个细节我强烈建议每一位学习者都手动走一遍。我在面试时就被问到这个判断的意义,如果答不上来,面试官会认为你只是背了代码而不是真正理解滑动窗口。
4.2 最小覆盖子串:收缩时机决定一切
LeetCode 76题,最小覆盖子串是滑动窗口里难度较高的一道,要求找到 s 中包含 t 所有字符的最短子串。
这里的基本思路是:先用右指针扩大窗口直到覆盖 t 中所有字符,然后尝试移动左指针收缩窗口,在仍然覆盖 t 的前提下找最短子串。这样窗口在"扩张—收缩—扩张—收缩"的循环中前进,每个字符至多被左右指针各访问一次,整体 O(n)。
def minWindow(s, t): from collections import Counter need = Counter(t) missing = len(t) left = 0 res = "" min_len = float('inf') for right, ch in enumerate(s): if need[ch] > 0: missing -= 1 need[ch] -= 1 while missing == 0: # 窗口已覆盖所有目标字符,尝试收缩 if right - left + 1 < min_len: min_len = right - left + 1 res = s[left:right + 1] chl = s[left] need[chl] += 1 if need[chl] > 0: missing += 1 left += 1 return res这个写法用missing变量记录"还差多少个字符才算覆盖"。注意这里的need字典对不属于 t 的字符也做了减法和加法,但这其实是无害的,因为只有当need[ch] > 0时missing才会变化。判断是否收缩的时机是missing == 0,一旦窗口内的字符覆盖了 t,立刻尝试收缩左边界。
我最初看到一个版本的计数器逻辑,直接用need[ch] > 0来判断当前字符是否为所需字符,思路也是对的,但在字符种类很多的情况下略显绕。missing的做法更直观,也方便调试。
4.3 滑动窗口的两个常见误区
第一个误区是窗口内计数信息的更新时机。拿最小覆盖子串来说,很多人会在右指针移动时更新need,却在左指针移动时忘掉need的还原,导致窗口状态失真。其实左右指针移动时都要同步修改need,因为你的窗口边界变了,字符计数自然要跟着变。
第二个误区是用"是否存在重复"类题目去硬套"覆盖类"题目的模板。无重复字符最长子串的重点是维护不重复;最小覆盖子串的重点是维护覆盖。两者虽然都是滑动窗口,但收缩触发条件完全不同。前者遇到重复立刻收缩,后者只有在完全覆盖后才能收缩,而且收缩后还要继续验证覆盖状态。想通这一点,滑动窗口类题目基本就不会懵了。
5. 双指针的复杂度、适用场景与三个容易搞混的点
5.1 为什么双指针能做到 O(n):摊还分析的直观理解
双指针题目的时间复杂度看起来是两重循环,比如滑动窗口里的 while 嵌套 for,实际上每个指针在每个循环里只移动一次。整个过程下来,left和right分别最多移动 n 次,总操作次数不超过 2n,因此复杂度是 O(n)。
这种分析方法叫摊还分析。通俗理解就是:哪怕代码看起来像嵌套循环,只要每个指针在每个单位时间里只移动一次、且指针从不回头,整体扫描次数就是线性的。
我把这个结论贴在笔记里提醒自己:以后遇到双指针题,别看到 while 嵌套就以为复杂度是 O(n2),先数一数每个指针一共移动了多少次。
5.2 什么时候选用双指针、什么时候该选哈希或二分
双指针不是万能的。在有序数组中找两数之和,双指针是标准答案;但如果是无序数组的两数之和,哈希表明显更合适,复杂度同样是 O(n),且不需要额外排序。
来看一道题:力扣1题两数之和,数组无序,要求返回下标。用哈希是最直接的:
def twoSum(nums, target): seen = {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] = i return []你可能会问:那先排序再用对撞指针行不行?可以,但排序的时间复杂度是 O(n log n),而且排序会丢失原下标信息,如果题目要求返回原下标,排序后的数组还得额外记录原位置,反而更麻烦。
所以我的选择经验是:
- 数组有序:优先考虑对撞指针。
- 数组无序但只关心值不关心位置:可以先排序再对撞。
- 数组无序且必须返回原下标:直接哈希。
- 连续子串、子数组的最值问题:滑动窗口。
- 链表环检测、链表中点:快慢指针。
这个决策流程看起来简单,但真正做题时能帮你节省大量试错时间。
5.3 双指针不适合哪些场景
双指针不适合的场景主要有三类:
第一类是需要输出所有组合但组合数量本身就很大的题目。比如找出所有和为 target 的四元组,即使双指针能做到 O(n3),实际输出数量可能非常大,这时候算法的瓶颈不在查找而在输出。
第二类是数据源不是线性结构的时候。双指针通常作用于数组、链表这类线性结构,一旦换成树、图,双指针就基本用不上了。树上的常见技巧是 DFS 或 BFS,图的环检测要用拓扑排序或 DFS 标记,而不是简单的快慢指针。
第三类是字符串匹配类的复杂模式匹配。比如正则表达式匹配、单词接龙这类,滑动窗口无法处理复杂的约束关系,这时候该用动态规划或 BFS。
判断"能不能用双指针"最核心的标准只有一个:问题能否通过指针的单调移动(永不回头)来逐步缩小搜索范围或维护有效区间。如果你发现指针在某些情况下必须回头或重新扫描,那大概率不适合直接用双指针。
5.4 双指针与二分查找的相似与不同
对撞指针和二分查找都依赖有序数组,都通过"缩小搜索区间"来定位答案,但它们的目标完全不同。二分查找是在单调区间内找某一个特定值或边界,每次直接丢弃一半区间;对撞指针是同时从两端出发逐步逼近,每次只丢弃一个端点,适用于"找一对元素满足某种关系"的场景。
举个容易混淆的例子:有序数组中找 target 的插入位置,用二分;有序数组中找两个数之和等于 target,用对撞。前者需要的是"单点定位",后者需要的是"双元素关系"。如果把两者搞混,代码要么多此一举,要么直接失去有序性优势。
6. 把双指针用到面试和工作中:我的三个实战观察
最后想聊一点面试和工作层面的实际体会。
第一,面试中遇到双指针题,先把指针职责说清楚再写代码。比如面试官问"怎么找有序数组中和为 target 的两个数",你上来就写 while 循环,万一写错很难看出思路。但如果你先说"左指针维护当前最小元素,右指针维护当前最大元素,和小于 target 就把左指针右移,和大于 target 就把右指针左移",面试官一眼就知道你有清晰的分析能力。码农界有句话叫"Code tells how, comments tell why",双指针题的"why"就是指针移动的依据,这点比代码本身重要得多。
第二,边界条件要用小规模用例主动验证。我刷题这些年,最大的进步来自于养成了"写完代码立刻用空数组、单元素数组、元素全相等数组、目标值比所有元素都大/都小"这五组用例去自测的习惯。很多边界 bug 在这样的自测下无处遁形。比如三数之和的nums[i] > 0提前 break 这个优化,如果没有全正数组的测试用例,你可能根本不会发现它可以提前终止循环。
第三,双指针思想在业务代码里也有用武之地。我处理过一个日志合并的需求:两个按时间排序的日志文件需要合并成一份按时间排序的总日志,正好可以用两个指针分别指向两个文件的当前行,谁的时间戳小就输出谁,然后移动对应指针。这种场景不需要任何框架,双指针的思想直接就能落地。类似地,合并两个有序数组(力扣88题)在业务中经常以"合并两个配置文件、合并两份统计数据"的形式出现。
这些观察也许不如刷题模板直观,但我觉得,一个算法技巧真正内化的标志,是你能在脱离"算法题库"语境后,还能在真实数据面前本能地想到它。双指针就是这样一种"既简单又狡猾"的技巧,简单在思想,狡猾在边界。多看多练多想,比死记硬背任何一个模板都管用。