删除链表倒数第N个节点:双指针与边界处理全解 很多人第一眼看到“删除链表倒数第N个节点”这道题第一反应都是“太简单了”。但我在实际面试和带新人的过程中发现这道题恰恰是链表这类题目里最能拉开差距的一道能写出来的人不少能把边界情况处理干净、能把双指针思路讲明白的人却不多。如果你正在准备算法面试、复习数据结构或者想把链表的基础补扎实这道题值得花时间吃透。这篇文章我会从最基础的思路讲起一直延伸到双指针、边界陷阱和工程场景全部是实操经验。1. 这道题到底在考什么链表思维的三层递进1.1 基础层单链表只有一个方向的现实约束先回到链表本身。和数组不同单链表里的每个节点只保存了指向下一个节点的指针没有指向前一个节点的指针。也就是说你只能顺着“a.next b”这条链从前往后走没法从最后一个节点回头找倒数第二个节点。题目要删除“倒数第N个节点”乍一听是个很自然的描述我们日常里说“倒数第几个”的时候脑子里是知道总数的比如一排有10个人倒数第2个就是正数第9个。但链表恰恰不给你这个“总数”的上下文。你站在头节点面前看不到链表的尽头更不知道它有多长。这个问题转换一下就是在不支持随机访问、不能从尾部反向遍历的约束下如何拿到一个从尾部数的位置。很多人写这道题时卡住不是不会写代码而是没意识到“倒数”这个概念在单链表里天然是反直觉的。理解了这一层约束你才算真正进入了链表思维的门。1.2 进阶层从“倒数”到“双指针”的思路转换“倒数第N个”对应“正数第 length - N 1 个”。一旦你算出链表长度删除操作就变成了正数定位这是最容易想到的解法也是所有解法的基础。但在面试场景里面试官大概率会追问一句“能不能只遍历一次”这时候就需要转换思路能不能用一个“前后有一定间距”的标记组合让前面的标记先走N步然后两个标记一起走当前面的标记触底时后面的标记自然就落在目标位置附近这种“间距固定的双指针”思想不只在链表题里管用在很多数组类问题、滑动窗口类问题里都是核心工具。1.3 面试层为什么这道题是高频题我见过很多企业面试题库里都有这道题它被反复选中的原因很实在代码量不大十分钟内能做完但考察点非常密集——链表遍历、指针操作、边界处理、空间时间权衡、以及“能不能从O(2L)优化到O(L)”的思路演进。一个候选人能不能把这道题写干净足以看出他写链表代码的熟练度和对边界条件的敏感度。所以这道题不是一个“会了就会了”的题它更像一把尺子量的是你处理指针类问题的工程手感。2. 解法一两次遍历先求长度保底方案也有讲究2.1 算法步骤与核心公式第一次遍历链表统计节点总数记为 length。假设我们要删除倒数第N个节点那么它的正数位置是targetIndex length - N 1要删除第 targetIndex 个节点我们需要找到它的前驱节点也就是第 targetIndex - 1 个节点即第 length - N 个节点。找到前驱后让前驱的 next 直接跨过目标节点prev.next target.next这就是“两次遍历”解法的主干。第一次遍历算长度第二次遍历定位前驱然后执行删除。有个细节值得单独说为什么要找前驱而不是直接找目标节点因为单链表删除的本质是“让前一个节点改指后一个节点”。如果不保存前驱就算你走到了目标节点也没法把上一个节点指过来。除非你把链表设计成双向的或者额外存一个 prev 引用否则单链表删除一定绕不开“前驱”这个概念。2.2 代码实现与边界处理以 JavaScript 为例链表节点定义通常长这样function ListNode(val, next) { this.val val undefined ? 0 : val; this.next next undefined ? null : next; }两次遍历的完整实现var removeNthFromEnd function(head, n) { let length 0; let cur head; while (cur) { length; cur cur.next; } let dummy new ListNode(0, head); let prev dummy; for (let i 0; i length - n; i) { prev prev.next; } prev.next prev.next.next; return dummy.next; };这里我故意引入了 dummy 节点后面会详细说它的作用。核心逻辑只有两步while 循环数出 lengthfor 循环走到前驱位置。注意 for 循环走了 length - n 步不是 length - n 1 步因为我们要停在目标节点的前一个节点。Python 版本逻辑完全一致def remove_nth_from_end(head, n): def get_length(node): length 0 while node: length 1 node node.next return length length get_length(head) dummy ListNode(0, head) prev dummy for _ in range(length - n): prev prev.next prev.next prev.next.next return dummy.next2.3 复杂度分析为什么O(L)就是下限两次遍历的时间复杂度是 O(L) O(L - n)也就是 O(L)。空间复杂度 O(1)。从大O的角度看这已经是最优的复杂度了因为无论如何你起码得走一遍链表才能知道哪里是倒数第N个。但在实际面试时面试官问“能不能一次遍历”并不是因为两次遍历“复杂度超标”而是想看你有没有能力在遍历的同时记录额外信息。就好比你去一个不熟的街区找门牌号第一次去踩点数清楚门牌第二次再上门这件事并不低效但如果你能在第一次路过时就顺手干完显然是更聪明的做法。两次遍历解法最大的价值在于它是最不容易出错的保底方案也是理解所有进阶解法的基础。我在面试别人时如果候选人在五到十分钟内把两次遍历版本干净利落地写出来我都会先给一个基础分。但我也一定会接着问“怎么改成一次遍历”下面这个解法才是这道题真正的高光部分。3. 解法二双指针一次遍历把间距提前拉开3.1 双指针的原理制造N1的偏移双指针解法也叫快慢指针解法。核心思想是先让快指针提前走 N1 步然后快慢指针一起一步一步走。当快指针到达链表末尾null时慢指针刚好停在倒数第 N1 个节点上也就是目标节点的前驱直接执行删除。为什么是 N1 而不是 N我见过很多初写这道题的人在这里纠结。如果让快指针提前走 N 步然后快慢一起走当快指针到达末尾时慢指针停在倒数第 N 个节点上也就是目标节点本身。可我们要删除的是目标节点单链表删除需要前驱这样就还需要额外维护一个 prev。更麻烦的是如果目标节点本身就是头节点prev 就不存在又要单独处理。让快指针提前走 N1 步慢指针最终就会落在目标节点的前一个节点上这一下就把“找前驱”和“统一处理头节点”两个问题同时解决了。这里 N1 是决定代码简洁程度的关键。还有一点细节如果链表长度恰好等于 N快指针先走 N1 步时快指针已经走出链表变成了 null。这种情况并不用慌慢指针此时还停在 dummy 节点上而 dummy 本来就是用来兜住“删除头节点”这个场景的。快指针为 null 就让它继续“走”慢指针正好就位。3.2 图解推演以 1-2-3-4-5 为例假设链表是 1 - 2 - 3 - 4 - 5要删除倒数第 2 个节点也就是值为 4 的节点。第一步建立 dummy 节点dummy.next 指向头节点 1。慢指针 slow 指向 dummy快指针 fast 也指向 dummy。第二步fast 先走 N1 3 步从 dummy 走到 1、走到 2、走到 3。此时 slow 还在 dummy 位置fast 在 3。第三步slow 和 fast 一起走。fast 从 3 走到 4、走到 5、再走到 null总共 3 步slow 从 dummy 走到 1、走到 2、走到 3。此时 fast 为 nullslow 停在节点 3 上。节点 3 恰好是目标节点 4 的前驱。然后执行 slow.next slow.next.next把节点 3 的下一个指针从 4 改成 54 就从链表中被摘掉了。最后返回 dummy.next得到 1 - 2 - 3 - 5。这里要注意的是网上有些图示会说“快指针先走 N 步”然后慢指针指向目标节点再额外用一个 prev 记录前一个节点。那种写法也能做对但代码分支更多尤其头节点删除时要单独处理。对比之下先走 N1 步配合 dummy 节点是用“一步多余间距”换来了“全程无特判”我个人强烈推荐这个写法。3.3 代码实现与细节注释var removeNthFromEnd function(head, n) { let dummy new ListNode(0, head); let fast dummy; let slow dummy; // 先让快指针走 n1 步 for (let i 0; i n; i) { fast fast.next; } // 快慢指针一起走 while (fast) { fast fast.next; slow slow.next; } // slow 此时指向倒数第 n1 个节点即目标节点的前驱 slow.next slow.next.next; return dummy.next; };Python 版本def remove_nth_from_end(head, n): dummy ListNode(0, head) fast dummy slow dummy for _ in range(n 1): fast fast.next while fast: fast fast.next slow slow.next slow.next slow.next.next return dummy.next这段代码最需要注意的地方在两个地方一是 for 循环的边界是 i n总共走 n1 步二是 while 循环的条件是 fast 不为 null而不是 fast.next 不为 null。如果条件写成 fast.next ! nullfast 会停在最后一个节点而不是走出链表slow 就会落后一个位置删除的节点就错了。从复杂度上看双指针解法时间仍然是 O(L)空间 O(1)。它的优势在于只遍历了一次链表。严格来说快指针和慢指针加起来走过了 2L 个节点移动但在大O符号下仍然是 O(L)而且“只遍历一次”在面试描述里是一个更漂亮的答案。4. 边界条件与踩坑清单删除头节点、n等于链表长度、非法n4.1 dummy节点的价值统一处理头节点删除单链表删除有一个很常见的坑删除头节点时原来的 head 会失效返回的头节点就错了。很多解法为了处理它会单独写一个 if 分支比如“如果目标节点是头节点就返回 head.next”。分支一多代码就容易漏。dummy 节点的出现就是为了把这个特判消灭掉。我们新建一个假头节点让它指向真正的 head所有节点删除逻辑都统一变成“让某个节点的 next 跨过一个节点”。不管删除的是不是原链表的头节点最后直接返回 dummy.next 永远是正确的。这也是很多工业级链表代码里惯用的技巧不止这道题能用。以两次遍历解法为例没有 dummy 的写法要这样处理if (length n) { return head.next; }单独写一个分支不难但一旦题目变体增多忘记了这条分支程序就会在边界输入上报错。有了 dummy这段特判就不存在了。4.2 四类边界场景逐一拆解第一类场景删除的是末尾节点即 n 1。链表 1 - 2 - 3删除倒数第 1 个节点就是要删掉 3。双指针解法下fast 先走 2 步从 dummy 到 1、到 2。然后 fast 和 slow 一起走fast 到 3、到 null总共再走 2 步slow 从 dummy 到 1、到 2。slow 停在 2执行 slow.next slow.next.next也就是 2.next 从 3 变成 null删除完成。这个场景最容易错的地方是循环结束后想当然地认为 slow 指向目标节点直接删除 slow结果链表结构被破坏。要时刻记住你是用前驱来删的。第二类场景删除头节点即 n 等于链表长度。链表 1 - 2 - 3n 3删除倒数第 3 个节点就是删除 1。双指针下 fast 先走 4 步从 dummy 到 1、到 2、到 3、再到 null。由于 fast 已经是 nullwhile 循环不执行slow 保持在 dummy。然后 slow.next slow.next.next也就是 dummy.next 从 1 变成 2相当于删除了原头节点。最后返回 dummy.next得到 2 - 3。如果没有 dummy这个场景就要单独思考 head 怎么处理写起来容易乱。第三类场景n 非法比如 n 0 或 n 链表长度。题目通常约定 1 n length但实际工程里我们不能假设输入一定是合法的。如果面试官说要考虑健壮性可以在函数开头校验一下 n或者在双指针里通过 fast 是否提前为 null 来感知“n 太大”。我建议的做法是在移动 fast 的循环中如果走到一半 fast 已经为 null 且步数还没走完可以抛出一个异常或返回 null看面试官的接受程度。不过绝大多数刷题场景中你只需要在开头添加一句 if (head null || n 0) return head; 就足够了。第四类场景空链表。head 为 null 时没有节点可以删除。标准解法里dummy 创建出来后fast slow dummyfast 走 N1 步时直接变 nullwhile 不执行slow.next 为 null执行 slow.next slow.next.next 时就会报空指针错误。所以空链表需要在函数开头单独处理。实际面试中空链表这个用例出现的概率极高小公司面试尤其喜欢用空输入来考察候选人是否细心。4.3 测试用例设计与自检思路我每次写完这道题都会在脑海里跑至少五组用例这个习惯帮我抓出过很多隐蔽的 bug链表只有一个节点n 1删除后应该返回 null。链表有两个节点n 1删除末尾节点。链表有两个节点n 2删除头节点。链表有五个节点n 3删除中间节点。n 非法或 head 为空。你可以在本地用一个小工具函数把链表数组化方便对比输出function listToArray(head) { const res []; while (head) { res.push(head.val); head head.next; } return res; }构造链表的辅助函数也建议写一下这套工具在链表类题目里能复用很多次function arrayToList(arr) { let dummy new ListNode(0); let cur dummy; for (const val of arr) { cur.next new ListNode(val); cur cur.next; } return dummy.next; }测试时把原链表转成数组调用 removeNthFromEnd把结果再转成数组对比预期值几分钟就能覆盖所有边界场景。5. 变体解法与工程联想递归、栈、以及真实场景中的“倒数删除”5.1 递归解法回溯计数可行但需要警惕深度递归的思路很巧妙先递归到链表末尾然后在回溯过程中计数当计数等于 N1 时当前节点的 next 就是要删的节点于是把 next 指向 next.next。var removeNthFromEnd function(head, n) { let count 0; let dummy new ListNode(0, head); function dfs(node) { if (!node) return null; dfs(node.next); count; if (count n 1) { node.next node.next.next; } } dfs(dummy); return dummy.next; };这个解法适合用来向面试官展示你对递归和调用栈的理解但实际工程里我并不推荐。原因很简单链表长度不确定如果链表有几万个节点递归深度就跟链表长度一样大很可能触发调用栈溢出。比较长的链表在真实系统里非常常见所以递归解法更多是理论正确、实践有风险。5.2 栈解法用额外空间换取逻辑简单还有一种思路是把所有节点压入栈中然后弹出 N 个节点弹到第 N 个时栈顶就是它的前驱。var removeNthFromEnd function(head, n) { let dummy new ListNode(0, head); let stack []; let cur dummy; while (cur) { stack.push(cur); cur cur.next; } for (let i 0; i n; i) { stack.pop(); } let prev stack[stack.length - 1]; prev.next prev.next.next; return dummy.next; };栈解法的好处是逻辑非常直白我先把所有节点记住取出倒数位置时就有了上下文。代价是空间复杂度从 O(1) 变成了 O(L)。如果链表很长这个额外空间是实打实的成本。做题时可以作为参考答案之一但面试时我更推荐先说双指针。5.3 工程联想从算法题到消息队列与日志淘汰你可能觉得链表删除倒数第 N 个节点只是面试题但抽象一下这种“从尾部数第 N 个元素并处理”的需求在工程里并不少见。比如消息队列里要放弃最老的 N 条消息或者日志系统要批量丢弃若干条过期的记录这些数据结构的底层往往是链表或者类似链表的链式结构。“倒数第 N 个”本质上是对“从尾部开始的偏移量”的操作双指针在这里依然是有效的窗口技巧。又比如在实现撤销功能时需要回到历史操作列表的倒数第 N 步思路也同源。更重要的是这道题教给你一个通用模板链表题的边界大多集中在头部和尾部而 dummy 节点可以统一掉头部边界。我把这个模板应用在反转链表、合并有序链表、寻找中点等题目上写代码的自信心提高了很多。如果你正在用“每日一题”的方式刷题我的建议是这道题不要只看题解一定要自己手写一遍然后故意把 fast 先走的步数改成 N 试试看看代码会在哪个测试用例上报错。亲手踩过一次坑比背诵十遍正确答案都要记得牢。