LeetCode中等难度的链表经典题——删除链表的倒数第N个结点,这道题看似简单,但很容易踩坑,尤其是边界情况的处理,同时它也能帮我们巩固链表的核心操作,适合新手入门练习。
先看题目要求,帮大家梳理清楚核心需求:给定一个单链表,删除链表的倒数第n个结点,最后返回链表的头结点。题目还给出了链表节点的定义和一段初始代码,我们就基于这段代码,一步步分析解题思路、优化方向,再复盘易错点。
一、题目核心分析
1. 题干关键信息
-
单链表:只能从头结点依次遍历到尾结点,无法反向访问,这是解题的核心限制。
-
倒数第n个:不是从表头开始数,而是从表尾(null之前的最后一个节点)开始数,比如链表[1,2,3,4,5],倒数第2个就是4。
-
返回头结点:删除头结点时,需要正确返回新的头结点,这也是常见易错点。
2. 节点定义回顾(题目给定)
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
很标准的单链表节点定义:每个节点包含一个值val和一个指向next节点的指针(可能为null,代表尾节点)。
二、解题思路(双指针优化版)
看到“倒数第n个”,首先想到的暴力解法是:先遍历一遍链表,统计总长度len,再遍历第二遍,找到第len-n个节点(即倒数第n个节点的前驱节点),然后删除目标节点。但这种方法需要遍历两次链表,效率不高。
更优的解法是「双指针法」(一次遍历搞定),也是题目给定代码的核心思路,我们拆解一下:
核心逻辑:快慢指针+虚拟头节点
-
虚拟头节点(dummy):创建一个值为0、next指向head的虚拟节点。目的是统一“删除头结点”和“删除中间节点”的逻辑,不用单独判断头结点是否被删除。
-
双指针初始化:prev指针指向dummy(用于定位目标节点的前驱节点),curr指针指向head(用于遍历链表,充当“快指针”)。
-
计数器count:用于控制curr指针先走n步,拉开与prev指针的距离(此时prev和curr之间相差n个节点)。
-
同步遍历:当count达到n后,prev和curr同步向后移动,直到curr遍历到链表末尾(curr为null)。此时prev指向的就是“倒数第n个节点的前驱节点”。
-
删除节点:prev.next = prev.next.next,跳过目标节点,完成删除。
-
返回结果:返回dummy.next(因为dummy的next始终是新的头结点,避免头结点被删除后无法返回)。
思路图解(简化版)
以链表[1,2,3,4,5]、n=2为例:
-
初始状态:dummy(0)→1→2→3→4→5→null,prev=dummy,curr=1,count=0。
-
curr先走:count从0涨到2(此时curr走到3),count达到n=2。
-
同步移动:prev和curr一起向后走,直到curr=null(此时prev走到3,curr=null)。
-
删除节点:prev.next = prev.next.next(3的next从4改成5),删除4,最终链表为[1,2,3,5]。
三、题目给定代码逐行解析
题目已经给出了完整的TypeScript代码,我们逐行拆解,搞懂每一步的作用,以及为什么这么写:
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
// 1. 创建虚拟头节点,next指向head,统一删除逻辑
let dummy = new ListNode(0, head)
// 2. prev指向虚拟头节点(目标节点的前驱),curr指向头节点(遍历指针)
let prev: ListNode | null = dummy
let curr = head
// 3. 计数器,控制curr先走n步
let count = 0;
// 4. 遍历链表,curr不为null时继续
while (curr) {
if (count === n) {
// 5. count达到n,prev和curr同步向后移动
if (!prev) return null; // 边界保护(实际不会触发,因dummy存在)
curr = curr.next;
prev = prev.next;
} else {
// 6. count未达到n,curr继续前进,count递增
curr = curr.next;
count++;
}
}
// 7. 边界判断:确保prev和prev.next存在(避免n超出链表长度)
if(!prev||!prev.next)return null;
// 8. 删除目标节点:跳过prev.next(即倒数第n个节点)
prev.next = prev.next.next;
// 9. 返回新的头节点(dummy.next始终是有效头节点)
return dummy.next;
};
关键代码解读(易错点)
-
虚拟头节点dummy:如果不创建dummy,当删除的是头结点(比如链表[1], n=1)时,prev无法定位前驱节点,会导致错误。dummy的存在让我们可以轻松处理这种情况。
-
边界判断if(!prev||!prev.next)return null:防止n超出链表长度(比如链表长度为3,n=5),此时prev.next为null,执行prev.next = prev.next.next会报错,所以提前返回null。
-
count的作用:通过count控制curr先走n步,确保prev和curr之间的距离是n,这样当curr走到末尾时,prev刚好指向目标节点的前驱。
四、优化方向与易错点复盘
1. 代码优化(简化逻辑)
题目给定的代码已经很完善,我们可以简化一下while循环内的逻辑(去掉count,让curr直接先走n步),可读性更强:
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
const dummy = new ListNode(0, head);
let prev = dummy, curr = dummy;
// 让curr先走n步
for (let i = 0; i <= n; i++) {
if (!curr) return null; // 防止n超出链表长度
curr = curr.next;
}
// 同步移动prev和curr,直到curr为null
while (curr) {
prev = prev.next!;
curr = curr.next;
}
// 删除目标节点
prev.next = prev.next!.next;
return dummy.next;
}
优化点:用for循环替代count计数器,直接让curr先走n+1步(因为prev初始指向dummy),逻辑更简洁,减少判断。
2. 常见易错点
-
忘记处理“删除头结点”的情况:没有虚拟头节点,删除头结点时无法正确返回新头结点。
-
n超出链表长度:未做边界判断,导致prev.next为null时执行prev.next.next,触发报错。
-
双指针距离控制错误:curr先走的步数不足n,导致prev无法定位到目标节点的前驱。
-
遍历结束后直接删除:未判断prev.next是否存在,导致空指针异常。
六、总结
这道题的核心是「双指针法」和「虚拟头节点」的结合,既能实现一次遍历高效解题,又能统一删除逻辑,避免边界踩坑。
对于新手来说,重点要掌握:
-
虚拟头节点的作用:解决头结点删除的特殊情况,简化代码逻辑。
-
双指针的应用场景:在链表中,双指针常用于“找倒数第k个节点”“判断环”“合并两个有序链表”等场景,能大幅提升效率。
-
边界情况的考虑:任何链表操作,都要考虑“头结点”“尾节点”“空链表”“n超出范围”这几种情况。