news 2026/10/5 2:56:20

单链表删除节点全攻略:虚拟头节点与递归思路详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
单链表删除节点全攻略:虚拟头节点与递归思路详解

刷题这件事,大多数人都是从数组开始的,然后无一例外地栽在链表上。力扣第203题“移除链表元素”,是我这些年看下来最适合用来突破链表恐惧症的题目:它不涉及反转、排序那些花活,只考察一个最底层的问题——你会不会在一个单链表里删除一个节点。就这么个“简单”操作,能把虚拟头节点、双指针、递归返回值这些核心套路全串起来。这篇就围绕这道题,把思路拆开、把代码讲透、把坑填平,无论是刚入门刷题的新手,还是准备面试想快速过一遍链表基础的人,都能直接拿去参考。

1. 题面解读与核心难点

1.1 题目到底在考什么

题目本身一句话就能说清楚:给你一个链表的头节点head和一个整数val,请你删除链表中所有满足Node.val == val的节点,返回新的头节点。比如输入链表1 -> 2 -> 6 -> 3 -> 4 -> 5 -> 6,val = 6,输出应该是1 -> 2 -> 3 -> 4 -> 5。

说它简单,是因为解法就那两种:迭代和递归。说它经典,是因为“删除所有匹配节点”这句话背后藏着两个细节:第一,链表的删除操作本身需要找到待删节点的前驱;第二,头节点本身也可能被删掉,这时候整个链表的“入口”都变了,返回结果就必须跟着变。

很多新手在这一题上卡住,不是不知道p.next = p.next.next这种写法,而是从来没有认真想过一个问题:如果头节点就是待删除节点,那我们的“前驱”从哪里来?如果没有前驱,那头节点该怎么删?这就是整道题的核心难点,也是为什么虚拟头节点(dummy node)这个技巧在这道题里几乎是“规定动作”的原因。

1.2 删除操作的本质:先找到前驱

要想理解这题,先理解链表删除的物理结构。链表里的每个节点就像一个珠子,串在一根线上,每个珠子手里只攥着通向下一个珠子的那根线。现在要拿走一个珠子,只能让前一个珠子松手,改攥住后一个珠子的线。这里的关键点就出来了:你至少得知道前一个珠子在哪里。

但链表本身只给了你头节点,你在遍历的时候永远只能知道“当前节点”和“下一个节点”,没办法直接得知“上一个节点”。所以删除操作的实际执行,是在当前节点判断“下一个节点是不是我要删的”,如果是,就让当前节点跳过它。这意味着,遍历指针其实一直在扮演着“前驱”的角色。

这就解释了为什么很多第一次写这道题的人会写出这样的代码:遍历到cur.val == val的时候,让cur = cur.next试图“跳过”它。打印链表一看,没删掉。因为在单链表结构里,你让指针往后跳,只是让遍历视线挪开了,但前一个节点的next依然指着那个待删节点。真正要改的,是前一个节点的指针。

2. 迭代法实操:虚拟头节点一劳永逸

2.1 完整代码与逐行解析

我以C++版本为主讲,Python和Java版本放在后面对照,逻辑完全一致。先看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* del = cur->next; cur->next = cur->next->next; delete del; // OJ上不写也行,面试时最好清理 } else { cur = cur->next; } } return dummy->next; } };

逐行解释。第一件事是创建一个虚拟头节点dummy,它的next指向真正的head。cur指针也指向dummy。为什么要这样?因为我们需要一个工具人——一个总处在待删节点之前的节点。有了dummy,即使原来的head等于val,我们也能用cur->next访问到它,并且统一用“跳过”的方式删除它,不需要为头节点单独写分支。

主循环的条件是while (cur->next != nullptr),注意这里不看cur自身,而是看cur的下一个节点。因为我们要判断的是“下一个节点是不是需要删除”。如果下一个节点是需要删除的,就执行cur->next = cur->next->next,把指向它的线直接接到它的下一个节点上,完成删除。这一轮cur不要往后移动,因为新的cur->next是刚刚接过来的节点,还需要再次检查它是不是也等于val,否则连续重复节点就漏删了。

如果下一个节点不需要删除,cur = cur->next,平移到下一个节点,继续检查它的下一个。循环结束后,所有值为val的节点都被跳过,返回dummy->next,也就是新的头节点。这里还有一个隐藏好处:即使原链表所有节点都被删光,dummy->next也会是nullptr,不会出现返回悬空指针的问题。

2.2 Python与Java版本对照

Python版本的代码结构完全一样,只是没有手动释放内存这一步(交给GC回收):

class Solution: def removeElements(self, head: ListNode, val: int) -> 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

Java版本也一样:

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; } }

三种语言的要点就一句话:判断cur.next,而不是判断cur。这是迭代法唯一的灵魂。

2.3 时间复杂度与空间复杂度分析

时间复杂度是O(n),因为每个节点最多被访问两次:一次是作为“下一个节点”被检查,如果没删,cur会移到它身上再作为“前驱”检查下一个。空间复杂度是O(1),只用了一个虚拟头节点和两个指针,没有额外申请与链表长度相关的存储。复杂度这块没悬念,真正有悬念的是递归解法,它看起来简洁得令人怀疑人生,但空间开销实实在在。

3. 递归解法:思路简单但要注意返回值

3.1 递归的核心思想

递归的本质是“把一个大问题拆成同构的小问题”。对于链表来说,天然适合递归——每个节点都可以看作“当前节点 + 一条更短的链表”。题目说删除所有值为val的节点,那么对当前节点来说只有两种选择:如果当前节点等于val,就整个跳过它,只返回后半部分的处理结果;否则保留当前节点,并把它的next指向后半部分的处理结果。

用一句人话说:让函数自己处理“接下来的链表”,然后当前节点决定要不要跟在后半部分前面。写递归最重要的就是抓住“子问题是什么”,而不是在脑子里展开整个递归过程。

3.2 递归代码实现

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; } };

这段代码短得吓人,但每一行都有讲究。首先是边界条件:节点为空时返回nullptr,这是递归的出口。然后无条件先递归处理后面的链表,把结果接回head->next。最后看当前节点的值:如果等于val,说明当前节点也要被删掉,那就不返回它,直接返回已经处理好的后半段结果;如果不等于val,当前节点就还在,返回它。

用例子走一遍流程。链表是1 -> 2 -> 6 -> 3,val = 6。最深处先到3:3的next是空,返回nullptr后,3 != 6,返回3。上一层是6:6先接收了后半部分3(也就是6->next = 3),接着判断自己等不等于6,等于,所以直接返回3,放弃了自身。再上一层是2:接收了3接在2->next,判断2 != 6,返回2 -> 3。最上层是1:接收了2,返回1 -> 2 -> 3。完美。

3.3 递归的空间代价与注意事项

递归解法的代码简洁,但有一个不能忽视的成本:空间复杂度是O(n)。每次递归调用都会在系统栈上占用一份栈帧,链表的长度就是递归深度。对于长度几百的链表无所谓,但在嵌入式的内存受限场景,或者链表有几万几十万个节点时,可能直接栈溢出。刷题时能用迭代就用迭代,除非面试官明确要求写递归,或者题目本身就在练习递归。

还有一个常见误区:有人会把递归写成“先判断再递归”的形式,也就是在if (head->val == val)的时候返回removeElements(head->next, val),否则递归处理后面。这在逻辑上没错,但代码会变成两个返回值路径,容易漏掉head->next的赋值,导致“删除后链表还是连着旧节点”的诡异问题。我记得踩过这个坑:写完发现输出结果里待删节点虽然不在返回链表中,但它的next还残留在某些路径上,打印循环直接死循环。所以递归写法推荐“先处理后判断”,一条返回路径走到底,不容易出幺蛾子。

4. 常见问题与本地调试实践

4.1 新手最容易翻车的三个场景

这道题别看简单,实际一跑就出错的情况我见得太多了。第一个是连续重复节点漏删。比如链表1 -> 6 -> 6 -> 3,val = 6。如果在删除节点后立刻把cur往后移动,第二个6就溜过去了。正确做法前面强调过:删除之后cur原地不动,继续验证新的cur->next。第二个是用while (cur != nullptr)作为循环条件,然后在循环里判断cur->val,结果发现删除最后一个节点后压根不知道前一个节点是谁,万般无奈又翻回头写pre双指针。虚拟头节点明明就是为了解决这个问题,没必要绕远路。第三个是忘记返回新头节点,最后返回了head。如果原头节点就是待删节点,这么做直接返回了一个已经在链上“悬空”的节点,输出结果完全不对。

4.2 常见错误对照速查表

错误类型典型代码写法的隐患正确做法
头节点无法删除直接while (cur != nullptr)判断cur->val用虚拟头节点或单独处理头节点
连续重复节点漏删删除后立即cur = cur->next删除后保持cur不动,下一轮继续判断
删除失败但看着像删了直接cur = cur->next试图跳过节点必须修改前一个节点的next指针
返回值错误返回head或dummy返回dummy->next
死循环遍历条件写while (cur)且未更新cur确保每一轮都更新cur或改变cur->next
空链表崩溃未判断head == nullptr入口加空判断,或让虚拟头节点兜底

4.3 本地可复用的调试工具函数

刷题进度的最大杀手其实是“本地一跑就编译报错,根本轮不到逻辑出错”。我的建议是平时就备好一套链表调试工具,不要每次都现写。比如C++里面,写一个从数组构建链表的函数和打印链表的函数:

ListNode* buildList(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 != nullptr) { cout << head->val; if (head->next != nullptr) cout << " -> "; head = head->next; } cout << endl; }

有了这两个函数,配合测试用例[1,2,6,3,4,5,6]、[6](单节点单删)、[](空链表)、[6,6](全部删除),直接本地验证逻辑,跑通了再贴回力扣提交。我喜欢在本地先把所有边界用例都跑一遍,再去OJ提交,省得反复试错。

5. 举一反三:从203到链表全家桶

5.1 同类型题目一网打尽

203题做会了,后面好几道题其实都是它的变体。力扣83题“删除排序链表中的重复元素”,是只保留一个重复元素,循环里改一个判断条件就行。力扣82题“删除排序链表中的重复元素II”更狠一点,重复元素全部删除,这就要先用前置指针判断“下一批是不是重复的”,本质还是前驱节点那一套。力扣19题“删除链表的倒数第N个结点”,需要用快慢指针先拉开距离,但删除时依然是寻找前驱节点的套路。力扣237题“删除链表中的节点”更特殊,它只给你待删节点本身,不给你头节点,巧妙做法是用下一节点的值覆盖当前节点再跳过下一节点——背地里玩的还是“前驱”概念。

这些题串起来看,你会发现链表删除题的核心就三条:找到前驱、改指针、处理头节点。虚拟头节点一上,三条路全通。做题时应该主动总结这个套路,而不是一题一题孤立地背代码。

5.2 刷题笔记如何记录才有价值

我一直建议刷题的人准备一份偏差笔记,记录的不是题解全文,而是“我当时为什么没想到”。比如203题,就记录“我试图直接用cur删除自己,但链表没有回头路,必须用前驱”。这种一句话复盘,比抄十遍代码都管用。具体做法:每道题做完,花两分钟在笔记里写三行——第一行是题号和题目一句话描述;第二行是核心套路(比如“虚拟头节点 + 前驱指针”);第三行是我踩的坑或者恍然大悟的点。一个月后回头看,这份笔记才是真正的刷题资产。

还有个小习惯,就是讲题给别人听。找一个朋友或者干脆对着空房间,把这道题从头到尾讲一遍:为什么用虚拟头节点、循环条件为什么是cur->next、递归的返回路径是什么。你只要能把一个完全没准备的人讲明白,这道题就真吃透了。这个做法比再刷十道题都管用,尤其是在链表这种“自以为懂了但一动笔就卡壳”的知识点上。

5.3 迭代和递归到底怎么选

做203题时很多人会纠结:两种解法都会,该用哪种?我的建议是:默认迭代,笔试面试都优先给迭代解法,因为空间复杂度更低,而且虚拟头节点的思路可迁移性极强。递归解法可以当练习写一遍,体会“子问题”的分解方式,对后续二叉树的递归题有热身作用。但面试时如果主动写了递归,要有心理准备被追问“这个会不会栈溢出”——你能接住“深度为n时空间复杂度O(n),而迭代是O(1)”这种回答,面试官反而会认可你对复杂度理解够深。

解题顺序上,先想清楚迭代怎么走,再试递归怎么写。反过来往往容易陷进递归的调用过程出不来,越画栈越懵。


最后分享一个实战小技巧。不管用什么语言,调试链表题时我都习惯把测试用例设计成五类:空链表、单个节点、头节点就是要删的那个、连续重复节点散落在中间、整条链全都要删。把这五类固定下来,每次做链表题先跑一遍这套用例,逻辑出错的概率能降一半。203题只是一个起点,但如果你能把这题的虚拟头节点和指针移动彻底想明白,后面遇到19题、82题、甚至复杂一点的链表反转题,都会明显感觉轻松不少。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 2:56:18

Aeternum C2威胁情报分析:高级持久性与网络规避实战

这篇标题涉及的是网络安全威胁情报分析&#xff0c;我按照资深安全研究员的视角&#xff0c;围绕标题、关键词&#xff08;Aeternum、C2、高级持久性、网络规避&#xff09;展开&#xff0c;深度拆解这个C2基础设施的技术画像、工作机制与防御应对。第一篇博文正文如下&#xf…

作者头像 李华
网站建设 2026/10/5 2:56:00

HTTP与HTTPS的底层差异及生产环境实战部署指南

早上七点&#xff0c;手机弹出一条告警&#xff1a;线上订单回调大面积超时。我打开后台一看&#xff0c;日志里全是HTTP 400和连接重置&#xff0c;追了半天才发现是上游服务走了明文 HTTP&#xff0c;被网关拦了。那会儿我才真正意识到&#xff0c;HTTP 和 HTTPS 的差距不是在…

作者头像 李华
网站建设 2026/10/5 2:56:00

Windows上跑Linux:WSL安装、终端配置与apt依赖管理实战

刚接触 Linux 时&#xff0c;我面临的最大障碍不是命令记不住&#xff0c;而是“一台 Windows 电脑怎样才能舒服地跑 Linux”。双系统要重启切换&#xff0c;虚拟机又觉得笨重。直到用上 WSL&#xff08;Windows Subsystem for Linux&#xff09;&#xff0c;这个问题才算真正解…

作者头像 李华
网站建设 2026/10/5 2:56:00

毕业季论文降AI工具实测:原理、选择与实操流程全解析

又到了2026年毕业季&#xff0c;实验室、宿舍里的电脑屏幕清一色开着论文编辑器和各种检测平台。身边好几个学弟学妹都在问我同一个问题&#xff1a;AI痕迹太重&#xff0c;降AI工具到底该选哪个&#xff1f;我花了差不多两周时间&#xff0c;把市面上能接触到的降AI方案挨个测…

作者头像 李华
网站建设 2026/10/5 2:55:31

长江内河物流的毛细血管模式:从华光源海看港口网点运营

做内河物流这些年&#xff0c;我越来越觉得&#xff0c;长江这条江人人都看得见&#xff0c;真正能把它跑成一张网的人并不多。最近反复拆解的一个样本是华光源海&#xff0c;标题里那组数字相当有代表性&#xff1a;25个港口网点、19.39亿营收、沿江毛细血管模式。这三个词放在…

作者头像 李华
网站建设 2026/10/5 2:55:28

OpenClaw(Clawdbot)开源AI助理:部署与Skills实战指南

2026年了&#xff0c;如果你还在每天花两三个小时整理周报、批量改页面样式、复制粘贴重复文件&#xff0c;那我建议你认真看一下OpenClaw这个项目。它还有个名字叫Clawdbot&#xff0c;本质是一个开源的AI个人助理框架&#xff0c;你只要给它装上一批Skills&#xff0c;它就能…

作者头像 李华