双指针算法详解:对撞、快慢与滑动窗口实战
1. 双指针到底解决了什么问题先从一个最常见的场景说起。假设你拿到了一个有序数组要找出其中两个数使它们的和等于某个目标值。最直接的想法是两层循环暴力枚举第一层固定一个数第二层遍历剩下的数去检查。这个方案的复杂度是O(n²)数据量小的时候没什么感觉一旦数组规模来到上万级别性能就会明显吃力。而同样的问题用双指针可以在O(n)时间内解决差距是数量级的。我在实际刷题和写业务代码时对双指针的第一感受是它本质上是用两个下标协同移动来替代两层循环的暴力枚举。很多O(n²)的暴力方案之所以慢是因为它做了大量无意义的重复扫描。而双指针通过维护两个指针的移动规则把哪些组合不需要检查直接剪掉从而把复杂度降下来。双指针并不局限于数组。链表里判断是否有环、字符串里找最长无重复子串、有序数组里找两数之和这些都绕不开双指针。它甚至不只是算法题里的技巧在日常业务代码里合并两个有序列表、过滤重复项、查找满足条件的连续区间同样可以用这套思路来优化。这篇文章不打算讲理论空壳我想直接把几种最常用的双指针模式拆开来说清楚对撞指针、快慢指针、滑动窗口式的同向指针以及它们各自的适用前提、代码骨架、典型例题和我实际踩过的坑。先说一个最重要的判断标准一道题能不能用双指针取决于数据是否具备某种单调性或者是否可以在移动指针的过程中保持这种单调性。举个例子在有序数组里找两数之和数组从左到右是递增的。如果左指针指向的值加右指针指向的值已经大于目标值那么左指针再往右移动和只会更大所以唯一合理的操作是让右指针左移。这个能明确判断该往哪个方向移动的性质就是双指针能成立的根本原因。没有单调性双指针就失效了。理解了这一点后面所有场景都能往这个核心逻辑上靠。2. 对撞指针有序场景下的收窄查找对撞指针是双指针里最直观、最好理解的一种模式。两个指针分别从序列的两端出发向中间移动直到相遇。它的代码骨架极其简单但这层简单背后藏着利用有序性剪枝的核心思想。2.1 代码骨架与移动逻辑def two_sum_sorted(numbers: list[int], target: int) - list[int]: 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 [-1, -1]这段代码的移动逻辑是两数之和小于目标值时说明需要更大的数左指针右移两数之和大于目标值时说明需要更小的数右指针左移。整个过程没有回溯每一步都基于当前状态做出确定性的方向选择。为什么这个逻辑成立关键在于数组有序。当前和偏小意味着左指针右边所有元素和右指针当前元素的组合只会更大但左指针右边的元素已经通过左移右指针的方式排除掉了所以唯一剩下可能凑出目标值的方向就是左指针右移。反过来也一样。这就是剪枝的含义——不需要检查的组合直接被跳过。2.2 回文串判断对撞指针的另一个高发场景判断一个字符串是否为回文串是对撞指针的经典应用。左指针从头部出发右指针从尾部出发两个指针不断向中间靠拢每一步比较两个字符是否相等。def is_palindrome(s: str) - bool: left, right 0, len(s) - 1 while left right: if s[left] ! s[right]: return False left 1 right - 1 return True这个场景看似简单但有一个实际业务中很容易踩的坑如果字符串中包含空格、标点符号并忽略大小写那就不应该直接比较字符而应该先做预处理或跳过非字母数字字符。很多新手在LeetCode上做这个题的时候直接把原始字符串拿来比较结果在边界用例上报错。def is_palindrome_with_filter(s: str) - bool: left, right 0, len(s) - 1 while left right: while left right and not s[left].isalnum(): left 1 while left right and not s[right].isalnum(): right - 1 if s[left].lower() ! s[right].lower(): return False left 1 right - 1 return True这里有一个细节值得注意内部while循环一定要加上left right这个条件否则指针可能越界。我自己就犯过这个错误——字符串全部是非字母字符时左指针一路往右走直接冲出数组边界。2.3 盛最多水的容器不需要有序也能用对撞指针很多人以为对撞指针只适用于有序数组实际上这是一个误解。有一道很经典的题目——盛最多水的容器给定一组高度值选择两个位置构成容器问最多能装多少水。这个题没有排序数组是任意的但依然可以用对撞指针。核心逻辑是容器的容量由较矮的那块板决定如果移动较高的一侧容量只会减少或不变所以每次都只移动较矮的那一侧。def max_area(height: list[int]) - int: left, right 0, len(height) - 1 max_water 0 while left right: current_area min(height[left], height[right]) * (right - left) max_water max(max_water, current_area) if height[left] height[right]: left 1 else: right - 1 return max_water这里单调性体现在短板效应上两个边界之间的容量由较短边决定。如果保留短边、移动长边新的容量不可能超过当前值因为高度被短边限制而宽度在缩小。这个规律使得哪边短就移动哪边成为一个确定性规则。所以对撞指针真正需要的不是整个数组有序而是存在一个可以确定性的方向选择规则。这个结论很重要能帮你跳出双指针只能用于有序数组的思维定式。3. 快慢指针链表与数组中的状态检测快慢指针与对撞指针最大的不同在于两个指针的起点相同方向相同但速度不同。常见组合是一快一慢快的每次走两步慢的每次走一步。这种模式在链表问题里尤其常见但也适用于数组。3.1 环形链表检测Floyd判圈算法的Python实现判断一个链表是否成环最经典的解法就是快慢指针。想象两个人绕操场跑步速度快的人迟早会追上速度慢的人——如果赛道是一个环的话。如果赛道是直的快的人只会率先到达终点。class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def has_cycle(head: ListNode) - bool: slow, fast head, head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False这里要注意while循环的终止条件fast and fast.next两个都不能为空。因为fast每次走两步如果fast本身为空或者fast.next为空就说明链表走到了尽头不可能有环。少了这个条件代码会在访问fast.next.next时抛空指针异常。进一步问如果链表中存在环如何找到环的入口这需要一点数学推导。设头节点到环入口的距离为a环入口到相遇点的距离为b环的周长为c。slow走的路程是abfast走的路程是abkck为fast在环内多走的圈数。由于fast的速度是slow的两倍所以2(ab) abkc ab kc a kc - b (k-1)c (c-b)这意味着从相遇点继续往前走(c-b)步加上(k-1)圈就能到达环入口而从头节点走a步也能到达环入口。所以相遇之后把一个指针移回head两个指针都以一次一格的步速同步前进再次相遇的位置就是环入口。def detect_cycle_entry(head: ListNode) - ListNode: slow, fast head, head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: break else: return None slow head while slow is not fast: slow slow.next fast fast.next return slow这段代码的for...else实际上是while...else结构在Python里很实用循环正常结束未break时执行else分支返回None。手写链表题的时候这个写法可以少写一个标志位。3.2 数组中的快慢指针原地去重快慢指针在数组里最常见的应用是原地去重。给定一个有序数组要求原地删除重复元素返回去重后的长度。慢指针指向已去重区间的末尾快指针用于遍历整个数组。def remove_duplicates(nums: list[int]) - int: 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这里慢指针维护的是新数组的写入位置快指针负责扫描原数组。每当快指针遇到一个新值就把它写到慢指针的下一个位置。整个过程利用了数组有序这个前提——重复元素一定相邻所以只需要比较相邻位置的值不需要额外空间时间复杂度O(n)。我在实际做这类题的时候有一个心得快慢指针在数组中的应用本质上是一种读写分离。快指针负责读慢指针负责写。读得快、写得慢中间被覆盖的区域就是已经不需要的旧值。这个视角一旦建立很多变种题比如把特定元素移动到数组末尾、移除指定元素都能用同一套模板快速写出来。3.3 找链表的中间节点另一个快慢指针的典型应用是找链表的中间节点。快指针到终点时慢指针刚好在一半的位置。def middle_node(head: ListNode) - ListNode: slow fast head while fast and fast.next: slow slow.next fast fast.next.next return slow这个技巧的实际价值不只是找中间点它还是很多更复杂问题的基础步骤。比如判断一个链表是否为回文链表标准解法就是先找中点再反转后半段最后两边同步比较。整个流程里快慢指针找中点是第一步如果没有这个技巧你就只能先遍历一遍统计长度再用计数器走一半代码会啰嗦不少。4. 滑动窗口双指针中最容易被低估的变体滑动窗口可以理解为同向双指针的进阶版两个指针都从左往右移动且始终保持left right。右指针负责扩展窗口左指针负责收缩窗口整个窗口像一条毛毛虫一样在序列上爬行。很多人在学习双指针的时候会把滑动窗口当作另一个独立的知识点其实它底层就是双指针。理解这一点很重要——一旦你接受了滑动窗口是双指针的一种你就不需要额外记忆一套新的模板只需要记住同一个骨架的不同变体。4.1 无重复字符的最长子串题目给定一个字符串找出其中不含有重复字符的最长子串的长度。这是滑动窗口最经典的入门题。def length_of_longest_substring(s: str) - int: 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核心逻辑右指针每次扩展一个字符如果这个字符已经在窗口里就不断收缩左指针直到把重复字符移出窗口。窗口内的字符始终用一个set来维护判断重复的复杂度是O(1)。这个题的坑点在于什么时候更新答案。正确做法是在把新字符加入窗口之后更新因为此刻的窗口才是合法的无重复窗口。如果你在while循环之前就更新答案会把包含重复字符的子串长度也算进去导致结果偏大。这个顺序问题不亲自跑一遍永远记不住。4.2 最小覆盖子串窗口内字符计数的精细化比无重复子串进阶一些的是最小覆盖子串问题给定一个源字符串s和一个目标字符串t在s中找到包含t所有字符的最短子串。这个题的难点在于t中的字符可能有重复所以不能用set需要dict来记录每个字符的需求量。from collections import Counter def min_window(s: str, t: str) - str: if not s or not t: return need Counter(t) window {} formed 0 required len(need) left 0 ans float(inf), None, None for right in range(len(s)): c s[right] window[c] window.get(c, 0) 1 if c in need and window[c] need[c]: formed 1 while left right and formed required: if right - left 1 ans[0]: ans (right - left 1, left, right) d s[left] window[d] - 1 if d in need and window[d] need[d]: formed - 1 left 1 return s[ans[1]:ans[2] 1] if ans[0] ! float(inf) else 这里的formed变量记录已经满足需求的字符种类数。只有当某个字符在窗口中的数量恰好等于需求数量时formed才加一收缩窗口后如果数量不足formed再减一。这个题的复杂程度主要来自状态维护但骨架依然是滑动窗口的标准结构。我总结了几个关键点required代表t中不同字符的个数不是t的总长度判断条件用window[c] need[c]而不是因为一旦超过formed不会重复增加收缩窗口时window[d] - 1不能省否则状态会越走越偏4.3 什么时候用滑动窗口而不是我的直觉方案遇到最长子串最短子串满足某条件的连续区间这类问题时先想到滑动窗口就对了。这种问题的共性特征是子数组或子串是连续的且存在一个随着窗口扩大逐渐变好、随着窗口收缩逐渐变差的单调关系。比如无重复字符的最长子串窗口扩大可能引入重复字符变差收缩可以恢复合法性变好。这种扩大变差、收缩变好的性质就是滑动窗口可以使用的信号。如果题目要求的是不连续的子序列滑动窗口就派不上用场那是动态规划或回溯的领地。独立判断这个信号比背模板重要得多。模板背多了会发现at the end每个双指针题都长得差不多但判断能不能用才是面试和实战里真正的分水岭。5. 双指针的边界条件与常见错误复盘双指针代码写起来不难难的是把边界条件一次写对。下面这些坑都是我在实际编码里踩过或者看别人踩过的。5.1 循环条件到底是left right还是left right对撞指针的循环条件多数情况下用left right。因为在两个数相加这个场景里left和right指向同一个元素没有意义——一个元素不能用两次。但在回文串判断里如果字符串长度为奇数中间那个字符和自身比较天然相等所以left right就足够覆盖了。如果用left right在偶数长度回文串的最后一轮会导致left越界。快慢指针找中点时循环条件是while fast and fast.next这和数组对撞的left right在形式上完全不同一定要区分记忆。5.2 指针移动后状态没有同步更新这是滑动窗口最容易出错的地方。比如在最小覆盖子串里你收缩左指针后必须同步更新window字典里对应字符的计数还要检查formed是否需要减一。很多人在笔试时把formed的判断逻辑写在收缩窗口之外结果窗口已经非法了状态却还停留在合法状态导致答案错误。这类问题的调试很痛苦因为整体跑了看不出问题只有跑到特定输入才会错。我的建议是每次移动指针之后立刻检查所有和这个位置绑定的状态变量是否同步更新。写代码的时候不要先写移动逻辑再补状态更新而是先把移动指针会导致哪些状态变化列出来再动笔写。5.3 数组越界与链表空指针对撞指针中内部while循环缺少left right条件会越界。快慢指针中fast.next可能为None。链表题中空链表的head为None。这些都是空指针异常的高发点。实践中我倾向于在写代码之前先把极端输入列在注释里空数组、单元素数组、全重复数组、全不重复数组、空字符串、单字符字符串、无环链表、只有头节点的链表。把这些case都跑通了双指针代码基本就不会有边界问题。6. 如何判断一道题该不该上双指针我的实战决策路径最后分享一套我自己的判断路径。拿到一道算法题我先看数据结构的类型和问题的形状再决定要不要用双指针。第一步看数据是否连续。数组、字符串、链表这类线性结构天然适合指针操作树和图一般不适合双指针因为路径不是线性的。第二步看题目是否要求O(n)或O(nlog n)的时间复杂度。如果暴力解O(n²)是可以接受的双指针带来的收益就不明显但大多数LeetCode中等的题目暴力解都会被卡时间这时候双指针就是性价比最高的解法之一。第三步看是否存在单调性或者固定的移动规则。有序数组找两数之和有单调性盛最多水的容器有移动矮边的确定性环形链表有快慢必相遇的逻辑。任何一个成立都可以放心用双指针。第四步看能不能通过排序建立单调性。比如三数之和本身数组无序但先排序就可以用对撞指针来优化两数之和部分。排序的时间复杂度O(nlog n)比整体O(n²)的暴力方案仍然快不少。把这几步走完就能避免拿双指针硬套所有题的尴尬。双指针不是银弹它适合的题目都有同样清晰的信号。我现在处理算法相关的问题都是先用双指针筛一遍不行再上前缀和、二分、堆这些更重的工具。这种由轻到重的决策顺序帮我省下了大量调试时间。希望这套思路对你也同样有用。

相关新闻

EtherCAT网关选型全攻略:协议、芯片、线缆三大关键

EtherCAT网关选型全攻略:协议、芯片、线缆三大关键

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 2:11:53 阅读更多 →
Docker搭建Redis集群实战:从主从复制到哨兵模式与Cluster分片

Docker搭建Redis集群实战:从主从复制到哨兵模式与Cluster分片

1. 为什么要用Docker搭Redis集群:从单节点到主从哨兵的演进逻辑先说一个很多人问我的问题:单机Redis用得好好的,为什么要费劲搭集群?这个问题其实分两层。第一层,如果你的业务量还没到单节点扛不住的程度,那…

2026/10/11 2:11:53 阅读更多 →
大华ICC平台联接客户端部署与联调实战:从安装到信令抓包排障

大华ICC平台联接客户端部署与联调实战:从安装到信令抓包排障

简介:大华ICC平台联接客户端资源包面向安防运维人员、系统集成商及需要远程视频监控管理的技术人员,围绕大华ICC智能云连接平台的客户端部署与设备接入展开。压缩包共4个文件,约277.47MB,包含1个exe安装程序、2个xml配置说明文件及…

2026/10/11 2:10:52 阅读更多 →

最新新闻

电池异常检测竞赛方案:特征工程与阈值调优全复盘

电池异常检测竞赛方案:特征工程与阈值调优全复盘

我参加过一场能源AI挑战赛,任务落在电池异常检测上,最终排名守在第二,持续多轮没掉出头部。复盘时我经常被问到:这个第二名到底赢在哪?其实答案很朴素——不是某个神秘模型,而是把从数据解读、特征构造、模…

2026/10/11 3:04:22 阅读更多 →
MySQL性能优化实战:从慢查询定位到索引设计的系统方法

MySQL性能优化实战:从慢查询定位到索引设计的系统方法

1. 慢查询日志配置:先把“病号”抓出来,再谈治病1.1 三个核心参数与一套推荐配置做MySQL性能优化,我从来不是一上来就翻代码或者加索引,而是先打开慢查询日志。很多团队的MySQL实例跑了几年,慢查询日志一直是关闭状态&…

2026/10/11 3:04:22 阅读更多 →
STM32纯软件仿真入门:不买开发板也能跑通GPIO、定时器、串口与中断

STM32纯软件仿真入门:不买开发板也能跑通GPIO、定时器、串口与中断

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 3:04:22 阅读更多 →
Linux性能排查:perf工具定位CPU热点函数实战指南

Linux性能排查:perf工具定位CPU热点函数实战指南

接手一台 CPU 飙到 200% 的机器,top 上看不到哪个进程异常,vmstat 显示 us 很高,pidstat 又说某线程在忙,可就是说不清它到底在忙什么。这种时候,我一般会直接上 perf。perf 是 Linux 内核自带的性能剖析工具&#xff…

2026/10/11 3:04:22 阅读更多 →
开源神经接口Muse:肌电腕带与Home Link生态解析

开源神经接口Muse:肌电腕带与Home Link生态解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 3:04:22 阅读更多 →
电磁泄漏防护全解析:从屏蔽室建设到红黑分离的工程实践

电磁泄漏防护全解析:从屏蔽室建设到红黑分离的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 3:03:21 阅读更多 →

日新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 10:38:42 阅读更多 →