题目描述

✅ 25. K 个一组翻转链表

image-20260928181838063

image-20260928181838064

题意分析

从链表头开始,每连续 k 个节点划为一组,分别反转各组内部的连接方向,组与组的先后顺序保持不变。最后如果剩余节点不足 k 个,这一段必须保持原来的顺序,不能反转。

题目要求改变节点连接,不能只交换节点中的值。与反转整条链表相比,这里还要解决三个问题:当前组是否完整、反转到哪里停止、反转后的这一组怎样接回前后两段。

反转后,原组尾成为新组头,原组头成为新组尾。因此每轮先确认有 k 个节点,再保存后一段的入口,最后接好这一组的两端。用一个虚拟头节点统一处理第一组,就能在 $O(1)$ 额外空间内完成整个过程。

解法:分组迭代反转

核心思路

[!blue]

用 groupPrev 指向当前待处理组的前一个节点。每轮开始时,groupPrev 及其之前的链表都已处理完毕,groupPrev.next 是还未处理部分的起点。最初让 groupPrev 指向虚拟头节点 dummy,第一组也就拥有了可修改的前驱连接。

先从 groupPrev 向后走 k 步,找到本组最后一个节点 kth。如果途中走到空节点,说明剩余节点不足 k 个;由于还没有修改这一段的任何连接,可以直接结束,保留原顺序。只有找到完整组,才保存 groupNext = kth.next,将它作为固定的结束边界和后一段入口。

反转范围从 groupPrev.next 开始,到 groupNext 之前结束。与普通链表反转一样,用 curr 指向尚未处理的节点,prev 指向已经反转部分的头;区别是这里把 prev 初始化为 groupNext。这样第一步修改原组头的 next 时,就已经把这个未来的新组尾接到了后一段,反转完后不必再补接尾部。

每次先保存 curr.next,再令 curr.next = prev,最后移动 prev 和 curr。当 curr == groupNext,说明恰好处理完本组的全部节点,此时 prev 与 kth 都指向新组头,原组头已经成为接向 groupNext 的新组尾。

还差前一段到本组的连接:把 groupPrev.next 改为 kth 即可。不过在覆盖这条连接之前,要先用 oldHead 保存它原来指向的旧组头。再令 groupPrev = oldHead,它作为新组尾,恰好就是下一组的前驱。这样处理好的前缀增长一整组,下一轮继续维护相同的关系。

解题步骤

  1. 创建 dummy 并令它指向 head,初始化 groupPrev = dummy。
  2. 从 groupPrev 出发向后走 k 步寻找 kth;如果 kth 为空,返回 dummy.next,保留不足一组的尾部。
  3. 保存 groupNext = kth.next,初始化 prev = groupNext、curr = groupPrev.next。
  4. 当 curr != groupNext 时,依次保存后继、反转连接、移动指针,完成这一组的反转。
  5. 保存旧组头 oldHead = groupPrev.next,再令 groupPrev.next = kth,把前一段接到新组头。
  6. 令 groupPrev = oldHead,继续检查下一组。最终头节点从 dummy.next 取得。

代码实现

class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        ListNode dummy = new ListNode(0, head);
        ListNode groupPrev = dummy;

        while (true) {
            ListNode kth = groupPrev;

            for (int i = 0; i < k && kth != null; i++) {
                kth = kth.next;
            }

            if (kth == null) {
                return dummy.next;
            }

            ListNode groupNext = kth.next;
            ListNode prev = groupNext;
            ListNode curr = groupPrev.next;

            while (curr != groupNext) {
                ListNode next = curr.next;

                curr.next = prev;
                prev = curr;
                curr = next;
            }

            ListNode oldHead = groupPrev.next;

            groupPrev.next = kth;
            groupPrev = oldHead;
        }
    }
}
func reverseKGroup(head *ListNode, k int) *ListNode {
    dummy := &ListNode{Next: head}
    groupPrev := dummy

    for {
        kth := groupPrev
        for i := 0; i < k && kth != nil; i++ {
            kth = kth.Next
        }
        if kth == nil {
            return dummy.Next
        }

        groupNext := kth.Next
        prev, curr := groupNext, groupPrev.Next
        for curr != groupNext {
            next := curr.Next
            curr.Next = prev
            prev, curr = curr, next
        }

        oldHead := groupPrev.Next
        groupPrev.Next = kth
        groupPrev = oldHead
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,n 为链表节点数。完整组中的每个节点只会在定位组尾和反转时各处理一次,尾部不足一组的节点只被检查一次;虽然有内外循环,总处理次数仍与节点数成正比。
  • 空间复杂度:$O(1)$,只使用一个虚拟头节点和固定数量的指针,不使用数组或递归栈。

关键点总结

[!green]

  • 检查完整性与反转分开:先确认节点数量再修改,避免反转了不完整的尾段后还要恢复。
  • 两端连接各有安排:prev = groupNext 让新组尾接向后一段,groupPrev.next = kth 让前一段接向新组头。
  • 前驱应移动到旧组头:它是反转后的组尾,也是下一组前面的节点,不能把 groupPrev 移到新组头。
  • 边界自然处理:k = 1 时每组只有一个节点,连接保持不变但前驱仍向后移动;最后恰好满一组时 groupNext 为空,反转后新尾节点也正确指向空。

易错点总结

[!yellow]

  • 寻找组尾的步数错误:从组前驱 groupPrev 出发,需要走 k 步才到组尾;走 k - 1 步会少算一个节点。
  • 少反转一个节点:循环条件必须是 curr != groupNext,不能写成 curr != kth,否则第 k 个节点会被漏掉。
  • 结束边界跟着改变:groupNext 必须提前保存。反转过程中 kth.next 会变化,不能继续用它判断是否到达边界。
  • 丢失旧组头:代码在反转后保存 oldHead 仍然有效,因为此前没有修改前驱的 groupPrev.next;但必须在把这条连接改为 kth 之前保存。
  • 返回了原头节点:第一组反转后头节点可能变化,应返回 dummy.next,原来的 head 已经是第一组的尾节点。

相似题目

题目 难度 关联与区别
24. 两两交换链表中的节点 中等 把k固定为2后即为两两交换,可先用此最小分组练习接回前后链段。
92. 反转链表 II 中等 每个完整分组可视为一次局部反转,但本题需要重复处理并保留不足k的尾段。
143. 重排链表 中等 拆分链表后反转并重新连接;本题按固定长度分组反转,该题从中点拆分并交替合并首尾。
234. 回文链表 简单 拆分链表后反转并重新连接;本题按固定长度分组反转,该题反转后半段后比较对称值。
206. 反转链表 简单 拆分链表后反转并重新连接;本题按固定长度分组反转,该题完整反转链表。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/67306654
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!