LeetCode 19:删除链表的倒数第 N 个节点复盘| dummy + 快慢指针

一、题目

给定一个链表:

复制代码
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

fastslowPre 都从 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 个
链表尾部距离
前后相差固定长度

都应该敏感地想到:

复制代码
双指针 + 固定距离
相关推荐
玖玥拾1 小时前
LeetCode 290 单词规律
算法·leetcode·哈希算法·散列表
liliangcsdn1 小时前
多空价差收益序列汇总统计的分析和代码示例
人工智能·算法·机器学习
啥都想学点的研究生1 小时前
一篇文章讲清楚:Adaboost算法与GBDT
人工智能·算法
旖旎夜光2 小时前
LeetCode 525:连续数组(前缀和) —— 题解
数据结构·c++·算法·leetcode·前缀和
zander2582 小时前
LeetCode 32. 最长有效括号
算法
lvwangshu2 小时前
AC 自动机
算法·字符串
shehuiyuelaiyuehao12 小时前
算法31,前缀和,可被k整除的子数组
数据结构·python·算法
203号居民13 小时前
LeetCode hot 100 — 141. 环形链表2
算法·leetcode·链表
玖玥拾14 小时前
LeetCode 202 快乐数
算法·leetcode