LeetCode 2095. 删除链表的中间节点
题目描述
题意分析
给一条单链表,删掉「中间节点」后返回新的头节点。这里的「中间」不是凭感觉的中间,而是一个精确定义:设链表长度为
n,要删的是下标⌊n/2⌋的节点,下标从 0 起算。这个定义必须先算清楚,否则偶数长度一定翻车。
n为奇数时⌊n/2⌋就是唯一的正中间;n为偶数时有两个居中的节点,⌊n/2⌋取的是偏右那个 —— 例如n=4删下标 2 而不是下标 1,官方样例[1,2,3,4]→[1,2,4]删掉的正是值为3的节点。把前几项列出来核对最稳妥:n=1删 0,n=2删 1,n=3删 1,n=4删 2,n=5删 2,n=6删 3,n=7删 3。第二个要点是结构性的:单链表只能往后走,要摘掉某个节点,手里必须先攥着它的前驱,把前驱的
next越过它接到后继上。所以真正需要定位的目标不是下标⌊n/2⌋,而是下标⌊n/2⌋ - 1。而当⌊n/2⌋恰好为 0(也就是n=1)时,这个前驱根本不存在于链表之中,这就是所有特判的来源。边界只有一个,但必须记牢:
n=1时删掉唯一的节点,链表变空,必须返回空,而不是把原来那个节点交回去。约束里写着节点数在 1 到 $10^5$ 之间,前者说明不用操心空输入,后者则在暗示别写反复遍历的解法,最好一趟走完并且只用常数额外空间。
解法:快慢指针定位中间节点的前驱
核心思路
长度未知却要定位到
⌊n/2⌋,最直觉的做法是先遍历一趟数出n、再遍历半趟走过去。但只要让两个指针以固定的速度差同时出发,就能把这两趟压成一趟:fast每轮走两步,slow每轮走一步,全程维持不变量 ——fast走了2k步时slow恰好走了k步。当fast因为走不出第二步而停下时,它走过的路程已经覆盖了整条链表,此时slow走过的步数正好是总长的一半,这就是「一趟定位分数位置」。真正的关键在于让
slow落在前驱上,而不是中点本身。假如slow停在下标⌊n/2⌋,手里只有中点,那么slow.next = slow.next.next删掉的是中点的后继,方向整个错了;而单链表没有回头路,想补救就得再遍历一趟去找前驱,一趟的优势白白丢掉。所以要在起点上做手脚:把slow的出发位置整体往前挪一格,它抵达的位置自然也往前一格,正好落在⌊n/2⌋ - 1。「往前挪一格」在
n=1时会挪到链表外面去,于是哨兵节点登场:造一个dummy让dummy.next指向head,slow从dummy出发、fast仍从head出发。这样无论⌊n/2⌋是不是 0,slow手里永远有一个合法的前驱可用 ——n=1时slow一步都不走、原地停在dummy上,dummy.next = dummy.next.next就把唯一的节点摘掉了,最后return dummy.next自然返回空。「删除头节点」这个特判被哨兵整个吃掉了,正文里一个if都不用写,这也是面试官在链表删除题上最想看到的处理方式。循环条件必须是
fast != null && fast.next != null:前半句管住fast已经走到空的情况,后半句保证fast.next.next不会踩空,两句缺一不可。顺带一提,「先数长度再走一半」其实是同一个思路的两趟版本,走⌊n/2⌋步同样要从哨兵起步才能停在前驱上;正确性一致,只是多扫一遍链表。
解题步骤
- 建哨兵
dummy并令dummy.next = head。为什么:给slow一个位于链表之外的合法起点,从根上消除⌊n/2⌋ = 0时没有前驱的特判。- 令
slow = dummy、fast = head。为什么:两者起点相差一格,fast到头时slow才会落在⌊n/2⌋ - 1而不是⌊n/2⌋。- 当
fast != null && fast.next != null时进入循环。为什么:fast每轮要迈两步,两个判空分别防住「已经越过尾部」和「只剩一个节点、迈不出第二步」。- 循环体内
slow走一步、fast走两步。为什么:维持fast步数是slow两倍的不变量,fast走完全长时slow刚好走完半程。- 循环结束后执行
slow.next = slow.next.next。为什么:slow此时是待删节点的前驱,把它的next越过待删节点直接接到后继,删除就完成了。- 返回
dummy.next。为什么:头节点有可能已经被摘掉,head是失效的旧引用,只有dummy.next才是真正的新头。以
[1,2,3,4]走一遍:n=4,应删下标 2(值3)。初始slow = dummy、fast = 节点1。第一轮,fast的next是节点2 不为空,于是slow前进到节点1(下标 0),fast前进两步到节点3(下标 2)。第二轮,fast = 节点3的next是节点4 不为空,于是slow前进到节点2(下标 1),fast越过节点4 变成空。第三轮判空失败,退出循环,slow停在下标 1,正是⌊4/2⌋ - 1。执行slow.next = slow.next.next,节点2 直接指向节点4,节点3 被摘出链表,return dummy.next得到[1,2,4]。
n=1边界:输入[7],slow = dummy、fast = 节点7,因为fast.next为空,循环一轮都不进,slow原地停在dummy上。执行dummy.next = dummy.next.next,即dummy.next变成空,返回dummy.next得到空链表,符合「删掉唯一节点后链表为空」。
n=2边界:输入[2,1],应删下标 1。第一轮fast = 节点2的next不为空,slow前进到节点2(下标 0),fast越过节点1 变成空,随即退出。slow停在下标 0,删掉slow.next(下标 1、值为1的节点),返回[2]—— 偶数长度删偏右那个,与定义吻合。
代码实现
class Solution {
public ListNode deleteMiddle(ListNode head) {
// 哨兵让 slow 有一个链表外的起点,从根上消除「删头节点」的特判。
ListNode dummy = new ListNode(0, head);
// slow 起点比 fast 早一格,因此终点也早一格,落在待删节点的前驱上。
ListNode slow = dummy;
ListNode fast = head;
while (fast != null && fast.next != null) {
// 不变量:fast 走 2k 步时 slow 走 k 步。
slow = slow.next;
fast = fast.next.next;
}
// slow 是下标 ⌊n/2⌋ 节点的前驱,直接跨过待删节点。
slow.next = slow.next.next;
// 头节点可能已被删除,必须返回 dummy.next 而不是 head。
return dummy.next;
}
}
func deleteMiddle(head *ListNode) *ListNode {
// 哨兵让 slow 有一个链表外的起点,从根上消除「删头节点」的特判。
dummy := &ListNode{Next: head}
// slow 起点比 fast 早一格,因此终点也早一格,落在待删节点的前驱上。
slow, fast := dummy, head
for fast != nil && fast.Next != nil {
// 不变量:fast 走 2k 步时 slow 走 k 步。
slow = slow.Next
fast = fast.Next.Next
}
// slow 是下标 ⌊n/2⌋ 节点的前驱,直接跨过待删节点。
slow.Next = slow.Next.Next
// 头节点可能已被删除,必须返回 dummy.Next 而不是 head。
return dummy.Next
}
复杂度分析
- 时间复杂度:$O(n)$,
fast至多走满整条链表一趟就停下,slow走的是半趟,两者共用同一次遍历,没有任何回头或重扫。- 空间复杂度:$O(1)$,只额外用了一个哨兵节点和两个指针,与链表长度无关;全程原地改指针,没有建新链表或辅助容器。
关键点总结
- 要删节点,就让指针停在它的前驱上。单链表的删除动作只能由前驱发起,定位目标要顺着这个约束往前移一格,而不是先定位到目标再想办法找前驱。
- 哨兵节点专治头节点特判。凡是「可能删掉头节点」的链表题,加一个
dummy就能把头和其他节点抹平成同一种情况,收尾统一return dummy.next;代价是一个节点的空间,换来的是少写一个容易漏的if。- 通过错开起点来平移终点。快慢指针的落点由「速度差 + 起点差」共同决定,想让慢指针停在前驱,改起点比改循环条件安全得多 —— 起点差一格就是终点差一格,推导直观且不容易写错边界。
- 走两步的指针必须做两次判空。
fast != null和fast.next != null各自拦住一种越界,写成一个条件就会在某类长度上炸掉。- 面试视角:这题面试官几乎一定会追问两件事 —— 一是「奇偶长度下你的两个指针分别停在哪」,请当场用
n=1,2,3,4把慢指针的下标报出来(依次是 dummy、0、0、1,即⌊n/2⌋ - 1),说明偶数取的是偏右的中点;二是「能不能一趟完成」,先数长度再走一半是两趟,快慢指针把它压成一趟,同时强调哨兵让「删头」不再需要特判。能主动讲出不变量和n=1返回空这两点,基本就过了。
易错点总结
- 慢指针从
head出发,最终停在中点本身:[2,1]→slow停在下标 1,slow.next已经是空,slow.next.next直接抛空指针异常;[1,2,3]→ 输出[1,2],删掉的是中点的后继而非中点。这是本题最常见的错法,根因就是没意识到删除必须由前驱发起。- 提前
if (head.next == null) return head;想省掉单节点情况:[1]→ 输出[1],而正确答案是空链表;其余长度全部正常,n=1是唯一暴露口,本地随手测两三个用例极易漏掉。- 删完返回
head而不是dummy.next:[1]→ 输出[1],因为head恰好就是被摘掉的那个节点,head已成失效引用。这条最阴 ——n≥2时全对,三个官方样例也全过,只有n=1会挂。- 循环条件写成
fast.next != null && fast.next.next != null:[1,2,3,4]→ 输出[1,3,4](删了下标 1),[2,1]→ 输出[1](删了下标 0)。偶数长度慢指针少走一步,删成了⌊n/2⌋ - 1偏左那个;奇数长度反而正确,官方样例 1 长度为 7 会侥幸通过,掩盖住 bug。fast从head.next出发,以为能「提前一步」:与上一条同病同果,[1,2,3,4]→[1,3,4],[2,1]→[1],偶数长度一律偏左一位。起点想动就得重新推一遍落点,不能凭感觉。- 循环条件只写
while (fast != null),忘了检查fast.next:[1,2,3]、[1,2,3,4,5]一律抛空指针异常,因为奇数长度下fast会停在最后一个节点,fast.next.next就踩空了;偶数长度侥幸不崩,错觉是「代码没问题」。- 循环条件放成
while (fast != null)再在循环体里用三目补空:[1,2,3]→ 输出[1,2](删下标 2),[1,3,4,7,1,2,6]→ 输出[1,3,4,7,2,6](删了下标 4 的1而不是下标 3 的7)。补了空判不崩了,但慢指针多走一步,奇数长度整体偏右一位。fast和slow都从哨兵出发:[1,2,3]→ 输出[1,2](删下标 2),[1]→ 输出[1](slow.next为空,加判空后什么都没删)。哨兵只该给slow用,fast跟着挪就把一格的起点差抹平了。- 先数长度但多走了一步,写成走
n/2 + 1步:[1,2,3,4]→ 输出[1,2,3],[2,1]→ 输出[2,1](原样返回)。从哨兵起步只该走⌊n/2⌋步就停在前驱,多一步就落到中点本身了。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 876. 链表的中间结点 | 简单 | 同样快慢指针求中点,但只要返回中点本身,慢指针从 head 起步,无需前驱和哨兵 |
| 19. 删除链表的倒数第 N 个结点 | 中等 | 双指针间距固定为 n 步而非二倍速,定位的是倒数位置而不是分数位置,同样靠哨兵消除删头特判 |
| 237. 删除链表中的节点 | 中等 | 只给待删节点、拿不到前驱,只能复制后继的值再摘掉后继,反向印证本题为何必须停在前驱 |
| 203. 移除链表元素 | 简单 | 按值批量删除而非按下标删一个,前驱指针只在不删时才前移,哨兵的作用完全一致 |
| 143. 重排链表 | 中等 | 快慢指针求中点只是第一步,后面还要反转后半段并交叉拼接,中点是中间产物不是答案 |