
聊到链表题很多人第一反应就是“指针、边界、空指针”觉得套路单一但细节一堆。LeetCode 25、61、82 这三道题恰好把链表操作里最典型的三个能力点串了起来按组翻转、整体平移、去重删除。如果你正在准备面试或者想把链表基本功重新打磨一遍这三道题非常值得放在一起复盘。先说结论三道题难度从 Hard 到 Medium覆盖了链表题最常见的三种操作形态。25 是 K 个一组翻转链表61 是旋转链表82 是删除排序链表中的重复元素 II。它们的核心解法都不依赖高深算法翻来覆去就是 pre、cur、next 这些指针怎么挪边界条件怎么判循环结束之后链表有没有断链、有没有成环。把这几个问题搞清楚等于把面试里链表题的一大半场景都过了一遍。我刷题有个习惯同类型的题会归到一张笔记里反复比对。25、61、82 这三道题我在不同阶段写过很多遍每次都会有新发现——有的是边界没判有的是指针提前动了有的是断链后忘了接回去。这篇就当是给同路人做的一份完整笔记重点不是给你贴个能跑的答案就完事而是把每个关键步骤背后的“为什么”讲透顺便把容易踩的坑提前标出来帮你省下几轮编译器教育。1. 三道链表题的共同主线指针移动与边界条件掌控1.1 为什么这三道题适合放在一起复盘单独看25、61、82 都是各自高频出现的题目合在一起看它们恰好构成了一组完整的“链表操作能力体检”。25 考的是“能不能在一个接着一个的小区间上做翻转并且无损缝合”61 考的是“能不能通过成环、找断点来平移整条链表”82 考的是“能不能在排序链表中识别并整段删除重复值”。三者都只用到了单链表这一个数据结构外加一个容易被忽略的哨兵节点。我刷了一段时间链表题之后的感受非常直接链表题根本不考你记不记得住反转模板而是考你在指针换来换去的时候能不能保持每一段连接的完整性。25 题里断开的子链表如果不接回后续节点整条链就散了61 题里成环之后不切断你返回的就不是链表而是死循环82 题里删除重复段之后 pre 如果盲目前进紧跟着的下一段重复就会被漏掉。这些问题的本质全是对“节点连接关系”的掌控力。这三道题在面试现场出现的频率也相当高。25 题虽然标着 Hard代码量其实只有二十多行61 和 82 标着 Medium真正的坑全藏在循环退出条件和指针推进时机里。这也就解释了为什么面试官喜欢拿它们写白板代码——代码短、陷阱密、讨论空间大。1.2 难度、考点与解法一览题号标题难度核心考点常用解法时间复杂度25K 个一组翻转链表Hard分组翻转、断链与回缝、哨兵节点四指针迭代 / 递归O(n)61旋转链表Medium链表长度、取模、闭合为环再断链闭合成环 / 快慢指针O(n)82删除排序链表中的重复元素 IIMedium虚拟头节点、跳过整段重复值dummy 前后指针O(n)从上表能看出一个很明显的规律三种解法的空间复杂度都是 O(1)不考虑递归栈。这也算是链表题的一个共性——算法本身不消耗什么额外空间胜负手全在指针操作。我建议把这三题的复杂度结论背熟面试被问到“能不能原地完成”的时候可以不假思索作答。接下来按题逐个拆。2. LeetCode 25K 个一组翻转链表的核心拆解2.1 题目本质分组 局部翻转 重新缝合25 题给你一条链表和一个整数 k要求每 k 个节点为一组在组内做翻转如果末尾剩下的节点不足 k 个保持原顺序不动。例如 1→2→3→4→5k2结果是 2→1→4→3→5k3结果是 3→2→1→4→5。很多人一看到“翻转”就去背反转链表模板这远远不够。这道题真正的地方在于“边界”——你需要先确认当前这一组是否够 k 个节点够了才翻转不够就原地不动翻转完成之后还要把这一组的头尾和前后邻居正确接上。用一句话总结就是你要在一个不断变化的链表上反复执行“找到区间 → 断开 → 翻转 → 缝合”的循环。而翻转本身其实是所有环节里最简单的那一步。为什么要先“数够 k 个”再翻转因为如果把不足 k 个的尾巴也翻转了就违背题意。面试官最常见的考察点就在这里很多候选人反转写得很顺却忘了对剩余节点做“够不够一组”的判断结果整道题逻辑上直接错误。为了避免这个问题最稳妥的办法是每次处理前都派出一个探测指针从当前起点往后走 k 步中途一旦遇到 None立刻收手返回。2.2 四指针法pre、start、end、next 的职责划分迭代解法中维护四个指针是标配pre当前这一组前面的锚点节点。第一组的前面是空的所以引入 dummy 节点充当 pre 的初始值。start当前组的第一个节点。翻转完成后它变成这一组的最后一个节点也是回缝的关键点。end当前组的最后一个节点。翻转完成后它变成这一组的新头。next_group当前组的后一个节点。在断开 end.next 之前必须先保存它否则后半段链表就找不回来了。整个循环是这样推进的每次先用预置好的 pre 找 end把 end 从 pre 出发往后移动 k 次。如果中途 end 变成了 None说明剩余节点不够一组直接返回 dummy.next。找到 end 之后令 start pre.nextnext_group end.next然后把 end.next 置空——这样当前组就独立出来了。接着调用 reverse 函数翻转以 start 为头部的这一段返回新头 prev。此时把 pre.next 指向新头把 start.next 指向 next_group最后把 pre 更新为 start进入下一轮。为什么断开 end.next 之后再翻转因为 reverse 函数会一路把指针往回指如果不先断掉尾巴翻转过程会把后面的链表也卷进来要么翻转过头要么最终无法收尾。保存 next_group 也是同理——断开之前先拍照缝合的时候才能对准。这里的经验是凡是需要“拆开”一段链表先保存它两端的邻居再动手拆顺序千万不能反。2.3 完整代码与逐步走样下面给出我习惯的 Python 写法class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class Solution: def reverseKGroup(self, head, k): dummy ListNode(0) dummy.next head pre dummy while True: # 1. 从 pre 出发找第 k 个节点 end pre for _ in range(k): end end.next if not end: return dummy.next # 剩余不足 k 个直接返回 # 2. 记录四个关键位置 start pre.next next_group end.next # 3. 断开当前组独立翻转 end.next None new_head self.reverse(start) # 4. 缝合回原链 pre.next new_head start.next next_group pre start def reverse(self, head): prev None cur head while cur: nxt cur.next cur.next prev prev cur cur nxt return prev拿 1→2→3→4→5→6k3 过一遍第一轮predummyend 走三步到达 3start1next_group4把 3.next 置空翻转 1→2→3 得到 3→2→1pre.next3start1让 1.next4此时链表变成 3→2→1→4→5→6pre 更新为 1。第二轮end 从 1 出发走三步到达 6start4next_groupNone断开 6.next本来就是 None翻转 4→5→6 得到 6→5→4pre.next6start4让 4.nextNone链表变成 3→2→1→6→5→4。如果最后一个组不足 k 个比如 1→2→3→4→5k2处理完 2→1→4→3→5pre 落在 3 的位置end 从 3 走两步时先碰到 5 再往后就是 None循环在第二布探测时返回剩余节点 5 保持不动。这个“探测失败就整体返回”的机制保证了题目要求的“尾部不满一组不翻转”。2.4 我踩过的坑与面试追问这题我犯过最典型的错误是缝合顺序。最开始我在翻转后先改 start.next再改 pre.next结果把后半段链在翻转前就被覆盖丢了。现在我的固定动作是先保存 next_group再断开再翻转回缝时先接 pre再接 start。四个步骤的顺序一旦固化出错率大幅下降。另一个常见问题是 while 循环写成了普通循环而不是 while True导致不知道什么时候退出。其实退出信号只有一个探测 end 时遇到 None。所以把退出条件放在 for 循环里面最直观代码也最干净。面试时如果面试官追问通常会有这几个变体k 等于链表长度时等价于反转整条链表k1 时链表原样返回递归解法怎么实现能不能一次遍历就完成。递归解法核心是“先翻第一组然后递归处理剩余部分再把两部分接起来”代码更短但递归深度是 n/k面试官可能会让你分析这算不算额外空间。我个人倾向现场写迭代版因为不用和面试官讨论栈空间边界也更可控。提示25 题是 24 题“两两交换链表中的节点”的泛化版本。24 题就是 k2 的特例。先把 24 题写完再上 25 题难度曲线会平滑很多。3. LeetCode 61旋转链表先算长度再动指针3.1 题目本质右移 k 位就是在找断点61 题要求把链表整体向右旋转 k 个位置。比如 1→2→3→4→5k2得到 4→5→1→2→3。注意这里只是“右移”每个节点之间的相对顺序完全没有变变的是整段链表的起点。所以这道题的本质不是“移动元素”而是“找到新的头节点和新的尾节点然后把它们接起来”。这其实和数组右移有相似之处但链表更简单数组右移通常要用到三次翻转或者额外数组链表则只需要一次长度扫描加一次指针移动。为什么因为链表天然可以首尾相连形成一个环然后从环的某个位置剪开就完成了旋转。你不需要交换任何值只需要移动剪断的位置。新头的位置怎么算链表长度为 n向右旋转 k 位等价于向右旋转 k % n 位。新的头节点是从原头节点开始数第 n - k % n 个节点从 1 开始数。比如 n5k2k%n2新头是第 3 个节点也就是 4。没错从原链表的第 1 个节点数到第 3 个节点就是 4。新尾就是它前面的那个节点也就是原来的 3。3.2 两种主流解法闭合成环与快慢指针解法一先扫描一遍算长度同时把尾节点的 next 指向 head形成环然后通过长度和 k 算出走多少步能到达新尾节点把环剪开。解法二快慢指针。快指针先走 k 步慢指针不动然后两个指针一起走直到快指针到达尾节点。此时慢指针的下一个节点就是新头。表面看这样不用知道链表长度但前提是 k 已经对长度取过模否则 k 很大的时候快指针直接走到 None根本没法处理。所以快慢指针法的隐藏前提仍然是“先算一次长度”。我倾向于闭合成环法理由很直接代码短、逻辑线性、不容易错。快慢指针的思路上听起来很帅但面试时一旦紧张很容易在“k 要不要取模”“慢指针最后停在哪”上卡住。闭合成环法只有两个动作算长度成环按步数找切断点。下面以闭合成环法为主来写代码。3.3 完整代码与关键细节class Solution: def rotateRight(self, head, k): if not head or not head.next or k 0: return head # 1. 计算长度并定位尾节点 length 1 cur head while cur.next: cur cur.next length 1 # 2. 对 k 取模避免多余旋转 k % length if k 0: return head # 3. 成环 cur.next head # 4. 从 head 出发找到新尾节点 steps length - k - 1 new_tail head for _ in range(steps): new_tail new_tail.next # 5. 剪断成环返回新头 new_head new_tail.next new_tail.next None return new_head用 1→2→3→4→5k2 细走一遍扫描后 length5cur 停在 5k%5 还是 2把 5.next 指向 1链表暂时成环steps5-2-12从 head1 出发走两步到 3所以 new_tail3new_head3.next4把 3.next 置 None返回 4。最后结果是 4→5→1→2→3和题目示例一致。有一个细节值得多说取模后如果 k 变成 0说明旋转整倍长度不会改变链表直接返回原 head。这个判断放在成环之前可以避免后面不必要的指针移动也让逻辑更清晰。如果你把成环放在判断之前返回前必须记得先断开环否则面试官的测试程序会在打印链表时死循环。3.4 实战中的常见失误第一个高频失误是忘记对 k 取模。很多人写完第一步就拿着原始 k 去走 stepsk10^9 时循环直接爆炸或者快指针一步走到 None。链表题的经典教训就是凡是有长度参与的移动先取模再动手。第二个失误是成环后忘了剪断。如果只做了 cur.next head却在返回前忘记 new_tail.next None你交出去的“链表”其实是个环。本地测试时如果用了 while head 打印会永远打印不完在线判题则大概率被系统检测为非法结构判超时。第三个失误是 steps 计算混淆。length-k-1 中间这个减 1 很关键它表示从原头节点出发移动到“新尾节点”需要跳多少条边。比如一个 4 节点链表 k1新尾节点是第三个节点从第一个节点到第三个节点需要走 2 步正好是 4-1-12。这个减 1 我建议用具体例子硬算一遍而不是凭记忆写。提示如果想在面试里展示两种解法可以先讲闭合成环法作为主答案被问到“有没有别的思路”时再补充快慢指针并主动指出 k 要先取模的坑通常能给面试官留下不错的印象。4. LeetCode 82删除排序链表中的重复元素 II4.1 与 83 的本质区别一个删剩一个一个整段删除排序链表的去重题有两道83 题要求重复元素只保留一个82 题要求把重复元素全部删除一个都不留。比如 1→2→3→3→4→4→583 的结果是 1→2→3→4→582 的结果是 1→2→5。注意 3 和 4 因为出现过重复而被完全清掉。这个区别直接决定了算法结构的不同。83 题的做法是cur 从 head 开始如果 cur.val 等于 cur.next.val就删掉 cur.next否则 cur 前进。它本质上是“逐个检查相同的去掉后面那个”。82 题不能这么写因为你不仅要删掉后面的重复节点连当前这个节点本身也要删。这意味着 head 也可能被删除所以虚拟头节点在这里不是可选项而是必需品。另一个本质区别在于 cur 指针的推进节奏。83 题里 cur 可以稳定地一个一个往前走82 题里当发现一段重复时cur 必须先沿着重复段一路跳到下一个不同值的位置并且删除过程中 pre 不能跟着动因为新的 cur 有可能是另一段重复的起点。4.2 虚拟头节点与“跳过一整段”的写法由于头节点可能被删我们必须用一个 dummy 节点站在链表最前面最后返回 dummy.next。算法维护两个指针pre 指向当前已确定保留的最后一个节点cur 为探测节点。整体逻辑如下如果 cur.next 存在且 cur.val cur.next.val说明 cur 正处于一段重复之中记录下 val cur.val然后让 cur 沿着相同值一路跳过直到 cur 指向一个值不等于 val 的节点或 None把 pre.next 直接接到 cur 上。如果 cur 和 cur.next 值不同说明当前节点可以保留pre 前进到 curcur 前进到 cur.next。这个思路的关键在于遇到重复时我们不是一个个删除重复节点而是把 pre.next 整体指向重复段之后的第一个节点。用一句话概括就是“跳过一整段连接”而不是“逐个删”。跳段式写法的好处是干净利落不需要额外的删除操作指针移动次数也最少。4.3 细节陷阱pre 什么时候动这道题最容易被忽视的细节是 pre 的推进时机。看一个例子1→2→2→3→3→4→5。如果按“发现 2 重复就把 pre.next 接到 3”的逻辑处理接着逻辑还应该重新检查 3 是不是也重复。假如你在第一次跳过重复后立刻把 pre 更新成 cur那么后续“3 重复”的判断就建立在错误锚点上3 会被错误地保留。正确的做法是只要发现了重复并执行了跳段pre 就不动。因为 pre.next 已经被接到了一个“未知节点”这个节点可能又是一段重复的起点必须重新进入 while 循环继续探测。只有确认 cur 和 cur.next 值不同时pre 才前进。这个“删除后不推进保留后才推进”的原则是 82 题最容易踩的坑也是和 83 题最大的行为分界。4.4 完整代码与复杂度分析class Solution: def deleteDuplicates(self, head): dummy ListNode(0, head) pre dummy cur head while cur: if cur.next and cur.next.val cur.val: # 当前节点是重复段的开头整段跳过 val cur.val while cur and cur.val val: cur cur.next pre.next cur # 注意 pre 不推进 else: pre cur cur cur.next return dummy.next对 1→2→2→3→3→4→5 走一遍predummycur1。1 和 2 不同保留pre1cur2。cur2 时cur.next.val2进入重复分支val2cur 一路跳到 3pre.next3pre 仍为 1。下一轮 cur3cur.next.val3又进重复分支val3cur 跳到 4pre.next4。再下一轮 cur4无重复pre4cur5最后 cur5无重复pre5curNone。返回 1→4→5。如果这里 pre 在第一次跳段后也前进了3 就会被保留下来结果就错了。复杂度上cur 和 pre 各自最多遍历一遍节点所以时间 O(n)额外空间 O(1)。实际上整段跳跃过程中 cur 走的路径和正常前进路径加起来每个节点也只会被访问常数次所以不用担心多次扫描带来的复杂度膨胀。注意这里有一个很容易混淆的相似题 80 题“删除有序数组中的重复项 II”它允许每个元素最多保留两次针对的是数组。链表题里对应的是区间跨度更长的连续重复删除写代码前一定要先看清题意是“保留一个”还是“全部删除”。5. 三题横向对比与链表题通解套路5.1 三题共用套路dummy 节点 指针定位 定向连接把三道题放在一起看解法骨架其实惊人地一致先用 dummy 节点解决“头节点可能变化”的问题再用多个指针锁定操作区间的边界最后通过修改 next 连接完成变换。区别只在“指针推进策略”不同。题目dummy 是否必须关键指针推进策略最容易错的地方25必须pre / start / end / next_group每处理完一组把 pre 移到本组新尾翻转后忘记回缝 start.next61不必须cur / new_tail先算长度再定位断点k 没取模、成环后没剪断82必须pre / cur无重复时 pre 前进有重复时 pre 原地跳段后盲目推进 pre我常说链表题的核心就是一句话你要改动哪个节点的 next就得在改动之前把和它相关的所有邻居都用变量存好。25 题存的是 next_group61 题存的是 new_tail 的地址82 题存的是 pre 这个锚点。本质上都是一种“先拍照、再动手术”的工程思维和现实里改水管、拆电路是完全一样的场景。5.2 边界条件自查清单链表题 80% 的 bug 都出在边界条件上而不是主逻辑上。我在写完这三个题后总结了一份自查清单每一道题都能套用链表是否为空head 是否为 None链表只有一个节点时代码会不会走特殊分支头节点是否会改变如果会有没有用 dummy 兜底题目的 k 是否可能大于链表长度是否取模移动 k 步的过程中end 或 fast 有没有可能出现 None断开某个 next 之前它后面的节点有没有被提前保存成环操作之后返回前是否一定剪断了环循环退出的条件是否和题目要求的“剩余片段保持原样”一致我把这份清单贴在工位上每次写链表题之前在脑子里过一遍当场能揪出大半问题。尤其第 4 条和第 6 条算是 25、61、82 三个题各自的血泪教训。5.3 面试时的思考路径与表达顺序如果面试现场遇到链表中等或 Hard 题我建议按这个顺序表达思路先讲入参检查和特殊用例。说清楚空链表、单节点、k 等于 0 或 k 大于链表长度时怎么处理。很多面试官其实在等待你主动提出这些而不是等你写崩了才发现。再讲主流程。不要一上来就写指针先用自然语言描述“我要做几步”比如 61 题说“先算出长度把尾接成环再走 length-k-1 步找到新尾剪断”面试官一听就知道你有清晰规划。最后写代码时逐个解释指针职责。写 pre 时顺手说明“它负责锚定上一组的结尾”写 end 时说明“它负责确认这一组的边界”。这种边写边讲的方式会让人觉得你不是在背代码而是真正理解结构。6. 常见问题与排查技巧实录6.1 典型错误现场先说三个真实踩过的错误都是我或身边同事在本地调试时遇到过的第一个是 25 题里“断链后链表只剩半截”。原因是翻转子链表之前没有保存 next_group。现象是本地打印只输出了前几个节点后面莫名其妙消失。排查思路很简单把每一步缝合前后的链表都打印出来很快就能定位到哪一步丢了链接。第二个是 61 题里“本地测试卡死”。卡死几乎都是成环后忘记断环造成的。遇到死循环先检查返回的最后一个节点是不是还指向了前面的某个节点。你可以在本地写一个“带步数限制的打印函数”比如循环 100 次自动退出这样就能在不卡死终端的情况下观察循环结构。第三个是 82 题里“多保留了重复值”。典型现象是 1→2→2→3→3→4→5 输出成 1→2→3→4→5也就是错误地保留了 2 和 3。原因几乎总是一个跳段后 pre 依然前进了。调试方式是用表格纸画一张节点图标出 pre 和 cur 每轮的位置三十秒就能看清问题在哪。6.2 调试链表题的三个实用习惯链表题最忌讳直接在脑子里推演多个指针。我现在的固定习惯主要有三个第一个是写一个通用的打印辅助函数。输入链表的头节点循环收集值并拼接成字符串方便在每一步前后对比。核心代码很简单def build_linked_list(arr): dummy ListNode(0) cur dummy for v in arr: cur.next ListNode(v) cur cur.next return dummy.next def print_linked_list(head, limit50): values [] while head and len(values) limit: values.append(str(head.val)) head head.next if head: values.append(...) print( - .join(values))第二个是“三步一打印”。遇到复杂一点的轮次在 pre 更新前后各打印一次链表。打印点不用多但一定选在“连接关系发生变化”的位置比如 25 题的缝合后、61 题的剪断前、82 题的跳段后。这样一旦结果不对反推是哪一步出的错会非常快。第三个是准备一组覆盖边界的小用例。我固定会用这些head build_linked_list([1, 2, 3, 4, 5]) print_linked_list(Solution().reverseKGroup(head, 2)) # 2-1-4-3-5 head build_linked_list([1, 2, 3, 4, 5]) print_linked_list(Solution().rotateRight(head, 2)) # 4-5-1-2-3 head build_linked_list([1, 2, 2, 3, 3, 4, 5]) print_linked_list(Solution().deleteDuplicates(head)) # 1-4-5再加几组空链表、单节点、k 等于长度的用例。这些用例跑到通过基本就能覆盖大部分判题系统的隐藏数据了。6.3 个人体会有感写得多了以后我发现这三道题虽然题号挨得不近但在能力训练上其实是递进关系82 题让你学会“识别重复段并跳过去”25 题让你学会“在一个个小区间里断链再接链”61 题则强迫你跳出“从头走到尾”的线性思维学会用环和取模来改变遍历起点。把这三层能力都练熟链表相关的“怎么改结构”“怎么找边界”“怎么处理循环”三大问题基本就通透了。最后再分享一个小技巧每道链表题写完不要急着提交先在脑子里把代码从头到尾手动走一遍两三个节点的链表。虽然看起来浪费时间但每次手动走一遍都能帮你提前抓住一两个边界 bug。这个习惯我保持了挺久后来写链表题的通过率有了特别明显的提升。编程这件事慢一点反而快链表题尤其如此。