news 2026/10/19 3:16:14

删除链表倒数第N个节点:双指针与虚拟头节点详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
删除链表倒数第N个节点:双指针与虚拟头节点详解

1. 题目本质与适用场景

“删除链表倒数第N个节点”这道题,基本是链表入门阶段绕不开的一道坎。它的题目描述很简短:给定一个单链表,删除倒数第N个节点,并返回链表的头节点。看起来只有一句话,但真正动起手来,很多人在边界条件上栽跟头——比如删除的是头节点怎么办,N恰好等于链表长度怎么办,链表只有一个节点又该怎么办。

先说一个很多人没意识到的点:这道题考的根本不只是“会不会删除节点”,而是三个底层能力的复合体——对链表内存结构的理解、双指针技巧的灵活运用、以及边界条件的防御性编程意识。在算法面试或日常开发里,这三个能力恰恰是最常被考察的,所以这道“每日一题”的价值远远超过它表面那点代码量。

从实际应用场景来说,这种“倒数第N个”的需求在业务代码里并不罕见。比如一个消息队列要保留最近N条记录,超出部分批量清理;再比如日志系统要做滚动删除,只留最新的N个文件;还有撤销栈的深度限制、连接池里淘汰最老的连接……只要数据结构是链表或者链式结构,你随时都可能写出类似逻辑。这道题练熟了,后面碰到“找链表中点”“判断链表是否有环”“旋转链表”这些经典题目,其实都是一套思路的变体。

适合什么人拿来练手?我个人建议有三类人可以重点刷这道题:一是刚学完链表基本操作、想找个有挑战但不超纲的题目巩固基础的新手;二是面试前想快速过一遍链表高频考点的求职者,这道题的出镜率相当高;三是在业务里写过链表相关逻辑但总被边界问题坑到的开发者,刷完你会对自己代码的健壮性有新的感知。

2. 核心思路拆解:为什么双指针是这道题的最优解

2.1 最直观的思路是两次遍历,但痛点很明显

拿到这道题,第一反应大概率是朴素的暴力解法:先遍历一遍链表,数出总长度L,然后倒数第N个节点其实就是正数第 L-N+1 个节点,删除它需要找到它的前驱,也就是第 L-N 个节点,再走一遍链表到那个位置改指针。

这个思路没问题,时间复杂度是O(L),空间复杂度O(1),其实已经达标了。两轮遍历对于面试来说也能过。但它有一个天然的局限:你需要先完整遍历才能得到长度。如果链表很长,或者N的语义在遍历过程中还可能调整,这种“先扫一遍再回头”的方式就不够优雅了。

而且有个比较坑的细节:如果N恰好等于链表长度,要删除的就是头节点,这时候你的删除逻辑必须单独判断“前驱是否存在”,代码会多出分支。很多人在这一步开始头晕,因为链表的删除操作本质上依赖“找到前驱再改next”,而头节点没有前驱。

2.2 双指针(快慢指针)的核心思想

双指针方案解决的核心问题并不是时间复杂度——它和两次遍历一样是O(L)——而是只用一次遍历就定位到目标节点的前驱。

具体思路是这样铺垫的:

假设用两个指针,一个叫 fast,一个叫 slow。先让 fast 往前走 N 步,此时 fast 和 slow 之间就拉开了 N 个节点的距离。然后保持这个距离,让两个指针同步一次走一步。当 fast 走到链表末尾(也就是 fast == null)的时候,slow 所在的位置,正好就是倒数第 N 个节点的前驱。

为什么?我们做个简单推导:链表长度为 L,fast 先走了 N 步,剩余距离是 L - N 步。fast 从当前位置走到末尾,还需要走 L - N 步。两个指针同步移动时,slow 同样走了 L - N 步。之前 slow 还在头节点(可以理解为从头部开始走了0步),现在它停在第 L-N 个节点的位置,而倒数第 N 个节点是正数第 L-N+1 个,第 L-N 个节点正是它的前驱。

这个推导的核心是“保持固定间距”。一旦快慢指针间距锁定为 N,当快指针碰到终点时,慢指针天然站在目标位置上,根本不需要知道链表总长度。

2.3 为什么虚拟头节点是删除操作的救星

上面说暴露了一个问题:如果倒数第 N 个节点恰好是头节点,那它的前驱是不存在的。这时候 slow 站在哪里?按照双指针逻辑,fast 先走 N 步,如果链表长度也恰好是 N,那 fast 走完 N 步时已经指向 null 了,此时 slow 还站在头节点——但我们要删除的是头节点本身,慢指针却站在了待删除节点上,而不是它的前驱。

解决方案业界早就统一了:加一个虚拟头节点(dummy node)。在真实头节点前面挂一个新节点,它的 next 指向原头节点。然后 fast 从虚拟头节点出发先走 N 步,slow 也从虚拟头节点出发,保持间距同步移动。当 fast 碰到末端时,slow 停在的正好是待删除节点的前驱,哪怕是头节点也有前驱——因为虚拟头节点顶替了这个位置。

注意:加了虚拟头节点之后,代码里的循环起始状态、步数计数都要跟着调整。最常见的错误是还按不加虚拟头节点时的步数走,结果快指针越界或者慢指针定位偏了。

虚拟头节点的本质是用空间换边界处理的简洁性。它多占一个节点,但让“删除头节点”这个分支直接消失,代码逻辑统一成了一句话:slow.next = slow.next.next。在工程代码里,这种“用一个哨兵节点消除特判”的思路也应用很广,比如带哨兵的链表实现、环形缓冲区的读写指针设计。

3. 手写一套完整实现:从抽象思路到可运行代码

3.1 定义链表节点结构

不管用什么语言,链表的节点定义都差不多。这里我用最常见的两种语言给出结构定义:

# Python 版节点定义 class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next
// Java 版节点定义 public class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }

定义本身没什么可说的,关键提醒一个新手容易忽略的坑:这类面试题里经常出现“节点通过 next 串联成链表”的形式,但测试框架可能会用数组来初始化链表。也就是说,你只需要实现删除逻辑,不需要自己从数组构建链表。但也有部分平台会把构建也得交给你,所以链表的尾插法构建还是要练一练。

3.2 双指针完整代码(Python版)

def removeNthFromEnd(head, n): dummy = ListNode(0) dummy.next = head fast = dummy slow = dummy # fast 先走 n 步 for _ in range(n): fast = fast.next # fast 和 slow 同步前进 while fast.next is not None: fast = fast.next slow = slow.next # 此时 slow 指向待删除节点的前驱 slow.next = slow.next.next return dummy.next

这套代码非常经典,但有两个细节值得展开说明。

细节一:为什么 fast 先走 n 步,然后 while 条件用 fast.next 而不是 fast?

因为我们想让 slow 停在待删除节点的前驱。如果 fast 从 dummy 先走 n 步,那么 fast 和 slow 间距为 n;接下来循环条件是 fast.next 不为空,这意味着 fast 最终停在最后一个有效节点,而不是越过最后一个节点变成 null。fast 停在最后一个节点时,slow 比它落后 n 个位置,正好是倒数第 n+1 个节点——也就是倒数第 n 个节点的前驱。这个“多走一步”和“少走一步”的微妙差别,是代码是否正确的分水岭。

细节二:返回值为什么是 dummy.next 而不是 head?

如果删除的节点就是原来的头节点,head 这个指针已经指向了被删除的节点(虽然它本身还在内存里,但已经从链表中断开了),直接返回 head 就错了。而 dummy.next 永远指向删除之后的新头节点,无论头节点有没有被删,它都是安全的。这也是虚拟头节点除了消除特判之外的第二个重要价值。

3.3 核心步骤的推演与验证

我们用例子走一遍,感受这套逻辑的运转过程。假设链表是1 -> 2 -> 3 -> 4 -> 5,要删除倒数第 2 个节点(也就是值为 4 的节点)。

  • 建立 dummy 节点,dummy.next 指向节点1,fast 和 slow 都先指向 dummy。
  • fast 走 2 步:第一次走到节点1,第二次走到节点2。此时 fast 在节点2,slow 在 dummy。
  • 循环开始,fast.next 不为空,两个指针同步走:
    • fast 走到节点3,slow 走到节点1
    • fast 走到节点4,slow 走到节点2
    • fast 走到节点5,slow 走到节点3
    • fast.next 为空,循环结束。slow 停在节点3,也就是待删除节点4的前驱。
  • 执行 slow.next = slow.next.next,节点3的下一个指针跳过节点4直接指向节点5,删除完成。
  • 返回 dummy.next,也就是节点1。

整个流程下来,链表变成1 -> 2 -> 3 -> 5,完全正确。

3.4 双指针方案的复杂度分析

时间复杂度:两个指针各遍历链表一次,但整体只走了一遍链表的长度级别,准确说是 fast 走了 n + (L-n) = L 步,slow 走了 L-n 步,合起来是 2L - n 步,仍然是 O(L) 级别。因为两次遍历方案的常数也是 2L 左右,所以从大O的角度看两者持平,但双指针只需要一次完整遍历,在链表特别长、节点数据分散在内存各处时,缓存命中率和遍历效率会略好一些。

空间复杂度:只用了 dummy、fast、slow 三个指针,O(1) 额外空间,这也是这道题要求的标准答案。有些解法用栈先全部压栈再弹出第 n 个,空间复杂度会变成 O(L),面试时千万不要用这种方案作为最终答案。

4. 边界条件与常见错误排查实录

4.1 四种最容易踩的边界场景

场景一:删除的节点是头节点

链表1 -> 2 -> 3,n=3。不加虚拟头节点的话,慢指针会站在头节点上,而不是前驱,直接slow.next = slow.next.next改的是头节点的后一个节点,逻辑完全错位。加了 dummy 之后这个问题自动消失,但前提是你记得返回的是 dummy.next。

场景二:链表只有一个节点

链表1,n=1。fast 从 dummy 走 1 步到节点1,此时 fast.next 为空,循环直接跳过,slow 仍指向 dummy。执行 slow.next = slow.next.next,也就是 dummy.next 指向了 null,返回 dummy.next 为 null。逻辑完全自洽。但如果没有 dummy,这个 delete 操作根本无法写,因为头节点就是唯一节点,没有前驱。

场景三:n 等于链表长度

这个其实就是“删除头节点”的换皮说法。比如链表1 -> 2 -> 3 -> 4,n=4。fast 走 4 步后已经是 null 了(因为 dummy 是第0个参考点,走4步刚好越过最后一个有效节点)。如果此时你的 while 条件是while fast is not None,那么 next 循环里 fast 已经为 null,直接空指针访问fast.next就会报错。所以务必注意:fast 先走 n 步后,判断条件应该用 fast.next 而不是 fast,这保证了 fast 停在最后一个有效节点上。

场景四:空链表

如果 head 本身是 null,那逻辑怎么办?按题目约束,n 一定合法,但作为工程代码还是要防御一下。dummy.next 本来就指向 null,fast 走 n 步时如果 n 大于 0 直接空指针异常。实际写代码时可以加一个判定:if head is None: return head。刷题环境里通常不会出现这种情况,但写上没坏处。

4.2 常见错误排查表

错误现象根本原因排查方向
返回结果丢了原来的头节点返回了 head 而不是 dummy.next检查返回值是否跟随虚拟头节点
删除节点后链表尾部出现 null 截断while 条件用了 fast 而不是 fast.next画出快指针最终停靠位置,确认是停在最后一个节点还是越过尾部
删除的总是正数第 n 个而不是倒数第 n 个快指针和慢指针的起始位置不一致确认 fast 和 slow 都从 dummy 出发
删除后链表长度没变slow.next.next 赋值方向反了检查是否写成了 slow = slow.next.next 而忘记修改 next 指向
出现了 n 步之后 fast 为 null 的异常循环条件选择错误对照 n=L 的场景,用 test case 手动跑一遍

第一条排查方向尤其值得多说两句。很多人以为返回 head 和返回 dummy.next 是等价的,实际上只有当头节点没被删除时它们才等价。一旦删除的恰好是头节点,head 指向的那块内存已经被“逻辑摘除”,虽然节点对象还在,但它已经不属于链表的一部分了。刷算法题时,返回一个悬挂的非链表节点,轻则答案错误,重则遍历时死循环。

4.3 手写代码时的高频失误点

失误一:给 fast 赋值时忘了从 dummy 出发

有人从 head 出发先走 n 步,同时 slow 从 dummy 出发,这会破坏快慢指针的初始间距,导致极难察觉的位置偏差。写成fast = head后 fast 走在真实节点上,slow 却站在 dummy 上,两者间距变成了 n+1,最后 slow 会停在前驱的前驱,删除结果整个错位。

失误二:循环里忘记更新 fast

这个低级错误在笔试手写时非常频繁,while fast.next:里面只写了 slow 的移动,忘了 fast 也要同步走,结果死循环。建议养成习惯:两个指针捆绑移动,写在一行里,或者严格上下对齐,做到写完代码立刻目测检查。

失误三:删除操作前不记得做非空校验

在真实业务里,链表中途可能出现 null,比如并发环境下别的线程改了结构。但在面试题场景里这条不强调,因为链表结构是静态的。真正的业务场景中,slow.next可能为 null,如果再取slow.next.next就抛异常。习惯上我会写一个防御:if slow.next is None: return dummy.next。

失误四:对 n 的取值范围不做防御

题目明确说 n 合法,但工程代码中 n 可能为负数或者超过链表长度。作为个人项目或刷题实践,建议加一行为后续扩展留余地:

if n <= 0 or head is None: return head

5. 从这道题延伸出的链接技巧与变体

5.1 快慢指针在其他链表题中的通用模式

双指针(快慢指针)这套操作,在链表类面试题里几乎是个“万能钥匙”。它最经典的应用远不止这一道题。

找链表中点:快指针每次走两步,慢指针每次走一步,快指针到末尾时,慢指针正好在中点。这个技巧和“先走 n 步保持间距”是同一个原理的不同表现形式——一个靠“速度差”,一个靠“距离差”。两者结合记忆,效果比孤立刷题好得多。

判断链表是否有环:快慢指针同时从头出发,快指针每次走两步,慢指针每次走一步,如果链表有环,两者一定会在环内相遇。这是 Floyd 判圈算法的基础。环的入口位置还可以通过“相遇后把一个指针移回头部再同步走”来定位,本质上还是一个数学推导下的双指针技巧。

旋转链表:右移 k 个位置的场景里,往往可以先遍历得到长度 L,再通过快指针先走 k % L 步拉开距离,找到断开点。和删除倒数第 N 个节点的思路可以说同根同源。

寻找两个链表的交点:用两个指针分别从两个链表头开始走,走到尾部后切换到另一个链表的头部,最终相等的位置就是交点。这里面的“消除长度差”思想,也带着双指针的影子。

一旦把双指针吃透,这些题不需要死记硬背,只需要记住核心逻辑:想办法让两个指针在关键位置相遇,而相遇的位置经过步数差或速度差来精确控制。

5.2 删除链表节点的其他实现方案对比

除双指针之外,这道题还有一些常见解法,整理成表格方便对照。

方案核心思路时间复杂度空间复杂度优点缺点
两次遍历第一次统计长度,第二次走到前驱位置O(L)O(1)思路简单,容易理解需要两轮扫描,边界分支多
栈辅助一次遍历全部压栈,再弹 n 次找到目标O(L)O(L)代码短,方向感强空间消耗随链表长度线性增长
双指针快指针先走 n 步,慢指针同步前进O(L)O(1)一次遍历,空间最优初始步数和循环条件需要仔细推敲
递归回溯递归到尾节点后逐层回溯计数O(L)O(L)(递归栈)实现优雅,计数器传递直观链表太长可能爆栈,空间复杂度高

实际工程中,双指针方案几乎是唯一推荐。栈方案在面试时可以用作思路讨论,但千万不要作为最终答案提交;递归方案虽然代码好看,遇到大规模链表时有栈溢出风险,生产环境里能不用就不用。

5.3 删除节点后的内存管理问题

这个点容易被大家忽略。在像 Python 这种带垃圾回收的语言里,删除节点就是简单地断开引用,节点对象会被自动回收,不用操心。但在 C/C++ 里,手动释放被删除节点的内存是必须的。正确的操作顺序是:先把前驱的 next 指向被删节点的后继,然后保存被删节点的指针,最后delete它。

顺序不能反,否则你先 delete 了,再去访问它的 next 就是野指针操作。这种“先摘链,再释放”的习惯在刷题时养成,后面做底层库开发会少踩很多坑。

5.4 更进阶的变体:一次遍历能解决的“倒数第N个”的所有变式

这套双指针框架不止能删除倒数第 N 个节点,还能做很多扩展。

返回倒数第 N 个节点(不删除):这个更简单,快指针先走 N 步,然后快慢指针同时走,快指针到 null 的时候慢指针正好在倒数第 N 个节点。注意这里不需要虚拟头节点,因为不需要找前驱。

交换倒数第 N 个节点和正数第 N 个节点:可以先用双指针找到倒数第 N 个节点,再从头走 N 步找到正数第 N 个节点,不过要注意两者位置是否重叠、是否相邻两三种特殊情况。

每 k 个节点一组反转:这道经典难题里面也用到了“计数 + 分组 + 翻转”的组合逻辑,虽然不像双指针那么直接,但思想上的“分组边界定位”和本题的“精确位置定位”背后都是对链表指针关系的深刻理解。

如果你刷完删除倒数第 N 个节点之后感觉驾轻就熟,建议连续做几道变体,把双指针的肌肉记忆打牢。

6. 关于这道题的一些实战心得

做了这么多年链表题,自己也在项目里写过不少链表工具,我最大的感受是:链表题真正考验的不是语法,而是你是否能在脑海里把指针的每一步移动都模拟出来。很多人代码写着写着就“晕链”了,尤其是快慢指针、指针交换这一类操作,问题几乎都出在抽象层级太高、没有落到具体的节点位置上。

所以我练这道题时给自己立了一个规矩:拿到任何链表题,先在草稿纸上画出节点和指针,每走一步就划掉旧的指针、画上新的指向,绝不凭空脑补。这个方法看起来笨,但对训练链表感特别有效。

另外一个心得是:边界条件不要靠背,要靠推演。网上很多题解会告诉你“注意 n 等于链表长度的情况”,但如果你只是把这个结论记下来,下次遇到类似的题照样会踩坑。更好的做法是把边界场景当成一个独立的输入,手动模拟一遍流程。比如这道题,你就在纸上写链表1 -> 2、n=2,一步步走双指针,看看 fast 停在哪儿、slow 停在哪儿、删除后的链表长什么样。做过一遍,这个边界就永远是自己的了。

还有一个值得留意的点:千万不要带着“会做了一道题”的满足感就收工。链表题的变体很多,但底层能力是相通的。“删除倒数的节点”这道题,最值得沉淀的能力是双指针的间距控制——这个能力在找中点、判环、旋转链表、找交点这些题目里全部用得上。练一道题,能顺手掌握一组题的解法逻辑,投入产出比其实非常高。

如果你刚开始刷链表,建议从这题入手之后,紧接着找“链表中点”“链表环检测”“两个链表的交点”这三道题做组合练习。四道题全部吃透后,你会发现自己再看链表的视角已经不一样了——不再是一堆指针的操作拼凑,而是一套有章法的空间位置推演。这种“质变”,才是每天刷一道题的意义所在。下次再遇到“每日一题”,别急着看答案,先画图,再推演,最后写码,这套流程走完,题才算真正进了你的脑子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 3:16:06

STM32L0x1低功耗ADC设计:时钟、校准与采样避坑指南

简介&#xff1a;低功耗 STM32L0x1 ADC 示例程序包&#xff0c;面向基于 ARM Cortex-M0 超低功耗 MCU 的模拟量采集场景&#xff0c;适合电池供电的物联网节点、传感器数据采集项目&#xff0c;也适合想掌握低功耗外设配置的嵌入式开发者。压缩包约 4.6MB&#xff0c;共 178 个…

作者头像 李华
网站建设 2026/10/19 3:16:02

从协议到生产:企业级MCP服务实战指南

最近大模型应用圈里最热的话题&#xff0c;莫过于 Model Context Protocol&#xff0c;也就是常说的 MCP。很多人把它当成又一个新框架来学&#xff0c;这没错&#xff0c;但如果你真想把 MCP 服务从本地 Demo 变成能扛住生产流量的企业级服务&#xff0c;里面要补的东西比想象…

作者头像 李华
网站建设 2026/10/19 3:15:53

具身智能中的协同机理研究(143):TVA-VLA边缘部署三重挑战全景图

前沿技术探索&#xff1a;TVA智能体&#xff08;简称TVA&#xff09; TVA智能体&#xff08;亦称“AI智能体视觉”&#xff09;是依托Transformer架构与“因式智能体”理论构建的新型工业视觉系统&#xff0c;也是当前最具代表性的具身视觉技术之一。它有机融合深度强化学习&a…

作者头像 李华
网站建设 2026/10/19 3:15:41

海康摄像机OSD字符叠加实战:BCB6.0通过ISAPI协议动态配置字幕

简介&#xff1a;面向海康网络高清摄像机二次开发工程师&#xff0c;该例程演示在BCB6.0集成开发环境中通过官方SDK实现字符叠加OSD功能。资源内含完整的“海康IP网络高清叠加测试”工程&#xff0c;源码包含DSCamerOSDTest.cpp主程序、UnitMain窗体单元、HCNetSDK头文件和多个…

作者头像 李华
网站建设 2026/10/19 3:15:27

PyTorch实战:2卷积1全连接实现MNIST手写数字识别

1. 从一行标题拆出来的完整项目思路1.1 这个项目到底在做什么先把标题拆开看&#xff1a;PyTorch 实现 MNIST 手写数字识别&#xff0c;网络结构是 2 个卷积层加 1 个全连接层。这句话信息量其实不小&#xff0c;它同时锁定了四件事&#xff1a;框架用 PyTorch&#xff0c;数据…

作者头像 李华