如果你已经刷过几十道链表题,回头看 LeetCode 19 删除链表的倒数第N个节点,你可能会觉得它并不难。但这道题偏偏是我见过面试翻车率最高的链表题之一:有人上来就两次遍历,有人能写出快慢指针却处理不好删除头节点,还有人把“倒数第 N 个”硬生生理解成“正数第 N 个”。
这道题表面问的是“删除一个节点”,实际考的是三件事:链表只能单向遍历的约束、删除动作必须找到前驱节点、以及各种边界条件下的指针处理。无论你刚入门数据结构,还是准备算法面试,把 LeetCode 19 吃透,等于把链表题里最常用的一组基本功打了个底。这篇内容我会从最朴素的遍历思路讲起,重点拆解快慢指针为什么是“一趟扫描搞定”的关键,也用真实测试样例带你把边界情况走一遍。
1. 题目在考什么:拆穿“倒数第 N 个”这层窗户纸
1.1 “倒数”在单向链表里是一道翻译题
题目本身一句话就能说清:给你一个单链表的头节点 head,再给你一个整数 n,删除链表的倒数第 n 个节点,返回链表的头节点。题目给出的链表结构基本长这样:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
很多初学者第一次看到“倒数”就懵了。数组里想找倒数第 N 个元素,直接用 arr[length - N] 就能定位,因为数组支持随机访问。但单链表每个节点只保存了一个后继指针,你只能从 head 开始,通过 next 一路往前走,永远不知道终点在哪里,更没法从尾部向回退。
所以“删除倒数第 N 个节点”这个问题,本质上是一个翻译题:你没法直接对“倒数”做下标操作,只能先把“倒数”翻译成“从某个位置开始走多少步”。最直观的翻译方式就是先遍历一遍,数出链表长度 L,那么倒数第 N 个节点就是正数第 L - N + 1 个节点。这个思路没问题,也是很多参考解法里“两次遍历”的由来。
但 LeetCode 19 有一个进阶要求:能不能用一趟扫描完成?这个“一趟扫描”的要求就引出了双指针,也是面试官真正想看到的点。两次遍历只是做了翻译,双指针才是对链表结构更深的理解。
1.2 链表删除的本质:先找到待删节点的前驱
写这道题时很多新手会有一个下意识错误:他们想用一个指针 cur 从头遍历,等 cur 指向待删除节点时,再想办法把 cur 自己删掉。但单链表做不到这一点,因为一个节点并不知道它的前一个节点是谁。
正确的删除操作其实是“跳过”:
python复制pre.next = pre.next.next
意思就是:让待删节点前一个节点 pre 的 next 指针,直接越过待删节点,指向它后面的节点。至于被跳过的节点,在 Python 里由垃圾回收处理;在 C++ 这类语言里,还得手动释放内存。这个细节对 LeetCode 19 尤其关键,因为如果待删节点恰好是头节点,那么它根本没有前驱,所以我们的操作会退化成一个特殊分支:head = head.next。
一个链表删除动作背后,其实藏着三处基本功:一是遍历链表,二是定位前驱,三是处理边界。LeetCode 19 巧妙地把这三样东西缝在了一道题里,所以它才适合作为链表面试的第一道经典题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次遍历的钥匙:快慢指针与哑节点的配合
2.1 哑节点的作用:让删除头节点的场景不再特殊
先说说哑节点,英文里叫 dummy node。做法是在真正的头节点前面额外加一个虚拟节点,它不存储业务数据,只是为了让代码统一处理删除操作。
为什么需要它?我前面说过,删除节点必须找到前驱。当你想删除的是整个链表的头节点时,从逻辑上来讲,head 之前并没有节点,所以你必须写一条单独的逻辑去处理“删头节点”的情况。加入 dummy 之后,dummy 变成 head 的前驱,删除头节点就变成普通的“让 dummy.next 跳过 head”的操作,不再需要 if-else 分支。
在 LeetCode 19 的代码里,我们通常这样建:
python复制dummy = ListNode(0)
dummy.next = head
最后返回 dummy.next,而不是直接返回 head。原因是如果删掉的是原头节点,head 变量已经被跳过,直接返回 head 就会出错;而 dummy.next 永远指向删除后的真正头节点。
我见过不少人在这一步偷懒:函数参数里有 head,就直接拿 head 当链表的头去操作。表面省了一个节点,实际会在头节点删除和返回值两个地方埋雷。面试时能主动提出“用哑节点统一头节点与普通节点的删除逻辑”,通常是一个加分项。
2.2 快慢指针的间隔:先走 n 步,还是先走 n+1 步?
快慢指针的思路说起来很形象:两个指针从同一起点出发,快指针先走 n 步,慢指针原地不动。此时快指针领先慢指针 n 个节点。接下来两个指针步调一致,每次同时往后移动一个节点。当快指针走到链表末尾、无法继续前进时,慢指针恰好停在待删除节点的前一个位置。
这里我建议代码里用“先走 n 步,然后以 fast.next 是否为空作为循环条件”的写法,原因后面会展开:
python复制# 快指针先走 n 步
for _ in range(n):
fast = fast.next
# 快慢指针一起走,直到快指针抵达尾节点
while fast.next:
fast = fast.next
slow = slow.next
为什么循环条件是 fast.next 而不是 fast 本身?因为我们要让 slow 停在待删节点的前驱。如果循环走到 fast 为 None 才停,slow 就会多走一步,指向待删节点本身,这样还得再额外记录一个 prev,代码就绕了。
有人可能会问:另一种常见写法是快指针先走 n+1 步,然后 while fast 循环,也可以让 slow 停在待删节点的前一个节点。对,两种写法都能 work,只是循环终止条件不同。我在这里推荐先走 n 步,是因为它的边界更直观:当 n 刚好等于链表长度时,快指针先走完 n 步后正好落在尾节点,此时 fast.next 为 None,循环体一次都不执行,slow 停留在 dummy,下一步直接删除原头节点。这个边界行为刚好是题目最容易考的情况。
2.3 用一条完整样例在脑子里跑一遍
举个例子:链表为 1 -> 2 -> 3 -> 4 -> 5,n = 2,目标是删除倒数第 2 个节点,也就是值为 4 的节点。
- 建 dummy,dummy.next = 1。
- fast 和 slow 都先指向 dummy。
- fast 先走 2 步:从 dummy 到节点 1,再到节点 2。
- 进入 while fast.next 循环:
- fast 从 2 到 3,slow 从 dummy 到 1;
- fast 从 3 到 4,slow 从 1 到 2;
- fast 从 4 到 5,slow 从 2 到 3;
- fast.next 为空,循环结束。
- 此时 slow 指向节点 3,slow.next 就是节点 4。
- 执行 slow.next = slow.next.next,让节点 3 直接指向节点 5。
最终链表变成 1 -> 2 -> 3 -> 5,符合预期。
注意这里 slow 从 dummy 开始一共走了 3 步,最终停在正数第 3 个节点。这个位置向前正好是待删节点的前驱,向后正好剩 n 个节点。慢指针的移动次数不是随手写的,它等于“链表总长度减去 n”,因为快指针先走的那 n 步已经把这段距离预先抵消掉了。
3. 三种写法的核心代码与实现差异
3.1 Python 实现:最贴近思路的版本
Python 的链表题写起来最不费劲,因为不需要操心内存释放,写出来的代码几乎就是伪代码。提交版本如下:
python复制class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
dummy = ListNode(0)
dummy.next = head
fast = dummy
slow = dummy
# 快指针先走 n 步
for _ in range(n):
fast = fast.next
# 快慢指针同步前进,fast 到表尾时停止
while fast.next:
fast = fast.next
slow = slow.next
# 跳过待删除节点
slow.next = slow.next.next
return dummy.next
关键是最后返回的是 dummy.next,这一点我再强调一次。即使 n 恰好等于链表长度、删除的是原始头节点,dummy.next 也已经指向新的头节点,不会出现返回空指针或者返回错误节点的情况。
3.2 Java 实现:对象引用与参数传递的陷阱
Java 版本的代码和 Python 很像,重点是理解 Java 里的对象引用:
java复制class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slow = dummy;
for (int i = 0; i < n; i++) {
fast = fast.next;
}
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
}
Java 初学者容易犯的一个错误是直接在 head 引用上做修改,然后在 main 方法里打印 head,发现链表没有变化。原因很简单:Java 方法传递的是引用副本,你把形参 head 指向别处,外部实参并不会改变。而链表内部节点的 next 修改会通过引用生效,因为那修改的是对象内部状态。平时自测时,记得在方法外部用一个变量接收返回值。
3.3 C++ 实现:别忘了 delete 掉被删节点
C++ 除了逻辑正确,还有一个语言特有的任务:手动管理内存。完整版本是这样:
cpp复制class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* fast = dummy;
ListNode* slow = dummy;
for (int i = 0; i < n; ++i) {
fast = fast->next;
}
while (fast->next != nullptr) {
fast = fast->next;
slow = slow->next;
}
ListNode* toDelete = slow->next;
slow->next = slow->next->next;
delete toDelete; // 释放被删除节点的内存
ListNode* newHead = dummy->next;
delete dummy; // 释放哑节点内存
return newHead;
}
};
很多刷 LeetCode 的 C++ 用户会忽略 delete,因为在线判题系统不会因此报错,进程退出后操作系统会回收内存。但在真实项目和面试手写代码时,释放被删节点是一个会被追问的点,面试官很可能问一句:“如果这里不 delete,会发生什么?”所以我在代码里特意留了 toDelete 这个变量,便于显式释放。至于 dummy 节点,它也是 new 出来的,工程上同样应该释放,避免内存泄漏。
4. 不只是双指针:两次遍历法和递归计数法的取舍
4.1 两次遍历法:先求长度,再转成删除正数第 L-N+1 个
很多教科书答案会先给两次遍历的解法,因为它是“从零开始推理”最容易到达的方案:
python复制class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
dummy = ListNode(0)
dummy.next = head
length = 0
cur = head
while cur:
length += 1
cur = cur.next
cur = dummy
# 走 length - n 步后,cur 指向待删节点的前驱
for _ in range(length - n):
cur = cur.next
cur.next = cur.next.next
return dummy.next
这个解法成立的原因是:倒数第 n 个节点,在长度为 L 的链表中等价于正数第 L - n + 1 个节点。如果要删除它,我们只需要走到它前面那个节点,也就是走 L - n 步。
两次遍历最容易被挑毛病的地方就是“多走了一趟”。如果链表很大,而 n 又比较小时,先完整遍历一次再回头删除,感觉有点浪费。不过它的正确性非常好理解,适合作为面试时的兜底方案,或者作为向双指针思路推导的铺垫。
4.2 递归计数法:用调用栈实现“从后往前数”
还有一种思路是用递归,在回溯阶段计数。递归函数一路调用到底,遇到空节点返回 0,每回溯一层就加 1。当计数到 n+1 时,当前节点正好是待删节点的前驱,于是修改它的 next:
python复制class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
dummy = ListNode(0)
dummy.next = head
def dfs(node):
if not node:
return 0
num_from_end = dfs(node.next) + 1
if num_from_end == n + 1:
node.next = node.next.next
return num_from_end
dfs(dummy)
return dummy.next
递归的巧妙之处在于,它把“倒数第 n 个”转换成了“回溯阶段的第 n 层”。你不需要知道链表总长度,系统调用栈帮你实现了从后往前的效果。
但这种做法有两个非常现实的缺点。第一,空间复杂度是 O(L),因为递归深度等于链表长度,而快慢指针只需要 O(1) 额外空间。第二,当链表足够长时,递归可能直接爆栈。所以在实际应用中,我一般只把递归解法作为思路拓展,不会在主方案里推荐它。
4.3 三种思路放在一起怎么选
下面这张表可以帮你快速回顾这道题的几种解法:
| 解法 | 时间复杂度 | 额外空间 | 遍历次数 | 特点 |
|---|---|---|---|---|
| 两次遍历 | O(L) | O(1) | 2 | 最好理解,适合新手 |
| 快慢指针 | O(L) | O(1) | 1 | 最优解,值得重点掌握 |
| 递归计数 | O(L) | O(L) | 1(利用调用栈) | 思路巧妙,注意爆栈风险 |
如果在面试中遇到这道题,比较理想的做法是先讲两次遍历的思路,表明你能把“倒数”翻译成“正数”;再提出进阶要求“能不能一趟完成”,引出快慢指针;最后在白板上写出通过哑节点统一处理的完整代码。这三步本身就是一个完整的答题链路,比直接默写代码更能体现能力。
5. 最容易翻车的一组边界条件与排查口诀
5.1 删除头节点:最经典的特殊情况
题目示例往往给一个中间节点删除的情况,但测试数据里必然会覆盖“删除头节点”的用例。比如链表是 1 -> 2 -> 3,n = 3,那要删除的就是值为 1 的头节点。
没有哑节点时,你会被迫写:
python复制if ???:
head = head.next
这个判断条件往往是整段代码最难看的地方。有哑节点后,流程完全不需要特殊分支:快指针先走 3 步。如果链表长度为 3,快指针会走到节点 3 的 next,那里是 None。在推荐写法里,其实是 fast 先走 3 步?等等,我们再核对一下:链表 1->2->3,n=3,dummy->1->2->3,fast从dummy走3步:dummy->1(1步)->2(2步)->3(3步),此时 fast 指向尾节点 3,fast.next 为空。while 循环不执行,slow 停留在 dummy。slow.next = slow.next.next,就是把 dummy 的下一个节点从 1 改成 2。返回 dummy.next,正确删除头节点 1。
如果没有 dummy,删除头节点这种情况会让返回值处理变得很麻烦:你不能直接 return head,因为 head 已经被跳过。所以这道题用 dummy 不是“锦上添花”,而是“结构性需要”。
5.2 链表只有一个节点时
链表只有 1 个节点,n = 1,删除后链表应该为空。用快慢指针跑一遍:
- dummy -> node。
- fast 先走 1 步,此时 fast 指向 node。
- while fast.next 判断,node.next 是 None,循环不执行。
- slow 仍在 dummy,slow.next = slow.next.next,于是 slow.next 从 node 变为 None。
- 返回 dummy.next,即 None。
如果面试官允许你打印链表,这一步容易让人疑惑:返回的 None 和“空链表”在 LeetCode 里是同一个意思,输出会用 [] 表示。代码本身没有做任何特殊处理,却能正确处理单节点链表,这正是哑节点带来的好处。如果去掉了 dummy,删除唯一节点时还得临时把 head 置空,代码就会多出不少分支。
5.3 假设 n 超过链表长度会怎样
LeetCode 原题说明 n 是有效的,也就是说 n 不会超过链表长度。所以很多题解压根不写防御判断。如果 n 超过链表总长度,快指针在 for 循环里还没走完 n 步就可能遇到 None,接着再访问 fast.next 就会抛空指针异常。
真实面试里,面试官也许会追问:“如果输入不合法,怎么让程序健壮一些?”这时你可以在开头补一段长度检查,或者先遍历一次确认长度。但是这样就失去了“一趟扫描”的优化意义。更实际的方案是,在快指针先走 n 步的循环里加一个保护判断,一旦 fast 为空就说明 n 不合法,可以直接返回原链表或者抛出异常。这种防御性编程在力扣上用得不多,在工程代码里却很重要。
5.4 自测时的排查口诀
这道题的边界问题集中在三个位置:
- 参数 n 是否等于链表长度,对应“删除头节点”。
- n = 1,对应“删除尾节点”。
- 链表只有一个节点,正好同时满足前面两种情况。
我在刷题和面试复盘时习惯每道链表题都先自问一遍:如果删除的是第一个节点怎么办?如果删除的是最后一个节点怎么办?如果链表为空怎么办?LeetCode 19 的坑主要在第一个问题,只要用了哑节点,后面两个问题其实会被一并化解。如果你写完代码测试不过,别急着怀疑双指针逻辑,先打印出 fast 和 slow 每一步的位置,绝大多数问题都是“循环多走了一步”或“少走了一步”。
6. 吃透这一题后,链表面试题的延伸套路
6.1 所有“删除指定节点”类问题的通用模板
如果你认真分析 LeetCode 19,会发现它的代码结构可以抽象成一个模板,适用于很多链表的节点删除问题:
先建哑节点,避免头节点特殊处理;再用一个指针 prev 定位到待删节点的前驱;最后通过 prev.next = prev.next.next 完成删除,返回值统一用 dummy.next。
这个模板可以套到不少题目上,例如:删除链表中所有值等于某个给定值的节点、删除排序链表中的重复元素、两两交换链表中的相邻节点。它们共同的难点都不在“删除”这个动作本身,而在于“找到正确的前驱节点”。
很多初学者会把链表题做成一堆 if-else 堆起来的复杂分支,其实链表题的优雅解法往往只有“哑节点 + 前驱定位 + 指针跳过”这几步。你越早领悟这一点,后面刷单链表逆序、链表插入等问题就越顺手。
6.2 从“倒数”延伸到“找中间节点”和“环形链表”
LeetCode 19 的快慢指针思想还有几个非常出名的亲戚。
第一个是找链表的中间节点:快指针每次走两步,慢指针每次走一步,快指针到末尾时,慢指针正好在中间。这是同一种“快慢速度差”的思路,只不过 LeetCode 19 用的是“先走固定步数”,找中间节点用的是“速度差”。
第二个是判断链表是否有环:快指针走两步,慢指针走一步,如果链表有环,两个指针最终会相遇。这个思路也是快慢指针,只是逻辑从“间距固定”变成了“速度不同,追逐判断”。
第三个是倒数第 K 个节点,它几乎就是 LeetCode 19 去掉删除动作后的简化版:快指针先走 K 步,再与慢指针同步前进,快指针到表尾时,慢指针就是倒数第 K 个节点。理解 LeetCode 19 之后,这道题你甚至不需要看题解,顺手就能写出来。
6.3 面试时还会被追问什么
在真实面试场景里,面试官知道你能写出快慢指针后,通常不会直接收工,而是会往下问几个点。
第一个问题:为什么用 fast.next 而不是 fast 做循环条件?这考察你是否真的理解慢指针需要停在“前驱”。你如果能解释清楚,说明不是背答案。
第二个问题:如果是双向链表,这道题还需要哑节点吗?答案是不太需要,因为双向链表的每个节点有 prev 指针,删除头节点时可以单独处理,也可以通过哨兵节点统一,但单向链表才是考察指针操作能力的重点。
第三个问题:如果链表特别特别长,快慢指针和递归哪个更合适?答案是快慢指针,因为它的额外空间是 O(1),递归在链表长度很大时有栈溢出风险。这个问题在 C++ 和 Java 面试里尤其容易出现,因为它们的函数调用栈不像 Python 那样容易“假装没事”,真的会栈溢出。
第四个问题:如果被删除的节点很关键,需要通知其他模块做清理怎么办?这已经从算法题跳到了工程领域,面试官想看的是你有没有“数据节点不仅仅是一个值,还可能关联资源”的意识。这时候删除节点就不只是改指针,还要先摘除它的业务状态,再释放内存或解除引用。工程里的链表操作往往比 LeetCode 上的更繁琐,因为每个节点都可能背着额外的生命周期。
我个人刷这道题最大的体会是:链表题不要怕慢,怕的是没想清楚指针最后停在哪里就动手写。先画一条链表,把 fast、slow 的每一步位置标出来,再写代码,比我见过的大多数“背诵解法”要可靠得多。LeetCode 19 快慢指针的核心就是一个位置问题,你把 slow 停在前驱这个点记牢了,以后但凡遇到“删除链表固定节点”的变体,其实都是在重复这套动作。
