LeetCode 203. 移除链表元素
题目描述

题意分析
给一条单链表的头节点和一个整数
val,把链表里所有值等于val的节点全部摘掉,返回处理后的头节点。有两个信号决定了写法的形状。第一,要删的是「所有」而不是「第一个」,所以不能找到就收工;而且待删节点可能连续成片,删完一个之后紧跟着的仍可能是待删节点。第二,返回类型是链表头而不是
void,这在提醒头节点本身可能被删,甚至可能被连删好几个——极端情况下整条链被清空,答案是空链表。从操作层面看,单链表摘除一个节点只能借助它的前驱:把前驱的
next指向被删节点的后继。所以遍历时真正该盯住的是「当前节点的后继」,而不是当前节点自己。边界要单独想清楚四种:链表本身为空;头节点就是待删值;开头连续多个都是待删值;整条链的值全等于
val。这四种都要落到同一段逻辑里,不该各写一支。
解法:虚拟头节点迭代删除
核心思路
朴素写法会分成两段:先用一个
while (head != null && head.val == val) head = head.next;剥掉开头的连续待删节点,再从新的head出发处理中间部分。这能过,但两段逻辑近乎重复,而且第一段循环的判空条件很容易写漏,链表被删空时还要额外照顾返回值。瓶颈在于「头节点没有前驱」这个例外,它把代码撕成了两半。既然缺的是前驱,那就造一个。在真实头节点前面挂一个不参与比较的哑节点,链表里每个节点就都有前驱了,删头和删中间彻底同构。
剩下的关键是游标何时前进。用
cur指向「已确认保留的最后一个节点」,每次只检查cur.next:如果它要删,就执行cur.next = cur.next.next,此时cur必须原地不动,因为新接上来的后继还没被检查过;如果它要留,才把cur前移一格。不变量是:每轮循环开始时,从
dummy到cur这一段中已经不含任何值为val的节点,且cur.next是下一个待判定的节点。 循环在cur.next为空时结束,此时不变量覆盖了整条链,说明删除已经彻底。返回dummy.next即为新头:原头被删时它自动指向后面第一个保留节点,全删光时它就是空。
解题步骤
- 新建
dummy并令dummy.next = head。这一步是全部简化的来源,它把「删头节点」变成了「删dummy的后继」。- 令
cur = dummy。从哑节点起步而不是从head起步,才能让第一个被检查的对象是head本身。- 循环条件是
cur.next != null,即还有待判定的节点。判断的是后继而非cur自己,与「盯住后继」的整体设计一致。- 若
cur.next.val == val,执行cur.next = cur.next.next摘除节点,并且不移动cur。这是本题与「只删一个」类题目的核心差别:连续待删节点必须靠原地重复检查才能一次性删净。- 否则把
cur前移一格,表示这个节点确认保留。- 循环结束后返回
dummy.next。不能返回head,因为head可能早已被摘除,只是一个游离对象。以
head = [1,2,6,3,4,5,6]、val = 6走一遍:挂上哑节点后链是dummy → 1 → 2 → 6 → 3 → 4 → 5 → 6,cur = dummy。第一轮,cur.next是1,值不等,cur前移到节点1。第二轮,cur.next是2,保留,cur前移到节点2。第三轮,cur.next是6,命中,执行摘除后节点2的next变成3,cur仍停在节点2。第四轮,cur.next现在是3,保留,cur前移到3。第五、六轮同理保留4和5,cur依次前移到4、5。第七轮,cur.next是尾部的6,命中,执行cur.next = cur.next.next,而那个6的后继为空,于是节点5的next被置空,cur仍停在5。第八轮,cur.next为空,循环退出。返回dummy.next即节点1,链为[1,2,3,4,5]。再以head = [7,7,7,7]、val = 7走一遍:cur始终停在dummy,四轮里每轮都命中并摘除一个,dummy.next依次变为第二、第三、第四个7,最后变为空,循环退出,返回空链表。
代码实现
class Solution {
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode cur = dummy;
while (cur.next != null) {
if (cur.next.val == val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return dummy.next;
}
}
func removeElements(head *ListNode, val int) *ListNode {
dummy := &ListNode{Next: head}
cur := dummy
for cur.Next != nil {
if cur.Next.Val == val {
cur.Next = cur.Next.Next
} else {
cur = cur.Next
}
}
return dummy.Next
}
复杂度分析
- 时间复杂度:$O(n)$,其中 $n$ 是链表长度。每个节点恰好被作为
cur.next检查一次,检查后要么被摘除、要么被跨过,两种结果都让待判定集合减少一个,所以总轮数就是 $n$。- 空间复杂度:$O(1)$,只额外创建一个哑节点和一个游标指针,不随链表长度增长。
关键点总结
- 哑节点是链表题消除头部特例的标准动作。判断要不要加,只需问一句「头节点会不会被改动」,会就加,代价只有一个对象。
- 删除类遍历中,游标推进必须是有条件的:删除时原地不动,保留时才前进。把「推进」和「保留」绑定在一起,是防止漏删连续目标的关键。
- 循环条件盯
cur.next而不是cur,这样每轮的判定对象和可修改对象天然对齐,不需要额外维护前驱变量。- 返回值要写
dummy.next而非head,因为head表示的是「原来的头」,删除后未必还是「现在的头」。- 面试视角:先说「先剥头部再处理中间」的两段式写法并指出其重复与易漏,再引入哑节点合并成一段,能完整展示化简过程。若面试官要求递归版本,可以给出
head.next = removeElements(head.next, val); return head.val == val ? head.next : head;,同时主动说明它的栈空间是 $O(n)$。- 面试视角:常见追问是「和只删第一个匹配节点有什么区别」。答案落在游标推进上:只删一个时找到就退出,删全部时删完必须原地重查,这一句话能体现你真的理解循环结构而不是背模板。
易错点总结
- 错误写法:删除后仍然执行
cur = cur.next。用例head = [1,2,6,6,3]、val = 6→ 摘掉第一个6后游标前移,第二个6被跳过,结果是[1,2,6,3],连续目标漏删。- 错误写法:
cur从head起步而不是从dummy起步。用例head = [6,1,2]、val = 6→ 头节点从未被检查,返回[6,1,2],删除完全失效。- 错误写法:返回
head而不是dummy.next。用例head = [7,7,7,7]、val = 7→head指向已被摘除的第一个7,返回结果仍是[7,7,7,7]或以孤立节点开头的错误链。- 错误写法:循环条件写成
cur != null并在体内访问cur.next.val。用例head = [1,2]、val = 3→ 游标走到尾节点后cur.next为空,读它的val直接抛空指针异常。- 错误写法:删除时写成
cur = cur.next.next,把指针移动误当成指针改写。用例head = [1,6,2]、val = 6→ 链表结构没有任何变化,只是游标跳过了目标,返回原链。- 错误写法:不加哑节点,只在开头写一次
if (head.val == val) head = head.next;。用例head = [6,6,1]、val = 6→ 只剥掉一个头节点,第二个6变成新头并留了下来,且head为空时还会先崩在head.val上。- 错误写法:用两个变量
pre和cur遍历,但删除后忘记把cur更新为pre.next。用例head = [1,6,6,2]、val = 6→cur指向已被摘除的节点,沿着它的next继续走会绕回被删链段,行为不可预期。- 错误写法:先统计待删数量再新建一条链复制保留节点,却用值相等判断跳过。用例
head = [1,2,6,3,4,5,6]、val = 6→ 结果虽对,但额外开了 $O(n)$ 空间并新建了节点,不符合原地删除的意图,面试中会被追问。- 错误写法:认为链表被删空是非法情形,于是在返回前加
if (dummy.next == null) return head;。用例head = [7,7,7,7]、val = 7→ 正确答案就是空链表,这条兜底反而把已删除的内容又还了回去。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 剑指 Offer 18. 删除链表的节点 | 简单 | 只删第一个匹配节点,找到即可结束遍历 |
| 83. 删除排序链表中的重复元素 | 简单 | 利用有序性比较相邻节点,重复值需保留一个 |
| 82. 删除排序链表中的重复元素 II | 中等 | 重复值一个不留,需要先探测整段再统一跳过 |
| 面试题 02.01. 移除重复节点 | 简单 | 链表无序,需借助哈希集合记录已出现过的值 |
| 237. 删除链表中的节点 | 中等 | 拿不到前驱,改用复制后继值再删后继的技巧 |
| 面试题 02.03. 删除中间节点 | 简单 | 同为无前驱删除,考察对值覆盖思路的迁移 |