大学上数据结构课的时候,单链表章节停留得特别短,教材写完节点定义、头插法、尾插法、删除某个节点就匆匆翻过去了。可一到刷题、应付笔试或者做课设,链表的题突然就变难了:判断有没有环、找倒数第几个节点、找中间点、合并两条有序链表,每一道都让我绕半天。后来我才意识到,自己缺的不是链表的基本操作,而是“同时操作多个节点位置”的意识。这也是双指针技巧在单链表问题里真正的意义:链表这种结构只能朝 next 方向走,没法回头,没法随机访问,但如果你愿意同时安排两个甚至三个指针在链表上移动,很多看起来需要“先遍历一遍数长度”的问题,就能在一次扫描里解决。
这篇文章不打算把链表所有知识点铺开讲,只聚焦双指针在单链表里的几种经典用法:快慢指针、前后指针、双游标、以及修改链表结构时常见的三指针思路。我会配合可以直接跑通的代码和手动推演,把每一步为什么这么写说明白。适合正在学数据结构、准备考研复习或者刚开始刷链表题的朋友,也适合那些已经会写几道题但总在边界条件上翻车的同学。
1. 先搞清楚一件事:单链表为什么比数组更依赖双指针
1.1 数组里你能“跳着顾两头”,链表里只能顺着走
数组支持随机访问,你给我下标,我立刻算出内存地址取到元素。所以在数组题里,双指针最常见的形态是从两头往中间夹:一个 i 从左往右,一个 j 从右往左,或者一个快指针扫描,一个慢指针记录结果位置。这种写法的前提是“我能随意把指针挪到任意下标”,这在数组里是 O(1) 的。
单链表完全不一样。每个节点只保存自己的值和 next 指针,你拿到的往往只是一个头节点。想访问第五个节点,必须从头开始,一个个地 next 四次。更麻烦的是,链表只能单向移动,一旦你从某个节点走到下一个节点,如果没有其他指针帮忙记录,前面那个节点就彻底找不回来了。
很多人刚开始写单链表算法时会有一种别扭感:明明脑子里想的是“我要把这个节点删掉”,动手写代码时却发现,单链表删除某个节点其实要操作的是它的前驱节点。因为每个节点的 next 决定了下一位是谁,你把前驱的 next 一改,后面的节点就自然脱离了链表。这意味着,在很多操作里,你真正需要盯住的不是目标节点本身,而是目标节点的前后关系。
这也是我说双指针不是“技巧”而是“刚需”的原因——当链表只能前进、不能后退时,保留多个遍历上下文,就是弥补结构限制最直接的办法。
1.2 双指针真正解决的是“相对位置”的测量问题
单链表题里经常有这种需求:找出链表的中间节点,找出倒数第 k 个节点,判断链表是否成环。朴素做法通常分两步。第一遍从头走到尾,统计出链表长度;第二遍根据长度算出目标位置,再从头走一次。
比如找中间节点,第一次遍历得到 n,第二次从头走 n/2 步。这样不是不能做,但有两个代价:一是要遍历两次,如果题目明确限制“只能扫描一遍”,这种写法直接出局;二是很多场景下,目标位置的参照不是“从头数多少个”,而是“距离末尾还有多少个”。单链表并没有记录末尾位置,你不知道总长度时,连第二遍该走多少步都算不出来。
双指针的思路是换一种测量方式:不直接问“目标在哪”,而是让两个指针保持某种速度差或距离差,同时往前走。因为指针的相对位置是可控的,当其中一个指针到达某个特殊位置时,另一个指针自然就停在目标位置附近。
举个例子可能更直观。想象一条单行道上有两辆车,后车速度是前车的一半。前车开到终点的时候,后车一定正好在整条路的中间。你再想一下“找倒数第 k 个节点”这件事:如果一辆车先出发,开了 k 个路口后停下来等,另一辆车从起点出发,然后两车保持同样的速度一起走,当前面那辆车开到终点时,后面那辆车距离终点恰好也是 k 个路口。先出发那辆车就像一把“有刻度的尺子”,量出了从当前位置到末尾的精确距离。
这就是双指针在链表问题里的核心价值:并不是说普通写法的时间复杂度一定有多差,双指针也不是什么玄学,它是一种把“绝对位置测量”转换成“相对位置测量”的手段,而且额外空间复杂度能压到 O(1)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢、前后、双游标:三种常见双指针关系速查
2.1 速度差型:快慢指针
快慢指针是最出名的一种双指针,控制方式是让一个指针每次走一步,另一个指针每次走两步。因为速度不同,两个指针在链表上的相对位置会不断变化。
它最经典的应用是判断链表是否有环。如果链表无环,快指针会先到达链表末尾,循环自然结束;如果链表有环,快慢两个指针迟早会进入环内,由于快指针每次相对慢指针多走一步,它们最终会相遇。这个“迟早”是确定的,不是碰运气。
找中间节点也属于速度差型的应用。快指针到达末尾时,慢指针因为只有它一半的速度,刚好停在中间区域。注意这里有个细节:链表节点数是奇数还是偶数,会影响“中间节点”的定义,后面我会单独说。
2.2 距离差型:前后指针
前后指针的典型控制方式不是速度不同,而是“先跑一段,再同速前进”。
比如找倒数第 k 个节点,可以先让快指针从头出发走 k 步,此时快慢指针之间相隔 k 个节点。然后两个指针每次都走一步,保持这个距离差不变。等快指针走到链表末尾的 null 位置时,慢指针相对于快指针还落后 k 步,所以慢指针正好停在第 k 个节点上。
有同学会把前后指针和快慢指针搞混。区分方法很简单:快慢指针关心的是“速度差”,前后指针关心的是“距离差”。前者用来制造相遇或者等比位置,后者用来维护一个固定大小的窗口。
2.3 分工型:双游标
除了在一条链表上操作,双指针还能用在两条链表或者一条链表的不同片段上。比如合并两个有序链表,就需要两个游标分别指向两条链表的当前节点,每次都拿两个游标指向的值比较,把较小的节点接进结果链,然后只移动那一路游标。
这种模式还有不少变体。求两个链表的第一个公共节点,可以让两个游标从两条链表头同时出发,走到 null 就切换到另一条链表的头继续走;反转链表则需要三个指针分工,一个指向已经处理完的部分,一个指向当前节点,一个临时保存后继。说到底,都是“让多个指针各自承担不同的位置记忆任务”。
2.4 三种模式怎么选:一张速查表
| 关系类型 | 移动方式 | 控制的核心变量 | 典型问题 |
|---|---|---|---|
| 快慢指针 | 一个每次 1 步,一个每次 2 步 | 速度差 | 环检测、环入口、找中间点 |
| 前后指针 | 一个先走 k 步,再同步前进 | 距离差 / 固定窗口 | 倒数第 k 个节点、删除倒数第 n 个 |
| 双游标 | 两个指针分别在不同链表上推进 | 比较或对齐 | 合并有序链表、找两个链表的交点 |
这张表我建议你贴在手边,做题时先判断题目想要的是“相遇”“定位到窗口内”还是“两条链路同时比较”,再决定用哪种模式。别一看到链表题就默认用快慢指针,选错模式会绕很多弯路。
3. 环检测与环入口:最值得亲手推导一遍的快慢指针应用
3.1 怎么才能知道链表“绕圈了”
先看最基础的有环判断。单链表的节点通过 next 相连,如果某个节点的 next 指回了链表前边的节点,就形成了一个环。朴素做法需要额外空间,把每个访问过的节点地址存进哈希表,遇到重复说明有环。双指针则可以在 O(1) 空间内解决。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
为什么循环条件要写成 fast and fast.next?因为快指针每次要走两步,走之前必须确保当前节点和下一个节点都非空。如果 fast 已经到链表末尾,fast.next 就是 None,再执行 fast.next.next 会直接触发空指针异常。判断顺序也重要,要先判断 fast 本身不是 None,再去访问 fast.next。
还需要注意,Python 里判断两个节点是否是同一个对象,应该用 is 而不是 ==。如果两个不同节点恰好有相同的 val,== 可能会误判,而链表有没有环关心的是“节点地址是否重新出现”,不是节点值是否相等。这个细节在面试代码里很容易被忽略。
3.2 找环入口的数学推导:为什么从头和相遇点各走一步就能碰头
判断有环还不够,很多题会继续要求返回环的入口节点。Floyd 判圈算法不仅能判断有没有环,还能找出环的起点。这里值得认真推一遍数学关系,理解了以后你就不需要背代码。
假设链表头到环入口的距离是 a,环入口到快慢指针第一次相遇点的距离是 b,相遇点继续沿着 next 方向走回环入口的距离是 c。那么整个环的长度 L = b + c。
慢指针从起点到相遇点走了 a + b 步。快指针的速度是慢指针的两倍,所以它走了 2(a + b)。快指针在进入环之后可能已经绕了好几圈才等到慢指针,假设它比慢指针多绕了 n 圈,于是有:
2(a + b) = a + b + nL
两边同时消去 a + b,得到:
a + b = nL
把这个式子变一下形:
a = nL - b = (n - 1)L + L - b = (n - 1)L + c
这个等式的意思是:从链表头走 a 步会到环入口;从相遇点沿 next 方向走 c 步也会到环入口,就算中间多绕了 (n - 1) 圈,最终停留的位置不变。
所以操作就变得很简单:把慢指针重新放回链表头,快指针保持在相遇点,然后两个指针每次都只走一步。当慢指针走了 a 步到达环入口时,快指针从相遇点走了 a 步,相当于走了 c 步后又绕了若干圈,同样也停在环入口。它们的第一次相遇点,就是环的入口。
python复制def detect_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
# 有环,开始找入口
ptr = head
while ptr is not slow:
ptr = ptr.next
slow = slow.next
return ptr
return None
3.3 实现环入口的代码里藏着哪些容易错的点
第一,找入口的第二个 while 循环很可能走不到。如果链表的环入口不是 head,那循环会一直执行,直到 ptr 和 slow 指向同一个节点。如果链表没环,根本不会进入这一段,因为前面的 has_cycle 检测已经返回 None 了。
第二,快慢指针第一次相遇的位置不一定在环入口。很多人误以为相遇点就是入口,这是最常见误区。从上面的推导能看出,只有在 a = c 的特殊情况下,相遇点才会恰好是入口。一般情况下需要重新从 head 出发找第二次。
第三,处理环形链表时不要随便打印整个链表,否则程序会卡死在环里。调试时可以只打印当前轮次里 slow 和 fast 指向节点的 val,但也要设一个步数上限,防止出现意外。
4. 中点、倒数第K与删除第N个:指针偏移量差一位都错
4.1 找中间点:停止条件决定中点偏左还是偏右
找链表中点的代码看起来很短,但终止条件会直接影响答案。LeetCode 的 876 题要求偶数长度时返回第二个中间节点,也就是偏右的那个。用下面这个版本就够:
python复制def middle_node(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
以 1 -> 2 -> 3 -> 4 -> 5 为例,快指针走到 5 时 next 为 None,循环退出,慢指针在 3,正确。以 1 -> 2 -> 3 -> 4 -> 5 -> 6 为例,循环退出时慢指针在 4,这是偏右的中点。
如果题目要求偶数长度时返回偏左的中点,比如某些回文链表拆分场景,就需要把循环条件改成 while fast.next and fast.next.next。这样 fast 不会走到最后一个节点之后,slow 最终会停在偏左的位置。写链表代码前先想清楚题意,不然这个微妙差别会导致整个算法输出错误。
4.2 找倒数第K个节点:为什么“先跑K步”而不是“K减1步”
先说结论:找倒数第 k 个节点时,快指针从头先走 k 步,再和慢指针一起走。快指针走到 None 时,慢指针就是目标节点。
python复制def find_from_end(head, k):
fast = slow = head
# 快指针先走 k 步
for _ in range(k):
if not fast:
return None
fast = fast.next
# 双指针同步前进
while fast:
fast = fast.next
slow = slow.next
return slow
为什么是 k 步而不是 k - 1 步?因为我们要让快慢指针之间形成“k 个节点的距离差”。注意这个距离差是拿 null 作为参照的。当快指针走到 null 时,它相当于排在链表所有节点之后,处在“倒数第 0 个”的位置。此时慢指针比快指针落后 k 个位置,自然就是倒数第 k 个节点。
用一个具体例子验证。链表 1 -> 2 -> 3 -> 4 -> 5,k = 2。快指针先走 2 步,停在 3。然后快慢一起走:
- 快指针从 3 到 4,慢指针从 1 到 2
- 快指针从 4 到 5,慢指针从 2 到 3
- 快指针从 5 到 None,慢指针从 3 到 4
退出循环后慢指针停在 4,正好是倒数第 2 个节点。如果你写的是 k - 1,快指针初始只走到 2,最后慢指针会停在 3 或者出现奇怪的错位,而且很容易在 k 等于链表长度时产生空指针问题。
4.3 删除倒数第N个节点:哑节点省去头节点特判
删除倒数第 n 个节点和找倒数第 n 个节点有一个关键差异:删除操作需要找到目标节点的前驱。只定位到目标节点还不够,你没法通过它自己完成删除。
绕过这个问题最顺手的方案是引入哑节点。哑节点是一个虚拟的头前节点,它的 next 指向真正的 head。这样链表的每个真实节点都有前驱,头节点的处理逻辑和普通节点完全一致。
python复制def remove_nth_from_end(head, n):
dummy = ListNode(0, head)
fast = slow = dummy
# fast 先走 n+1 步
for _ in range(n + 1):
fast = fast.next
# fast 走到 null 时,slow 正好指向待删除节点的前驱
while fast:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
这里有一个很容易写错的点:为什么从 dummy 出发要让 fast 先走 n + 1 步,而不是 n 步?
因为 slow 也在 dummy 上,dummy 并不
