news 2026/10/9 3:10:45

链表操作核心:移除元素与反转链表,掌握指针与虚拟头节点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表操作核心:移除元素与反转链表,掌握指针与虚拟头节点

我先把结论放在前面:链表这类题,在LeetCode上属于典型的“看着简单、一写就错”。数组问题写错了多半是边界管得不好,链表问题写错了几乎都是因为对指针变化时机的理解不到位。而“移除链表元素”和“反转链表”这两道题,恰好把链表操作里最核心的两种基本功——删节点、改指向——一次性覆盖了。把这两道题吃透,后面再做合并有序链表、两两交换节点、判断环形链表之类的问题,会轻松很多。

这篇文章适合正在刷题准备面试的朋友,也适合刚学完数据结构、想把这些基础操作真正落到代码里的同学。我不会只贴一个能通过的答案,而是把为什么这么写、哪里容易错、画图时应该盯住哪些地方都讲一遍,争取看完之后你能闭着眼在白板上把这两道题写出来。

1. 先说清楚:链表操作到底难在哪

很多人第一次接触链表,都会觉得“这不就是结构体里加个指针嘛”,结果一上手写删除、反转,逻辑就开始打架。原因很简单:链表这套东西,语法层面确实不难,难的是它和数组处理的思维方式完全不同。

1.1 从一块数据积木说起:节点与指针

链表里的节点,说白了就是一块存了数据、还带了一个“指向下一个节点地址”的变量。单链表里每个节点只有一条“线”通向后面,所以它天生就是一条单行道。

class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next

这段代码是LeetCode里最常见的链表节点定义。val 存值,next 存下一个节点的引用。就这么点东西,但所有的链表操作,本质上都是在回答一个问题:当前有几个指针分别指着谁,接下来要让谁指向谁。

数组里想做删除,是把后面的元素整体往前搬。链表里想删除一个节点,只需要让前一个节点的 next 跳过它、指到它的下一个节点就行。听起来更简单,但问题是,数组里搬元素的时候,原数组还在原地,你有的是时间慢慢改;链表里一旦你把某个节点的 next 改了,原来的指向就没了,一旦没了记录,你手头又没有别的指针指着它,那就找不回来了。

我见过太多人在这里翻车:想删 cur 这个节点,脑子一热直接写cur.next = cur.next.next,然后发现链表断了,因为根本没有记录前一个节点是谁。你手里只有一个 cur 去遍历,它往前一走,前面那个节点就没人管了。

1.2 链表操作的三条心法:画图、保引用、三步走

这三条是我自己反复撞墙之后总结出来的,适用于所有链表题。

第一条:动手写代码前,先画图。别嫌麻烦。你随便画四个方框代表节点,用箭头连起来,然后拿笔模拟指针移动。大部分链表题的思路,画完图就自己浮出来了。很多人在脑子里空想,越想越乱,其实就是没把内存里的指向关系实体化。

第二条:改指针之前,先保住要丢的引用。这一点是链表操作里最核心的工程素养。想象你要把一个节点从链上摘下来,或者要反转两个节点的方向,总有一个 next 会被覆盖。被覆盖之前,你得先把那个值存下来,存到临时变量里。这就是面试官常说的“备份 next”。做题的时候,凡是发现某一步要改 next,立刻问自己一句:改了之后,原来指向的那个节点还能不能找到?找不到就加个临时变量。

第三条:任何一步操作都遵循“先断后接、保旧再换新”的顺序。可以先画三条线,标清楚当前这几个指针的状态,然后按顺序执行:先用temp保存即将失去引用的节点,再改A的next指向B,再改B的next指向A……每一步之间不要跳。链表题之所以容易错,就是因为代码很短、步骤很密集,脑子稍微快进一下,就把顺序弄反了。

这三条心法放到所有链表题里都适用,后面的两个题目我也会反复回到这些原则上。

2. 移除链表元素:真正难处理的其实是“头”

题目本身不复杂:给定一个链表的头节点 head 和一个整数 val,删掉链表中所有值等于 val 的节点,返回新的头节点。这是LeetCode第203题,也是一道“送分题”,但它的送分程度取决于你是否处理对了头节点。头节点一旦特殊化,很多人就开始漏判。

2.1 题目和第一反应:单指针遍历会漏掉什么

大多数人第一反应是:遍历链表,看到一个节点的值等于 val,就把当前节点删掉。可问题是,删除一个节点,必须要知道它的前一个节点是谁。用单指针 cur 遍历的时候,你发现当前节点要删,你根本不知道它的前一个节点在哪,于是只能另想办法。

最容易想到的笨办法是:单独处理头节点。先写个循环,不停地看 head 的值是不是 val,是的话 head 就往后挪;处理完头节点之后,再从新的头节点往后遍历,用前一个节点 pre 来执行删除。这个方案能跑,逻辑也对,但代码写得比较别扭,而且新手特别容易在处理完头节点之后忘了更新遍历起点。

还有一种更极致的情况:如果整个链表的值全是 val,比如 head 是1->1->1->1,要删掉所有 1。用上面的笨办法,单独处理头的循环会一直把头往后挪,直到 head 变成 None,这时候函数返回的就是一个空链表。逻辑没问题,但很多人写到这里会开始怀疑人生,觉得空链表也要单独考虑。

2.2 虚拟头节点:让删除逻辑统一

我强烈建议从一开始就用虚拟头节点(dummy head)的写法。它的思想很简单:在真正的头节点前面,再造一个节点,它的 next 指向 head。这样整个链表的每一个节点都变成“有前驱”的节点了,删除逻辑可以被统一处理,不需要再为头节点单独开一条分支。

def removeElements(head: ListNode, val: int) -> ListNode: dummy = ListNode(0) dummy.next = head pre = dummy cur = head while cur: if cur.val == val: pre.next = cur.next else: pre = cur cur = cur.next return dummy.next

这里最关键的是一点:pre 什么时候动、什么时候不动。如果当前节点 cur 被删掉了,pre 不能动。因为 pre 的下一个节点已经变成了 cur 的下一个节点,这个“新的下一个节点”还没被检查过,下一次循环里 cur 会指到它,如果 cur 不需要删除,pre 才移动。

有人可能会问,cur 都已经指向被删节点了,cur = cur.next还能拿到它原来的下一个节点吗?当然能,因为cur.next这个引用在你删除动作发生之前就已经存在了,删除只是修改了 pre.next,cur 自己还握着自己原来的 next 指向呢。这就是链表的特性,一个节点可以同时被多个变量指着,你改掉其中一个变量的指向,其他变量不受影响。

你可以对比一下没删的时候和删了之后的状态:

状态precurpre.next
删除前指向前一个有效节点指向待判断的节点cur
删除操作后指向cur原来指向的下一个节点仍然指向被删节点(本轮用完即弃)cur.next
下一轮循环可能不动或移动到cur的位置移到原cur.next取决于新节点是否删除

这个表格我建议你配合代码一起看。逻辑清楚了,代码就是几行的事。

2.3 双指针版本:pre 和 cur 的配合

虚拟头节点版本的另一种常见写法是双指针,其实上一个代码已经就是双指针了。pre 永远指向“最后一个确定不需要删除的节点”,cur 负责往前探索。每当 cur 发现一个要删的节点,pre 就直接把 cur 从链表中摘出去;每当 cur 发现一个不用删的节点,pre 就往前走一步,和 cur 保持同步。

这种写法的核心是:pre 和 cur 之间既可能齐头并进,也可能隔着被删掉的节点。划分清楚这两者什么时候同步、什么时候不同步,是整道题的题眼。用虚拟头节点包裹之后,pre 的初始值是 dummy,而不是 head,于是整个遍历过程中,删除任何节点的动作都是一模一样的代码分支,没有任何特殊情况。

如果不用虚拟头节点,你还得先想办法让 head 跳到第一个不等于 val 的位置,然后再初始化 pre=head、cur=head.next。那样写不是不行,而是每次读代码都要格外小心,生怕把头节点那一段逻辑漏了。我自己刷题的经验是:能在操作前加一层“统一外壳”,就不给自己留特殊分支的机会。虚拟头节点就是链表的统一外壳。

2.4 操作时间与空间的硬性指标:复杂度分析

这道题的时间复杂度是 O(n),n 是链表长度,因为每个节点都被遍历了一次。空间复杂度是 O(1),除了几个指针变量外没有额外申请空间。这属于链表的常规操作水平,面试时建议主动说出来。

很多人在复杂度分析上有个误区,觉得虚拟头节点多申请了一个节点,空间复杂度不应该是 O(1)。其实不对,申请单个固定大小的节点是常数级空间,复杂度分析里依然记作 O(1)。只有申请了和输入规模成正比的空间,才会记作 O(n)。

3. 反转链表:一次把方向改到底

反转链表是LeetCode第206题,也是所有链表题里地位最“基础中的基础”的一道。说它基础,是因为迭代解法只需要三个指针;说它重要,是因为递归解法涉及对递归边界和“返回值是谁”的深刻理解,理解透了之后能直接迁移到反转前N个节点、反转区间、K个一组反转等一堆问题。

题目内容:给定一个单链表的头节点 head,反转链表,返回反转后的新头节点。

3.1 迭代反转:pre、cur、temp 三个指针的接力

迭代反转的核心思想特别朴素:把每个节点本来指向后一个节点的 next 指针,改成指向前一个节点。问题在于,一旦你把 cur.next 改了,cur 原来指向的下一个节点就找不到了,所以你得提前用一个临时变量把它存下来。

def reverseList(head: ListNode) -> ListNode: pre = None cur = head while cur: temp = cur.next cur.next = pre pre = cur cur = temp return pre

顺序特别重要:先保存,再改指,最后移动。丢失引用的根源就是顺序写反。只要先存了 temp,后面怎么改 cur.next 都不用怕。

这里有两个必须自己想明白的点,说实话也是面试官最爱追问的点:

第一个,为什么 pre 的初始值是 None,而不是 head 或者别的什么东西?因为反转之后,原来的头节点要变成新的尾节点,它的 next 必须指向 None,所以第一个处理节点时,它的 next 就要指向 None,也就是 pre 的初始值。

第二个,为什么最后返回的是 pre?循环退出时 cur 已经变成 None,pre 指向的是最后一个被处理的节点,也就是原链表的尾节点。反转之后,它就是新链表的头节点。如果你返回 head,那只是回到了原链表的尾节点上去,白忙一场。

我一个很直观的建议是:拿一支笔,画四个节点,从头开始一步步执行这段代码,把每一个循环迭代里 pre、cur、temp 各指向谁标出来。画完三个迭代你就会发现,这个算法的本质就是一条“传送带”:temp 拎住后面的链条,pre 和 cur 分别往前滚动,同时把方向扭过来。

3.2 递归反转:换个视角,“后面的已经反转好了”

递归解法的切入点和迭代完全不同。迭代是从头开始,一个个改变 next 的方向;递归是想办法先走到链尾,然后从后往前改变方向。

def reverseList(head: ListNode) -> ListNode: if head is None or head.next is None: return head new_head = reverseList(head.next) head.next.next = head head.next = None return new_head

第一次看到这段代码的人,最难接受的是这一行:head.next.next = head。它的意思是,让 head 的下一个节点的 next 反过来指向 head。注意这里的 head 是当前节点的名字,不是整个链表的头节点。在递归的某一层里,head 可能是中间某个节点。

我建议用“后面的已经反转好了”这个视角来理解。reverseList(head.next) 被调用之后,它会返回一个已经反转好的链表的头节点。打个比方,你面前有一串项链,你想把它整体倒过来。现在你只需要处理最前面的那一颗珠子:它现在还指着第二颗珠子,而第二颗珠子因为后面的都反转完了,正好成了“后半段反转链表的尾节点”。你把第二颗珠子的 next 指向第一颗珠子,再把第一颗珠子的 next 置空,整个项链就倒过来了。

递归的边界条件也值得拆一下。head is None处理空链表;head.next is None处理只有一个节点的链表。这两种情况都是直接返回 head。只有这两个边界才意味着“不需要再反转了”。

3.3 迭代与递归的取舍对照

很多人会纠结考试时要写哪种。我的建议是:练到两种都会,面试时按需选。两种解法各有各的脾气。

维度迭代法递归法
空间复杂度O(1)O(n),递归栈会占用空间
理解难度指针步骤直观,画图即懂需要相信“后面的已经反转好”
代码量略长极短
适合场景大多数面试实操展示对递归的理解时可以秀一把

如果你刷题起步不久,我建议先死磕迭代,因为它是所有指针操作的祖传基本功。递归版可以等迭代版能闭眼写了之后,再拿它加深对递归的理解。两道都会写,才是真的通。

4. 从模板题到变式:怎么把套路用到新题上

像“移除链表元素”和“反转链表”这种题,练完之后真正的价值在于,你能不能用同一套底层动作去解决它的变式。下面我挑几个高频变体,说明模板题是怎么被改造的。

4.1 反转类变式:前N个节点和区间反转

反转整个链表学会之后,最常见的变式是“反转链表的前N个节点”和“反转链表区间[m, n]”的节点。前者要求只反转前N个,后者要求只反转中间一段。它们的共同点是:反转操作本质上还是那三个指针的接力,只是边界和收尾方式需要额外记录。

拿区间反转来举例。假设链表是1->2->3->4->5,要反转 2 到 4 这三个节点,结果是1->4->3->2->5。这里的做法分三步:先走到第 m-1 个节点,这个位置叫 pre;然后用迭代反转的手法把 pre 后面那一小段的方向改过来;最后把这一段的前后接缝缝合好。处理接缝时,你至少要提前记录两个节点:pre 和原来 pre 的下一个节点(也就是反转后这一段的尾节点)。这个类型LeetCode第九十二题,面试考频不低。

从“反转整个链表”到“反转前N个”的递归版本也很有意思。你只需要在递归边界上做一个特殊处理:反转前N个节点时,当递归深度到第N个节点时,要先把第N个节点原本指向的下一个节点记录下来,作为整个新链表和后半段之间的“接线点”。很多人第一次做这道题会卡在这里,因为标准全量反转时,head.next 可以直接置空,但反转前N个时不能置空,要接到剩余部分上。

4.2 删除类变式:去重与按值删除

“移除链表元素”的变式中,最常见的是“删除排序链表中的重复元素”。比如1->1->2->3->3,删完之后是1->2->3。这类题比按值删除更简单,因为链表是排序过的,重复元素一定连在一起。你只需要遍历时比较 cur.val 和 cur.next.val,相等就跳过那一个节点;不相等才移动 cur。

另一种变式是“移除未排序链表中的重复节点”,这种需要借助哈希集合记录已经出现过的值。思路就是把“按值删除”和“去重”结合在一起,遍历的时候,如果当前值出现过就删除,没出现过就在集合里标记一下并移动 pre。有了虚拟头节点的基本功,这种题写起来会顺手很多。

还有一道很有代表性的合并题——“合并两个有序的单链表”。它考察的其实是“在正确的位置插入节点”的能力,和删除、反转虽然动作不同,但底层的“保引用”原则一模一样:当你把一个节点从链表A上摘下来接到结果链表上时,必须先保存好这个节点在A上的下一个节点位置,否则A的剩余部分就丢了。

说到“循环单链表”,它是单链表的一种变体,最后一个节点的 next 不指向 None,而是指回头节点。它的问题套路也往往围绕“判断链表中是否有环”“找到环的入口”展开。基础的反转和删除原则在那里同样适用,只是边界判断从“cur is None”变成了“cur 绕一圈回来”。你如果现在把普通链表的删除和反转吃透了,以后接触循环链表和心理上的摩擦力会小很多。

4.3 链表题的共同底层动作:改指针前先备份

我观察过很多人的刷题轨迹,发现一个规律:链表题做多了之后,大家下意识会做的一个动作就是——看到 next 被赋值,就先问自己“原来的引用丢了没”。

这个动作几乎能概括链表题的一半功力。删除节点要保 pre.next,反转链表要保 cur.next,插入节点要保后一个节点,合并链表要保两个链表的剩余部分……所有操作的共同底层逻辑都是这套“先备份,再改指向,最后移动”的心法。你甚至可以把它当成一个口诀来背:改前先备份,改动要连看,move 前再确认。这个口诀虽然不严谨,但对初学阶段的人来说,比任何高深理论都管用。

5. 最后说点刷题阶段的实在话

这两道题代码量都很小,但如果你只看答案不练习,爆发的机会基本没有。我见过太多人在白板上写反转链表时,前面写得挺顺,到了第三行开始犹豫:到底是先改 cur.next 还是先动 pre?一旦开始犹豫,面试官基本就能判断你对链表操作还不够敏感。

5.1 循环不变量:让代码一写就对的秘密武器

我真正觉得帮到我的,是一个叫“循环不变量”的思维工具。听起来很玄,其实就是一句话:在每一轮循环开始之前,当前这几个指针分别处于什么状态?把这个状态定义清楚了,循环体里每一步都是在维持这个状态。

拿反转链表来说,每一轮循环开始前,pre 指向的就是“已经反转好的新链表的尾节点”,cur 指向的是“当前要处理的节点”,temp 在循环体内保存 cur.next。只要你确信“进入循环前 pre 和 cur 各是谁、出去时应该变成谁”,整个算法的正确性就立住了。写代码的时候心里揣着这个不变量,就不太会出现那种“逻辑大体对但边界跑飞”的问题。

递归解法也一样。递归版的不变量是:“reverseList 接收一个参数 head,返回一个以 head 为原头节点的链反转后的新头节点。”有了这个契约,你才敢放心地调用 reverseList(head.next),然后在此基础上处理 head。

5.2 特殊用例自测清单

我每次写完链表代码,不管多简单,都强制自己过一遍这组测试用例。也推荐给你,拿去当自测清单:

  • 空链表:head 是 None,看代码能不能直接返回。
  • 单节点链表:只有一个节点,删除或反转后有没有问题。
  • 所有节点都要删除:比如全是目标值,看循环会不会把 head 一路移动到 None。
  • 头节点被删除:头节点的值正好是目标值,检查虚拟头节点有没有帮上忙。
  • 删除后连续删除:比如目标值在链表中间连续出现了好几个,检查 pre 不动但 cur 持续后移的情况。
  • 反转后尾部指向:反转完成后,新链表的最后一个节点 next 是不是 None。

这组用例花不了多少时间,但能帮你挡掉绝大多数低级错误。千万不要觉得代码能过一次测试用例就万事大吉,链表的边界情况往往藏在“看似正确”的回显里。

5.3 我的个人体会

我个人刷链表题最大的体会是:链表操作这种东西,本质上是一场“保存引用”的杂技。你手上能握住的指针数量是有限的,所以每一步都要想清楚,哪些引用是临时的、哪些引用是最终要留下来的。

写完这两道题,你可以做一个练习:不看任何资料,在一个空白编辑器里把 removeElements 和 reverseList 从零开始写一遍,每写一步注释一句话,说明当前这一步背后维持的是什么状态。然后跑一下那些特殊用例,看看哪里会挂。多数人第一次做这个练习时会发现,自己以为懂了的地方,其实还有一两个细节没打通。

我第一次真正感到“链表通了”,是连续三天每天晚上手写一次反转链表,写完之后再画图核对每一步。第三天开始,看到任何链表题,脑子里的第一反应不再是“背代码”,而是“改哪个 next、先保哪个引用”。这种感觉一旦建立,后面再做链表相关的题目,基本就进入顺风局了。

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

C#仓库条码管理系统源码解析:从WinForms到扫码入库的落地实践

简介:面向毕业设计场景的C#仓库条码管理系统源码,围绕入库、出库、库存查询和条码扫描等核心业务展开,提供一套可直接运行的Windows窗体应用方案,适合需要完成课设或毕设的C#学习者。压缩包共132个文件,以47个cs源码、…

作者头像 李华
网站建设 2026/10/9 3:09:34

高并发秒杀系统架构实践:限流、Redis原子扣减与MQ异步落库

简介:这是一套面向Java初、中级开发者的秒杀系统入门实现项目,基于Spring Boot 2.x编写,适合想理解高并发抢购场景核心应对思路的读者。项目针对限流、缓存预加载、分布式负载、消息队列异步处理、验证码防刷等关键机制组织代码,内…

作者头像 李华
网站建设 2026/10/9 3:08:49

Flutter for OpenHarmony实战:Visibility组件解析与最佳实践

去年我把一个原本跑在 Android 上的 Flutter 应用迁移到 OpenHarmony 开发板上,最让我意外的不是插件兼容清单有多长,而是一个被大多数人当成“if/else 语法糖”的组件——Visibility。当时前端同事看我代码时问了一句:“你这块为啥包个 Visi…

作者头像 李华
网站建设 2026/10/9 3:08:02

Flask部署到Kubernetes:从配置到自动化管理实战

前两篇文章我们把 Flask 应用的开发环境和容器化都捋顺了,镜像能跑、端口能通、依赖也没问题,但这只是万里长征走完了前半程。真正让项目从“能在本地跑”进化成“能稳定对外提供服务”,还差最关键的一步:把它扔进 Kubernetes 集群…

作者头像 李华
网站建设 2026/10/9 3:07:32

Linux 7 安装 Oracle 11g 全流程:环境适配、静默安装与踩坑记录

1. 在Linux 7上装Oracle 11g,先别急着下介质最近生产环境扩容,新申请的服务器清一色RHEL 7.9,业务侧却还绑死在Oracle 11g上。这种"新系统装老数据库"的组合,看起来简单,实际上一堆兼容性陷阱等着你踩。我在…

作者头像 李华
网站建设 2026/10/9 3:07:21

基于SVD与SGNS的汉语子词向量构建与评测实战

简介:本资源面向自然语言处理课程学习者与词向量入门研究者,围绕汉语子词向量构建与相似度评测展开,提供基于SVD分解与基于SGNS两种方法的完整Python实现。语料采用训练集与测试集并集,SVD方法取K5获取高维分布表示后降维得到vec_…

作者头像 李华