LeetCode 19. 删除链表的倒数第 N 个结点:解题详解+代码复盘

10 阅读6分钟

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个节点的前驱节点),然后删除目标节点。但这种方法需要遍历两次链表,效率不高。

更优的解法是「双指针法」(一次遍历搞定),也是题目给定代码的核心思路,我们拆解一下:

核心逻辑:快慢指针+虚拟头节点

  1. 虚拟头节点(dummy):创建一个值为0、next指向head的虚拟节点。目的是统一“删除头结点”和“删除中间节点”的逻辑,不用单独判断头结点是否被删除。

  2. 双指针初始化:prev指针指向dummy(用于定位目标节点的前驱节点),curr指针指向head(用于遍历链表,充当“快指针”)。

  3. 计数器count:用于控制curr指针先走n步,拉开与prev指针的距离(此时prev和curr之间相差n个节点)。

  4. 同步遍历:当count达到n后,prev和curr同步向后移动,直到curr遍历到链表末尾(curr为null)。此时prev指向的就是“倒数第n个节点的前驱节点”。

  5. 删除节点:prev.next = prev.next.next,跳过目标节点,完成删除。

  6. 返回结果:返回dummy.next(因为dummy的next始终是新的头结点,避免头结点被删除后无法返回)。

思路图解(简化版)

以链表[1,2,3,4,5]、n=2为例:

  1. 初始状态:dummy(0)→1→2→3→4→5→null,prev=dummy,curr=1,count=0。

  2. curr先走:count从0涨到2(此时curr走到3),count达到n=2。

  3. 同步移动:prev和curr一起向后走,直到curr=null(此时prev走到3,curr=null)。

  4. 删除节点: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是否存在,导致空指针异常。

六、总结

这道题的核心是「双指针法」和「虚拟头节点」的结合,既能实现一次遍历高效解题,又能统一删除逻辑,避免边界踩坑。

对于新手来说,重点要掌握:

  1. 虚拟头节点的作用:解决头结点删除的特殊情况,简化代码逻辑。

  2. 双指针的应用场景:在链表中,双指针常用于“找倒数第k个节点”“判断环”“合并两个有序链表”等场景,能大幅提升效率。

  3. 边界情况的考虑:任何链表操作,都要考虑“头结点”“尾节点”“空链表”“n超出范围”这几种情况。