资讯动态

链表刷题核心:虚拟头节点与反转链表技巧解析

发布时间:2026/10/18 7:14:08 来源:尧图企业网站定制
1. 链表为什么让你又爱又恨先看懂它在内存里怎么玩刷到链表这一天基本上说明你已经熬过了数组和二分查找的基础关。链表这座山说难不难说简单又到处都是坑。很多人卡在链表上不是因为看不懂代码而是脑子里没有“指针在内存里跳来跳去”的画面感导致一写边界条件就翻车。day3 的链表专题在“代码随想录”这套体系中属于数据结构地基级别的部分。它要解决的核心问题有三个链表的定义与基本操作、虚拟头节点的妙用、双指针技巧在链表题里的灵活应用。只要你把这三点吃透后面遇到环形链表、合并链表、反转区间这类进阶题至少不会一看就懵。先从根上理解一件事数组在内存里是一段连续的空间而链表是分散的空间靠指针串起来的。这个差异决定了所有操作方式的本质不同。数组按下标访问是 O(1)链表找第 n 个节点只能老老实实从头遍历。数组增删元素要搬移大量数据链表只需要改几个指针指向——前提是你得先找到目标节点的位置。这种“查找慢、增删快”的特性决定了链表的解题思路永远绕着遍历和指针打转。另外很多人忽略的一点是链表题非常依赖“语言level”的理解。在 JS 里对象天生就是引用类型你创建的节点本身就是一个挂在堆内存上的对象而变量名只是指向它的指针。所以你写let cur head改的其实是指针指向而不是复制了一个节点。理解这一点你才算真正能看懂链表的代码而不是靠背答案活命。day3 的题目设计其实很讲究。它不是上来就甩几道难题而是先让你老老实实搞清楚节点的结构、遍历的方式、增删的指针操作再用虚拟头节点这种技巧把“头节点特判”这种脏活累活干掉最后才用反转链表这种经典题来检验你的指针操作是否真的过关。整个节奏是递进的每一题都踩在前一题的基础上。我个人刷完这一天的经验是别急着追求 AC通过判题先把每一题的链表结构在草稿纸上画一遍写上每一步的指针状态再去写代码。这个习惯能帮你省下后面绕弯路的时间因为链表的题一旦指针写错调试起来是真的痛苦控制台打印看半天都不知道问题出在哪个节点上。2. 先摸底链表的基本操作到底在考察什么2.1 节点定义与构造函数——所有操作的起点链表的节点在代码里是怎么定义的用 JS 写的话很简单function ListNode(val, next) { this.val val undefined ? 0 : val; this.next next undefined ? null : next; }或者用 ES6 的 class 风格class ListNode { constructor(val, next) { this.val val undefined ? 0 : val; this.next next undefined ? null : next; } }这里有个细节值得注意val和next都给了默认值。这意味着你可以用new ListNode(5)只传值next 自动指向 null方便创建尾节点也可以用new ListNode(5, node)直接指定下一个节点。这种默认参数的写法在构造测试用例时非常方便不用一个个手动补 next。掌握节点的定义之后有一个操作层面的认识需要建立起来所有链表的算法最终都是对“节点对象”的字段进行读写。你翻转链表本质是把每个节点的 next 从指向后一个改成指向前一个你删除节点本质是让前一个节点的 next 跳过当前节点直接指向后一个。一切的一切都是 val 和 next 的操作。2.2 遍历链表while 循环里的玄机遍历链表的代码看起来简单到不能再简单let cur head; while (cur ! null) { // 处理当前节点 cur cur.next; }但这里面藏着一个新手最容易犯的迷糊while (cur)和while (cur.next)到底什么区别前者会遍历到最后一个节点包括最后一个此时 cur 为 null 才停后者会在最后一个节点停下此时 cur 指向的是末节点而不是 null。这个区别直接影响了你会不会出现空指针异常。比如你要统计链表长度应该用while (cur)每经过一个节点就计数如果你要找到倒数第二个节点方便在末尾插入就要用while (cur.next)因为当 cur.next 为 null 时cur 正好是倒数第一个节点此时你没法在它后面插入但你如果想让 cur 停在倒数第二个节点条件就得是cur.next.next ! null之类的变体。画一个简单的例子。链表 1 - 2 - 3 - null如果用while (cur)cur 依次指向 1、2、3、null循环体执行 3 次。如果用while (cur.next)cur 依次指向 1、2、3循环体也执行 3 次但当 cur 指向 3 时cur.next是 null所以循环结束——此时 cur 还在 3 上而不是 null 上。这两种遍历方式各有用武之地关键是你得清楚自己要停在哪个位置。2.3 增删节点的指针操作顺序决定生死往链表中间插入一个节点的步骤是固定的三步// 在 node 后面插入 newNode newNode.next node.next; node.next newNode;这两行的顺序不能反。如果你先执行node.next newNode那原来 node.next 指向的后续链表就丢了因为没有任何变量再保存它。必须先把node.next的原始值存到 newNode.next 里再修改 node.next。删除节点的操作就没这么温柔了——它是直接绕过让前一个节点指向后一个节点// 删除 node 的后一个节点 node.next node.next.next;这条语句干了两件事一是把 node 的下一个指针直接跳到后面第二个节点跳过的那个节点没有变量指向它之后就会被垃圾回收机制处理掉在 JS 里。看起来简单但有一个前提node.next 不能是 null否则访问 node.next.next 会直接抛 TypeError。这里顺带提一句很多链表题的核心技巧都是“用一个变量提前存住某个引用”。无论插入、删除还是反转你只要发现代码里有多步指针修改就先想想哪些引用在修改后会丢失把要丢的先存起来。这是链表操作里最通用的方法论。3. 虚拟头节点永远不要在头节点上特判3.1 为什么引入虚拟头节点假设你要删除链表中某个指定值的所有节点一个很自然的写法是// 先处理头节点本身 while (head ! null head.val target) { head head.next; } // 再处理中间节点 let cur head; while (cur ! null cur.next ! null) { if (cur.next.val target) { cur.next cur.next.next; } else { cur cur.next; } }看着没什么问题但你会发现光是“头节点可能需要删除”这一个情况就得单独写一个循环来特判。如果题目是“删除倒数第 n 个节点”头节点的处理逻辑会更别扭。只要涉及删除操作头节点和中间节点的处理方式天然不对称因为头节点没有前驱节点。虚拟头节点dummy node就是专门来解决这个不对称问题的。思路很简单在真正的头节点前面再挂一个临时节点让所有人都拥有前驱。这样头节点就变成了普通节点统一走一套逻辑不需要任何特判。3.2 虚拟头节点的标准写法以“删除指定值的节点”为例用虚拟头节点的写法变成const dummy new ListNode(0, head); let cur dummy; while (cur.next ! null) { if (cur.next.val target) { cur.next cur.next.next; } else { cur cur.next; } } return dummy.next;代码量少了不说最直观的变化是不再需要单独处理 head 本身了。dummy 永远存在cur 从 dummy 出发走的是完全一样的逻辑。最后返回dummy.next这才是真正的链表头。为什么返回值是dummy.next而不是dummy因为 dummy 是我们自己造的假节点不是题目里链表的一部分。这个假节点占一个内存位置最终要绕过它把真头返回出去。如果你把 dummy 返回给判题系统等于链表里多了一个值等于 0 的节点结果必错。3.3 虚拟头节点的双指针配合删除倒数第 n 个节点这道题是虚拟头节点和双指针思路配合的经典场景。做法是先让 fast 指针向前走 n 步然后 slow 和 fast 一起匀速前进等 fast 到达链表末尾时slow 恰好停在要删除节点的前一个位置。const removeNthFromEnd function(head, n) { const dummy new ListNode(0, head); let fast dummy; let slow dummy; // fast 先走 n1 步这样才能让 slow 最终停在待删节点的前一位 while (n 0 fast ! null) { fast fast.next; n--; } while (fast ! null) { fast fast.next; slow slow.next; } slow.next slow.next.next; return dummy.next; };注意这里的细节fast 先走的是 n1 步而不是 n 步。因为我们要让 slow 落在待删节点的“前一个”位置所以快慢指针之间要保持 n1 的间距。如果只让 fast 先走 n 步那 fast 到尾时 slow 恰好指向待删节点本身删起来还得额外处理前驱引用麻烦了不少。这个“先走几步再同步走”的套路在链表题里出现频率极高。除了删除倒数第 n 个节点快慢指针找中间节点一个走一步一个走两步也用同样的思想。它的底层逻辑很清晰两个指针从同一起点出发快指针先建立间距慢指针就能精确停在目标位置的前驱上。3.4 哪些场景不需要虚拟头节点虚拟头节点不是万能的也不是所有题都要用它。如果你做的操作不涉及“修改头节点”比如只是查找节点、判断是否有环那你直接用原始 head 就够了。虚拟头节点的主要价值在于「增删操作中避免头节点的特殊处理」。如果你为了用而用反而会让代码多一层无关的节点增加理解成本。反转链表这道题就不需要虚拟头节点因为你只要把每个节点的指向改一下就行头节点本身是哪个节点并不会导致逻辑分支不同。这点在下一节会详细展开。实操下来什么时候该用 dummy其实写着写着就有感觉了——当你发现自己开始写if (head null || head.val xxx)这种特判的时候就可以考虑引入 dummy 来消掉特判。4. 反转链表逐行拆解指针翻转的肌肉记忆4.1 迭代法三个指针轮流转反转链表是链表题的试金石。题目要求很简单把 1 - 2 - 3 - 4 - 5 变成 5 - 4 - 3 - 2 - 1。但它考察的不是你能不能背出代码而是你懂不懂指针翻转的本质。迭代法的思路遍历链表时让每个节点的 next 指向前一个节点。需要三个指针prev 指向前一个节点cur 指向当前节点next 暂存后一个节点。const reverseList function(head) { let prev null; let cur head; while (cur ! null) { let next cur.next; // 先存住下一个节点因为马上要断开 cur.next prev; // 当前节点的 next 指向前一个节点 prev cur; // prev 前进 cur next; // cur 前进 } return prev; };第 4 行代码是核心也是新手崩溃点cur.next prev一执行原来的后续链表就丢了。所以必须在它之前把cur.next暂存在 next 变量里。这个暂存的顺序绝对不能在循环体里换位置。循环结束后cur 变成了 nullprev 指向原来的尾节点——也就是反转后的头节点。所以返回的是 prev 而不是 cur。这是写反转链表最常见的返回值错误几乎每个人至少踩过一次。4.2 递归法用函数栈理解反转递归法写起来非常短但理解成本高一点const reverseList function(head) { if (head null || head.next null) { return head; } const newHead reverseList(head.next); head.next.next head; head.next null; return newHead; };你可以这么理解reverseList(head.next)把除 head 以外的部分整个反转了返回的是反转后链表的头也就是原链表的尾。然后head.next.next head这句把原本是当前节点下一个节点的那个节点的 next 指回来形成反转。最后head.next null是因为 head 现在是新的尾节点它的 next 必须是 null。递归法的返回值是 newHead这个值在每一层递归里都不变它永远是原链表最后一个节点。你只需要确保在每一层返回时都把这个 newHead 原封不动地传回给上一层。我有一个判断自己是否真的理解递归反转的方法把 1 - 2 - 3 的递归过程完整画出来从最内层开始一步步记录每个节点 next 的指向变化。画完之后如果你能解释清楚每一步为什么这么走才说明你真的懂了。光看代码看懂和能给别人讲明白中间隔着一次“动手推演”的距离。4.3 区间反转反转链表的进阶版本day3 里未必直接要求做区间反转但反转链表的基础操作一掌握区间反转就是顺水推舟的事。题目要求反转从第 left 到第 right 的这段节点。思路是找到 left 的前驱节点然后对区间内的节点做局部反转再接回去。这一类题最容易出的问题就是边界。比如 left 1 时整个区间的开始就是头节点没有前驱。这时候虚拟头节点又派上用场了——用 dummy 顶在前头让一切都有前驱可循不用单独特判头节点。不过这里我不打算展开区间反转的完整代码因为它的细节更多一般刷题节奏会把它放在后面的进阶日安排。day3 的重点是把基础反转写到条件反射级别做到闭着眼都知道哪一步先哪一步后。5. 链表题实战中的五个常见坑与排查方法刷链表题的时候错误往往长得很像。我把最常见的坑和排查思路整理成表格每一条都是实测过会中招的常见问题典型表现原因与排查方法访问了 null 的 next运行报错Cannot read property next of null循环条件写了while (cur)循环体里却用了cur.next.next。先确认 cur 此时是否可能为 null链表丢失一段调试输出只有前几个节点多半是修改 next 之前没有暂存后面的节点。检查是否有引用在你改指针之后被覆盖返回了错误节点结果对但漏了最后/最前的节点检查返回值是 head 还是 dummy.next 还是 prev。反转链表末尾返回 prev删除类题返回 dummy.next死循环判题超时通常是环没断开。比如反转后某处 next 还指向自己或者尾节点的 next 没有改为 null修改了原链表第二次操作时数据不对递归/迭代时没有用新节点重建直接改动了传入 head。需要时用复制节点的方式处理排查的具体建议是在代码里用console.log打印关键节点的 val 和 next。不要只在出错后打印在反转、删除的关键步骤前后各打印一次对比前后指针指向的变化。链表题目调试非常依赖这种对比思路因为链表本身没法像数组一样直接打印全貌。还有一个经验如果你写的链表代码一次通过大概率是运气好或者这道题偏简单。别急着开心回去看一遍你的边界条件是不是真的覆盖了所有情况——比如空链表、单节点链表、删除头节点、删除尾节点。这些边界在判题系统里基本都会测没覆盖到的必然红一大片。6. 从 day3 往后看链表的下一步怎么走链表这一章学完后不是就完事了。后面还有这些经典题目等着你它们的底层都依赖你今天练的这套基本功环形链表检测用快慢指针一个走一步一个走两步看是否能相遇。相交链表先对齐长度再同步遍历或者用浪漫的双指针遍历法。合并两个有序链表虚拟头节点加上大小判断天然适合递归或迭代。两数相加链表代表逆序数字边遍历边进位。设计链表这个题把你的基本功全部串起来增删改查从头写到尾。这些题目有个共同点解法本身不超过二十行但每一行都需要你对“节点、指针、next 变更顺序”有非常强的掌控力。day3 不是背完就扔的它是后面所有链表复杂操作的一个底基底子打好了后面学起来是真的快。实操中我建议你每天花 10 分钟手写一遍“反转链表”的迭代版。这个动作看起来机械实际上是在强化你对指针顺序的肌肉记忆。坚持一周之后你会发现写链表题的手感和写数组题完全不同——对内存操作的直觉会慢慢长出来。再分享一个小技巧做链表题时先画图再写代码。不要直接上手写哪怕只是花 30 秒在草稿纸上画出三个节点和箭头的变化过程也能帮你避免绝大多数“逻辑对但代码错”的尴尬。这不是浪费时间这是链表题专属的降维打击。最后说说我对链表刷题的整体感受。如果数组题考的是你的数学直觉和下标计算那链表题考的就是你对“引用”和“状态”的理解。这种理解不是靠背题得来的而是靠一次又一次地画图、推演、试错慢慢长出来的。刷到 day3 的你正在经历从“看代码”到“写代码”的分水岭。沉住气把每个指针的变化都搞清楚链表的这关就算真正过了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑