资讯动态

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

发布时间:2026/10/19 3:16:17 来源:尧图企业网站定制
1. 题目本质与适用场景“删除链表倒数第N个节点”这道题基本是链表入门阶段绕不开的一道坎。它的题目描述很简短给定一个单链表删除倒数第N个节点并返回链表的头节点。看起来只有一句话但真正动起手来很多人在边界条件上栽跟头——比如删除的是头节点怎么办N恰好等于链表长度怎么办链表只有一个节点又该怎么办。先说一个很多人没意识到的点这道题考的根本不只是“会不会删除节点”而是三个底层能力的复合体——对链表内存结构的理解、双指针技巧的灵活运用、以及边界条件的防御性编程意识。在算法面试或日常开发里这三个能力恰恰是最常被考察的所以这道“每日一题”的价值远远超过它表面那点代码量。从实际应用场景来说这种“倒数第N个”的需求在业务代码里并不罕见。比如一个消息队列要保留最近N条记录超出部分批量清理再比如日志系统要做滚动删除只留最新的N个文件还有撤销栈的深度限制、连接池里淘汰最老的连接……只要数据结构是链表或者链式结构你随时都可能写出类似逻辑。这道题练熟了后面碰到“找链表中点”“判断链表是否有环”“旋转链表”这些经典题目其实都是一套思路的变体。适合什么人拿来练手我个人建议有三类人可以重点刷这道题一是刚学完链表基本操作、想找个有挑战但不超纲的题目巩固基础的新手二是面试前想快速过一遍链表高频考点的求职者这道题的出镜率相当高三是在业务里写过链表相关逻辑但总被边界问题坑到的开发者刷完你会对自己代码的健壮性有新的感知。2. 核心思路拆解为什么双指针是这道题的最优解2.1 最直观的思路是两次遍历但痛点很明显拿到这道题第一反应大概率是朴素的暴力解法先遍历一遍链表数出总长度L然后倒数第N个节点其实就是正数第 L-N1 个节点删除它需要找到它的前驱也就是第 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 个节点的前驱。为什么我们做个简单推导链表长度为 Lfast 先走了 N 步剩余距离是 L - N 步。fast 从当前位置走到末尾还需要走 L - N 步。两个指针同步移动时slow 同样走了 L - N 步。之前 slow 还在头节点可以理解为从头部开始走了0步现在它停在第 L-N 个节点的位置而倒数第 N 个节点是正数第 L-N1 个第 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, val0, nextNone): 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 个位置正好是倒数第 n1 个节点——也就是倒数第 n 个节点的前驱。这个“多走一步”和“少走一步”的微妙差别是代码是否正确的分水岭。细节二返回值为什么是 dummy.next 而不是 head如果删除的节点就是原来的头节点head 这个指针已经指向了被删除的节点虽然它本身还在内存里但已经从链表中断开了直接返回 head 就错了。而 dummy.next 永远指向删除之后的新头节点无论头节点有没有被删它都是安全的。这也是虚拟头节点除了消除特判之外的第二个重要价值。3.3 核心步骤的推演与验证我们用例子走一遍感受这套逻辑的运转过程。假设链表是1 - 2 - 3 - 4 - 5要删除倒数第 2 个节点也就是值为 4 的节点。建立 dummy 节点dummy.next 指向节点1fast 和 slow 都先指向 dummy。fast 走 2 步第一次走到节点1第二次走到节点2。此时 fast 在节点2slow 在 dummy。循环开始fast.next 不为空两个指针同步走fast 走到节点3slow 走到节点1fast 走到节点4slow 走到节点2fast 走到节点5slow 走到节点3fast.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 - 3n3。不加虚拟头节点的话慢指针会站在头节点上而不是前驱直接slow.next slow.next.next改的是头节点的后一个节点逻辑完全错位。加了 dummy 之后这个问题自动消失但前提是你记得返回的是 dummy.next。场景二链表只有一个节点链表1n1。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 - 4n4。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 本来就指向 nullfast 走 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 的异常循环条件选择错误对照 nL 的场景用 test case 手动跑一遍第一条排查方向尤其值得多说两句。很多人以为返回 head 和返回 dummy.next 是等价的实际上只有当头节点没被删除时它们才等价。一旦删除的恰好是头节点head 指向的那块内存已经被“逻辑摘除”虽然节点对象还在但它已经不属于链表的一部分了。刷算法题时返回一个悬挂的非链表节点轻则答案错误重则遍历时死循环。4.3 手写代码时的高频失误点失误一给 fast 赋值时忘了从 dummy 出发有人从 head 出发先走 n 步同时 slow 从 dummy 出发这会破坏快慢指针的初始间距导致极难察觉的位置偏差。写成fast head后 fast 走在真实节点上slow 却站在 dummy 上两者间距变成了 n1最后 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 head5. 从这道题延伸出的链接技巧与变体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、n2一步步走双指针看看 fast 停在哪儿、slow 停在哪儿、删除后的链表长什么样。做过一遍这个边界就永远是自己的了。还有一个值得留意的点千万不要带着“会做了一道题”的满足感就收工。链表题的变体很多但底层能力是相通的。“删除倒数的节点”这道题最值得沉淀的能力是双指针的间距控制——这个能力在找中点、判环、旋转链表、找交点这些题目里全部用得上。练一道题能顺手掌握一组题的解法逻辑投入产出比其实非常高。如果你刚开始刷链表建议从这题入手之后紧接着找“链表中点”“链表环检测”“两个链表的交点”这三道题做组合练习。四道题全部吃透后你会发现自己再看链表的视角已经不一样了——不再是一堆指针的操作拼凑而是一套有章法的空间位置推演。这种“质变”才是每天刷一道题的意义所在。下次再遇到“每日一题”别急着看答案先画图再推演最后写码这套流程走完题才算真正进了你的脑子。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑