跳到主内容

双指针题型套路一文搞懂:对撞、快慢与原地操作的题型映射实测 ​

第一次刷「两数之和 II」这类有序数组求和题时,绝大多数人的第一反应是两层循环穷举所有配对。数组小的时候能过,一旦数据量上到十万级别,O(n²) 的写法立刻超时。双指针的价值就在这里:利用数据本身固有的单调性或有序性,把两重循环里大量不可能的配对直接剪掉,整体扫描一遍就出答案。它不是某一道题的技巧,而是一整类问题的通用建模方式——有序数组的配对问题、链表的环与中点问题、数组原地修改问题,背后都是同一套指针移动逻辑。

难点不在于写代码,而在于拿到题目时判断「这题该用哪一类双指针」。三种形态长得像,移动方向和判断条件却完全不同,套错框架写出来的代码要么漏解,要么死循环。这篇文章把三类指针的题型映射讲清,配合可运行的参考实现与复杂度推导,最后沉淀一份避坑清单。

三类指针的划分依据:往哪个方向移动 ​

双指针的分类标准其实只有一条:两个指针的相对移动方式。对撞指针从数组两端向中间收拢,典型场景是「有序数组找配对」与「回文判断」,移动条件由比较结果决定——和偏大就动右指针,偏小就动左指针。快慢指针用于链表,两个指针同向前进但速度不同,慢的走一步、快的走两步,靠速度差构造出「中点」和「环内追及」这两个结构性结论。同向(读写)双指针用于数组原地操作,读指针负责扫描每个元素,写指针负责标记「下一个可写入的位置」,两者之间的区域是待丢弃的垃圾区。

指针形态典型题型移动方式依赖的数据性质
对撞指针有序数组两数之和、盛水容器、回文验证两端向中间收拢有序性导致的单调剪枝
快慢指针链表环检测、找中点、倒数第 k 个节点同向异速环结构下的追及必然性
同向读写指针移动零、删除有序数组重复项读指针单向扫描原地修改时的读写分离

判断题型的决策顺序也值得记住:先看是链表还是数组,链表优先考虑快慢指针;再看题目是否要求原地修改,要求就地调整就上读写指针;剩下的有序结构配对问题基本都归对撞指针。这套映射建立起来之后,双指针题目的「感觉」就有了。

实操代码:三个高频题的参考实现 ​

先看对撞指针的代表题:在升序数组中找出两数之和等于目标值的下标。核心逻辑是利用有序性剪枝——两端的和如果偏大,说明右端任何一个元素参与配对都偏大,右指针可以安全左移;偏小同理移动左指针。

python
# 对撞指针:有序数组中找两数之和等于 target 的下标(Python)
def two_sum_sorted(nums: list[int], target: int) -> tuple[int, int]:
    left, right = 0, len(nums) - 1  # 左右指针分别指向首尾
    while left < right:             # 相遇即无解,循环终止
        current = nums[left] + nums[right]
        if current == target:
            return left, right      # 命中目标,返回配对
        if current < target:        # 和偏小,只有左移左指针才可能变大
            left += 1
        else:                       # 和偏大,右指针左移
            right -= 1
    return -1, -1                   # 循环结束仍未命中,说明无解

复杂度上,每轮循环两个指针合计至少移动一步,指针总共最多移动 n−1 次就必然相遇,所以时间复杂度是 O(n),空间复杂度 O(1)。对比暴力双层循环的 O(n²),剪掉的正是「不可能成为答案」的那一整片配对区域——这也是双指针正确性的全部来源:每次移动都排除了一个指针位置的所有剩余配对,而排除的依据是被移动方向上的单调性保证的。

再看快慢指针的经典题:判断链表是否有环。如果不限制空间,用一个哈希集合记录访问过的节点也能做,但快慢指针把空间开销压到了 O(1):快指针每步走两格,慢指针每步走一格,一旦链表有环,快指针必然从后方追上慢指针;无环则快指针先到达空节点。

python
# 快慢指针:检测链表是否有环(Python)
def has_cycle(head) -> bool:
    slow = fast = head              # 两指针同起点出发
    while fast and fast.next:       # 快指针能走两步才有继续的意义
        slow = slow.next            # 慢指针每次一步
        fast = fast.next.next       # 快指针每次两步
        if slow is fast:            # 环内速度差必然导致追及相遇
            return True
    return False                    # 快指针到头仍无相遇,无环

正确性依赖一个简单的算术事实:进入环之后,每走一轮快慢指针的相对距离缩小一格,相对距离是严格递减的非负整数,迟早归零。这个「相对速度」视角也是处理快慢指针变体(比如找环的入口)的通用思考工具。

最后是同向读写指针:把数组中的零移动到末尾且保持其余元素相对顺序,要求原地操作、不开新数组。读指针负责扫描,写指针记录非零元素应放置的位置,扫描完再把写指针之后的区域统一填零。

python
# 同向读写指针:原地移动零到数组末尾(Python)
def move_zeroes(nums: list[int]) -> None:
    write = 0                       # write 指向下一个非零元素的落点
    for read in range(len(nums)):   # read 负责逐个扫描
        if nums[read] != 0:         # 遇到非零元素才搬运
            nums[write], nums[read] = nums[read], nums[write]
            write += 1              # 落点右移,垃圾区扩大
    # 循环内通过交换完成搬运,write 之后自然全为零,无需二次填充

这题如果改成「先收集非零元素再补零」的两段式写法也能通过,但交换式写法只需要一次扫描,且天然满足「保持相对顺序」的要求。读写指针类题目的通用套路就在这个分离上:read 指针判断「要不要」,write 指针决定「放哪里」,两个职责分开之后逻辑就清晰了。

容易写错的边界条件与避坑总结 ​

  1. 对撞指针的循环条件要用 left < right 而不是 <=。两数之和这类「找两个不同位置」的题,两指针相等时只剩一个元素,不构成合法配对;如果题目允许同一元素使用两次(如两数之和 III),条件才需要放宽,动手前先确认这一点。
  2. 无序数组不能直接上对撞指针。单调剪枝的前提是有序,乱序数组上移动指针会误删正确答案;正确做法是先排序(O(n log n)),排序后下标语义变化时还要注意题目是否要求返回原始下标,必要时记录原始位置。
  3. 快慢指针的循环判断要写 fast 和 fast.next 两个判空。只判 fast 会在偶数长度链表末尾抛空指针异常;判断顺序也应该是先判空再前进,避免对空节点取 next。
  4. 读写指针的交换对象是 read 位置而非固定元素。很多人写成 nums[write] = nums[read] 之后再补零,遇到「保留重复不超过两次」这类变体就漏掉覆盖逻辑;保持交换写法可以少维护一个补零循环。
  5. 用复杂度反推选题方向。看到 n 在 10⁵ 量级、暴力解是 O(n²) 时,基本可以断定出题人想要 O(n) 的线性扫描结构——双指针、滑动窗口、单调栈是三大候选,再按「窗口可滑动吗、需要单调性吗」做二次排除,命中率会显著提高。

双指针表面上是一个技巧,本质上是「用结构性单调信息换掉无效枚举」的思维方式。把三类指针的移动逻辑各自吃透,再遇到线性结构上的优化题,先问自己一句:哪个方向的移动是安全的、为什么安全——把这个问题回答清楚,代码往往就是水到渠成的十几行。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。