这个问题是典型的**“双指针”**应用场景。它的巧妙之处在于:不需要先测量整个链表的长度,通过两个指针的“距离差”,只需一次遍历就能找到倒数第NNN个节点。
1. 核心思路:快慢指针(等距离滑动)
要删除倒数第NNN个节点,我们需要找到它的前驱节点(即倒数第N+1N+1N+1个节点)。
- 设置虚拟头节点 (
dummy):依然是老规矩,为了处理删除“第一个节点”的情况,先创建一个dummy指向head。 - 快指针 (
fast) 先走:让fast先向后移动N+1N+1N+1步。此时,fast和慢指针slow(此时在dummy)之间隔了NNN个节点。 - 同速前进:让
fast和slow同时向后移动。当fast到达链表末尾(None)时,slow刚好停在倒数第N+1N+1N+1个节点上。 - “跨越”删除:执行
slow.next = slow.next.next。
原理直观图示:
假设链表长度为LLL,找倒数第NNN个,就是找正数第L−N+1L-N+1L−N+1个。
让fast先走NNN步,剩下还要走L−NL-NL−N步到头。此时slow从头开始走L−NL-NL−N步,正好停在倒数第NNN个的前面。
2. 代码实现 (Python)
classSolution:defremoveNthFromEnd(self,head:ListNode,n:int)->ListNode:# 1. 哨兵节点,处理删除头节点的情况dummy=ListNode(0,head)fast=dummy slow=dummy# 2. 快指针先走 n + 1 步# 这样快慢指针之间就会间隔 n 个节点for_inrange(n+1):fast=fast.next# 3. 快慢指针同时移动,直到快指针指向空whilefast:fast=fast.nextslow=slow.next# 4. 此时 slow 停在倒数第 n 个节点的前驱节点上# 直接跳过倒数第 n 个节点slow.next=slow.next.nextreturndummy.next3. 为什么是走n + 1步?
这是一个容易出错的细节。
- 我们要删除的是“倒数第NNN个”。
- 在链表里删除一个节点,必须找到它的前一个。
- 如果你让
fast先走NNN步,当fast走到None时,slow会停在“倒数第NNN个”上。这时候你没法删除它,因为你拿不到它的前驱节点。 - 所以我们让
fast多走一步(N+1N+1N+1步),这样slow就会正好落在“倒数第NNN个”的左边那位,也就是倒数第N+1个位置,这样slow的next就可以指向slow.next.next
4. 复杂度分析
- 时间复杂度:O(L)O(L)O(L),其中LLL是链表长度。我们只对链表进行了一次遍历。
- 空间复杂度:O(1)O(1)O(1)。只额外使用了两个指针。
总结:
这道题展示了双指针的一种常用技巧——“固定距离滑窗”。通过让一个指针先跑,制造出一种“空间换时间”的效果,避免了“先数一遍长度,再跑第二遍”的尴尬。
这个问题紧接着我们刚才讨论的“快慢指针”思路,是该技巧的又一经典应用。
1. 核心技巧:快慢指针(速度差法)
要找到并删除中间节点,我们可以利用“快指针的速度是慢指针的两倍”这一特性:
- 慢指针 (
slow):每次走1步。 - 快指针 (
fast):每次走2步。
当快指针走到链表末尾时,慢指针刚好就在中点附近。
寻找“前驱节点”的艺术
和删除倒数第NNN个节点一样,删除中点必须拿到中点的前一个节点。
- 如果链表长度n=4n = 4n=4,中点是下标222。我们需要停在下标111。
- 如果链表长度n=5n = 5n=5,中点是下标222。我们需要停在下标111。
为了方便处理头节点(比如只有一个节点的情况),我们依然请出dummy节点。
2. 代码实现 (Python)
classSolution:defdeleteMiddle(self,head:Optional[ListNode])->Optional[ListNode]:# 1. 特判:如果只有一个节点,删除后为空ifnotheadornothead.next:returnNone# 2. 设置虚拟头节点,pre 最终要停在中点的前一个dummy=ListNode(0,head)slow=dummy fast=head# 让 fast 从 head 开始,slow 从 dummy 开始# 3. 快慢指针跑起来# 当 fast 走完 2 步,slow 走 1 步whilefastandfast.next:fast=fast.next.nextslow=slow.next# 4. 此时 slow 恰好停在中点的前驱位置# 比如 1->2->3->4->5,slow 会停在 2,中点是 3slow.next=slow.next.nextreturndummy.next3. 为什么这样跑能精准定位?
让我们手动模拟一下:
场景 A:长度n=4n = 4n=4(偶数)
链表:dummy -> 1 -> 2 -> 3 -> 4 -> None
- 初始:
slow在dummy,fast在1。 - 第一轮:
fast移到3,slow移到1。 - 第二轮:
fast移到None,slow移到2。 - 循环结束:
slow停在2。中点下标⌊4/2⌋=2\lfloor 4/2 \rfloor = 2⌊4/2⌋=2(即节点3)。slow.next正好指向节点3。定位正确!
场景 B:长度n=5n = 5n=5(奇数)
链表:dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> None
- 初始:
slow在dummy,fast在1。 - 第一轮:
fast移到3,slow移到1。 - 第二轮:
fast移到5,slow移到2。 - 此时
fast.next为空,循环结束:slow停在2。中点下标⌊5/2⌋=2\lfloor 5/2 \rfloor = 2⌊5/2⌋=2(即节点3)。slow.next正好指向节点3。定位正确!
4. 复杂度分析
- 时间复杂度:O(N/2)O(N/2)O(N/2),即O(N)O(N)O(N)。我们只遍历了半个链表长度(快指针到头即止)。
- 空间复杂度:O(1)O(1)O(1)。只用了两个指针。
总结
链表的题目其实很有规律:
- 怕断掉:操作前先存
next。 - 怕头变:先建
dummy。 - 找位置:
- 固定距离用“滑窗双指针”(倒数第NNN个)。
- 比例距离用“速度差双指针”(中点)。
虽然它们都叫“双指针”,但**“滑窗”和“速度差”**在逻辑设计和解决的问题类型上有着本质的区别。
我们可以通过下面这个对比来清晰地理解:
1. 固定距离:滑窗双指针 (Sliding Window)
核心逻辑:两个指针的步速相同(每次都走 1 步),但出发时间不同。
- 操作方法:先让
fast走NNN步,然后slow才开始从起点走。 - 物理意义:两指针之间维持一个恒定的刻度线(窗口)。
- 解决的问题:寻找相对于末尾固定位置的节点。
- 例子:倒数第 5 个。无论链表多长,我只要保证我身后的“影子”跟我差 5 个身位,当我撞到终点墙壁时,影子就在倒数第 5 个。
2. 比例距离:速度差双指针 (Fast & Slow Pointers)
核心逻辑:两个指针的出发时间相同,但步速不同(通常是 2:1)。
- 操作方法:
fast每次走 2 步,slow每次走 1 步。 - 物理意义:两指针之间维持一个恒定的比例关系。
- 解决的问题:寻找相对于全长比例位置的节点。
- 例子:中点(1/2 处)。因为Vfast=2×VslowV_{fast} = 2 \times V_{slow}Vfast=2×Vslow,所以在相同时间内,
slow走过的路程永远是fast的一半。当fast跑完全程(100%)时,slow自然就在中点(50%)。
- 例子:中点(1/2 处)。因为Vfast=2×VslowV_{fast} = 2 \times V_{slow}Vfast=2×Vslow,所以在相同时间内,
3. 直观对比表
| 特性 | 滑窗双指针 (Fixed Gap) | 速度差双指针 (Ratio Gap) |
|---|---|---|
| 步频 | 一样 (1:1) | 不一样 (通常 2:1) |
| 间距 | 固定长度(Gap = N) | 动态增长(Gap 随步数增加) |
| 关键参数 | NNN(具体的个数) | 1/21/21/2或1/31/31/3(比例) |
| 典型应用 | 倒数第NNN个节点 | 寻找中点、判断是否有环 |
4. 为什么会有这种区分?
这取决于你对**“终点”**的利用方式:
- 滑窗利用的是“终点”作为一个锚点。因为我们不知道链表有多长,但我们知道“终点”在哪里,所以通过一个固定长度的“标尺”往回倒推。
- 速度差利用的是“终点”作为一个计时器。我们用快指针跑完全程的时间,来换取慢指针刚好走到比例位置的结果。
进阶思考:判断“环”
在LeetCode 141. 环形链表中,我们使用速度差双指针。
为什么不用滑窗?因为在环里,没有“倒数第几个”的概念(没有尽头),但速度差会让快指针在环里“多跑一圈”最终追上慢指针(套圈),就像操场长跑一样。
这就引出了链表最神奇的一个算法:弗洛伊德判圈算法。快慢指针是怎么在环里“相遇”的吗?
这个问题是**“速度差双指针”**最经典的应用场景,通常被称为“龟兔赛跑算法”(Floyd’s Cycle-Finding Algorithm)。
1. 核心原理:套圈
想象一下在一个环形跑道上比赛:
- 兔子(快指针
fast):每次跑 2 个台阶。 - 乌龟(慢指针
slow):每次跑 1 个台阶。
如果跑道是直的,兔子会先到达终点,乌龟永远追不上兔子。
如果跑道有环,兔子进入环后会开始转圈。由于兔子比乌龟快,它最终一定会从后面**“套圈”**乌龟,两人必然会在某个点相遇。
2. 代码实现 (Python)
classSolution:defhasCycle(self,head:Optional[ListNode])->bool:# 如果链表为空或只有一个节点且无环,直接返回 falseifnotheadornothead.next:returnFalseslow=head fast=head# 只要快指针没跑出边界(即没遇到 None)whilefastandfast.next:slow=slow.next# 走 1 步fast=fast.next.next# 走 2 步# 如果两个指针相遇了,说明有环ifslow==fast:returnTrue# 如果跳出了循环,说明快指针走到了终点,没有环returnFalse3. 深度思考
为什么快指针每次走 2 步?走 3 步、4 步行吗?
- 2 步是最稳妥的:当快指针进入环并开始追赶慢指针时,它们之间的相对速度是2−1=12 - 1 = 12−1=1。这意味着它们之间的距离每走一步就缩短 1,最终距离一定会变成 0(相遇),不会跳过去。
- 如果走 3 步:相对速度是 2。如果它们之间的距离是奇数,快指针可能会在某一跨度直接“跳过”慢指针,导致它们需要多跑好几圈才能相遇,甚至在某些离散数学的模型下永远不相遇。
为什么不需要dummy节点?
因为这道题不涉及修改链表结构(不删除也不反转),我们只是在链表上“跑步”。即使从头节点开始就有环,slow == fast的逻辑依然成立,所以不需要哨兵节点。
4. 复杂度分析
- 时间复杂度:O(N)O(N)O(N)。
- 如果没有环,快指针走N/2N/2N/2步到头。
- 如果有环,快指针进入环后,最多在慢指针转完一圈之前就能追上它。
- 空间复杂度:O(1)O(1)O(1)。我们只用了两个指针,没有使用额外的哈希表。