题目描述
思路解析
一句话答案:LeetCode 19 删除链表的倒数第 N 个结点,一次遍历的解法是快慢指针加哑结点:fast 先从哑结点出发走 n+1 步,然后与 slow 保持这个间距同速前进,fast 走到 null 时 slow 恰好停在待删结点的前驱上,改一次 next 即完成删除。时间 O(L)、空间 O(1)。
这道题真正在问什么
给单链表的头节点和一个数 n,删掉倒数第 n 个结点后返回头。难点全在「倒数」两个字上:单链表只能从头往后走、不能回头,而且事先不知道总长度——站在任何一个节点上,你都说不清它是倒数第几个。
两遍扫描为什么能做但不够好
最直接的思路是先扫一遍数出链表总长 L,「倒数第 n 个」就换算成「正数第 L-n+1 个」,再从头走到它的前一个节点做删除。完全正确,但要把链表扫两遍。面试的经典追问是:能不能只走一遍?
关键观察是:「倒数第 n」这个信息虽然单个指针拿不到,但可以翻译成一个相对关系——待删结点与链表末尾之间恰好隔着固定的距离。距离是可以用两个指针「保持队形」来维护的:先把两个指针拉开固定间距,再让它们同速前进,前面的到达终点时,后面的位置就被这个间距唯一确定了。
为什么 fast 要先走 n+1 步而不是 n 步
删除链表节点必须拿到它的前驱,因为删除动作是「前驱.next 跳过待删点」。所以我们真正想让 slow 停的位置不是待删结点本身,而是它前面一个。
把间距设为 n+1:fast 走到 null(越过末尾一格)时,slow 距离末尾 n+1 格,正好落在倒数第 n 个结点的前驱上。如果只拉开 n 步,slow 会停在待删结点身上——单链表到了这里已经拿不到前驱,只能用「复制后继值再删后继」之类的别扭补救。多走的那一步,本质是给「删除需要前驱」这个约束预留的。
哑结点是怎么把删头特判消掉的
有一种情况会让「找前驱」失效:n 恰好等于链表长度,待删的就是头结点,而头结点天然没有前驱。在头前面垫一个不存数据的哑结点 dummy,让 fast 和 slow 都从 dummy 出发,头结点就有了统一的前驱,删头和删中间、删尾用同一段代码,最后返回 dummy.next 就是新头。
这也是链表题的通用经验:凡是「可能对头结点做修改」的操作(删除、插入、反转),加个哑结点几乎总能把边界分支压成零。
复杂度与正确性小结
时间 O(L):fast 从头到尾恰好走一遍链表,slow 走的路程更短,总步数是线性的。空间 O(1):只有两个指针加一个哑结点。
正确性可以一句话说清:两个指针同速移动时间距永远不变,所以「fast 到 null」与「slow 在倒数第 n+1 个」是同一时刻发生的同一件事,不依赖链表内容,也不需要知道 L 的具体值。题目保证 n 不超过链表长度,所以 fast 先走 n+1 步时最多走到 null,不会越界。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
记住这条间距:fast 先冲 N+1 步,slow 永远落后 N+1 个身位。
哑结点 D 接在头前。fast、slow 都从 D 出发——D 不是真实结点,只为统一删头的写法。
fast 单独往前走一格(现在第 1/3 步)。slow 还按兵不动,间距正一格一格拉开。
fast 单独往前走一格(现在第 2/3 步)。slow 还按兵不动,间距正一格一格拉开。
fast 单独往前走一格(现在第 3/3 步)。slow 还按兵不动,间距正一格一格拉开。
fast 走满 3 步停下。此刻 fast 与 slow 之间恰好隔 3 格——这就是「倒数第 N」的秘密:保持这个间距一起走即可。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 也挪一格跟上。两个指针像被一根长度固定的杆连着,一起往末尾滑。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 也挪一格跟上。两个指针像被一根长度固定的杆连着,一起往末尾滑。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 也挪一格跟上。两个指针像被一根长度固定的杆连着,一起往末尾滑。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 也挪一格跟上。两个指针像被一根长度固定的杆连着,一起往末尾滑。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 也挪一格跟上。两个指针像被一根长度固定的杆连着,一起往末尾滑。
现在 fast、slow 同速前进。先看 slow 挪一格——间距始终保持不变。
fast 再走一步就越过了末尾(指向 null)。停!此刻 slow 落在哪?——正好是「待删结点的前驱」。
fast 到 null,slow 停在值 6——它正是待删结点(值 7)的前驱。下一步让前驱跳过待删点。
把前驱的 next 跨过灰掉的值 7,直接接到它的下一个(值 8)。值 7 就此从链上脱钩。
删除完成。返回哑结点的 next(跳过 D 本身),得到结果链表 1→2→3→4→5→6→8。一次遍历搞定,没有先数长度。
删头、删尾、删到空——哑结点把这些都收进同一套逻辑。
两个高频追问。
参考代码
def removeNthFromEnd(head, n): dummy = ListNode(0, head) # 哑结点统一删头/删中 fast = slow = dummy for _ in range(n + 1): # fast 先走 n+1 步 fast = fast.next while fast: # 一起走到 fast 出末尾 fast = fast.next slow = slow.next slow.next = slow.next.next # 前驱跳过待删点 return dummy.next复杂度
- 时间:O(L),指针只走一遍链表
- 空间:O(1),仅两个指针 + 哑结点
易错点
面试追问把动画讲成自己的话
追问为什么用哑结点而不直接判 head?
追问能不能不用双指针?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
复制带随机指针的链表
LeetCode 138 · 中等 · 沿着 链表 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题