图 1:本文封面,点明题目与「先拉开间距再同步推进」这个核心动作。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「删除链表倒数第 N 个节点」是链表题里的高频款,题面一句话,考点却藏在边界里——很多人能写出答案,但删头节点就翻车。
想象面试现场:给定一个单链表和一个整数 N,删除倒数第 N 个节点,返回头节点。要求:只遍历一趟。
第一步,先给出两趟遍历的基准解。
直觉解法:第一趟从头到尾数出链表长度 L,第二趟走到第 L - N 个节点(待删节点的前驱),把它的 next 指向跳过下一个节点。正确,但走了两趟。这个解法的价值在于暴露了本质:「倒数第 N 个」和「正数第 L - N 个」是同一个位置,问题是怎么在不事先知道 L 的情况下找到它。
第二步,核心思路:哑节点加固定间距双指针。
把「倒数」换算成「间距」。准备两个指针 slow 和 fast,先让 fast 领先 slow 整整 N 步,然后两个指针以同样的速度一起往前走。当 fast 走到链表末尾时,slow 落后它 N 步——slow 的下一个节点,正好就是倒数第 N 个。一趟走完,删除动作顺带完成。
哑节点(dummy)是这套写法的另一半灵魂。删除操作需要拿到待删节点的前驱,可如果要删的恰好是头节点(N 等于链表长度),头节点没有前驱,代码就得为这种情况单开分支。在表头前面挂一个哑节点,slow 和 fast 都从哑节点出发,「删头」就变成了和删任何节点一模一样的操作:slow.next = slow.next.next。边界情况被结构本身消化掉,而不是靠 if 补丁。
间距控制有一个关键细节:fast 先走的必须是 N 步而不是 N+1 步。从哑节点出发走 N 步之后,fast 领先 slow 的节点数恰好是 N,fast 到尾时 slow 正好停在待删节点的前驱上——多走一步,slow 就停在了待删节点自己身上,删错位置。
第三步,写出可运行的代码并实测边界。
下面这份实现用 Node.js 22.15.0 直接运行(node nth-from-end.js)即可,内置四组边界用例:删中间、删头、删尾、单节点删成空表,外加 N 超过长度的非法输入:
'use strict';
function buildList(values) {
const dummy = { val: null, next: null };
let tail = dummy;
for (const v of values) {
tail.next = { val: v, next: null };
tail = tail.next;
}
return dummy.next;
}
function toArray(head) {
const out = [];
for (let p = head; p; p = p.next) out.push(p.val);
return out;
}
// 一趟遍历删除倒数第 n 个节点,返回新头节点
function removeNthFromEnd(head, n) {
if (!Number.isInteger(n) || n < 1) throw new RangeError('n 必须是 >= 1 的整数');
const dummy = { val: null, next: head }; // 哑节点:统一「删头节点」的边界
let slow = dummy;
let fast = dummy;
for (let i = 0; i < n; i++) { // fast 先走 n 步,拉开固定间距
if (!fast.next) throw new RangeError('n 超过链表长度');
fast = fast.next;
}
while (fast.next) { // 同步推进,fast 到尾时 slow 停在待删节点的前驱
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next; // 绕过目标节点
return dummy.next;
}
const cases = [
{ input: [1, 2, 3, 4, 5], n: 2, expect: [1, 2, 3, 5] },
{ input: [1, 2, 3, 4, 5], n: 5, expect: [2, 3, 4, 5] }, // 删头节点
{ input: [1, 2, 3, 4, 5], n: 1, expect: [1, 2, 3, 4] }, // 删尾节点
{ input: [7], n: 1, expect: [] }, // 单节点删成空表
];
for (const c of cases) {
const got = toArray(removeNthFromEnd(buildList(c.input), c.n));
const ok = JSON.stringify(got) === JSON.stringify(c.expect);
console.log(`删除 [${c.input}] 倒数第 ${c.n} 个 -> [${got}] ${ok ? '通过' : '失败'}`);
}
try {
removeNthFromEnd(buildList([1, 2]), 3);
} catch (e) {
console.log(`n 超过长度时:${e.name}: ${e.message}`);
}
实测输出:
删除 [1,2,3,4,5] 倒数第 2 个 -> [1,2,3,5] 通过
删除 [1,2,3,4,5] 倒数第 5 个 -> [2,3,4,5] 通过
删除 [1,2,3,4,5] 倒数第 1 个 -> [1,2,3,4] 通过
删除 [7] 倒数第 1 个 -> [] 通过
n 超过长度时:RangeError: n 超过链表长度
图 2:四步走完一趟删除,哑节点与间距 N 各管一件事。
可能的追问与易错点。
「时间空间复杂度?」时间 O(L),只走一趟;空间 O(1),只用了两个指针加一个哑节点。「N 非法怎么办?」本文代码在 fast 探路时就拦下了 N 超过长度的情况,直接抛异常——面试时主动提这一嘴是加分项,说明你想到了题目没说的输入。易错点集中在三处:不用哑节点导致删头节点单独写分支还容易写错;fast 先走 N+1 步导致 slow 停错位置;忘记判断 fast 提前走到空,N 超长度时访问空指针。
这套「先拉开固定间距、再同步推进」的技巧不止属于链表题:滑动窗口求第 K 大、流式数据里截取末尾一段缓冲、推理流式输出时的窗口回退,都是同一个动作的变体——间距就是你的「倒数第 N」。回到面试现场,建议的答题节奏:先给两趟基准解并指出瓶颈,再引出间距换算,主动用哑节点消化边界,最后落笔写码并把 N 的非法输入交代清楚。你在链表题上踩过边界的坑吗?欢迎分享。