链表算法题刷题笔记:虚拟头节点与双指针技巧详解
这段时间在跟代码随想录算法训练营的打卡进度前面几天的内容都整理得还算顺利结果到第四天这里卡了一下——任务量倒不算大但恰好撞上春节前后的杂事硬是把原定1月31日要完成的几道链表题拖到了现在。这篇就当是补卡记录把第四天的四道题拿出来重新梳理一遍24.两两交换链表中的节点、19.删除链表的倒数第N个结点、面试题02.07.链表相交、142.环形链表II。链表的题目有一个好处就是它是少数几类“画图就能解清楚”的算法题比动态规划、贪心这类纯抽象的问题要友好得多。但这几道题也有一点共通的小门槛几乎全都要用到虚拟头节点和指针的精细操作但凡画图不仔细写出来的代码基本就是跑一次崩一次。我一开始踩了不少坑慢慢调完之后发现这四道题放在一起其实是有机组合的——它们从“操作节点结构”“处理位置关系”“解决环形结构”这几个角度把链表的核心难点覆盖得比较全。如果你也正在刷链表相关的题目或者打算跟着训练营往前走这篇内容应该能帮你少走几步弯路。每道题我尽量写得细一些从思路拆解、代码实现到听过的教训都捋一遍四个题全是我自己用C在一遍遍调试之后确认过的版本通用语言思路也直接能迁移到 Java 或 Python 上。1. 整体思路为什么链表题先要画图再写代码先说一个很多人刷链表题都会踩的坑拿到题目就开始在脑子里推流程觉得“这不就是改几个 next 指针嘛”结果一写就错一错就慌越慌越乱。链表题和普通数组题最大的不同在于它的每一步操作都会真实地改变后续节点的可达性——你丢掉一个指针引用后面一整段节点就直接找不回来了。所以在处理任何链表结构变化的问题前我现在的习惯是先在纸上画出三到四个节点用箭头把 next 关系标清楚然后把手里的指针也标记出来每执行一步操作就跟着把图重画一遍。这样做的原因很简单链表操作的边界条件集中在头节点、尾节点和空节点上图上能直接看出来交换、删除类题目需要操作的指针数量在三个左右纯靠脑内模拟特别容易少改一条线虚拟头节点可以统一处理头节点的删除和交换逻辑但如果不画图虚拟头节点的作用也体现不出来。这种“先画图再写码”的方式可以说是天费半天时间调试换来的教训。第四天这四道题里只有142环形链表II纯靠数学推导和快慢指针就能出解剩下的三道题画图几乎都是标准动作。还有一个学习节奏上的建议。四道题看起来不多但难点各不相同如果一口气全写出来容易在一道题上卡太长时间。比较合理的分配是24和19先做因为它们解决的是“节点怎么换、节点怎么删”难度中等偏易能把虚拟头节点的用法练熟02.07和142再往后放前者需要理解“两个链表长度差”这个关键点后者要绕进环形结构找入口理解成本会明显上一个台阶。2. 节点交换与删除的细节拆解2.1 两两交换链表中的节点别急着动指针先理清三段关系24题的要求很简单给定一个链表两两交换其中相邻的节点并返回交换后链表的头节点。你不能只是改节点的值而是需要真的把节点之间的连接关系换过来。示例来说原来的链表是 1-2-3-4处理完要求是 2-1-4-3。如果链表只剩一个节点或者为空这个节点就保持不变。这道题的边界条件正好卡在链表长度上只用考虑奇数长度时最后剩一个节点不用交换这一条判断写成while(cur-next cur-next-next)就能处理掉。当时我解题时的具体操作思路是用虚拟头节点让 cur 指向虚拟头节点开始每次循环处理 cur 后面的两个节点。步骤拆开是这样的定义虚拟头节点 dummyHeadnext 指向原链表的 head用一个 cur 指针从 dummyHead 开始只要 cur-next 和 cur-next-next 都不为空就继续循环把 cur 后面两个待交换的节点分别记为 node1 和 node2先让 cur-next 指向 node2这一步等于先把后面的节点提到前面来再让 node1-next 指向 node2-next这一步等于把原来的第二段关系接到 node1 后面最后让 node2-next 指向 node1交换关系闭环更新 cur 为 node1进入下一轮。这里最容易出问题的就是第4步和第5步的顺序。如果不先保存 node2-next当 node1-next 指向它的时候里面的值和你想象中会完全不同。因为 node2-next 是下一对待处理的第一个节点一旦被覆盖就再也找不回来了。我当时的实现代码大概是这样的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) {} }; ListNode* swapPairs(ListNode* head) { ListNode* dummyHead new ListNode(0); dummyHead-next head; ListNode* cur dummyHead; while (cur-next ! nullptr cur-next-next ! nullptr) { ListNode* node1 cur-next; ListNode* node2 cur-next-next; cur-next node2; node1-next node2-next; node2-next node1; cur node1; } ListNode* result dummyHead-next; delete dummyHead; return result; }复杂度方面时间上每个节点最多被访问一次所以是 O(n)空间上只用了固定数量的临时指针是 O(1)。我最初写的时候犯过一个非常典型的错误直接在 head 上操作结果发现头节点交换之后返回 head 就变成了原来的第二个节点整个链表的“头部”丢失了。这才意识到虚拟头节点的价值不只是简化头节点的删除它让“不关心哪个节点是头节点”这件事变得自然而然——你操作的永远是某个固定位置的“后面两个节点”而不用为边界情况单独写一层 if。提示解完后记得把动态申请的 dummyHead delete 掉。虽然力扣的判题环境一般不会因为这个扣分但现实中写项目如果每做一次操作就泄漏一块内存程序跑长了还是会出问题的。2.2 删除链表的倒数第N个结点双指针是最省心的解法19题也属于链表里的经典题型给你一个链表删除链表的倒数第 n 个结点并且返回链表的头节点。这里的 n 保证是有效的也就是说 n 不会超过链表的总长度。最粗暴的想法肯定是先遍历一遍数出总长度然后从头走到 总长度-n 的位置再删。这种方式虽然能工作但缺点也很明显你必须走两遍链表。而这道题更干净的做法是用双指针一次遍历直接定位。具体思路是这样先用一个快指针向前走 n 步然后让慢指针从虚拟头节点开始和快指针同步向前走。当快指针到达链表的末尾时慢指针正好停在待删除节点的前一个节点上。前一个节点很重要——删除操作需要改写前一个节点的 next如果直接把慢指针停在待删节点身上还得把它的前驱单独找一遍。我用过这个思路之后真的觉得非常顺它和生活中的“倒数第几个”这个概念其实也很契合。你可以想象自己在排队快指针是前面那个带队的人——他先提前走出去 n 步然后整个队伍再一起往前走。等到领队走到队伍的最后你回头看你站的位置离队尾正好是 n 步。这个理解方式对初学者特别友好。代码是这样写的ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode* dummyHead new ListNode(0); dummyHead-next head; ListNode* fast dummyHead; ListNode* slow dummyHead; while (n-- fast ! nullptr) { fast fast-next; } fast fast-next; while (fast ! nullptr) { fast fast-next; slow slow-next; } slow-next slow-next-next; ListNode* result dummyHead-next; delete dummyHead; return result; }这里有个细节值得多说两句为什么 fast 要先多走一步因为最终我们希望 slow 指向待删节点的前一个节点而不是待删节点本身。如果 fast 只走 n 步那 fast 到达末尾时 slow 正好停在待删节点上——要删它还得额外保留它的前驱指针多一层麻烦。先让 fast 多走一步相当于把 slow 的最终位置整体往后延迟一位等循环停下来slow 就已经待在前驱的位置了。这题还有一个变体值得思考如果 n 给定的大小正好等于链表的长度也就是要删除头节点怎么办你会发现 fast 先多走一步之后fast 直接变成 nullptrslow 还在 dummyHead 上然后执行slow-next slow-next-next完美地把头节点删掉了而且返回的是 dummyHead-next也就是原来的第二个节点完全不用额外判断。这就是虚拟头节点的隐藏价值中的另一个体现——它把边界情况吞进了统一的逻辑里。2.3 两题放在一起练虚拟头节点的两种使用思路24题和19题放到一块儿看虚拟头节点的用法其实是分成了两个不同的方向24题里虚拟头节点是“统一交换起点”的作用。因为交换动作涉及节点1和节点2如果从原头节点开始操作每次交换的“前驱”都在变化虚拟头节点让我们永远有一个固定的 cur 起点19题里虚拟头节点是“统一删除逻辑”的作用。因为删除头节点的时候没有前驱节点可以直接改写而虚拟头节点让它变成了一种非常常规的删除操作。理解了这个区别以后再遇到链表的插入、删除、移动类题目第一反应就应该是要不要加一个虚拟头节点大部分情况下答案是肯定的。另外还有一个很小的操作细节就是这两道题里面我用cur-next-next或者slow-next-next的地方非常多这些看起来重复的表达式里其实藏着一个容易忽略的核心判断——后面这两个指针的那个“节点”必须存在否则访问空指针的 next 就会直接崩掉。写 while 条件的时候只要涉及这种深层的指针访问一定要保证那两层的节点都不是空的。这是链表题基本功中的基本功但越基础的往往越容易大意。3. 从“查找”到“相交”的进阶链表相交的破题关键3.1 面试题02.07.链表相交到底什么是“相交”题目描述不算长给你两个单链表的头节点 headA 和 headB请你找出并返回两个单链表相交的起始节点如果两个链表没有交点返回 null。有一个非常关键的认知误区必须在这里说清楚题目里的“相交”不是值相等而是同一个节点对象被两条链表的 next 同时指向。换句话说两个链表在某个节点之后所有节点都是同一个内存地址。如果你在 LeetCode 上做题核心判断条件比较的应该是nodeA nodeB而不是nodeA-val nodeB-val。很多人第一次刷这道题的时候都会踩这个坑。当时我也在这里卡了一段时间——我一开始以为就是找第一个值相等的节点结果一看提交界面一堆测试用例过不了回过去看题目描述才发现是地址相等。其实仔细想想也比较合理如果只是值相等那“相交”的意义就被稀释了毕竟可以存在两个不同节点存着同一个数值。只有地址相等的情况下链表才算真正在结构上“汇合”。3.2 思路与实现先对齐末尾再从头找相同这道题的暴力解法没什么技术含量——固定一个链表中的节点另一个链表整体从头遍历一遍找有没有同一节点地址时间复杂度是 O(m*n)一般不推荐。这里比较经典的解法基于一个观察如果两个链表相交它们的最后一个节点一定相同因为相交之后的部分是同一个链表。所以可以从尾部对齐开始考虑但链表是单向结构要想从尾部向上找又没有父指针所以现实中更常用的做法是“长度对齐”。思路其实非常清晰分别遍历两个链表求出它们的长度 lenA 和 lenB让较长的链表指针先走完长度差的步数 gap然后两个指针同步向后移动每移动一次就检查是否指向同一个节点找到第一个相同节点直接返回走到 null 都没遇到就返回 null。对齐这一步的思想跟“两条跑道上的人如果起跑线不同先把较长的那个往回退几步”很相似——注意不是真的后退而是让它的指针先往前走。保证两个指针在“到终点还剩下的路径长度相同”的前提下开始同步前进这样一旦存在公共尾巴两个指针就一定能在某个节点相遇。代码实现如下ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) { ListNode* curA headA; ListNode* curB headB; int lenA 0, lenB 0; while (curA ! nullptr) { lenA; curA curA-next; } while (curB ! nullptr) { lenB; curB curB-next; } curA headA; curB headB; if (lenA lenB) { swap(lenA, lenB); swap(curA, curB); } int gap lenA - lenB; while (gap--) { curA curA-next; } while (curA ! nullptr) { if (curA curB) { return curA; } curA curA-next; curB curB-next; } return nullptr; }这里我用了一个小技巧为了保证后面代码只写一种情况先把 lenA 固定为较长的那一条链表。如果输入里 headB 更长就把 curA、curB 交换这样后续代码就只需要处理 curA 先走 gap 步这一个方向不用把同样的逻辑复制两遍。这个习惯在写链表题尤其是多分支判断的时候特别有用能显著压缩代码量并减少出错概率。注意判断相交节点时必须比较“节点指针地址”而不是节点里的值。这个问题的验证方式很简单——如果两个链表是相交的那么从相交点开始往后遍历所有节点的地址都应该对应相同。一旦出现“你的下一个节点和我这个节点地址不同但值相同”那就说明这不是相交点只是数值巧合。3.3 扩展思考如果允许改链表还有更优雅的做法题目常规设置下链表节点不允许修改。但如果是自己做练习或者写项目还有一种非常经典的思路值得了解一下双指针同时从两个链表的头节点出发一个走完 A 再走 B另一个走完 B 再走 A。如果存在交点两个指针相遇时走的路程相同如果没有交点它们最终都会在“空指针”处相遇。这个解法的本质是利用了两个链表拼接后的长度相等来消除长度差写起来非常短ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) { ListNode* pA headA; ListNode* pB headB; while (pA ! pB) { pA pA ? pA-next : headB; pB pB ? pB-next : headA; } return pA; }第一次看到这种写法的时候确实有点惊艳因为代码量压缩到了极致而且双指针的轮换逻辑完全消除了对长度的预计算。不过如果没有理解“对齐”的思想这段极简代码也容易让人摸不着头脑。我自己在实际刷题时优先推荐写长度对齐的版本因为它更直观、出错率更低这个“串联轮换”版本可以作为进阶理解以后面试现场如果需要炫技再看情况使用。4. 环形链表II从“判断有没有环”到“找出入口”4.1 快慢指针为什么它们一定会在环里相遇142环形链表II是四道题里最有意思的一道。题目要求给定一个链表的头节点 head返回链表开始入环的第一个节点。如果链表无环则返回 null。需要注意的是pos 是调用方传入的只是一个环境参数做题的时候不需要也不能自己修改链表。解题分成了两步第一步判断有没有环第二步找环的入口。判断有没有环的标准做法是快慢指针定义一个慢指针 slow 每次走一步快指针 fast 每次走两步从 head 出发。如果链表里有环fast 终究会追上 slow两者在环中相遇。它们一定会在环中相遇的证明其实不复杂fast 进入环之后可以把它看成在环里追赶 slow由于 fast 比 slow 每次多走一步它的相对速度是 1 步那么只要环是有限的它必然在有限步内追上 slow。这个过程和操场跑步非常像。两个人同方向跑一个人速度快一些另一个人慢一些只要跑道是环形封闭的跑得快的人一定会再次追上跑得慢的人。因为快的人相对慢的人每圈多跑一定的距离而这个多出来的距离在环形跑道上一定会凑成一个整圈。4.2 数学推导入口节点和相遇位置的关系真正让这道题比“判断有没有环”高一个档次的地方在于找入口。先定义几个量头节点到环形入口节点的距离为 x入口节点到 fast 与 slow 相遇节点的距离为 y从相遇节点到再次回到入口节点的距离为 z。slow 走的总距离是x y。fast 走的总距离呢因为 fast 比 slow 快一倍它走的距离是2 * (x y)。同时fast 在环里很可能已经转了一圈甚至多圈然后再和 slow 相遇所以它的总距离也可以表示为x y k * (y z)其中 k 是 fast 在环里转的圈数k ≥ 1。把两种表示相等x y k * (y z) 2 * (x y)化简得x y k * (y z)这里我们的目标是求 x头节点到入口的距离。把式子变一下形x k * (y z) - y (k - 1) * (y z) z这个公式看起来有点复杂但实际的含义很直观从相遇节点出发绕环转 (k-1) 圈后再走 z 步和从头节点出发走 x 步最终到达的是同一个位置——入口节点。正因为有这个结论当 fast 和 slow 第一次在环里相遇之后我们可以让一个指针从 head 重新出发另一个指针就在相遇节点原地不动两个指针每次都走一步。当这两个指针相遇时它们恰好都在环形入口处。这也是这个解法里最巧妙的地方它把一个只知道“相遇点”的问题转化成了用数学关系定位“入口点”的问题。4.3 完整实现与细节注意整个实现过程分成两个阶段。第一阶段fast 和 slow 从头节点出发fast 每次走两步slow 每次走一步直到它们相遇或者 fast 先走到链表末尾。第二阶段如果相遇了令 index1 指向相遇节点index2 指向 head两个指针同速走再次相遇的位置就是环的入口。这里有一个很重要的边界如果链表中只有一个节点而且它指向了自己即环就是节点自身这种情况下其实构成了一个自环。代码里快慢指针的处理逻辑是可以正确处理这种情况的——fast 从 head 出发走两步会回到自身slow 走一步也会到同一个节点两者直接相遇。但是题目本身的合法链表中由调用方指定的 pos 可能把环形入口设置在节点0这在题目的测试用例里是允许的。我写的代码是这样的ListNode* detectCycle(ListNode* head) { ListNode* fast head; ListNode* slow head; while (fast ! nullptr fast-next ! nullptr) { fast fast-next-next; slow slow-next; if (fast slow) { ListNode* index1 fast; ListNode* index2 head; while (index1 ! index2) { index1 index1-next; index2 index2-next; } return index1; } } return nullptr; }需要留意的细节有两个。第一个是 while 循环的终止条件fast ! nullptr fast-next ! nullptr。因为 fast 每次走两步如果 fast 已经是最后一个节点或者已经是 nullptr那么下一步操作就会访问空指针。这里的条件本质上是在判断链表是否“走到头了”——如果没有环fast 一定会先走到链表末尾如果有环fast 永远不会走到空指针上它会在环里和 slow 相遇。第二个是快慢指针的步数分配不能改。我见过有一些同学尝试把 slow 改成每次走两步、fast 改成每次走三步觉得这样能更快。这种做法虽然在某些情况下也能让两个指针相遇但会破坏掉 2 倍关系的数学推导——它的速度和距离关系不再满足fast 走的距离 2 * slow 走的距离后续找入口的那个公式也就没法继续用了。所以如果你想用“找到相遇点之后再同步走找入口”这个思路快慢指针的 1:2 速度比是不能乱动的。4.4 如果不要求“找到入口”只判断“是否有环”的话顺带谈一个几乎总会出现的扩展问题如果题目不要求返回入口只问链表里有没有环代码会更简单。判断逻辑和上面几乎一样快慢指针如果在移动过程中相遇就说明有环。如果 fast 走到了空节点说明链表没有环。从训练的角度来说建议先把“判断有环”的版本独立写一遍再在这个基础上加上“找入口”的逻辑。这样可以把问题分层第一步理解快慢指针的相遇条件第二步理解相遇后的数学推导和定位过程。实际刷题的时候很多人第一版只能通过“判断有环”的用例却卡在了入口的计算上面。你需要做的就是把上面的数学推导吃透然后再去动第二段 while 循环。5. 补卡过程中的常见问题与避坑技巧既然这篇是补卡记录那就顺便把这几天反复踩过的坑整理成一份“问题速查表”方便后续回顾也方便正在刷题的朋友们对着检查。这些坑基本都是真实出现过的有些是我自己踩的有些是和朋友讨论时发现的常见问题症状原因解决方案24题交换后丢节点输出链表变短或缺节点步骤顺序不对node2-next 被覆盖先把 node2-next 存下来或先改 node1-next19题删除头节点错误头节点没删掉或返回空链表没有使用虚拟头节点统一用 dummyHead返回 dummyHead-next02.07比较节点值部分用例返回错误节点误以为值相等即相交用指针地址比较curA curB02.07长度未对齐同步遍历时漏掉交点两个链表长度不一致先算长度差让较长链表指针先走 gap 步142没有考虑 fast 为 nullptr运行时报空指针异常while 里的终止条件不完整加fast ! nullptr fast-next ! nullptr142快慢指针步长设置错误永远等不到相遇快指针步长不是慢指针的 2 倍固定使用 1:2 速度比除了表里这些硬性的代码问题还有几个经验性的心得也想讲一下。第一件事刷链表题一定要先处理“头节点”这个特殊情况。不管哪道题只要操作可能涉及头节点就考虑一下虚拟头节点能不能用。这一招可以用“覆盖”很多种情况尤其是在 24 题、19 题里面没有虚拟头节点就需要为头节点单独写特殊情况判断代码瞬间多了几条分支还很难保持正确性。第二件事关于调试。链表题的调试方式我通常会在关键位置临时加一些打印逻辑把当前指针指向的节点地址和值都打出来。比如 24 题交换完一轮后打印一次19 题删除前打印 slow 和 fast 的位置这样能看到每一轮循环之后链表变成了什么样。刚开始我自己写链表题总是懒得打印直接对着代码硬推经常推不出来。后来改成“代码跑一遍 打印中间状态”的方式之后很多细节问题都变得清楚多了。第三件事LeetCode 上刷链表题时最好关注一下空链表输入的情况。有的题目会特意给一个空的 head你的代码如果第一步就直接访问head-next立刻就会崩。保持先判断指针是否为空的习惯很重要——每次访问 next 之前都想想这个节点有没有可能是空的。第四件事是从训练角度出发的建议同一道题建议用不同语言各写一遍。我用 C 写完一遍后再用 Python 默写一遍两种语言指针表达方式的差异会逼着你把“逻辑”和“语法”分开。比如 C 里面表达取地址和访问成员的方式比较直接Python 里面虽然有对象引用但写起来更容易把指针和变量混为一谈这两种语言互相对照着写会让“节点和引用”这个概念更加清晰。6. 从这四道题提炼出的链表核心考点经常有人问链表题到底在考什么如果归纳这四道题会发现所有考点都集中在这几件事上指针引用的管理。24 题的节点交换本质是三条 next 方向的变更19 题的节点删除本质是一条 next 方向的变更。全部需要确保在改指向之前不丢失任何后续节点的引用。虚拟头节点的运用。删除头节点、交换前两个节点这些常规思路下需要写分支的情况虚拟头节点都能让处理方式统一起来。双指针思想。19 题是“快慢指针找位置”的典型142 题是“快慢指针判断循环”的典型02.07 题则通过“长度对齐”实现了链表的异步同步问题。双指针在链表中几乎就是一个万能工具。数学推导能力。142题要求对快慢指针相遇点和入口位置之间的关系做推导这类证明题平时刷得少但真正遇到的时候能让你对算法产生信任感——因为你知道这个解法不是“试出来的”而是“推出来的”。除了这些硬核考点我还想多说一句链表题刷得好不好往往看的是画图能力。你可以在草稿纸上画出任意形状的链表——带环的、相交的、奇偶长度的——然后把每个操作步骤用箭头标出来。这种练习做到位之后再看任何链表题脑子里都会自动浮现出指针移动的动画而不是死记硬背代码。再顺带提一句时间复杂度的观察。这四道题的最优解都是 O(n) 时间、O(1) 空间的——也就是说它们是在限制额外空间的情况下处理链表问题的典型代表。相比之下很多数组题的最优解经常需要使用额外空间比如哈希表而链表因为本身就携带了“前后连接”的信息很多问题天然就可以用指针操作的方式就地解决。7. 写给自己和读者的几点心得补卡这件事本身虽然有点拖延的意味但好处在于回头再看这些题目的时候感觉反而比当时第一次刷的时候更清晰了。小结一下我这次补卡过程中比较有感知的一些体会吧。第一链表题可以错但一定要知道错在哪。第一次写 24 题的时候我就是在交换逻辑里把 node2-next 覆盖了导致只输出了前半段链表。后来我专门把三个关键步骤的下标关系在图上标了一遍才完全理顺。所以如果你在刷题时遇到类似报错一定不要急着改代码靠猜回去画图才是效率最高的调试方式。第二数学推导的题不要只看结论。142 题的那个公式一开始自己推了一遍中间犯过符号错误但正因为推过后面写代码的时候完全明白为什么相遇之后还要从头再走。如果只背结论碰到稍微变形的题很容易当场懵住。类似这种“看似代码很短但背后有推导”的题目一定要亲手把过程过一遍。第三双指针不是只要“两个指针”就叫双指针关键是理解两个指针之间的“速度关系”或“偏移关系”。19 题里双指针的作用是制造一个固定偏移量142 题里双指针的作用是制造一个 2 倍速度差02.07 题里双指针的作用是消除长度差异——三者看起来相似实际上背后逻辑各不相同。放到一起练之后这种区分就特别明显了。第四关于刷题记录。我是建议每题刷完都留一个独立笔记把自己当时的思路、卡住的位置、最终的解法和复杂度分析都记进去。这种记录不是给谁看的纯粹是为了以后复习时能快速找回自己的思考轨迹。比如下一次看到“链表相交”如果我的笔记写着“先算长度再对齐然后比较指针地址”我就能在几秒钟之内回忆起整个思路框架不用再从头推一遍。刷题这件事跟其他技能训练一样“复盘比刷量重要”这个道理越到后面体会越深。这四道题看似都是链表问题但每一题的核心技巧和易错点都不一样放在一起刷才能最清晰地看出它们之间的区别与联系。希望你看了这篇补卡记录之后也能把这些链表题理解得更扎实一些——尤其是你如果刚开始刷链表画图、虚拟头节点、双指针、快慢指针这四板斧趁早练熟它。