链表分组反转:为什么要先数够 k 个,再动手改指针

K 个一组翻转链表要求每 k 个相邻节点整体掉头,组与组的先后不变,末尾不足 k 个则保持原样。它的难点不在反转动作本身,而在组边界:必须先探测够 k 个再动手,否则最后一段无法以常数空间回滚。最常见的错误是边遍历边反转、反转后忘记把原组头接回下一组,以及把递归版的空间当成 O(n)——按组递归其实只压 n/k 层。读完你能写出两趟扫描、额外空间 O(1) 的迭代版本,说清哨兵与四个指针各自负责什么,并用边界用例自测。

是什么

K 个一组翻转链表(Reverse Nodes in k-Group)是这样一道指针题:给定单链表头节点和正整数 k,把每 k 个相邻节点划为一组,组内指针方向整体反转,组与组之间的相对次序保持不变;如果最后剩下不足 k 个节点,这一段必须维持原有顺序,不能反转。

一句话直觉:把链表看成一列货运列车,每 k 节车厢为一组原地掉头,编组顺序不变,最后一截不够 k 节就原样挂在车尾。

它的典型场景不只是面试手写:分块数据结构里的块内逆序、日志或协议缓冲区的批量重排、按页链组织的内存回收,都会遇到"定长分组、组内反向、尾部残块保留"这个模式。它和"反转整条链表"只差一条约束,难度却明显上一个台阶——因为所有麻烦都集中在边界上:组在哪里结束、尾巴够不够、上一组怎么接到下一组。

它也和"反转指定区间"那类题不同:区间题的左右端点是入参,位置事先已知;分组反转的组数由链表长度除以 k 动态决定,代码必须自己"发现"边界,再决定这一组要不要动。

核心原理

把一次分组反转拆成三个动作:数、翻、接。三件事分开想,比在一个循环里同时维护五个变量可靠得多。

数:从上一组反转后的尾巴 pre 出发,向后走 k 步,得到本组最后一个节点 end。如果中途走到 null,说明剩余不足 k 个,整个函数可以收工。这次探测不是多余的开销,它是"不足 k 个保持原样"的唯一低成本实现方式:指针一旦改了,就没有 O(1) 的回头路,想恢复得再遍历一遍。

翻:组内是标准的原地反转:prev 指向已经翻好的前缀,cur 指向待处理的节点,每轮先把 cur.next 取到临时位置,再让 cur.next 指向 prev。这里有个值得讲清楚的技巧——prev 的初值不设成 null,而是设成本组的后继 nxt。这样反转结束时,原来的组头(反转后变成组尾)天然就指向了下一组的车头,不需要事后再补一根线。

接:pre.next = end,让上一组接到本组的新头;pre 挪到 start(原来的组头,现在是组尾);start = nxt,准备处理下一组。哨兵节点 dummy 的价值就在这里:它让第一组也有一个合法的前驱,省掉"头节点会被换掉"的特判,最后返回 dummy.next 即可。

用调车来类比:车钩就是 next。要把 k 节车厢推上转盘掉头,先得确认这组真有 k 节;掉头之前还要把后面那列车的车头位置记下来(nxt),否则一松钩,后半列车就再也找不回来;调完把前车钩挂到新的头部,工作位置则挪到本组的新尾巴上,继续下一组。

拿 1→2→3→4→5、k=2 手推一遍:第一组 end=2、nxt=3,反转后变成 2→1→3,pre 落到 1,start 变成 3;第二组 end=4、nxt=5,反转后 4→3→5,pre 落到 3,start 变成 5。第三组从 pre=3 开始数,第一步到 5,第二步就撞上 null,于是直接返回 dummy.next,最终结果 2→1→4→3→5,最后一个 5 原样留在尾部。走一遍这个例子,比盯着代码看十分钟更能理解"pre 是上一组的尾巴、start 是这一组的头"这两个角色为什么不能混。

class Solution:
    def reverseKGroup(self, head, k):
        dummy = ListNode(0, head)      # 哨兵:让第一组也有前驱
        pre, start = dummy, head
        while True:
            end = pre
            for _ in range(k):         # 先数够 k 个,数不够就整段不动
                end = end.next
                if end is None:
                    return dummy.next
            nxt = end.next             # 记住下一组的车头
            prev, cur = nxt, start     # 组内反转;尾节点直接接 nxt
            while cur is not nxt:
                cur.next, prev, cur = prev, cur, cur.next
            pre.next, pre, start = end, start, nxt

复杂度:每个节点被探测一次、被反转一次,指针读写都是常数次,时间 O(n);除哨兵外只用了几个指针,额外空间 O(1)。递归写法每一组压一层栈,空间是 O(n/k) 而不是 O(n),这一点后面还会展开。

常见考点与误区

  • 误区一:边遍历边反转。有人为了"只扫一遍",每走过 k 个就顺手翻掉,结果最后发现不足 k 个时,前面已经改过的箭头无法在 O(1) 空间内还原。正确顺序永远是先探测、后反转;两趟扫描仍然是 O(n)。
  • 误区二:递归版空间是 O(n)。按组递归时,每层处理 k 个节点才下探一层,栈深是 ⌊n/k⌋,不是 n。只有 k=1 时才退化成 O(n)。但别因此掉以轻心:n 是一百万、k 等于 2 时仍有五十万层,照样栈溢出。
  • 考点三:dummy 到底解决什么。面试官常问"不用哨兵行不行"。行,但你要为第一组单独记录新头,并在循环里区分"pre 是否存在",两套逻辑容易漏。哨兵把特例变成通例,代价只是一个节点。
  • 误区四:反转完忘记接回下一组。组内反转只改了组内的边,原组头的新 next 必须指向 nxt,否则后半条链直接丢失。把 prev 初值设成 nxt 能顺手完成这件事,但要说得清为什么,而不是背下来。
  • 误区五:pre 前进到新头。pre 应该指向本组反转后的尾巴,也就是旧的 start;写成 pre = end 会让下一组的前驱指向错误位置,轻则顺序错乱,重则形成环、循环永不结束。
  • 考点六:k 的边界。k=1 应原样返回;k 等于链表长度时只反转一次;题面通常保证 k 不大于链表长度,但工程函数要自己防 k≤0 和空链表。
  • 考点七:改值还是改指针。如果允许交换节点里的值,代码会短很多;可一旦节点被哈希表、索引等外部结构按身份引用,改值就破坏了"这个节点还是这个节点"的语义,必须先问清约束。

实践建议

  • 把"数"和"翻"写成两段独立的循环或两个小函数,不要在同一个 while 里同时维护五个变量。面试时先把三步讲清楚再落笔,比写一半再回头改要快。
  • 纸上画四个指针:pre、start、end、nxt,每改一根箭头就划掉旧的那根。分组反转的 bug 绝大多数不是反转写错,而是漏存了 nxt。
  • 固定一份边界清单:空链表、单节点、k=1、k=2、n 能被 k 整除、余 1 个、余 k−1 个。写完至少用 k=1 和"余 k−1"各跑一遍,这两种最能暴露探测逻辑的错。
  • 生产代码优先选迭代版。递归版读起来短,但栈深随 n/k 增长,长链表上有实实在在的风险;Go、Java 里也建议用显式临时变量代替花式连续赋值,方便后来人维护。
  • 被追问"能否只扫一遍"时,可以答:探测和反转合起来,每个节点仍是常数次访问,所以两趟扫描也是 O(n);真正的取舍不是遍历次数,而是可回滚性——为了省一趟而边翻边数,会让余数段无法还原。
  • 如果节点同时被哈希表或外部索引引用(典型如 LRU 缓存),反转会改变"谁是头、谁可达"的语义,考虑改成重排索引或复制节点,不要直接改链。
  • 题面与对照实现可参考 LeetCode 25 题面 和 NeetCode 的两种解法对照,重点看它们如何处理余数段。

一句话总结:分组反转难在边界与缝合,不在反转本身——先数够,再掉头,最后接回。

相关阅读

← 返回文章志