LeetCode 25. K 个一组翻转链表:指针操作的天花板

K 个一组翻转链表(LeetCode 25,Hard)被广泛认为是链表指针操作的天花板:每 k 个节点一组翻转,不足 k 个保持原样——难点不在翻转本身(本库《反转链表》已教),而在「分组定位、组内翻转、与前后段拼接」时同时管理四个指针的关系。这类题的通用解法纪律(画图 + 不变量)比任何单个技巧都重要,本篇用它走一遍完整流程。

总体流程:dummy、分组定位、组内翻转、拼接

先立三个指针角色:dummy(虚拟头节点,让「头节点的翻转」不用特判——本库《反转链表》的老朋友)、group_prev(当前组的前驱,即上一组翻转后的尾节点)、以及组内的工作指针。流程四步循环:第一步,分组定位——从 group_prev 出发走 k 步,确认第 k 个节点存在;不足 k 个(走到 None)则结束(不足组保持原样)。第二步,记录切口——记下 group_start(本组第一个节点,翻转后将成为本组尾)与 group_next(下一组的第一个节点)。第三步,组内翻转——标准翻转从 group_start 翻到 group_next 之前。第四步,拼接——翻转后,group_prev.next 接向原组的尾(现在的头),group_start(现在的尾)接向 group_next,然后 group_prev = group_start,进入下一组。

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

def reverse_k_group(head: "ListNode", k: int) -> "ListNode":
    dummy = ListNode(0, head)
    group_prev = dummy

    def get_kth(node: "ListNode", k: int) -> "ListNode | None":
        # 从 node 出发(不含 node)走 k 步,返回第 k 个节点;不足则 None
        while k > 0 and node:
            node = node.next
            k -= 1
        return node

    while True:
        kth = get_kth(group_prev, k)     # 第一步:分组定位
        if not kth:                      # 不足 k 个,结束
            break
        group_next = kth.next            # 第二步:记录切口
        prev, curr = group_next, group_prev.next
        while curr != group_next:        # 第三步:组内翻转
            nxt = curr.next
            curr.next = prev
            prev, curr = curr, nxt
        tmp = group_prev.next            # 翻转前的组首(现在的组尾)
        group_prev.next = kth            # 第四步:拼接前口
        group_prev = tmp                 # 移动到下一组的前驱
    return dummy.next

这段代码最精妙的一处:组内翻转的 prev 初始为 group_next——翻转直接翻到「下一组的头」为止,翻转完成的瞬间,组尾已经自动指向 group_next,第四步的「拼接后口」被免费完成了;tmp = group_prev.next 要在改 group_prev.next 之前取(那是翻转前的组首,即现在的组尾)。这两处的顺序,是本题 90% 的错误来源。

通用方法:画图与不变量

Hard 链表题做对的通用方法不是「聪明」,是纪律。纪律一:每步画图。指针题的最大敌人是「脑内模拟」——四个指针的指向变化靠想象必然出错,正确姿势是每执行一步就在纸上/白板上画一次箭头的变化(本篇正文的四步,每步一图);写完代码后,拿 [1,2,3,4,5], k=2 在图上手动模拟一遍代码(不是模拟算法,是模拟你写的代码的每一行),与预期对照。纪律二:锁定不变量。循环的每一轮开始时,断言两条不变量成立:group_prev 指向「上一组翻转后的尾」(它之后是尚未处理的段);kth 与 group_next 之间的段是「即将翻转的本组」。不变量成立的循环才是正确的循环——写代码前先写出不变量,是这类 Hard 题从「碰运气」变成「有把握」的分水岭。纪律三:边界测试清单——k=1(不翻转,验证代码没把 k=1 弄坏)、k 大于链长(整体不翻转)、链长恰为 k 的倍数(无剩余组)、单节点。四个用例全过,才算跑通。

手动模拟:[1,2,3,4,5] k=2 的完整指针追踪

把代码在 [1,2,3,4,5]、k=2 上逐行追踪,每一轮的状态都列出来。初始:dummy→1, group_prev=dummy。第一轮:get_kth(dummy,2)——走两步到节点 2,kth=2 ✓(存在)。group_next = 3。翻转初始化 prev=3(group_next)、curr=1(group_prev.next)。翻转循环:curr=1≠3——nxt=2, curr.next=3(1 指向 3), prev=1, curr=2;curr=2≠3——nxt=3, curr.next=3(2 指向 3), prev=2, curr=3(=group_next 退出)。此刻链:dummy→1→2→3→4→5 且 1、2 都指向 3——还没拼接。tmp = group_prev.next = 1(翻转前的组首)。group_prev.next = kth(dummy→2 ✓ 前口接好)。group_prev = tmp = 1(新组尾)。第一轮结束的链:dummy→2→1→3→4→5 ✓。第二轮:get_kth(1, 2)——从 1 走两步到 3,kth=3。group_next=4。翻转 prev=4、curr=3:3 指向 4、4 指向 4... 逐步:curr=3≠4——nxt=4, curr.next=4, prev=3, curr=4;curr=4≠4? 不,curr==group_next(4) 退出——等等,curr 初始是 group_prev.next=1?重查:第二轮 group_prev=1,curr=group_prev.next=3(1 指向 3),prev=group_next=4。curr=3≠4——3.next=4, prev=3, curr=4;curr=4==4 退出。tmp=3。group_prev.next=kth=4(1→4 ✓)。group_prev=3。第二轮结束:dummy→2→1→4→3→5。第三轮:get_kth(3,2)——从 3 走两步:到 5、到 None——kth=None,退出。最终 [2,1,4,3,5] ✓。三分钟的逐行追踪,四个指针的每一次变向都在掌控之中。

链表 Hard 的 Checklist 化:四步流程的复用清单

K 个一组的天花板流程,可以压缩成一张可复用的 checklist——任何「分组处理链表」类 Hard 题都适用。Checklist 八条:□ 1 dummy 节点建好(消除头节点特殊性);□ 2 分组定位函数单独测试(不足 k 返回 None);□ 3 每组的「前驱、组首、组尾、后继」四个触点在纸上标出;□ 4 组内操作的起止边界确认;□ 5 组间拼接的两根线(前驱→新组首、新组尾→后继)逐一接上;□ 6 组指针移动到「新的组尾」;□ 7 循环终止条件(定位失败即停);□ 8 四类边界测试(k=1、k>链长、整除、单节点)。Checklist 的哲学:Hard 题的 Hard 不在思路在执行——八个检查点逐一打勾,把「容易错」变成「错不了」;这个方法与航空业的飞行检查单同源(本库《AI 事故报告》的行业参照),复杂系统的可靠性来自清单而非天赋。

一次真实的 debug 实录:order 错误的十分钟

还原一次真实的 order 错误调试过程,展示「画图定位」的实战用法。症状:[1,2,3,4,5] k=2 的输出是 [2,1,3,4,5] 而不是 [2,1,4,3,5]——第二组(3,4)没有被翻转。定位过程:第一轮输出正确(2,1),说明「第一组翻转 + 前口拼接」没问题——问题锁定在「第二轮的定位或翻转」。画图对照:第一轮结束时 group_prev 应指向节点 1——检查代码 group_prev = tmp,而 tmp = group_prev.next 在 group_prev.next = kth 之前取的——画图确认:翻转前 group_prev(dummy).next 是节点 1(组首),所以 tmp=1 ✓;翻转后 dummy.next=2 ✓;group_prev=1 ✓。第二轮 get_kth(1, 2):从 1 走两步——发现 bug:get_kth 是「不含起点走 k 步」,从 1 走两步到 3?链现在是 1→3(1 被翻转向 3)、3→4——从 1 走:第一步到 3,第二步到 4——kth=4 ✓。翻转 prev=5、curr=4:4.next=5... 追踪到这里发现翻转正常——继续发现真正的 bug 在 curr = group_prev.next:初始化写成了 curr = group_prev(从组前驱开始翻,把前驱也翻进去了!)——症状是组前驱被卷入翻转、链断裂。修复一行,全过。这次调试的启示:画图追踪「代码的每一行」而非「算法的每一步」——bug 藏在实现与算法的偏差里,不在算法里。

多语言实现的指针陷阱对比:Python 与 C++ 的差异

同一段翻转逻辑,不同语言的陷阱清单不同,值得对比。Python 的陷阱:没有真正的「指针」——变量是对象的引用,curr = curr.next 是引用转移(安全),但 a, b = b, a 的交换语义与 C++ 不同(右侧先全部求值——这个特性让 Python 的链表交换比 C++ 简单);对象的生命周期——被摘下的节点会被 GC 回收,不会有内存泄漏,但也意味着「暂时摘下的节点」必须保持引用(否则对象消失——调试时的诡异现象)。C++ 的陷阱:裸指针的 delete 时机(翻转中被摘下的节点要不要 delete——泄漏与悬垂指针的两难,智能指针的引入改变写法)、栈上对象的悬垂(dummy 在栈上,返回 dummy.next 之外的东西就悬垂)。Java 的陷阱:与 Python 类似的引用语义 + 无 GC 焦虑;但 Integer 的缓存陷阱(-128~127 的 Integer 缓存——== 比较节点值时用 Integer 对象会错)。多语言对比的总结:链表题的指针逻辑是跨语言通用的,但「内存管理」与「值语义」的陷阱是语言特有的——刷题用一门语言,工程识trap的能力靠对比学习。

一次 order 错误的 debug 实录:画图定位的十分钟

还原一次真实的 order 错误调试,展示「画图定位」的实战用法。症状:[1,2,3,4,5]、k=2 的输出是 [2,1,3,4,5] 而非 [2,1,4,3,5]——第二组(3,4)没有被翻转。定位过程:第一轮输出正确(2,1),说明「第一组翻转 + 前口拼接」没问题——问题锁定在第二轮的定位或翻转。画图对照:第一轮结束时 group_prev 应指向节点 1——检查代码 group_prev = tmp,而 tmp = group_prev.next 在 group_prev.next = kth 之前取——画图确认:翻转前 group_prev(dummy).next 是节点 1(组首),所以 tmp=1;翻转后 dummy.next=2;group_prev=1。第二轮 get_kth(1, 2):从 1 走两步——1→3→4,kth=4。group_next=5。翻转初始化 prev=5、curr=group_prev.next——发现 bug:curr 初始化写成了 curr = group_prev(从组前驱开始翻,把前驱也卷入翻转!)——症状是前驱被翻转、链断裂、第二轮输出错乱。修复一行(curr = group_prev.next),全过。这次调试的启示:画图追踪的是「代码的每一行」而非「算法的每一步」——bug 藏在实现与算法的偏差里,不在算法里。

dummy 节点的通用性:为什么所有链表题都应该有它

K 个一组翻转的 dummy 节点值得升华为链表题的通用守则:所有可能改变头节点的链表操作,都应该有 dummy 节点。它消除的不仅是「头节点翻转/删除」的特判,更是「返回值」的统一(永远返回 dummy.next 而不是「可能是新头也可能是旧头」的条件表达式)。dummy 的变体:双向链表的 dummy(首尾相连成环——LRU 的实现基础,本库《LRU 缓存》的双向链表正是这个形态);以及两个 dummy(分割链表为两段时各配一个 dummy,最后拼接)。dummy 的哲学:为「可能为空」的操作预留一个「永远存在的锚点」,是所有链表操作从「特判地狱」走向「统一处理」的第一步——一个额外节点的空间,买来了代码路径的统一。

小结

这篇收成四条:流程四步循环——分组定位、记录切口、组内翻转、前后拼接,每步一个指针角色;组内翻转的 prev 初始为 group_next,让「拼接后口」在翻转中免费完成,是本题最精妙的一处;tmp = group_prev.next 必须在改 group_prev.next 之前取——顺序错误是 90% 的 bug 来源;Hard 链表题的通用纪律——每步画图、锁定不变量、四类边界测试,把「聪明题」变成「纪律题」。链表三部曲(反转第一课 → 相交与回文 → 本篇的天花板)至此收齐——指针操作的功夫,到此可以出师了。

参考资料

  • LeetCode 25. Reverse Nodes in k-Group —— 题目与官方题解
  • 本库前篇:《LeetCode 206. 反转链表》《相交链表》《回文链表》—— 链表三部曲
  • 本库前篇:《LeetCode 146. LRU 缓存》—— 哈希表 + 双向链表的组合拳(链表进阶的另一面)
← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?