☰
LeetCode 203 移除链表元素:哨兵节点与递归写法详解
2026/10/13 7:36:17 网站建设 项目流程

这题我刷过好几轮,每次带新人或者写专题的时候都会把它拿出来当热身题。LeetCode 203 移除链表元素,题目短、标签简单,看起来就是把值等于目标值的节点删掉,但真上手写代码,能在一次内写对的人其实不多。原因倒不是题目本身难,而是链表题那几个坑——头节点特判、指针移动时机、连续删除相同值——全都集中在这道小题里了。如果你刚刷到链表这一章,或者刷了几遍还觉得自己写得不够顺,这篇就当作一份实操笔记来读。我会把两种主流的写法(迭代加哨兵节点、递归)拆开讲清楚,再把本地调试、内存释放、面试表达这些文档里不常写但实际很实用的东西一并补上。

1. 题目速览与问题拆解

1.1 题目到底要求我们做什么

题面本身就一句:给定一个单链表的头节点 head,删除链表中所有满足Node.val == val的节点,返回新的头节点。注意几个用词:“所有”意味着可能有多个重复值的节点被删,“返回新的头节点”暗示删除后链表的头部可能会变化。这两点恰恰是链表操作里最容易出错的地方。

链表删除和数组删除在思路上有本质区别。数组删除一个元素,后续元素要整体前移,复杂度是 O(n),而且还有“空位”要处理。链表删除则只需要修改前一节点的 next 指针,绕过被删节点即可,单次操作是 O(1) 的,代价是你必须找到“前驱节点”。这道题之所以值得写,就是因为它把“找前驱”这件事做到了极致:普通位置要找前驱,头节点位置也要找前驱,连续位置还要想办法不遗漏。如果这四个字“前驱节点”理解不到位,后面写 82 题删除重复元素 II、19 题删除倒数第 N 个节点都会栽跟头。

1.2 一眼能看懂,但为什么值得反复练

从算法难度上评价,203 确实配不上“难题”这个标签,官方归类也只是简单题。但我的看法是:简单题恰恰是检验基本功的试金石。链表专题里,很多中等难度题目最终都是在“删除一个节点”这个动作上叠加额外条件,比如有序链表去重、保留或删除重复项、合并两个链表、判断回文,基础操作都是同一套指针搬运。

这道题覆盖了链表操作的四项基本能力:第一,遍历链表时如何判断循环结束条件;第二,如何在遍历中安全地修改 next 指针而不丢链;第三,如何处理头节点被删除的特殊情况;第四,如何在释放内存时保证不会访问到悬空指针。这四点单独拎出来都不难,但组合在一起就很有欺骗性。我经常看到有人写出能跑通一两个用例的代码,却在head = [1,1,2], val = 1这种用例上翻车,原因多半是没想清楚“删除当前节点时,遍历指针到底该不该动”。

所以我给新人的建议是:这道题别急着用“背答案”的方式过掉,最好能在纸上画一画删除过程,把迭代版和递归版都写一遍,再自己造几个边界用例跑跑。花费的时间不会太多,但对后面刷链表题有实打实的好处。

1.3 几个一开始容易想到的“弯路”

先说说我在讨论区和新手代码里经常看到的三种错误倾向。

第一种思路是“新建一条链表”。遍历原链表,遇到不等于 val 的节点就拷贝一份接到新链表尾部。这样做程序当然能跑通,但没有利用原链表节点,反而额外申请了 O(n) 的空间。面试时面试官通常还会追问一句“能不能在原链表上操作”,如果你回答不上来,这道题就算废了。

第二种思路是“先把链表转成数组,删完再转回链表”。这个思路在工程上倒也能实现,但完全丢失了链表操作的训练意义,刷题角度来说属于自欺欺人。链表题考的就是指针操作和内存布局感知,转数组等于把考试内容绕过去了。

第三种思路稍微隐蔽一点:有人用“先删头节点,再删中间节点”的两段式处理。逻辑上没错,但需要单独写一段头节点循环,代码难看还容易漏。后面第 2 节我会重点讲虚拟头节点(也叫哨兵节点)如何彻底解决这个问题,让普通位置和头节点位置共用同一套删除逻辑。

2. 解法一:迭代加哨兵节点

2.1 没有哨兵节点时,问题出在哪

先看最朴素的迭代思路:用一个指针 cur 从头开始遍历,如果 cur->next 的值等于 val,就让 cur->next 指向 cur->next->next。这个思路本身正确,但有一个边界处理不了——头节点本身就是被删对象。

原因很简单:删除一个节点必须知道它的前驱,而头节点没有前驱。于是你只能额外写一段代码,先循环找到第一个不等于 val 的新头节点:

while (head != nullptr && head->val == val) { ListNode* tmp = head; head = head->next; delete tmp; }

这段代码删完头节点之后,才能进入正常的遍历删除逻辑。如果头节点连续好几个都等于 val,这个 while 循环就得一直转。逻辑虽然不难,但代码被硬生生拆成了两段,读起来很别扭。更重要的是,如果你用的编程语言是 C++ 这类需要手动管理内存的,头节点删除和中间节点删除的内存释放逻辑还必须保持一致,稍不留神就把指针搞丢了。

于是虚拟头节点(dummy node)这个经典技巧登场。它的思路是:自己 new 一个哑节点,让它的 next 指向真正的头节点。遍历从哑节点开始,这样原链表中的每个节点(包括原始头节点)都有了前驱。删除动作统一成一种写法,不再区分头节点和中间节点。虚拟头节点本身不参与业务判断,只是一个工具节点。

2.2 标准实现与完整代码

下面是 C++ 的迭代解法,也是我最推荐新手掌握的版本:

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) {} }; 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* tmp = cur->next; // 先保存待删节点 cur->next = tmp->next; // 跳过它 delete tmp; // 再释放内存 } else { cur = cur->next; // 只有不需要删除时才移动 cur } } ListNode* newHead = dummy->next; delete dummy; return newHead; } };

如果是面试中写伪代码或者用 Python 快速实现,代码还能更短:

class Solution: def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]: dummy = ListNode(-1) 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

两个版本的逻辑完全一致,区别只有 C++ 需要手动 delete 被移除的节点和最后的 dummy。Python 有垃圾回收,直接改引用就行。

2.3 指针移动的细节:删除后为什么不能动 cur

这段是整道题我最想强调的地方,也是新手最容易写错的地方。循环里,cur 永远指向“当前检查节点的前驱”。每次检查的是cur->next的值。

  • 如果cur->next->val == val,说明要删除cur->next。执行删除动作后,cur->next已经指向了下一个节点。此时cur 不能动,因为新的cur->next还没有被检查过,它可能也等于 val。
  • 如果cur->next->val != val,说明当前节点不需要删,这时才把 cur 移动到cur->next。

很多人写这段代码时,习惯性地在每次 if 判断后都执行cur = cur->next,结果就是遇到连续两个目标值时,第二个值被跳过了。比如链表1 -> 2 -> 2 -> 3,要删除值为 2 的节点。正确过程是:

  1. cur 指向 dummy,检查到第一个 2,删除。
  2. 此时 cur 仍然指向 dummy,它的 next 变成第二个 2。
  3. 再次检查,发现还是 2,再删除。
  4. cur 的 next 变成 3,不等于 2,cur 才移动到 3。

如果第 2 步之后 cur 被错误地移动到了第一个 2 的位置,那第二个 2 就无法被删除了。这个细节用一句话概括就是:删除当前待删节点后,你仍然停留在原位置继续看新的 next;只有确定 next 不需要删除,才往前走。画图理解最直观,纸上画三条链表状态就能彻底想通。

2.4 C++ 内存释放的注意点

C++ 版本里内存释放有两个知识点值得单独说。

第一,删除节点时要先保存待删节点指针,再改写 cur->next 的指向,最后 delete。代码里我写成了:

ListNode* tmp = cur->next; cur->next = tmp->next; delete tmp;

有人图省事会写cur->next = cur->next->next;,然后发现原节点指针已经找不到了,没法 delete,只能白白泄漏内存。在 LeetCode 在线评测里,不 delete 通常也能过,因为评测进程结束后操作系统会把内存回收,但如果你在本地跑,或者在面试现场手写,这就是明显的缺陷。实际工程里长期运行的服务器程序更不可能容忍这种泄漏。

第二,最后那句delete dummy也有讲究。我们不能在 return 之前直接delete dummy,因为返回的newHead是dummy->next,等号右侧的成员访问发生在 delete 之前,所以必须先保存newHead,再删除 dummy,最后返回。另外,如果直接在 return 语句里写return dummy->next;然后就没有后续了,dummy 会泄漏,其实也无伤大雅,但代码写全显得更严谨。

2.5 手推几个边界用例

写代码之前先在脑子里过一遍这几个用例,能在很大程度上避免低级错误。

输入链表val预期结果关键点说明
空链表[]任意空链表循环根本没有进入,返回 dummy->next 即 nullptr
[1]1空链表头节点被删,新头是 nullptr
[1]2[1]没有节点被删,返回原头节点
[1,1,2]1[2]连续头节点删除,需要循环删两次
[1,2,2,1]2[1,1]中间连续删除,验证 cur 不能乱动
[2,2,2]2空链表全部节点被删,最后返回 nullptr

把上面这些用例在纸上推一遍,你就会发现所谓边界条件,本质上就是“头节点变没变”和“有没有连续重复”两种情况的组合。哨兵节点统一了第一种情况,while 循环内不移动 cur 处理了第二种情况,剩下的就都是水到渠成。

3. 解法二:递归视角

3.1 递归的思考方式

迭代版本是顺着链表从前到后处理,递归版本则换了个方向:先把当前节点后面那一整条链表“处理干净”,再回过头来决定当前节点要不要保留。用递归的眼光看,removeElements(head, val)这个函数的意义是“返回一条从 head 开始、删掉所有 val 之后的链表”。那么对于当前节点 head,我们只需要关心两件事:

  • 删完之后,后面的链表长什么样?答案是removeElements(head->next, val)。
  • 当前节点 head 要不要留着?如果 head->val == val,就把它丢掉,直接返回后面处理好的链表;如果不等,就让 head 指向后面处理好的链表,返回 head。

这种“先信任递归能处理好子问题,再处理当前节点”的思考方式,一开始会有点不习惯,但它非常适合解决链表这类天然递归结构。你不需要手动跟着指针跳来跳去,只需要定义清楚每个节点的责任。

3.2 递归代码与逐步推演

递归版本的核心代码,C++ 和 Python 几乎一样:

class Solution { public: ListNode* removeElements(ListNode* head, int val) { if (head == nullptr) { return nullptr; } head->next = removeElements(head->next, val); if (head->val == val) { return head->next; } return head; } };
class Solution: def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]: if head is None: return None head.next = self.removeElements(head.next, val) return head.next if head.val == val else head

用1 -> 2 -> 3,val = 2 来推一遍。递归从最后一个节点开始返回:head 为 3 时,3->next是 nullptr,3 的 val 不等于 2,于是返回节点 3。回到节点 2,先算出2->next指向节点 3,然后发现自己的 val 等于 2,于是返回head->next也就是节点 3,相当于把节点 2 从链表中摘掉了。回到节点 1,1->next被设置为函数调用结果,即节点 3,最终返回节点 1。整条链变成了1 -> 3。

注意递归里那句head->next = removeElements(head->next, val)很关键。它把当前节点和“后面已经处理干净的链表”接起来,相当于一层一层往回组装。理解这个赋值方向,比死记递归代码有用得多。

3.3 递归的代价与面试取舍

递归版本代码简洁,思维上也很干净,但有一个不可忽视的代价:空间复杂度是 O(n)。n 是链表长度,每一层递归都会占用一个调用栈帧。链表长度几万时问题不明显,但达到几十万甚至上百万节点时,递归版本很可能直接把栈打爆。迭代版本维护一个 cur 指针,空间复杂度是 O(1)。

面试时如果选择递归写法,至少要把这一点主动讲出来,然后说明“工程上我更倾向迭代,因为链表长度不可控,递归有栈溢出风险”。这会让面试官觉得你不只是会写递归模板,还真的考虑过落地问题。反过来,如果面试官明确要求尝试递归写法,那就要把 base case 写对,并且能解释清楚每一步返回的含义。

我个人在刷题阶段会把两种写法都练,但实际解决问题和写业务代码时基本只用迭代版。递归版的价值更多在于帮助你理解“链表是什么”——它本质上就是一层层嵌套的节点结构,用递归去思考恰恰顺应了这个结构本身。

4. 本地调试与常见错误排查实录

4.1 先搭一个本地链表测试环境

我看很多人刷链表题,只用在线编辑器里默认给的测试用例,过了就不管了。这样很难发现自己代码的问题,因为测试用例太温柔。比较推荐的做法是本地写一个简单的测试框架,用数组构造链表,再把结果打印出来。有一个自己可控的环境,才能反复制造各种边界输入。

下面这个 C++ 示例可以直接当模板用:

#include <iostream> #include <vector> using namespace std; // ListNode 定义同前,省略 ListNode* buildList(const vector<int>& nums) { ListNode* dummy = new ListNode(0); ListNode* cur = dummy; for (int num : nums) { cur->next = new ListNode(num); cur = cur->next; } return dummy->next; } void printList(ListNode* head) { while (head) { cout << head->val << " -> "; head = head->next; } cout << "nullptr" << endl; } int main() { vector<int> nums = {1, 2, 2, 1}; ListNode* head = buildList(nums); head = removeElements(head, 2); // 调用待测方法 printList(head); return 0; }

这个框架很短,但能覆盖大多数链表题的本地调试需求。buildList里也用了哨兵节点,所以你其实会发现,“用一个 dummy 节点从头构建链表”这个思路和“用一个 dummy 节点删除链表元素”是同源的,本质上都是为了避免处理首节点的特殊情况。

4.2 高频 Bug 一:空指针访问

最典型的错误是这样写:

while (head != nullptr && head->next != nullptr) { if (head->next->val == val) { head->next = head->next->next; } head = head->next; }

看着好像没问题,但一旦链表为空,while 条件里先判断head != nullptr再判断head->next才能安全。如果只写while (head->next),空链表直接崩溃。还有一种常见的空指针场景:在删除动作之后又去访问cur->next->val,但此时 cur->next 已经成为了 nullptr,第二次循环进来之后就会出错。解决方案是在 while 里每轮都重新检查cur->next != nullptr,不要预先缓存。

4.3 高频 Bug 二:返回了旧的 head

不用 dummy 的版本容易出现这个问题。假设你写了:

ListNode* cur = head; // 删除操作... return head;

如果原链表头部有节点等于 val,你在头部做了删除操作后,head 这个指针本身并没有变——它依然指向一个已经被 delete 的旧头节点。可你返回的偏偏是它。这就是为什么必须用dummy->next作为新头来返回。用虚拟头节点之后,不管原头节点删没删,dummy->next永远指向删除后真正的第一个节点。这个设计不是可有可无,它是整个方案安全性的基石。

4.4 高频 Bug 三:删完节点后错误移动指针

这个 Bug 我在 2.3 节里已经详细分析过,代码上的复现长这样:

if (cur->next->val == val) { ListNode* tmp = cur->next; cur->next = tmp->next; delete tmp; } cur = cur->next; // 错误!删除后 cur 不该动

把cur = cur->next写在 if 外面,导致连续重复节点漏删。排查方法很简单:造一个[1, 2, 2, 1]删除值为 2 的测试用例,如果输出[1,2,1],那基本就是这个原因。我也曾经在这个坑里卡过很长时间,后来养成了一个习惯:每次写完删除逻辑,都会专门检查“删除分支里到底有没有移动遍历指针”。没有移动才是对的。

4.5 关于虚拟头节点的一处常见误解

有读者会问:万一val恰好也等于 0,而 dummy 节点的值我写成了 0,会不会把 dummy 也删掉?答案是:不会。因为我们的遍历是从 dummy 开始,但每次判断的都是cur->next->val,dummy 自己永远不会作为cur->next的检查对象?等等,准确地说,第一次循环时cur是 dummy,检查的是dummy->next->val,也就是原头节点的值,dummy 本身的值根本不会被读取。所以 dummy 的 val 字段设成多少完全无所谓,它只是一个占位节点,不参与业务逻辑。这个误解值得写出来,是因为确实有不少人在评论区问过,说明很多人对“哨兵节点只是工具”这个定位还不够清晰。

4.6 常见错误速查表

把上面几个问题汇总成一张表,方便你写完后自查:

错误类型现象根本原因对策
空指针访问程序崩溃while 循环里提前解引用空节点每轮循环都判断 cur->next 非空
返回旧头节点结果多出已删节点head 指针未更新返回 dummy->next
删除后移动 cur连续相同值漏删新 next 未检查删除分支不移动 cur
忘记 delete 被删节点内存泄漏(本地模式)改指针前未保存节点用 tmp 保存后再 delete
提前 delete dummy返回值变成野指针表达式求值顺序错误先保存 dummy->next 再释放

5. 从一道题看一类题与工程场景

5.1 面试时这道题怎么答才加分

如果面试官让你现场写这题,不要一上来就埋头敲代码。先花十秒钟把信息确认到位,这本身就是加分项。比如确认链表是单向还是双向、节点定义里有没有 tail、val 的取值范围、链表长度大概多少、能不能用额外空间。这些问题不是废话,它们直接影响解法选型。链表很长时递归版有风险;节点个数接近内存上限时,新建链表也不行。把这些背景问清楚,面试官对你的第一印象会好很多。

接下来讲思路。我习惯的顺序是:先说“我会用一个哨兵节点统一头节点和普通节点的删除逻辑”,再说“遍历时用 cur 指向待检查节点的前驱”,最后补一句“时间复杂度 O(n)、空间 O(1)”。这三句话说出来,核心方案已经清楚了。然后开始写代码,写完不要立刻说“我做完了”,而是主动跑一个例子。口头推演一个[1,1,2]删 1 的用例,把“连续节点为什么不漏删”讲出来,这道题基本就稳了。

5.2 同门兄弟题目:删除重复元素、删除倒数第 N 个

203 的技巧可以平移到好几道经典题上。最直接的是 83 题“删除排序链表中的重复元素”,要求在有序链表中删除重复值,每个值只保留一个。代码和 203 几乎同构,区别只是在cur->next->val == cur->val时删除 cur->next,而不是和固定 val 比较。

稍微进阶的是 82 题“删除排序链表中的重复元素 II”,它要求把所有重复的节点全部删除,一个都不留。这道题必须借助哨兵节点,因为它可能把原头节点也删掉。写法上要比对cur->next->val和cur->next->next->val的值,如果相等就记下这个重复值,然后循环跳过所有值相等的节点。看到这里你会发现:203 里“删除后 cur 不动”的技巧,在 82 里就是“跳过整段重复区间”的基础。

还有 19 题“删除链表的倒数第 N 个节点”,快慢指针法实现时,如果倒数第 N 个恰好是头节点,没有哨兵节点就会很麻烦。有了 dummy,slow 指针就能安全地停在待删节点的前驱。小结一下:203 练的是最底层的删除动作,83 和 19 是动作的两种变体,82 是动作最复杂的形态。把 203 写透,再去看后面这几题会轻松很多。

5.3 真实业务里链表删除的取舍

聊点刷题之外的东西。实际生产环境里,链表并没有算法题里那么常见,但一旦出现,删除逻辑往往是核心。我在维护模拟项目X里的怪物刷新列表时,就遇到过一个类似问题:所有怪物单位挂在一张单向链表上,有怪物死亡时需要把它从链表移除。由于头节点可能是任意一只怪物,移除时同样要小心首节点。当时我直接在链头加了一个哨兵节点,整个删除逻辑只需要写一次,和 203 的代码几乎一样。

工程场景中还会多考虑一个问题:延迟删除。如果链表节点在遍历过程中被另一个线程修改或删除了,你在这边改 next 指针就可能访问到已经回收的内存。所以很多实际系统会选择“标记删除”,也就是先给节点打一个删除标记,等到遍历结束后统一摘除。这与刷题时“就地立刻修改指针”的做法不完全一样,但底层的链表删除原理是相通的。真去做这种设计时,虚拟头节点仍然有用,因为即使统一摘除,你还是要处理“头节点可能被摘除”的边界。

另一个值得一提的区别是,工程代码里我很少用递归来删除链表节点。原因和 3.3 节说的一样:链表长度不可控,递归深度容易爆栈。刷题时递归版本可以作为思维训练,但生产代码里绝大多数情况会选择迭代加哨兵节点,这个选择不是编程语言决定的,而是稳定性和资源占用决定的。

5.4 我对链表刷题的一点体会

回到 203 这道题本身。它足够简单,简单到很多刷题的人一眼扫过去就跳过;但它又足够典型,典型到能把链表题里七成的边界问题浓缩进来。我个人刷链表专题时的一个习惯是:每道题先用无头节点写法硬啃一遍,再改成哨兵节点写法,对比两种写法的差异。这样你才能真正体会到哨兵节点为什么重要,而不是因为“模板里就是这么写的”而机械使用。

还有一个很小的经验想分享:链表题的 bug 往往不是逻辑上完全错了,而是对“哪一步该移动指针、哪一步不该移动”的把握出了问题。如果你也经常卡在这种地方,建议准备一个草稿本,每遇到一次就画一遍链表状态图。等你能随手画清楚cur、cur->next、dummy->next三者的变化关系,203 这类题目就真的过关了。后面不管碰到删除重复元素、反转链表还是合并有序链表,你都会发现,最核心的底层操作,始终还是今天说的这套东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询