刚好上周在社区里看到有人在问“为什么删除链表节点还要专门设一个虚拟头节点”,这让我想到LeetCode 203这道题。很多人刷到“移除链表元素”时会觉得:不就是遍历一遍,遇到值相等的节点就删掉吗?真上手写的时候才发现,头节点单独处理、连续重复值、递归回溯这些细节,每一个都能让你在测试用例上翻车。这篇文章我就把这题的思路、代码、边界情况和刷题之外的工程联想一起捋一遍。
1. 这道题为什么值得反复刷:一切从“删除节点”的本质说起
1.1 题目到底在考什么
先回顾一下题目本身:
给你一个链表的头节点
head和一个整数val,请你删除链表中所有满足Node.val == val的节点,并返回新的头节点。
这个题目在LeetCode上标注为“简单”,但简单不代表没东西可以挖。它最基础考察的是链表遍历和指针操作,往深了说,它涉及一个很多初学者一开始完全意识不到的陷阱——头节点也可能被删除。
数组删除元素,我们只用关心索引、移动元素;链表删除元素,本质上做的是把上一个节点的 next 指针绕过目标节点。这句话听起来很容易,但一旦牵扯到“待删除节点是头节点”的情况,整个处理逻辑就需要额外分叉。
1.2 单链表的底子要先打好
链表的基础结构(以C++为例)长这样:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
每个节点保存一个值和一个指向下一个节点的指针。单链表只能从 head 开始往后走,没法回头,也没法直接访问“上一个节点”。所以删除一个节点,关键不是“释放它”,而是让前一个节点的指针绕过它。
这就带来一个天然矛盾:如果是删除中间某个节点,我们站在“前一个节点”的角度去改 next 就能搞定;但如果要删除的是头节点,它根本没有前一个节点。这就是整个题目最容易出错的地方。
1.3 一句话说清核心思路
遍历链表时,用一个指针 cur 从 head 开始往后走,同时检查 cur->next 的值是不是目标值。如果是,就把 cur->next 指向 cur->next->next;如果不是,就正常前移 cur。这样一来,我们永远站在目标节点的前一个位置来删除它,避免“自己删自己”时无法取得前驱的窘境。
这套思路也是后面所有解法(迭代、递归、哨兵节点)共同的底层逻辑。把这一层想明白,代码写起来就只是“如何优雅地表达”的问题了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代法:为什么“虚拟头节点”是很多人的第一反应
2.1 没有虚拟头节点时,头节点要单独处理
很多刚接触这题的人最直接的想法是:先写一个循环,如果 head 本身的值就是 val,就先把 head 往后移,处理掉“开头连续几个都是目标值”的情况;然后再写第二个循环,处理中间节点。
伪代码如下:
cpp复制// 处理头部连续的目标值
while (head != nullptr && head->val == val) {
head = head->next;
}
// 处理中间节点
ListNode* cur = head;
while (cur != nullptr && cur->next != nullptr) {
if (cur->next->val == val) {
cur->next = cur->next->next;
} else {
cur = cur->next;
}
}
return head;
这种写法没有错,但写起来要特别注意顺序:第一个 while 必须把头部所有目标值全部清掉,不能只处理一个。不然就出现“删了一个头节点,结果新的头节点还是目标值”的遗漏。
那为什么不用这种“单独处理头节点”的方案作为标准解法?因为不够通用。后面遇到“删除倒数第N个节点”、“两两交换相邻节点”等题目时,你会发现“头节点可能变动”的场景非常多,每次都写 while (head != nullptr && ...) 就显得啰嗦,而且容易漏。
2.2 虚拟头节点:给链表加一个“假前驱”
虚拟头节点的思路特别简单:在真正的头节点前面,再创建一个不参与业务逻辑的节点,让它的 next 指向 head。这样一来,原来的头节点也变成了“中间节点”,同样可以通过“站在前一个节点”的方式删除。
cpp复制class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* cur = dummy;
while (cur->next != nullptr) {
if (cur->next->val == val) {
ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete; // 清理内存,防止内存泄漏
} else {
cur = cur->next;
}
}
ListNode* newHead = dummy->next;
delete dummy; // 用完后释放虚拟头节点
return newHead;
}
};
这段代码的重点有几个:
cur从dummy开始,而不是从head开始。- 删除节点时,
cur不移动,因为删除后cur->next已经指向了新节点,这个新节点也可能需要删除。 - 只有当
cur->next->val != val时,cur才前进一个节点。
Python版本更简单,因为不需要手动管理内存:
python复制class Solution:
def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]:
dummy = ListNode(0)
dummy.next = head
cur = dummy
while cur.next:
if cur.next.val == val:
cur.next = cur.next.next
else:
cur = cur.next
return dummy.next
2.3 一个容易忽略的细节:cur 什么时候该前进
我第一次写的时候,直接在 if 和 else 外面统一写了 cur = cur->next,结果遇到连续两个相同值的节点就漏删了。
举个例子:链表 1 -> 2 -> 2 -> 3,删除值为2的节点。
如果统一前进,处理到 1 的时候,检查 cur->next(第一个2),发现命中,于是 cur->next 指向第二个 2;然后 cur 前进,变成了指向第一个2的节点。下一次循环检查的就是第二个2的下一个节点,也就是 3,第二个 2 反而被跳过了。
所以正确做法是只有没有发生删除时,cur 才前进。这也算链表题里一个经典的反直觉点:删除节点后,当前指针“原地不动”反而是正确的。
2.4 为什么这种迭代写法是面试中的“安全牌”
面试官喜欢这道题,很大程度上是因为它能在很短时间里看出候选人是否理解“指针变化”的时机。用虚拟头节点,代码思路统一,不需要分情况讨论,即使问题变形(比如删除倒数第N个节点)也能快速迁移。加上C++里 delete 操作能体现出你对内存管理的意识,整体观感会好很多。
3. 递归解法:链表其实天生适合递归
3.1 递归的思路不只是“遍历到末尾再往回删”
聊完迭代,再说说递归。很多人学链表的时候只觉得链表是“循环、指针”,没把它当成“递归结构”。实际上,单链表可以非常自然地看作:
一个节点 + 一个较短的链表。
所以 removeElements(head, val) 这个问题可以拆分:
- 如果当前
head是空节点,直接返回空。 - 如果当前
head的值等于val,那么结果应该等于removeElements(head->next, val)。 - 如果当前
head的值不等于val,那么保留head,并把head->next更新为removeElements(head->next, val)。
写成C++:
cpp复制class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
if (head == nullptr) {
return nullptr;
}
head->next = removeElements(head->next, val);
return head->val == val ? head->next : head;
}
};
Python版本:
python复制class Solution:
def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]:
if not head:
return None
head.next = self.removeElements(head.next, val)
return head.next if head.val == val else head
3.2 递归真的“从后往前删除”吗
这是一个很常见的误解。很多初学者看到递归代码,会觉得它是从链表末尾开始处理。实际上函数调用时,会一直向下深入到链表末尾,但在返回过程中才做“是否删除当前节点”的判断。换句话说,真正的删除动作发生在回溯阶段,而不是递推阶段。
用 1 -> 2 -> 3 -> 2 -> null,删除 2 来走一遍:
removeElements(1)调用removeElements(2)。removeElements(2)调用removeElements(3)。removeElements(3)调用removeElements(2)。removeElements(2)调用removeElements(null)。removeElements(null)返回null。- 回到第四层,当前节点值是2,返回
null(即删除了这个节点)。 - 回到第三层,当前节点值是3,返回
3 -> null。 - 回到第二层,当前节点值是2,返回
3 -> null(删除该节点)。 - 回到第一层,当前节点值是1,返回
1 -> 3 -> null。
所以结果确实是“从后往前逐个确认”,但调用的过程是从前往后走的。理解这一点对后面学树的递归很有帮助,因为树的结构比链表更复杂,能在这里建立正确的递归心智模型,后面会轻松很多。
3.3 递归的代价和适用场景
递归解法代码短、逻辑清晰,但有两个代价:
- 栈空间:递归深度等于链表长度。极端情况下链表有10万个节点,递归就会栈溢出。所以这个解法在工程上不如迭代,但在面试中作为补充方案说出“递归也能做,不过要考虑栈溢出风险”,反而能体现你对边界条件的思考。
- 可读性:对于刚入门的人,递归不是那么直观。需要画图或者debug走几遍才能完全理解。
我个人建议是:迭代法是必会方案,递归法是理解性方案。两者都掌握,遇到“反转链表”“合并两个有序链表”这类递归友好的题目时,你会有更多选择。
4. 边界条件与高频踩坑:这些测试用例必须一次过
4.1 最容易被忽略的几类用例
这道题的隐藏扣分点基本都藏在边界条件里。我把它们列成一张表:
| 场景 | 示例 | 注意事项 |
|---|---|---|
| 空链表 | [],删除任意值 |
直接返回 nullptr |
| 所有节点都要删 | [5,5,5,5],删除5 |
删除后仍为空链表 |
| 头节点是目标值 | [2,1,2],删除2 |
删除头节点后,新头节点可能还是目标值 |
| 连续重复值在中间 | [1,2,2,2,3],删除2 |
删除后 cur 不前进,避免漏删 |
| 目标值不在链表中 | [1,2,3],删除4 |
链表应保持不变 |
| 只有一个节点 | [1],删除1 |
删除后返回空 |
我在LeetCode评论区看过不少“明明逻辑没问题”但提交失败的代码,基本都是栽在“所有节点都要删”和“连续重复值”这两类用例上。
4.2 一个典型的错误写法
这是我在一个讨论帖里看到的高频错误写法:
cpp复制class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
while (head && head->val == val) {
head = head->next;
}
ListNode* cur = head;
while (cur && cur->next) {
if (cur->next->val == val) {
cur->next = cur->next->next;
}
cur = cur->next; // 问题出在这里
}
return head;
}
};
它的问题是,删除 cur->next 之后,cur 仍然直接前移。如果链表中存在连续两个值为目标值的节点,第二个节点不会被删除。在 [1,2,2,3] 删除2时,这个写法会返回 [1,2,3],与预期 [1,3] 不符。
修正方法很简单:
cpp复制if (cur->next->val == val) {
cur->next = cur->next->next;
} else {
cur = cur->next;
}
这个错误太经典了,几乎每个刷链表题的人都会遇到。它背后反映的是一个核心习惯:指针移动必须考虑“当前节点的状态是否发生了变化”。链表题里,修改指针和移动指针经常是互斥的,这一点在“删除排序链表中的重复元素”“删除链表中的节点”等题目里也同样适用。
4.3 内存管理的额外提醒
如果你用C++写,并且显式 new 了节点,记得在删除时 delete 对应的节点。LeetCode的判题系统一般不会因为你不 delete 就报错,但面试或实际工程中,内存泄漏是大事。
虚拟头节点 dummy 在函数结束前也要 delete。有一个常见细节是:先保存 dummy->next,再 delete dummy,最后 return newHead。顺序不能反,否则拿不到返回的头节点。
cpp复制ListNode* newHead = dummy->next;
delete dummy;
return newHead;
还有一个更隐蔽的坑:如果你删除了 cur->next 指向的节点,然后立刻访问它,就会产生“悬垂指针”。所以最好先用临时变量保存待删除节点,改完指针后再释放:
cpp复制ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
5. 从LeetCode走向工程:链表操作的真实应用场景
5.1 操作系统和内核里无处不在的链表
刷题的时候,很多人会有一种“链表不就是面试用的吗”的错觉。实际上,链表在底层系统里出现频率高得惊人。
拿Linux内核来说,内核里大量使用链表结构来管理进程、文件系统、设备驱动等。为了方便管理,内核定义了一个通用的双向链表结构 struct list_head,嵌入到各个数据结构中。删除链表中某个节点时,内核也依赖“前一个节点的指针”来完成操作,和LeetCode 203的删除逻辑本质上是一样的。
再比如内存分配器中的空闲块管理、文件系统的目录项缓存、网络协议栈中的报文队列……这些都能看到链表的身影。你在这题里训练出来的“指针操作”“边界处理”意识,放在这些场景里同样是基本功。
5.2 LRU缓存淘汰:删除链表节点的经典工业级场景
在工程项目中,最常被拿出来和链表绑定的例子是 LRU(Least Recently Used)缓存淘汰策略。
LRU缓存通常用“哈希表 + 双向链表”实现:哈希表负责O(1)查找,双向链表负责记录访问顺序。当缓存满时,需要淘汰最久未使用的数据,也就是删除链表尾部的节点;当某个数据被访问时,需要把它移动到链表头部。
这里面涉及的操作——删除指定节点、在头部插入节点——本质上就是在做“移除链表元素”这件事。不过因为双向链表每个节点都有前驱指针,删除时可以直接通过 prev 回到上一个节点,不像单链表那样必须从头找前驱。这也是为什么工程上需要频繁删除节点时,更倾向于用双向链表而不是单链表。
刷完203之后,再去做“LeetCode 146. LRU缓存”会顺手很多,因为你对“链表中如何删除当前节点”已经有了肌肉记忆。很多人在LRU上卡住,不是哈希表不会用,而是链表节点的“摘除”和“插入”操作不够熟练。
5.3 游戏引擎、编辑器里的撤销重做
还有一个例子是撤销/重做(Undo/Redo)功能。很多编辑器用“操作链表”来保存用户历史操作记录,撤销时从链表尾部移除一条记录,重做时在链表尾部添加一条记录。如果用户做了新操作,还要清空“重做”分支里的所有记录。这些逻辑里同样充满“在链表中删除节点”的影子。
所以别小看LeetCode 203这种“简单题”。它是很多复杂链表问题的基础,也是工程中“管理一组动态对象”的抽象缩影。把简单题练扎实,后面面对复杂问题时的底气都不一样。
6. 我的编码习惯与刷题心得:三个值得养成的细节
6.1 先画图,再写代码
链表题最忌讳“空想”。哪怕只是删除一个节点,牵扯到两个指针的变动,大脑就很容易乱。我的习惯是:拿到题先画一个三到五个节点的链表草稿,把目标节点标出来,然后画出“删除前”和“删除后”的指针指向。画清楚之后,代码基本就是照着图翻译。
很多人觉得画图浪费时间,但实际恰恰相反。链表题在纸上多花三分钟,写代码时能省二十分钟。这个习惯不只在刷题时有用,工作中设计数据结构时也一样。
6.2 统一用“上一个节点视角”思考
不管是迭代还是递归,我都倾向于把问题转化为“站在目标节点的前一个节点,怎么修改next”。这样思考的好处是:避免了对头节点的特殊处理,也让代码更接近“工程实现”的思维。
从这一题开始,我养成了一个习惯:凡是涉及“删除节点”的问题,先问自己一句“怎么找到前驱”。单链表找前驱要么从头遍历,要么在遍历时保存上一个节点,要么用虚拟头节点统一边界。把这个核心问题想明白,删除类的题目就不会再慌。
6.3 用“最小用例”做快速验证
写完代码之后,我习惯用一个非常短的用例在脑子里跑一遍。比如 1 -> 2 -> 3,删除2:
cur从dummy开始,检查cur->next即节点1,不等于2,cur前进。- 检查节点2,等于2,删除,
cur不前进。 - 此时
cur->next已经是节点3,检查节点3,不等于2,cur前进。 - 结束,返回
dummy->next。
整个流程在脑子里过一遍不到一分钟,但能拦截掉大部分低级错误。等刷题量上去了,这一步会越来越快,慢慢变成直觉。
7. 从这一题延伸出去的思考:链表题的“举一反三”路径
7.1 下一题练什么
刷完203之后,我建议按这个顺序往下练:
- LeetCode 206. 反转链表:训练指针翻转,理解“头节点会变”的另一个典型场景。
- LeetCode 19. 删除链表的倒数第N个节点:需要先找到倒数第N个位置,然后做删除,正好用上双指针和虚拟头节点。
- LeetCode 83. 删除排序链表中的重复元素:和203很像,但判断条件变成“相邻节点值是否相同”,逻辑更简单,适合加深对“当前节点是否前进”的理解。
- LeetCode 82. 删除排序链表中的重复元素II:把重复节点全部删掉,难度上来了,需要更精细的指针控制。
- LeetCode 146. LRU缓存:综合运用双向链表+哈希表,是链表题的“毕业考”之一。
这些题目的核心套路都绕不开“指针指向的变更”和“边界节点的处理”,和203的底层能力是一致的。
7.2 用“哨兵节点”解决一类问题
虚拟头节点也叫哨兵节点(Sentinel Node)。这个技巧远不止能在203里用。凡是“头节点可能被操作”的链表题,哨兵节点几乎都是最优解之一。
比如删除倒数第N个节点时,如果不用哨兵节点,就需要单独处理“删除的是头节点”的情况;用了哨兵节点之后,所有删除操作统一成“站在前驱位置删除目标节点”,代码干净很多。
我自己的体会是:哨兵节点不只是一种代码技巧,更是一种思维模型——在边界情况面前,与其写一堆特判,不如加一个统一层,把边界变成普通情况。这个思维模型在很多其他地方也能用,比如数组题里加辅助空间、树题里加虚拟根节点等。
7.3 简单题的价值不在“简单”
LeetCode 203的难度标签虽然是“简单”,但它包含的细节量一点都不小。能一遍写对的人,通常是对链表遍历、指针移动时机、边界条件都有清晰认知的人;写不对的人,往往也不是不懂“怎么删除节点”,而是对“什么时候该移动指针”“头节点变化后怎么返回”这些问题没有形成稳定的处理模式。
所以我的建议是:不要因为它是简单题就跳过。认认真真把这道题吃透,把迭代法、递归法、边界用例、工程联想都过一遍,后面很多链表题都会变得顺畅很多。刷题这件事,真正拉开差距的往往不是刷了多少道,而是把每道题背后的思维模式内化了多少。
