很多人第一眼看到“删除链表倒数第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 循环数出 length,for 循环走到前驱位置。注意 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 双指针的原理:制造N+1的偏移
双指针解法也叫快慢指针解法。核心思想是先让快指针提前走 N+1 步,然后快慢指针一起一步一步走。当快指针到达链表末尾(null)时,慢指针刚好停在倒数第 N+1 个节点上,也就是目标节点的前驱,直接执行删除。
为什么是 N+1 而不是 N?我见过很多初写这道题的人在这里纠结。如果让快指针提前走 N 步,然后快慢一起走,当快指针到达末尾时,慢指针停在倒数第 N 个节点上,也就是目标节点本身。可我们要删除的是目标节点,单链表删除需要前驱,这样就还需要额外维护一个 prev。更麻烦的是,如果目标节点本身就是头节点,prev 就不存在,又要单独处理。
让快指针提前走 N+1 步,慢指针最终就会落在目标节点的前一个节点上,这一下就把“找前驱”和“统一处理头节点”两个问题同时解决了。这里 N+1 是决定代码简洁程度的关键。
还有一点细节:如果链表长度恰好等于 N,快指针先走 N+1 步时,快指针已经走出链表变成了 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 先走 N+1 = 3 步:从 dummy 走到 1、走到 2、走到 3。此时 slow 还在 dummy 位置,fast 在 3。
第三步,slow 和 fast 一起走。fast 从 3 走到 4、走到 5、再走到 null,总共 3 步;slow 从 dummy 走到 1、走到 2、走到 3。
此时 fast 为 null,slow 停在节点 3 上。节点 3 恰好是目标节点 4 的前驱。然后执行 slow.next = slow.next.next,把节点 3 的下一个指针从 4 改成 5,4 就从链表中被摘掉了。最后返回 dummy.next,得到 1 -> 2 -> 3 -> 5。
这里要注意的是,网上有些图示会说“快指针先走 N 步”,然后慢指针指向目标节点,再额外用一个 prev 记录前一个节点。那种写法也能做对,但代码分支更多,尤其头节点删除时要单独处理。对比之下,先走 N+1 步配合 dummy 节点,是用“一步多余间距”换来了“全程无特判”,我个人强烈推荐这个写法。
3.3 代码实现与细节注释
var removeNthFromEnd = function(head, n) { let dummy = new ListNode(0, head); let fast = dummy; let slow = dummy; // 先让快指针走 n+1 步 for (let i = 0; i <= n; i++) { fast = fast.next; } // 快慢指针一起走 while (fast) { fast = fast.next; slow = slow.next; } // slow 此时指向倒数第 n+1 个节点,即目标节点的前驱 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,总共走 n+1 步;二是 while 循环的条件是 fast 不为 null,而不是 fast.next 不为 null。如果条件写成 fast.next != null,fast 会停在最后一个节点而不是走出链表,slow 就会落后一个位置,删除的节点就错了。
从复杂度上看,双指针解法时间仍然是 O(L),空间 O(1)。它的优势在于只遍历了一次链表。严格来说,快指针和慢指针加起来走过了 2L 个节点移动,但在大O符号下,仍然是 O(L),而且“只遍历一次”在面试描述里是一个更漂亮的答案。
4. 边界条件与踩坑清单:删除头节点、n等于链表长度、非法n
4.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 -> 3,n = 3,删除倒数第 3 个节点就是删除 1。双指针下 fast 先走 4 步,从 dummy 到 1、到 2、到 3、再到 null。由于 fast 已经是 null,while 循环不执行,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 = dummy,fast 走 N+1 步时直接变 null,while 不执行,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 递归解法:回溯计数可行但需要警惕深度
递归的思路很巧妙:先递归到链表末尾,然后在回溯过程中计数,当计数等于 N+1 时,当前节点的 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 试试,看看代码会在哪个测试用例上报错。亲手踩过一次坑,比背诵十遍正确答案都要记得牢。