链表删除节点全解析:从LeetCode 203看指针操作与边界处理 开头刷LeetCode的链表题时总绕不开“移除链表元素”这道题。它是链表这一类题目的地基看起来简单——不就是删节点嘛——但很多人在第一次手写时都会卡住只拿到当前节点怎么把它的前一个节点连到后一个节点这个操作一旦想明白后面一堆链表题删除重复节点、删除倒数第N个节点、反转链表都会顺手很多。顺带提一句标题里写的是“Leetcode 97”我一开始还愣了一下97题不是“交错字符串”吗后来反应过来可能是题号记串了。“移除链表元素”在LeetCode里是203题难度简单但作为链表入门题它的地位一点都不“简单”。本文就围绕这道题展开把迭代法、递归法、边界条件、变种题目和调试经验一次说透。适合刚接触链表的初学者也适合准备面试想快速过一遍链表基础的人。1. 先搞清楚题目到底在考什么1.1 题干要点与题号的小误会先对齐一下题目信息。原题是给定一个链表的头节点 head 和一个整数 val删除链表中所有满足 Node.val val 的节点返回新的头节点。这里有个容易踩的坑很多人记题号只记个大概比如把203题记成“移除链表元素”的题号是97实际97题是字符串动态规划的经典题交错字符串完全不沾边。我建议在刷题时用“题目名称 题号”双重确认特别是搜题解的时候题号错了会直接搜到驴唇不对马嘴的内容白白浪费时间。题干里有两个关键词值得我们停下来细想第一是“所有满足条件的节点”这意味着目标值可能出现多次不能删掉一个就收工第二是“返回新的头节点”这句话才是真正的考点——如果被删除的恰好是头节点整个链表的入口就变了你怎么处理1.2 单链表为什么删节点这么别扭要理解这道题为什么存在先得看清单链表的结构。单链表里每个节点只有 val 和 next 两个字段你从当前节点可以拿到下一个节点但拿不到上一个节点。数组删除元素可以靠下标直接改链表不行——想删除节点 cur唯一的方式是让“前一个节点 prev”的 next 指向“cur 的下一个节点”也就是跳过 cur。这里藏着链表操作的第一性原理链表的一切增删改查本质都是“改指针”不是“改数据”。删除节点时真正被修改的是 prev.next而 cur 自己什么都没做。理解了这一点再看题目就会明白解题的关键不是“怎么删”而是“怎么在遍历过程中保住 prev 这个前驱节点”。很多人第一次写这道题时会写成下面这种错误版本ListNode* cur head; while (cur ! nullptr) { if (cur-val val) { cur cur-next; // 这里只是移动了指针并没有删除节点 } else { cur cur-next; } } return head;这段代码干的事是“遍历”不是“删除”。它从头到尾走了一遍没有任何一个节点的 next 被修改链表结构压根没变。真实做法是遍历到当前节点时先判断它的值如果等于 val就执行 prev-next cur-next让前驱跳过这个节点。2. 迭代法最直接的思路但要处理好三种边界2.1 哨兵节点的引入统一处理头节点删除迭代法的核心很简单维护一个 prev 指针从头开始走遇到目标值就删否则推进。但有一个例外状况很难受头节点本身就是要删的值。此时链表没有“前驱”你没法用 prev-next cur-next只能让 head head-next 把头节点摘掉。这样代码里就要分情况讨论多出一堆 if容易漏。解决这个问题的通用技巧是引入哨兵节点dummy node。哨兵就是一个不参与实际数据、只用来占位的节点它的 next 指向真正的头节点。有了它原来的头节点也变成了“普通节点”删除头节点和删除其他节点用同一套逻辑不需要特判。为什么这个技巧在链表题里出现频率这么高因为“头节点是特殊的”这个认知会极大干扰思维而哨兵把问题归约成了“从第二个节点开始所有删除操作都统一为 prev-next cur-next”写起来更不容易出错。面试中你用哨兵节点不会有任何面试官觉得你“讨巧”反而会觉得你对边界条件敏感。2.2 迭代删除的标准写法与逐步拆解先看完整的迭代实现我习惯用 C 写方便讲指针换成 Java、Python 思路完全一致class Solution { public: ListNode* removeElements(ListNode* head, int val) { ListNode* dummy new ListNode(0); dummy-next head; ListNode* prev dummy; while (prev-next ! nullptr) { if (prev-next-val val) { ListNode* toDelete prev-next; prev-next prev-next-next; delete toDelete; // 实际刷题时可以省略这行 } else { prev prev-next; } } ListNode* newHead dummy-next; delete dummy; return newHead; } };这段代码的精妙之处在于指针移动不发生在“删除分支”而发生在“保留分支”。删除时prev 原地不动——因为删掉一个节点后prev-next 已经指向了新的节点这个新节点还没被检查过只有当前节点值不等于 val 时prev 才向后挪一位。很多初学者犯错都是在删除分支里也写了 prev prev-next结果跳过了后一个节点没检查。还有两个细节值得展开。一是内存管理在 LeetCode 的评测环境里delete toDelete 写不写都能过因为整个测试进程结束时会统一回收。但实际上链表的删除操作在生产环境是必须释放内存的否则会造成内存泄漏。我建议刷题时保留 delete养成好习惯。二是返回值的处理不能直接 return head因为在刚才的删除过程中 head 可能已经被删掉了head 这个指针虽然还在但它指向的节点早已不属于新链表。正确做法是 return dummy-next。2.3 两个关键细节指针移动时机与连续删除刚才提到删除分支不能动 prev我再展开讲一下为什么。假设链表是 1 - 2 - 2 - 3val 2。当 prev 在节点 1prev-next 指向第一个 2值相等执行删除后 prev-next 指向第二个 2。如果此时 prev 也往后走prev 就变成了第一个 2——但它已经被删除了prev 指向一个悬空节点下一个循环判断 prev-next 就全部乱套。所以删除后 prev 必须保持不动让下一个循环继续检查新的 prev-next。另一个细节是空链表的处理。head nullptr 时dummy-next nullptrwhile 循环压根不进入直接返回 nullptr逻辑天然覆盖。这也是哨兵节点带来的好处——不需要专门写 if (head nullptr) 的特判。这里再补充一个我实际写代码时的微小技巧先写循环体里的主干逻辑再回头检查边界最后看返回语句。很多人喜欢先把“正常情况”写完再补“头节点删除”和“空链表”写成两套分支用哨兵节点的话从第一步起就不存在“特殊情况”所有场景统一处理代码简洁且不容易漏。3. 递归法换个视角看链表删除3.1 递归思路的本质把“删除”交给子问题迭代法是从“操作者”的角度思考我在遍历链表同时修改指针关系。递归法则完全换了个视角我能解决的问题是“以某个节点为头节点的子链表删掉所有值为 val 的节点后应该返回什么”。这个思路的精髓在于“信任子问题”。假设我有个递归函数 removeElements(head, val)它能处理好“以 head 开头的一整条链表”。那我只要解决最小的问题head 这个节点要不要留要留的话head-next 应该接什么答案是接 removeElements(head-next, val) 的结果——因为 head 之后的那段链表已经被递归函数处理干净了。整个过程就是“我只管当前这个节点剩下的交给递归”。很多初学者对递归恐惧是因为总想“跟踪整个递归过程”。但正确的心智模型是你不需要知道递归内部怎么一步步执行的你只需要确保两件事——递归函数返回的结果是正确的你自己处理当前节点的逻辑是正确的。这两点同时成立整个递归就是正确的。这就是数学归纳法也是所有递归题的通用解法。3.2 递归实现与代码解读直接看代码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; } else { return head; } } };这个实现短得惊人但信息密度很高。第一行递归出口空链表不需要删除返回空。第二行是关键先把后面那段链表的删除结果接回来再决定当前节点去留。第三四行当前节点值等于 val那就把它的 next 当作新链表的头返回等于“跳过了当前节点”不等当前节点保留。这里有个容易被忽略的细节head-next removeElements(head-next, val)这个赋值其实就是在构建新链表。递归返回时每个节点都会修改自己的 next删除操作以“重新接线”的方式完成。特别注意代码里没有显式的 delete 操作。递归法在 LeetCode 上跑没问题但在需要手动管理内存的语言里被跳过的节点需要先保存再释放否则会泄漏写法稍复杂些。这也是为什么我在实际工程中更倾向于用迭代法但面试时需要两种都能讲。3.3 递归的代价与面试场景取舍递归法虽然简洁但代价是隐性的函数调用栈。链表长度是 n递归深度也是 n极端情况下链表很长比如十万个节点递归会栈溢出。LeetCode 的测试用例通常不会过分到栈溢出但在真实系统中或者面试追问“如果链表有一百万个节点怎么办”时递归方案就不那么稳妥了。空间复杂度上递归法显然是 O(n)因为每一层递归都需要开辟栈帧。迭代法的空间复杂度是 O(1)。两者时间复杂度都是 O(n)。所以从工程角度讲迭代法更优从代码可读性和面试展示角度讲递归法更容易展现思维深度。我见过不少候选人两种方法都能写但讲不清递归的“信任模型”只会背代码——这种反而会被追问到崩溃。建议把递归理解为“用空间换简洁”并把复杂度分析牢记于心。如果面试官问“能不能把递归改成迭代”这题其实是在考察你对“显式栈模拟递归”的理解。你可以用栈来模拟递归调用过程但移除链表元素这道题用显式栈反而比迭代更复杂没必要。面试中比较自然的策略是先给出迭代法然后补充“我还可以用递归实现”把选择权交给面试官。4. 这题的几种常见变体和真题联动4.1 删除链表中重复节点双指针与去重思路“移除链表元素”刷完之后最自然的进阶题是“删除排序链表中的重复元素”。它分两种一种保留一个重复值一种把重复值全删光。两者都和移除链表元素的迭代法有直接关系——你依然需要一个 pre 指针依然要在删除时“原地不动”。保留一个版本的思路cur 从头开始走每次比较 cur-val 和 cur-next-val如果相等就把 cur-next 摘掉cur-next cur-next-next直到不相等再移动 cur。这和移除链表元素的核心动作“删除后不移动前驱”是一致的。全部删除重复值的版本更有意思它要求一个节点只要出现过重复就一个不留。这时需要额外记录一个“当前重复值”并用 pre 指向不重复段的最后一个节点。你可以看到这类题的复杂度从“删一个确定的值”升级成“删一段动态确定的值”但解决骨架没变哨兵节点 前驱不动几乎可以照搬。我强烈建议把三道题连起来刷203移除指定值→ 83去重保留一个→ 82去重全删一天内建立链表删除的完整认知体系。4.2 删除倒数第N个节点哨兵节点的另一个应用场景链表删除题的另一个高频变体是“删除链表的倒数第 N 个节点”。这道题存在一个经典陷阱删除头节点时如果不用哨兵节点你得单独处理。而一旦用 dummy 节点处理方式就统一了——pre 指针永远存在删倒数第 N 个节点变成“找到倒数第 N1 个节点”然后让它跳过下一个。具体做法是快慢双指针快指针先走 N 步慢指针和快指针同步走快指针到底时慢指针正好在倒数第 N1 个节点。这里如果再配合 dummy 节点原先“删除正数第一个节点”的特判就不需要了。你会发现双指针、快慢指针、哨兵节点这些概念在链表题里反复组合出现但底层逻辑都是“通过控制指针位置来改变节点之间的连接关系”。刷题不怕做得多怕的是每一题都当新题做没有归纳出这些共性模式。4.3 反转链表与删除操作的反向思考“反转链表”是另一道几乎所有面试必考的题它和删除节点表面上无关但有一层内在联系两者都是“在单链表中仅通过指针重组实现结构调整”的典型。删除操作要维护 prev反转操作则要维护 prev 和 cur 的轮转关系。我在刷题时的感受是如果你把“移除链表元素”吃透了你再去看“反转链表”的迭代写法会觉得那是同一个思路的变异——无非是把“prev 指向 cur-next”改成“cur 指向 prev”。很多人会忽略这样一个事实链表操作题的最高频错误不是算法设计错了而是指针顺序颠倒。比如反转链表时如果你先改了 cur-next下一步就找不到原来的下一个节点了删除链表时如果你先把 prev-next 改了再想取待删除节点会发现节点已经失联。处理这类问题的统一解法是先保存需要访问的指针再重新连线。我把这个原则称为“先存后改断链不移”。这个原则你只要在链表题里多实践几次就会彻底内化。5. 常见错误、调试技巧与测试用例设计5.1 高频翻车现场内存、指针、边界我见过这道题最高频的错误集中在三处。第一处是内存管理。C/C 选手如果不 delete 被移除的节点虽然 LeetCode 能过但面试官追问时容易露怯Java 选手虽然没有手动释放的问题但要记得被跳过的节点不再被引用后GC 会处理它不需要做额外动作。Python 同理。第二处是返回头节点的时机。很多人习惯在循环里每次更新 head其实只要最后返回 dummy-next 就行。问题是如果你删除后没把 head 更新掉后面所有遍历都基于错误的起始位置整个结果就错了。第三处是“只删一次”还是“删所有”。题目要求“所有满足条件的节点”很多人删掉第一个匹配值就 break 了。写循环时判断条件是“只要链表没走完就继续”不是“找到一个就停”。我建议提交前先在纸上把用例列出来链表只有一个节点且要删除、链表所有节点都要删除、目标值不在链表中、链表为空——这四个用例能过滤掉绝大多数错误。5.2 自己写测试用例的方法刷题不能只依赖 LeetCode 的评测用例自己构造用例是更硬核的训练方式。特别是链表题很多人不会写测试代码只能在网页上提交报错了就干瞪眼。我建议本地自己写一个简单的链表构造和打印函数ListNode* buildList(std::vectorint vals) { ListNode* dummy new ListNode(0); ListNode* cur dummy; for (int val : vals) { cur-next new ListNode(val); cur cur-next; } return dummy-next; } void printList(ListNode* head) { while (head ! nullptr) { std::cout head-val ; head head-next; } std::cout std::endl; }有了这两个函数你就可以批量测试以下几种情况空链表、单节点、头节点目标删除、尾节点目标删除、连续两个目标值、全部节点都是目标值、目标值夹杂在中间。手动跑一遍再和 LeetCode 的测试结果对比这样你对代码的信任度会高得多。说到这我想提供一个额外建议测试用例里一定要包含“连续两个目标值”的场景比如 [1, 2, 2, 3]val2。这个用例能直接检验你是否在删除分支里误移动了 prev。如果你把“删除后原地不动”写错了这个用例立刻暴露问题。而很多人的测试用例只有 [1,2,3]根本无法发现这个隐患。5.3 调试链表的实用技巧画图、打印、断点三者结合链表题的调试和普通数组题不太一样。数组题可以打印中间状态看索引链表题如果你不知道该打印什么只会打出一串节点值看不出结构变化。我调链表问题时常用的手段是第一画图、第二打印关键指针、第三在关键位置断点。画图不是面试时的行为而是自己梳理代码时的刚需。拿一张纸画出链表节点之间的箭头然后模拟代码执行prev 在哪、cur 在哪、谁的 next 被改了每画一步就把旧箭头划掉画新箭头。这个过程看似原始却实实在在地锻炼“指针操作可视化”的能力。尤其是删除分支里“prev 原地不动”的操作逻辑画三遍就能完全记住。打印关键指针的办法也有讲究。如果你在循环里打印链表整体长链表会把屏幕刷得乱七八糟更聪明的是只打印 prev 和 cur 的地址、值以及 prev-next 的变化。这里我分享一个经验调试的时候可以临时把链表长度控制到 3~4 个节点比长链表好观察得多。断点调试适合比较复杂的场景比如在循环进出各打一个断点看 prev 和 cur 的地址变化。如果你用的是 IDE把“节点对象”展开查看其 val 和 next 字段会比单纯打印直观得多。对付链表这类指针密集型代码肉眼观察状态变化远比“脑内模拟”靠谱。结尾写了几百道题、也帮别人 review 过不少代码之后我的体会是链表题不是靠聪明而是靠熟练。熟练到你看到“删除”两个字第一反应不是“把节点删了”而是“改前驱的指向”熟练到你知道哨兵节点不是套路而是消除边界问题的工程实践。这道“移除链表元素”虽然标着简单但它是所有链表操作的基石。最后再分享一个我刷题时养成的习惯每次做完一道题都顺手在代码注释里写一句“这题的关键是什么”。比如这道题我会写“删除操作只改动前驱的 next删除后前驱不动”。几个月后回头复习只看注释就能迅速唤起全部思路。这个习惯省了我大量复查时间也让我在面试前能快速过一遍已经沉淀的解题心得。希望你看完这篇文章也能把这道“简单题”真正吃透去迎接链表后面那些更有挑战的题目。