
聊一聊 Python 里那双无处不在的“手”——双指针Two Pointers。刷 LeetCode、应付笔试、处理数组链表字符串这三大类问题时我见到最多的解法技巧就是它尤其用 Python 写题的时候很多 O(n²) 的暴力枚举都能靠双指针轻松降成 O(n)。这篇内容主要面向刚把 Python 基础语法跑完、准备正式入门算法题的读者也适合面试前想系统过一遍常见套路的朋友。不是为了堆概念我们从“双指针到底在干嘛”开始一路拆解快慢指针、首尾指针、滑动窗口最后把那些普通文档里不会写的边界坑和调试经验一并整理出来。1. 双指针为什么好用它到底在解决什么难题1.1 暴力版的双重循环为什么慢先看一个最常见场景给一个有序整数数组nums找两个数使它们的和等于给定的target。很多人第一反应是双重循环def two_sum_brute_force(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 1, j 1] return []这段代码思路没问题但问题出在组合数量上。n 个元素里选两个组合数量是 n*(n-1)/2当 n 等于 10 万时大约要执行 50 亿次加法比较。Python 写这种循环跑完再快也要几十秒放到真实接口里早就超时了。双指针想解决的正是这类“需要同时关注两个位置但两个位置之间存在约束关系”的问题。它通过让两个指针按照规则移动把大量无效组合提前排除复杂度从 O(n²) 降到 O(n)。这个思想远比“指针”这个名词本身重要。我第一次用双指针替代双重循环时最大的感受是原来并不是所有组合都要枚举数组本身的结构就能帮我们筛掉一大批根本不需要尝试的情况。1.2 双指针的两种基本形态双指针不是某种特殊的数据结构它本质上是一种遍历策略。按照两个指针的移动方向我习惯把它们分成两类快慢指针两个指针同向出发速度不同。一个走得快一个走得慢常用来处理链表成环、找链表中间节点、找链表倒数第 k 个节点这类问题。首尾指针夹逼指针一个指向序列头部一个指向尾部向中间靠拢。适合有序数组的两数之和、反转数组、回文判断、原地去重等场景。用生活化的比喻来理解快慢指针就像操场上跑步速度不同的两个人速度快的会从后面追上慢的靠“相遇”来判断某些特殊结构首尾指针像两只手同时从一根长竹签的两端往中间捏每一步都在缩小剩余的空间范围。理解这两种形态之后再去看题目会轻松很多。看出一道题能不能用双指针关键看两点一来数据是否具备某种“方向性”比如有序数组的单调性、链表的单向移动二来两个位置之间是否存在明确的取舍关系比如大了要往小调、小了要往大调。2. 快慢指针实战链表成环检测与环入口定位2.1 判断链表是否有环的数学直觉先问一个问题给你一个单链表怎么判断它里面是否存在环不引入额外空间的场景下最自然的思路就是让两个指针一快一慢地往后走慢指针每次走一步快指针每次走两步。如果链表没有环快指针会先走到链表末尾如果有环快慢指针最终一定会在环内相遇。为什么一定会相遇这里可以换一个角度理解当两个指针都进入环之后环是闭合的快指针每次比慢指针多走一步相当于在环内不断追赶慢指针。由于环的长度是有限的快指针相对慢指针的“距离”每次缩短 1总有一个时刻会被追上。这个基础版本也叫 Floyd 判圈算法快指针走两步、慢指针走一步是最经典的写法。实际面试里面试官还会追问一步找到相遇点之后怎么继续找到环的入口节点2.2 Python 实现检测环并找到环入口找到环入口的完整实现如下class ListNode: def __init__(self, x): self.val x self.next None def detect_cycle(head: ListNode) - ListNode: slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: break else: return None slow head while slow ! fast: slow slow.next fast fast.next return slow代码不长但几个细节值得逐行拆开看。第一进入第一个while前要判断fast and fast.next因为快指针每次走两步如果fast本身为空或fast.next为空下一步访问fast.next.next就会直接报AttributeError。这个条件是所有快慢指针题目的“安全锁”。第二如果循环正常结束说明遇到了None说明链表没有环直接返回None。Python 里for...else和while...else的else分支很多人平时用不到但写在这里非常合适——它只在循环没有被break提前中断时才执行。第三找到第一次相遇点后把慢指针放回链表头快指针留在相遇点然后两个指针都以每次一步的速度同步移动。它们再次相遇的位置就是环的入口。这个结论背后有数学推导简单说就是从头节点到入口的距离等于从相遇点到入口再加上环长度的整数倍。直接背结论容易忘建议自己在草稿纸上画一个有环的链表标出距离用几步式子推一遍印象会深刻很多。这里强烈建议动手推一次因为面试时经常需要现场解释原理。2.3 快慢指针的其他经典用法快慢指针不只用在链表判环上。找链表中间节点也是标准的快慢指针题快指针走两步、慢指针走一步快指针到链表尾部时慢指针正好停在中间位置。这个技巧在链表排序、链表重排问题里经常作为前置步骤出现。另一个变体是找链表倒数第 k 个节点。做法是让快指针先走 k 步然后快慢指针同步走快指针到表尾时慢指针指向的就是倒数第 k 个节点。这类题目共同的核心都是利用“速度差”拉出一个距离差再用距离差定位目标位置。我早期写快慢指针时踩过一个坑在循环体里先移动了快指针再移动慢指针导致某个节点被跳过。这里注意先移动慢指针还是快指针不是重点重点是移动之后不要忘了判断是否相遇。不同的题目只要保证两个指针都正确移动相遇判断放在移动之后逻辑就没问题。3. 首尾指针实战有序数组去重与两数之和3.1 原地去重两个指针怎么做到一次遍历完成LeetCode 第 26 题“删除有序数组中的重复项”要求原地修改数组空间复杂度 O(1)。很多初学者第一反应是开一个新列表把不重复的元素放进去但这样就不满足“原地”的要求了。双指针在这个问题上表现得非常干净def remove_duplicates(nums: list[int]) - int: if not nums: return 0 i 0 # i 指向最后一个不重复的位置 for j in range(1, len(nums)): if nums[j] ! nums[i]: i 1 nums[i] nums[j] return i 1这里的i是慢指针只在遇到新元素时才移动j是快指针负责逐个扫描整个数组。外层用for j遍历天然保证了快指针不会越界也不用手动维护循环终止条件。i最终指向的是最后一个不重复元素的下标所以返回长度时要加 1。这个写法的时间复杂度是 O(n)空间复杂度是 O(1)每个元素只被访问一次。整个过程像两个人配合整理书架一个人把书从头到尾看一遍另一个人只负责把不重复的书按顺序排到前面。这是我个人认为双指针里最好理解的一个例子建议完全不动脑地默写一遍后面写更复杂的窗口题会顺手很多。3.2 两数之和 II夹逼法替代双重循环的完整思路有了去重题的基础再看两数之和就容易了。LeetCode 167 题的输入是有序数组正好可以用首尾指针def two_sum_sorted(nums: list[int], target: int) - list[int]: left, right 0, len(nums) - 1 while left right: current_sum nums[left] nums[right] if current_sum target: return [left 1, right 1] elif current_sum target: left 1 else: right - 1 return []这段代码的移动逻辑很关键。因为数组是有序的当nums[left] nums[right]小于target时说明左边元素太小唯一合理的调整是让left向右移动加大总和当总和大于target时说明右边元素太大应该让right向左移动。每一步都排除一个方向上的所有不可能组合所以最多遍历 n 个位置就能找到答案。这种夹逼写法的好处不止是快。相比哈希表方案它不需要额外空间也没有哈希冲突的烦恼相比双重循环它直接砍掉一个数量级的耗时。唯一需要注意的是输入必须有序。如果题目给的是无序数组直接套这个写法会得到错误答案。3.3 什么时候不能直接用首尾指针无序数组必须排序后才能用首尾指针但排序会丢失原始下标。如果题目要求返回原始下标比如经典版 Two Sum这时候用哈希表更合适。如果你实在想用双指针就得在排序前先给每个元素记录原始下标用一个自定义类或元组列表保存(值, 原始下标)排序后夹逼找到后再取出原始下标返回。从实际面试角度看能清晰说出“为什么这道题不能用首尾指针”往往比写对代码更能加分。首尾指针依赖数据的单调性和方向性一旦数据不满足这个前提任何强行夹逼都可能漏掉正确答案。这也是双指针题目最容易隐蔽出错的地方之一。4. 滑动窗口也算双指针最长无重复子串完整讲解4.1 先理解滑动窗口的“窗口状态”很多讲滑动窗口的资料会把它单独划成一类但从实现角度看滑动窗口就是两个同向移动的指针left和right。right负责扩大窗口left负责在窗口不满足约束时收缩窗口整个过程中窗口在数组或字符串上“滑”过去。滑动窗口最关键的一个概念是“窗口状态”。窗口状态不是固定的某个变量而是根据题目要求维护的一组信息。比如求无重复子串时窗口状态就是当前窗口内出现了哪些字符求最小覆盖子串时窗口状态就是窗口内每个目标字符出现了多少次。把这个状态想清楚模板才写得出来。一个通用的滑动窗口模板长这样left 0 for right in range(len(arr)): # 1. 更新窗口状态比如把 arr[right] 加入统计 # 2. while 窗口不满足条件: # 更新窗口状态比如把 arr[left] 移出统计 # left 1 # 3. 窗口满足条件后更新答案这个模板看起来很空洞但实际做题时基本就是往三个位置填逻辑。只要判断“窗口是否满足条件”的方法想清楚剩下就是机械操作。4.2 最长无重复子串的 Python 实现与易错点以“无重复字符的最长子串”为例完整代码如下def length_of_longest_substring(s: str) - int: window set() left 0 ans 0 for right, ch in enumerate(s): while ch in window: window.remove(s[left]) left 1 window.add(ch) ans max(ans, right - left 1) return ans逐行说清楚。right指针负责向右探索新字符每来一个新字符ch先检查它是否已经在window里。如果在说明当前窗口里有重复字符必须把left向右移动并从window中移出s[left]直到ch不再重复。这个while是核心中的核心它的作用是把窗口左边界“收缩”到合法位置。收缩完成后把ch加入窗口集合用当前窗口长度更新答案。这个写法的时间复杂度也是 O(n)因为每个字符最多被加入一次、移出一次。while里的操作虽然看起来可能多次执行但总体摊还下来仍然是线性级别。最容易出错的地方在于while ch in window这一步容易写成if ch in window导致只移除一个字符而不是连续移除直到没有重复。举个简单例子窗口内是[a, b, c]新来的字符是a如果只移除一个a看起来没问题但如果新来的字符是b而窗口内是[a, b, c, b]就说明窗口里有两个b单纯一次移除不够。面试里我见过很多人在这个细节上翻车所以这里提醒一下。4.3 一套模板扩展到更多子串问题这个模板可以扩展到很多同类型问题。比如“找到字符串中所有字母异位词”问题窗口状态需要保存每个字符在当前窗口中出现的次数使用两个数组或两个计数器来比较。再比如“最小覆盖子串”问题状态除了字符计数还需要维护“还需要多少个字符”这样一个标记变量用来快速判断窗口是否已经覆盖了目标串。核心套路不变变的只是窗口状态的复杂程度和收缩条件。遇到这类题我建议先写模板框架再逐步往三个位置填逻辑。先固定骨架再去思考状态维护和条件判断出错概率会明显下降。很多同学喜欢一上来直接写完整逻辑容易在边界条件和状态同步上顾此失彼。5. 双指针的边界条件与排查技巧实录5.1 三个最常翻车的底层细节写了大量双指针题之后我把翻车原因总结成三类基本覆盖了 90% 的报错现场。第一类是数组越界。比如使用nums[left 1]前没有确认left 1 len(nums)或者在while left right的循环里直接访问了right之后的位置。解决思路是涉及索引的访问优先写防御性判断尤其在while条件里只依赖一个指针时对另一个指针的合法性要有预估。第二类是死循环。常见于首尾指针更新逻辑不对称比如满足条件时只移动left不移动right导致两个指针永远卡在同一个位置。另一个高发场景是快慢指针中忘记修改快指针步长或者漏掉对fast.next的判空导致循环内出现空指针访问后异常退出。每次写完循环类双指针代码我都会自查一下两个指针在所有分支里是否都有朝对方移动的可能。第三类是窗口状态不同步。在滑动窗口里移动left之前必须先更新窗口状态否则窗口记录的数据和真实窗口范围不一致。举个例子window.remove(s[left])必须放在left 1之前否则移除的是错误位置的字符后面再用窗口状态时就会得到错误结果。这种问题不会报错只会在特定测试用例下得到错误答案非常隐蔽。5.2 题型速查表看到什么条件该用哪种双指针我做题时习惯先用一张简单的表来判断该用哪种双指针。这个表不是绝对的但能帮助快速定位方向题目特征推荐形态复杂度期望典型例子有序数组 找两数/三数满足某条件首尾指针O(n) 或 O(n²)两数之和 II、三数之和链表判环 / 找入口 / 找中间节点快慢指针O(n)环形链表、链表中点连续子数组 / 子串求最长或最短滑动窗口O(n)无重复最长子串、最小覆盖子串数组原地去重 / 压缩同向快慢指针O(n)删除有序数组重复项判断回文串首尾指针O(n)验证回文串如果题目同时出现“连续”“子数组”“不超过 K”“最长”“最短”这些词优先考虑滑动窗口如果输入是有序的且要找“两数之和等于某值”优先考虑首尾夹逼如果和链表环、中点、倒数位置有关优先想快慢指针。这个速查表帮我省下了大量试错时间。5.3 我常用的调试方法小样例和指针轨迹打印调试双指针代码我强烈建议先用小样例在纸上模拟而不是立刻对着大用例瞎猜。拿nums [1, 2, 2, 3, 3, 4]这类包含边界情况的样例把i和j在每一步的位置画出来代码没写对也能很快定位问题。第二个实用的方法是打印指针轨迹。在关键循环里加一句print(fi{i}, j{j}, window{window})跑一遍样例立刻能看出指针有没有按预期移动。很多人觉得打印语句很初级但实际用起来比任何调试器都直观。我一般在排查完问题之后再把打印去掉。也可以准备几组固定边界用例反复测空数组、单元素数组、元素全部重复、元素全部不重复、链表长度为 1 且指向自己、链表无环但有大量节点。这些边界测试用例比随机大数据更能暴露问题。双指针这块我实际刷题后的体会是它是所有算法技巧里投入产出比最高的几种之一上手快、模板固定、面试出现频率又高。建议不要一上来刷难题先把两种基本形态各挑三道经典题写熟再尝试把快慢指针和滑动窗口组合到同一道题里用。遇到错题也别急着看题解先判断是越界、死循环还是状态不同步再带着方向去调试。等你真正吃透双指针很多标着 Medium 的数组字符串题其实一眼就能看穿底层的解决套路。