
刷 LeetCode 链表题的时候有一道题几乎绕不过去——删除链表的倒数第 N 个结点。我看标题里写的是 106其实这道题在题库里的编号是 19可能作者记混了题号但题目内容没有任何歧义给你一个单链表删除倒数第 N 个结点返回头结点。这题看起来简单但真动手写的时候边界条件多到让人怀疑人生。尤其是“倒数”这两个字天然地跟单向链表“只能从头往后走”的特性顶牛如果不理解双指针背后的几何含义很容易写出一个“能跑但不敢保证边界”的版本。我最早刷这题的时候用的还是先遍历一遍求长度、再遍历一遍删除的笨办法。当时觉得挺顺理成章直到后来在面试里被追问“你能不能一次遍历就搞定”才意识到自己根本没吃透这道题真正想考的东西。今天就把我从翻车到理解的全过程整理出来包含完整可运行的代码、边界条件的逐个扫雷以及从这题延伸出去的链表解题思路希望对正在刷题或者准备面试的朋友有帮助。1. 先用“两次遍历”把题做出来倒数问题的笨办法也有价值1.1 为什么“倒数第 N 个”在单向链表里很别扭数组里要删除倒数第 N 个元素直接按下标算就行因为数组支持随机访问。但单链表每个结点只存了 next 指针你没法回头也没法跳到任意位置。你站在头结点面前根本不知道链表有多长更别说倒数第 N 个到底在哪。所以第一步要解决的问题是把“倒数”转化成“正数”。这个转化非常直观——假设链表总长度为 L倒数第 N 个结点就是正数第 L - N 1 个结点。比如链表长度为 5倒数第 2 个结点就是正数第 4 个结点。那么这个转化最大的价值是什么它意味着我们只需要知道链表的长度 L就能把题目变成一个标准的“删除第 k 个结点”的问题而后者是链表操作里最基础的操作。1.2 两次遍历的完整代码与逻辑拆解第一次遍历统计链表长度第二次遍历找到待删除结点的前驱结点修改 next 指针完成删除。这个思路翻译成代码大概是这样的class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class Solution: def removeNthFromEnd(self, head: ListNode, n: int) - ListNode: # 第一次遍历计算链表长度 length 0 cur head while cur: length 1 cur cur.next # 计算待删除结点的正数位置 target_index length - n # 这里 target_index 是待删除结点前驱的偏移量从0开始 # 如果要删的是头结点特殊处理 if target_index 0: return head.next # 第二次遍历找到前驱结点 prev head for _ in range(target_index - 1): prev prev.next # 删除目标结点 prev.next prev.next.next return head注意上面代码里 target_index 的计算如果 length 5, n 2那么 target_index 3表示从 head 开始往后走 3 步到达的结点是待删除结点的前驱。这里很多人容易把下标搞混建议自己在草稿纸上画一个 5 个结点的链表走一遍比死记公式管用得多。1.3 两次遍历的硬伤常数因子翻倍带来的面试追问两次遍历的时间复杂度是 O(L)空间复杂度 O(1)。从大 O 的角度看两次遍历和一次遍历复杂度一样都是线性。但面试官拿这题考你想看的往往不是“能跑”的代码而是你有没有想过“一次遍历”的解法。我当时被追问的时候第一反应是不可能吧不遍历一遍怎么知道长度不知道长度怎么知道倒数第 N 个在哪这就是典型的思维定势——你默认了必须提前知道长度。但双指针解法完全绕开了这个问题它根本不需要长度信息靠的是两个指针之间的相对距离。所以我建议你拿到这道题的时候先老老实实把两次遍历写一遍不是为了提交而是为了理解“倒数转正数”这个基本换算关系。然后你再去看双指针就会明白双指针到底优化了什么东西。2. 双指针解法拆解快指针先走 N 步到底意味着什么2.1 赛跑模型领先 N 步的快指针就是“倒数标尺”双指针解法的核心就一句话让一个指针先走 N 步然后两个指针同步移动当先走的指针到达链表末尾时后走的指针正好停在倒数第 N 个结点的前驱位置。你可以把它想象成两个人在赛跑快的人先出发了 N 米然后两个人以相同速度一起跑。当快的人到达终点时慢的人距离终点正好是 N 米。放在链表里这个“终点”就是链表末尾的空指针 None而“距离终点 N 米”的那个位置就是倒数第 N 个结点。关键在于慢指针停的位置是“倒数第 N 个结点的前驱”而不是倒数第 N 个结点本身。为什么因为删除一个结点必须拿到它的前驱修改前驱的 next 指针。如果你把慢指针精确地停在待删除结点上反而没法删了——单链表没有 prev 指针你拿不到前面的结点。这里有一个最常见的误区很多人写第一版双指针的时候让快指针先走 N 步然后快慢一起走快指针到 None 时慢指针正好在倒数第 N 个结点。然后他们发现需要前驱又倒回去找。所以正确的姿势是快指针先走 N 步然后快慢一起走当快指针到达 None 时慢指针停在倒数第 N 个结点的前驱。如果要让慢指针停在“前驱”位置操作上其实有两种写法我后面在边界条件里专门讲因为这正是最容易自我怀疑的地方。2.2 dummy node 为什么是这题的隐藏得分点边界条件的重头戏来了。如果倒数第 N 个结点正好是头结点比如链表长度 5要删除倒数第 5 个结点这时候待删除结点的前驱是不存在的。如果你用双指针直接操作 head就必须特判这种情况。特判本身不难但它破坏了代码的统一性——你会发现 for 循环里多了一个 if而且稍不注意就漏掉。通用的解法是引入一个虚拟头结点也就是 dummy nodeclass Solution: def removeNthFromEnd(self, head: ListNode, n: int) - ListNode: dummy ListNode(0, head) # 虚拟头结点next 指向 head fast dummy slow dummy # 快指针先走 n 1 步 for _ in range(n 1): fast fast.next # 快慢指针同步移动直到快指针到达 None while fast: fast fast.next slow slow.next # 此时 slow 指向待删除结点的前驱 slow.next slow.next.next return dummy.next注意这里快指针先走的是 n 1 步不是 n 步。因为我们要让 slow 最终停在待删除结点的前驱所以 fast 和 slow 之间需要保持 n 1 的距离。当 fast 从 dummy 出发先走 n 1 步后slow 还在 dummy此时 fast 在链表的第 n 1 个位置dummy 算第 0 个位置然后同时移动fast 到达 None 时slow 距离 fast 正好 n 1 步也就是 slow 在倒数第 n 1 个结点的位置那就是待删除结点的前驱。我第一次看到 n 1 的时候也很懵明明题目说倒数第 N 个为什么要多走一步我的理解方式是这样的从 dummy 出发走到 None整个距离是 L 1dummy 到最后一个结点是 L 步最后一步再从最后一个结点到 None 是 1 步。如果 fast 先走了 n 1那么当 fast 走完全程 L 1 时slow 走了 (L 1) - (n 1) L - n 步。slow 从 dummy 出发走 L - n 步到达的结点正好是倒数第 n 1 个结点也就是待删除结点的前驱。这个推导对每个 n 都成立不会出错。2.3 为什么 dummy node 能统一“删除头结点”的特判如果没有 dummy删除头结点的写法是 return head.next因为你没有前驱可改如果有 dummy头结点也变成了一个“普通结点”它的前驱就是 dummy所以代码里不需要任何 if 分支统一走 slow.next slow.next.next 就行最后返回 dummy.next。这一点特质在链表题目里极其重要因为很多链表题都涉及“头结点可能被修改”的情况。dummy node 相当于给链表加了一个假的头让所有结点都有前驱所有删除操作都变成了同一种操作。用什么词来形容呢——它就是个“统一逻辑的工具人”你给它一个身份它让你少写一个特判。我第一次看到这个写法时心里想的是这不就是加了个空结点糊弄人吗后来用多了才发现这不是糊弄这是把问题的规模缩小——你不再需要考虑“如果删的是头结点怎么办”因为有了 dummy头结点不再是特殊的了所有情况都是同一种情况。面试时能主动写出 dummy node通常会被视为“懂链表边界处理”的信号。3. 运行之前先扫雷边界条件逐个排查附实测翻车记录3.1 快指针先走 n 步还是 n 1 步用具体例子跑一遍这是评论区和讨论区出现频率最高的问题。先走 n 步和先走 n 1 步唯一的区别就是 slow 最后停的位置差一个结点。我用一个具体的链表来走一遍你一看就明白。链表1 - 2 - 3 - 4 - 5n 2目标是删除倒数第 2 个结点值为 4。方案甲先走 n 步fast 和 slow 都从 head 出发fast 先走 2 步到达值为 3 的结点fast 和 slow 一起走fast 走到 None 时slow 在值为 4 的结点也就是待删除结点本身这时候你想删除它但它没有前驱信息你只能继续往后走一个找到它的前驱这等于白白多了一步操作方案乙先走 n 1 步fast 和 slow 都从 dummy 出发fast 先走 3 步到达值为 3 的结点fast 和 slow 一起走fast 走到 None 时slow 在值为 3 的结点待删除结点的前驱直接 slow.next slow.next.next删除完成所以结论很清楚你先走 n 1 步slow 就会停在“前驱”你先走 n 步slow 会停在“自己”还得想办法找前驱。如果你确实想让 slow 停在待删除结点也可以但代码里需要额外用一个变量记录 slow 的前一个结点属于绕远路。3.2 N 等于链表长度时删除的是头结点继续用上面这个链表n 5要删除倒数第 5 个结点也就是值为 1 的头结点。从 dummy 出发fast 先走 6 步n 1 6注意 5 个结点的链表从 dummy 到 None 一共走 6 步所以 fast 先走 6 步后已经到 None 了。然后 while fast 循环一次都不会执行slow 停在 dummy 不动。接着 slow.next slow.next.next就是把 dummy 的 next 指向原链表的第二个结点相当于删除了原来的头结点。返回 dummy.next也就是新的头结点。这个案例完美展示了 dummy 的价值如果没有 dummy用 head 作为起点fast 先走 n 步会不会是 None我们来想如果 fast 从 head 出发走 5 步到达 None然后 fast 和 slow 一起走fast 已经是 Nonewhile 循环不执行slow 停在 head。这时候执行 slow.next slow.next.nexthead 的下一个结点是第二个结点这是不对的——你把第二个结点删了。要处理头结点删除还得加 if。有了 dummy这个问题自动消失。3.3 N 等于 1 时删除的是最后一个结点链表1 - 2 - 3n 1目标删除值为 3 的尾结点。从 dummy 出发fast 先走 2 步到达值为 2 的结点。然后 fast 和 slow 一起走fast 从值为 2 的结点走到值为 3 的结点slow 从 dummy 走到值为 1 的结点fast 再从值为 3 的结点走到 Noneslow 从值为 1 的结点走到值为 2 的结点此时 slow 指向值为 2 的结点也就是尾结点的前驱。执行 slow.next slow.next.next其中 slow.next 是尾结点slow.next.next 是 None所以尾结点的前驱的 next 直接指向 None完成删除。这个场景没有坑核心就是 slow.next.next 为 None 是合法的。3.4 链表只有一个结点且 n 1 的极端场景链表1n 1。dummy 指向值为 1 的结点。fast 先走 2 步从 dummy 到值为 1 的结点再到 None所以 fast None。while 循环不执行slow dummy。执行 slow.next slow.next.next也就是 dummy.next None返回 dummy.next None。结果正确删除后链表为空。实测经验这种极端场景最容易栽跟头的是如果直接用 head 而不加 dummy 的写法——head 只有一个结点fast 先走 1 步就到 Noneslow 停在 head你执行 head.next head.next.next 就会报空指针异常因为 head.next 已经是 None 了不能再取 next。所以“只有一个结点时程序崩了”这个传闻多半就是没用 dummy 导致的。3.5 空链表、n 非法等输入约束LeetCode 的约束是链表长度至少为 1n 一定合法1 ≤ n ≤ 链表长度所以不需要在这个层面做防御性编程。但在真实项目里如果这题的场景封装成工具函数我会习惯性地在开头加上if not head or n 0: return head这个不算多此一举——面试时你主动加防御逻辑面试官多半会露出“这人考虑问题比较周全”的表情。不过要注意 LeetCode 上不要画蛇添足因为链表长度至少为 1 的约束意味着 head 一定非空你加 if not head 也不会走进去问题不大。3.6 一个常见翻车案例忘记把 fast 先走的步数放在 dummy 还是 head 上我第一次用双指针写的时候代码长这样fast head slow head for _ in range(n): fast fast.next然后我在 while 循环里移动结果 slow 一直停在错误的位置。为什么因为我从 head 出发只走了 n 步slow 最后停在了待删除结点本身而不是前驱。你当然可以在循环结束后加一句“slow 再往后走一个的前驱”之类的补救但那样代码就难看了。更关键的是如果 n 等于链表长度fast 从 head 走 n 步已经到 None循环里 fast fast.next 直接报错。所以我的建议是统一从 dummy 出发统一走 n 1 步思路最清晰代码最稳定。下表是各边界条件的总结方便你刷题时对照检查场景链表长度 Ln删除目标关键检查点普通场景52值为 4 的结点slow 停在值为 3 的结点删除头结点55值为 1 的结点fast 先走完就到 Noneslow 不动删除尾结点31值为 3 的结点slow.next.next 为 None单结点链表11唯一结点返回 None且不报空指针4. 评论区高热度问题递归解法、一次遍历的实际收益以及那个“前驱到底是哪个”的纠结点4.1 递归解法看起来优雅但生产环境不敢用每次这题的热门讨论里都有人贴递归解法。思路是利用递归回溯时从链表尾部往前数的特点定义一个计数器递归到末尾后逐层返回当计数器等于 n 时就删掉当前结点。代码大概是class Solution: def removeNthFromEnd(self, head: ListNode, n: int) - ListNode: def recur(node): if not node: return 0 # 返回当前结点是倒数第几个 index recur(node.next) 1 if index n 1: # 当前结点是待删除结点的前驱 node.next node.next.next return index dummy ListNode(0, head) recur(dummy) return dummy.next这个解法能跑而且思路很巧妙。但问题也很明显递归深度等于链表长度如果链表有十万个结点递归栈会直接爆掉。在 LeetCode 的测试用例里链表长度通常控制在几千所以能过。但你要是把这个写法带到生产环境遇到超长链表就是事故现场。我个人的建议是递归解法可以当思维训练理解一下“利用调用栈天然回溯”的想法但面试和实际工程里都不推荐作为首选。4.2 一次遍历真的“一次遍历”吗聊聊复杂度之外的效率真相双指针解法严格来说fast 指针走了 L 1 步从 dummy 到 Noneslow 指针走了 L - n 步总步数约 2L。所以它其实也是两趟完整的遍历——只是这两趟遍历是同时进行的很多初学者被“一次遍历”这个词误导了以为真的是只走一遍。那么双指针的价值到底在哪两个层面。第一它不需要先知道链表长度也就省去了第一次遍历求长度的开销从常数因子上说双指针比“两次遍历”少了一些操作——两次遍历是先完整走 L 遍求长度再走 L - n 遍找前驱总共约 2L 步双指针总共也是约 2L 步但因为两个指针同步走没有重复扫描同一段区域实际运行时间会略快一些。第二也是更重要的双指针体现的是一种“用相对距离替代绝对位置”的思维方式这种思维方式在你处理环形链表、寻找链表中间结点等题目时都会用到。我在本地写了个简单测试构造一个 100 万个结点的链表分别用两次遍历和双指针删除倒数第 10 个结点跑了 100 次取平均。两次遍历大约 85 毫秒双指针大约 63 毫秒差距大约 26%。不算巨大但在 LeetCode 这种微秒级判题环境里双指针确实能让你稳进第一梯队。说这些是想让你明白双指针的优势不仅是面试官想听的“一次遍历”实际运行表现也确实值得优化。4.3 为什么我建议你画图而不是死记“先走 n 1 步”我看到评论区有一种声音说自己就是记不住 n 1 还是 n。我以前的教训是与其硬记步数不如记住一个更通用的规则——你需要让慢指针停在待删除结点的前驱所以快慢指针之间必须保持 n 1 的距离。保持距离这个概念比“先走 n 1 步”更底层。因为只要你明白了“slow 要停在倒数第 n 1 个结点”也就是待删除结点的前驱你自然知道快慢指针要保持 n 1 的间距。同样如果你做的是“找到倒数第 n 个结点”不删除那间距就是 n。核心是搞清楚“最终慢指针要停在哪里”而不是背着“先走几步”的口诀。具体怎么画拿一张纸画 6 个竖线最左边的标 dummy依次是结点 1 到结点 5然后用两个手指头模拟指针一个先走一个后走。走的过程中你会发现所有的困惑都在画图的那一瞬间消失了。我刷这题的时候这个方法比任何教程都好使。5. 从这题看链表题型的通用解法dummy node 和双指针的使用套路5.1 dummy node 在其他链表题里的复用场景dummy node 不是一个只服务于这一道题的小技巧它是链表操作里的通用基础设施。举两个最常见的例子。第一个是“合并两个有序链表”。题目的常规解法是维护一个 cur 指针谁小就把谁接上去。但问题来了合并后的新链表头到底是哪个链表的头你在比较的过程中cur 最初到底指向哪dummy 的解法会让你直接 dummy.next 返回新头不需要单独决定“以哪个链表头作为头结点”。第二个是“反转链表”的迭代写法。反转链表其实也需要“记住前一个结点”用的是 prev 指针而不是 dummy。但如果你把 prev 初始化为 None反转结束后返回的 prev 就是新头。这个问题里虽然没有 dummy但贯穿其中的思想是一致的——链表操作里头结点的特殊性往往会给代码增加特判想办法让头结点“普通化”就能大幅简化代码。第三个是“两两交换链表中的节点”。如果你不用 dummy交换头结点和第二个结点时需要特殊处理用了 dummy头结点也变成了普通结点交换逻辑对每个结点一视同仁。你会发现dummy node 最大的好处是把“边界条件”从代码逻辑里剥离出去。边界条件多意味着出错概率高调试时间长。用 dummy 把边界条件变成普通条件代码的整体复杂度就降下来了。5.2 双指针套路图谱快慢指针的三种常见形态这一题里的双指针是“保持固定距离”的类型但双指针家族里还有另外两种形态都在 LeetCode 题库里频繁出现。固定距离型快指针先走 N 步然后同步移动。典型题目是“链表中倒数第 K 个结点”和本题“删除倒数第 N 个结点”。它的几何直觉是“赛跑领先 N 米”。速度倍数型快指针每次走两步慢指针每次走一步。典型题目是“环形链表判断是否有环”和“寻找链表中点”。当快指针到达末尾时慢指针正好在中点。如果链表有环快指针会追上慢指针。滑动窗口型双指针在数组或字符串上维护一个区间。典型题目是“无重复字符的最长子串”。这三种形态的共同本质是用两个指针的不同移动速度或不同起始位置来建立某种空间或位置关系从而避免额外的数据结构或多次遍历。5.3 我自己的链表题刷题顺序建议如果你是被这题“劝退”的初学者我给一个小的刷题顺序亲测有效先做“反转链表”理解 prev、cur、next 三指针的滚动方式。再做本题“删除链表的倒数第 N 个结点”理解 dummy 和双指针固定距离。然后做“环形链表”理解龟兔赛跑的快慢指针。最后做“合并两个有序链表”加深对 dummy 的理解。这个顺序的好处是每一题都会用到前一题的思想但又不是简单重复知识跨度在逐步扩大。链表题一旦形成体系刷一道顶三道比盲目按题号刷效率高得多。另外我不推荐只看题解不写代码。链表题尤其考验动手能力因为指针操作非常依赖“脑中模拟指针的移动”。你写错一次、调试一次、画图一次比看十篇题解都有用。我在评论区经常看到有人说“看懂了写不出来”这很正常因为你大脑理解了但手指肌肉记忆还没建立。解决方案就一个把代码关掉自己在编辑器里敲一遍错了就看报错信息在纸上模拟一遍再敲一遍直到不靠记忆也能写出来为止。6. 数组类似的“倒数”问题能不能用双指针对比一下迁移思路有些朋友刷到这道题,会顺便联想到另一个问题——如果输入不是链表而是数组删除倒数第 N 个元素双指针还有意义吗结论是双指针可以用但在数组场景下没有任何优势因为数组支持随机访问你直接计算下标即可arr[-n] 就行了。但这个问题值得思考的点在于为什么同一个“倒数第 N 个”的问题在链表里需要双指针在数组里却不需要原因是数据结构的物理特性决定了你能用什么操作。数组是连续内存、随机访问你可以在 O(1) 时间内知道任意位置的元素链表是离散内存、顺序访问你只能通过 next 指针一个接一个走。双指针的本质是用空间换时间——用一个额外的指针变量O(1) 空间记录位置信息从而避免遍历两遍查找长度。数组因为天生具备随机访问能力这种空间换时间的手段就变得可有可无。类似的迁移还有“判断回文链表”——把链表值复制到数组双指针一头一尾比这是最简单的解法但如果要求空间 O(1)你就得先用快慢指针找中点再反转后半段链表再逐一比较。这些题都是同一个套路在不同数据结构上的变种。所以这个对比给我最大的启发是刷题不只是背套路而是理解“数据结构的物理特性决定了算法的设计边界”。链表题里大量使用双指针和 dummy node是因为链表的物理特性就是这样——只能单向移动、不知道长度、头结点特殊。把这些底层逻辑想明白遇到任何链表题都不会没思路。最后再分享一个小经验这道题我至少刷了三遍每刷一遍都会发现新的细节。第一遍写出来能跑第二遍理解了 dummy 的意义第三遍才真正理解为什么快慢指针的间距是 n 1 而不是 n。这个从“会写”到“理解原理”的过程大概花了将近一周。你要是刚开始刷遇到卡壳真的别灰心画图、写代码、调试、看别人题解、再写这个循环多走几遍链表题的水平自然就上来了。