题目解析:为什么这道题是链表操作的经典必刷题
LeetCode 19题要求我们删除链表的倒数第 N 个结点,并且只能遍历一次链表。这个"一次遍历"的限制直接否定了先遍历求长度再定位删除的暴力做法,逼着我们思考更高效的解法。题目看似简单,但隐藏着三个容易踩坑的关键点:如何准确定位倒数第N个节点、如何处理删除头节点的特殊情况、如何保证操作的时间复杂度为 O(n)。
举个例子,给定链表 1→2→3→4→5,并且 N=2,最终要返回的链表应该是 1→2→3→5。这个过程中我们需要找到倒数第2个节点(也就是4)并删除它,但关键在于我们并不知道链表的长度,这就像在黑暗中数台阶,必须用巧劲而不是蛮力。
链表基础:理解这道题的前置知识
在开始解题前,我们需要先明确链表的基本结构。链表是由节点组成的线性数据结构,每个节点包含两部分:存储数据的数据域和指向下一个节点的指针域。与数组不同,链表的元素在内存中不是连续存储的,这意味着我们无法像数组那样通过下标直接访问元素,必须从头节点开始逐个遍历。
在 Python 中,链表节点通常定义为:
class ListNode:def __init__(self, val=0, next=None):self.val = valself.next = next
这种结构决定了链表操作的特殊性:要删除某个节点,必须找到它的前一个节点,然后修改前一个节点的 next 指针。这也是本题的核心难点所在——如何在不知道链表长度的情况下,高效找到倒数第 N 个节点的前一个节点。
双指针法:一次遍历解决问题的最优解
双指针法是解决这道题的最优方案,它的核心思想是通过两个指针之间的距离差来定位倒数第 N 个节点。具体步骤如下:
创建两个指针 fast 和 slow,初始时都指向虚拟头节点
让 fast 指针先向前移动 N+1 步(为什么是 N+1?因为我们要找到倒数第 N 个节点的前一个节点)
然后让 fast 和 slow 指针同时向前移动,直到 fast 指针到达链表末尾(指向 null)
此时 slow 指针指向的就是倒数第 N 个节点的前一个节点,修改 slow.next 即可删除目标节点
为什么这种方法能奏效?因为当 fast 指针比 slow 指针超前 N+1 步时,当 fast 到达终点,slow 与终点的距离就是 N+1 步,也就是说 slow 指向的是倒数第 N+1 个节点,正好是我们要删除的倒数第 N 个节点的前一个节点。这种方法只需要遍历一次链表,时间复杂度为 O(n),空间复杂度为 O(1)。
栈解法:利用后进先出特性的巧妙思路
除了双指针法,栈也是解决这道题的常用方法。栈的后进先出(LIFO)特性可以帮助我们反向访问链表节点:
遍历整个链表,将所有节点依次入栈
然后连续弹出 N 个节点,此时栈顶元素就是倒数第 N+1 个节点
修改栈顶节点的 next 指针,指向倒数第 N-1 个节点,完成删除操作
栈解法的时间复杂度同样是 O(n),但空间复杂度为 O(n),因为需要额外存储所有节点。虽然空间复杂度不如双指针法优秀,但栈解法的思路更加直观,容易理解和实现。
多语言实现:Python/Java/JavaScript代码对比
Python实现
class Solution:def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:# 创建虚拟头节点dummy = ListNode(0, head)fast = slow = dummy# fast指针先移动n+1步for _ in range(n + 1):fast = fast.next# 同时移动fast和slowwhile fast:fast = fast.nextslow = slow.next# 删除节点slow.next = slow.next.nextreturn dummy.next
Java实现
class Solution {public ListNode removeNthFromEnd(ListNode head, int n) {// 创建虚拟头节点ListNode dummy = new ListNode(0);dummy.next = head;ListNode fast = dummy, slow = dummy;// fast指针先移动n+1步for (int i = 0; i <= n; i++) {fast = fast.next;}// 同时移动fast和slowwhile (fast != null) {fast = fast.next;slow = slow.next;}// 删除节点slow.next = slow.next.next;return dummy.next;}}
JavaScript实现
var removeNthFromEnd = function(head, n) {// 创建虚拟头节点const dummy = new ListNode(0, head);let fast = dummy, slow = dummy;// fast指针先移动n+1步for (let i = 0; i <= n; i++) {fast = fast.next;}// 同时移动fast和slowwhile (fast) {fast = fast.next;slow = slow.next;}// 删除节点slow.next = slow.next.next;return dummy.next;};
三种语言的实现思路完全一致,都是采用双指针法结合虚拟头节点的方式。代码结构清晰,核心逻辑都在15行左右,充分体现了双指针法的简洁高效。
虚拟头节点:解决头节点删除难题的关键技巧
虚拟头节点(Dummy Node)是链表操作中的一个重要技巧,它可以统一处理各种边界情况,避免对头节点进行特殊判断。
没有虚拟头节点时,删除头节点和删除其他节点需要不同的处理逻辑:
删除其他节点:找到前一个节点,修改其 next 指针
删除头节点:直接返回 head.next
而有了虚拟头节点后,无论删除哪个节点,都可以采用相同的处理逻辑:找到前一个节点,修改其 next 指针。虚拟头节点就像是一个哨兵,让所有节点都拥有了前一个节点,从而简化了代码逻辑。
边界情况处理:这些坑你必须知道
在解决这道题时,有几个边界情况需要特别注意:
删除头节点:当 N 等于链表长度时,要删除的就是头节点。没有虚拟头节点时,需要特殊处理这种情况。
链表长度为1:当链表只有一个节点且 N=1 时,删除后应该返回 null。
N 等于 0:虽然题目中说明 N 是正整数,但实际编程中还是应该考虑这种异常输入。
N 大于链表长度:同样需要考虑这种异常情况的处理。
双指针法结合虚拟头节点的方案已经考虑了这些边界情况,能够优雅地处理各种极端场景。
拓展思路:从删除倒数节点到删除中间节点
解决了删除倒数第 N 个节点的问题后,我们可以思考如何将这种思路拓展到其他链表操作问题:
删除链表中间节点:如果要求删除链表的中间节点(只允许遍历一次),可以使用快慢指针法:快指针每次走两步,慢指针每次走一步,当快指针到达终点时,慢指针正好指向中间节点。
删除链表的第 N 个节点:这是删除倒数第 N 个节点的正向版本,可以直接遍历找到第 N-1 个节点进行删除。
删除链表中等于给定值的所有节点:需要遍历整个链表,逐个判断并删除符合条件的节点。
这些问题虽然具体要求不同,但核心思路都是通过指针操作来定位和删除目标节点,体现了链表操作的共性。
总结:这道题教会我们什么
LeetCode 19题虽然是一道中等难度的题目,但它涵盖了链表操作的多个核心知识点:
双指针技巧:这是解决很多链表、数组问题的高效方法,通过两个指针之间的关系来定位目标元素。
虚拟头节点:简化边界情况处理的常用技巧,在很多链表操作题中都能用到。
边界情况处理:编程不仅要处理正常情况,还要考虑各种极端场景。
空间与时间复杂度权衡:双指针法(O(1)空间)和栈解法(O(n)空间)的对比,体现了算法设计中的取舍思想。
掌握这道题的解法,不仅能应对类似的链表操作问题,更能培养我们解决复杂问题的思维能力。很多时候,优秀的算法不是凭空出现的,而是通过不断优化和改进得到的。从暴力解法到双指针法,体现的正是这种优化思维。
最后记住,解决算法问题的关键不是记住答案,而是理解其中的思想和方法,并能将其应用到新的问题中。这道题中用到的双指针技巧和虚拟头节点思想,在很多其他链表问题中都能发挥重要作用。

