一、题目
给定一个链表:
1 → 2 → 3 → 4 → 5
以及:
n = 2
要求删除链表的倒数第 2 个节点。
倒数第 2 个节点是:
4
删除以后:
1 → 2 → 3 → 5
二、我的第一反应:快慢指针 + count
一开始我的思路是:
让
fast一直往后走,同时用count计数;当fast已经走了n步以后,让slow开始一起移动。
类似:
ListNode fast = head;
ListNode slow = head;
int count = 0;
while (fast != null) {
fast = fast.next;
count++;
if (count > n) {
slow = slow.next;
}
}
这个思路方向其实已经接近双指针:
让 fast 比 slow 多走一段距离
但是这里有一个关键问题:
删除一个节点时,我们真正需要的不是"待删除节点",而是"待删除节点的前一个节点"。
因为链表删除通常需要:
pre.next = pre.next.next;
所以如果 slow 最后正好走到待删除节点,位置就偏了一位。
三、为什么必须找到待删除节点的前一个节点?
例如:
1 → 2 → 3 → 4 → 5
↑
删除4
我们真正希望最终:
slowPre
↓
3 → 4 → 5
然后:
slowPre.next = slowPre.next.next;
相当于:
3.next = 5
于是:
1 → 2 → 3 → 5
完成删除。
所以这道题的目标其实可以重新定义为:
找到倒数第
n个节点的前一个节点。
四、为什么要使用 dummy?
这里还有一个特殊情况:
1 → 2 → 3
n = 3
要求删除倒数第 3 个节点。
其实就是:
删除头节点 1
如果没有 dummy:
1 → 2 → 3
↑
head
那么头节点前面根本没有节点。
而我们的统一删除逻辑:
pre.next = pre.next.next;
就不好用了。
所以引入虚拟头节点:
ListNode dummy = new ListNode(0);
dummy.next = head;
链表变成:
dummy → 1 → 2 → 3
现在即使删除 1,也有:
dummy
↓
1 → 2 → 3
可以直接:
dummy.next = dummy.next.next;
得到:
dummy → 2 → 3
最后:
return dummy.next;
即可。
所以链表题里可以形成一个重要经验:
如果题目有可能修改或删除头节点,优先考虑 dummy。
五、最终思路:dummy + 快慢指针
最终采用:
slowPre 从 dummy 开始
fast 从 dummy 开始
然后:
fast 先走 n + 1 步
接着:
fast 和 slowPre 一起走
直到:
fast == null
此时:
slowPre
恰好停在待删除节点的前一个节点。
然后:
slowPre.next = slowPre.next.next;
删除。
六、为什么 fast 必须先走 n + 1 步?
这是这道题最重要的地方。
很多时候容易记成:
fast 先走 n 步
但这里我们希望:
slowPre
最后停在:
待删除节点的前一个节点
而不是待删除节点本身。
所以两个指针之间应该保持:
n + 1
个 next 距离。
七、用 n = 2 实际走一遍
链表:
dummy → 1 → 2 → 3 → 4 → 5 → null
要求:
n = 2
删除:
4
初始化:
slowPre = dummy
fast = dummy
fast 先走 n + 1 = 3 步
第一步:
fast = 1
第二步:
fast = 2
第三步:
fast = 3
现在:
dummy → 1 → 2 → 3 → 4 → 5 → null
↑ ↑
slowPre fast
fast 比 slowPre 领先 3 步。
八、然后一起移动
第一轮:
slowPre = 1
fast = 4
第二轮:
slowPre = 2
fast = 5
第三轮:
slowPre = 3
fast = null
此时:
slowPre
↓
3 → 4 → 5
↑
删除
正好。
所以:
slowPre.next = slowPre.next.next;
得到:
1 → 2 → 3 → 5
九、为什么不是 fast 先走 n 步?
如果 fast 只领先:
n
那么最终 slow 会停在:
待删除节点
而不是:
待删除节点前一个
例如:
1 → 2 → 3 → 4 → 5
n = 2
如果只保持 2 步距离,fast 到 null 时:
slow = 4
但我们真正需要:
slowPre = 3
所以必须多留一位:
n + 1
十、最终代码
java
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
// 可能删除头节点,因此使用 dummy
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode slowPre = dummy;
ListNode fast = dummy;
// fast 先走 n + 1 步
for (int i = 0; i < n + 1; i++) {
fast = fast.next;
}
// 保持距离一起向后移动
while (fast != null) {
fast = fast.next;
slowPre = slowPre.next;
}
// slowPre 正好位于待删除节点前一位
slowPre.next = slowPre.next.next;
return dummy.next;
}
}
十一、slowPre 这个变量名为什么比较好?
相比:
slow
这里我更倾向写:
slowPre
因为它直接提醒自己:
slow 最终不是待删除节点,而是待删除节点的 previous。
也就是:
slowPre → deleteNode
最终:
slowPre.next = slowPre.next.next;
从变量名上就能理解代码。
十二、删除链表节点的本质
链表删除节点不是:
真的把节点从内存里删除
而是改变指针:
原来:
3 → 4 → 5
执行:
3.next = 5;
以后:
3 → 5
节点 4 已经无法通过链表访问。
在 Java 中,如果没有其他引用指向它,之后会由垃圾回收机制处理。
因此:
slowPre.next = slowPre.next.next;
实际上就是:
让前一个节点跳过待删除节点。
十三、为什么最后返回 dummy.next?
不能直接:
return head;
虽然很多情况下也能返回正确结果,但如果删除的是头节点:
1 → 2 → 3
删除 1 后:
2 → 3
原来的:
head
还指向旧节点 1。
而:
dummy.next
已经正确更新成:
2
所以统一:
return dummy.next;
最安全。
十四、完整边界情况:删除头节点
例如:
1 → 2 → 3
n = 3
添加 dummy:
dummy → 1 → 2 → 3 → null
fast 先走:
n + 1 = 4
步:
dummy
→ 1
→ 2
→ 3
→ null
于是:
fast = null
slowPre = dummy
while 不执行。
直接:
slowPre.next = slowPre.next.next;
也就是:
dummy.next = 2;
得到:
dummy → 2 → 3
返回:
dummy.next
即:
2 → 3
完美处理。
十五、复杂度分析
假设链表长度为:
L
fast 和 slow 都只会线性移动。
所以时间复杂度:
O(L)
只使用:
dummy
fast
slowPre
几个指针。
所以额外空间复杂度:
O(1)
十六、为什么这叫快慢指针?
严格来说,它不像环形链表那样:
slow 每次 1 步
fast 每次 2 步
这里两个指针之后移动速度其实一样:
fast 每次 1 步
slowPre 每次 1 步
区别是:
fast 提前走了 n + 1 步
从而保持固定距离。
所以更准确可以理解为:
前后双指针 / 快慢指针的距离差思想。
十七、和相交链表有什么联系?
前面做「相交链表」时也用了双指针。
虽然具体方式不同,但背后都是:
利用两个指针之间的相对距离解决链表位置问题。
相交链表:
通过换头消除长度差
删除倒数第 N 个节点:
人为让 fast 提前 n+1 步建立距离差
所以链表题里:
双指针 + 距离
是很常见的一种思维模式。
十八、和环形链表有什么区别?
环形链表:
slow 每次 1 步
fast 每次 2 步
有环
→ 最终相遇
删除倒数第 N 个:
fast 提前 n+1 步
之后:
fast、slowPre 都每次走 1 步
目的不是追赶,而是:
始终保持固定距离
所以虽然都叫快慢指针,但思想略有区别。
十九、面试讲法
如果面试官让我讲,我会这样回答:
我使用 dummy 节点加双指针。由于可能删除头节点,所以先创建虚拟头节点
dummy,让dummy.next = head。
fast和slowPre都从 dummy 出发,为了让slowPre最终停在待删除节点的前一个位置,我先让 fast 向前移动n+1步。然后 fast 和 slowPre 同时向后移动,直到 fast 到达 null。由于两个指针始终保持
n+1的距离,此时 slowPre 正好位于倒数第 n 个节点的前一个位置。最后通过
slowPre.next = slowPre.next.next删除目标节点,并返回dummy.next。时间复杂度为 O(L),额外空间复杂度为 O(1)。
二十、面试前 30 秒速记
看到:
删除链表倒数第 N 个节点
直接想到:
dummy + 双指针
模板:
java
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slowPre = dummy;
for (int i = 0; i < n + 1; i++) {
fast = fast.next;
}
while (fast != null) {
fast = fast.next;
slowPre = slowPre.next;
}
slowPre.next = slowPre.next.next;
return dummy.next;
核心理解:
为什么 dummy?
→ 可能删除头节点
为什么 n+1?
→ slowPre 要停在待删除节点前一位
为什么 return dummy.next?
→ 删除头节点后 head 可能变化
复杂度:
时间 O(n)
空间 O(1)
二十一、最容易写错的地方
1. fast 只走 n 步
错误:
for (int i = 0; i < n; i++)
如果我们的 slow 目标是:
待删除节点
可以这样考虑。
但这里要的是:
待删除节点前一个
所以应该:
for (int i = 0; i < n + 1; i++)
2. fast 和 slow 从 head 开始
如果:
ListNode fast = head;
ListNode slow = head;
处理删除头节点会更加麻烦。
统一从:
dummy
开始最简单。
3. 返回 head
return head;
删除头节点时可能错误。
应该:
return dummy.next;
4. slow 停在删除节点
如果最终:
slow → 要删除的节点
那么删除会不方便。
应该主动设计:
slowPre → 要删除节点
所以这道题关键不是:
找到倒数第 n 个
而是:
找到倒数第 n 个节点的前一个
二十二、最终总结
这道题最开始容易想到:
fast 先走
slow 后走
这个方向是正确的。
真正需要进一步想清楚的是:
我最终到底希望 slow 停在哪里?
因为删除链表节点需要:
pre.next = pre.next.next;
所以真正目标是:
slowPre
↓
待删除节点前一位
于是自然得到:
fast 比 slowPre 领先 n+1 步
整个逻辑就变成:
dummy 解决头节点
↓
fast、slowPre 从 dummy 开始
↓
fast 先走 n+1 步
↓
一起移动
↓
fast 到 null
↓
slowPre 在删除节点前一位
↓
slowPre.next = slowPre.next.next
这道题真正值得记住的不是代码本身,而是:
双指针可以通过人为制造固定距离,在一次遍历中定位"倒数第几个节点"。
以后看到:
倒数第 k 个
链表尾部距离
前后相差固定长度
都应该敏感地想到:
双指针 + 固定距离