--- tags: ["LeetCode", "链表", "双指针", "虚拟头节点", "迭代", "递归", "困难"] create time: 2026-05-17 10:00 --- # 31-K 个一组翻转链表 ## 题面 给你链表的头节点 `head`,每 `k` 个节点一组进行翻转,请你返回修改后的链表。 `k` 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 `k` 的整数倍,那么请将最后剩余的节点保持原有顺序。 **你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。** **示例 1:** ``` 输入:head = [1,2,3,4,5], k = 2 输出:[2,1,4,3,5] ``` **示例 2:** ``` 输入:head = [1,2,3,4,5], k = 3 输出:[3,2,1,4,5] ``` **提示:** - 链表中的节点数目为 `n` - `1 <= k <= n <= 5000` - `0 <= Node.val <= 1000` **进阶:** 你可以设计一个只用 O(1) 额外内存空间的算法解决此问题吗? --- ## 思路 > [!question] 💡 核心洞察 > 这是一道**组合题**——它把两个经典操作叠加在了一起:**分组 + 反转**。分解来看: > - 如果只有"反转链表",那是 23 题(基础) > - 如果只有"分组拼接",那只需要追踪头尾即可 > - 现在要把两者结合起来,难点在于**每次反转一段后如何正确衔接回主链** ### 关键问题:如何定位一段的起止? > [!warning] ⚠️ 最大陷阱 > 反转一个区间 `[a, b]` 时,反转完成后 a 会变成这段的**尾节点**,b 会变成**头节点**。如果你不提前保存 a.Next(即下一段的起点),反转完就找不到路了。 所以每组反转前,需要明确三个位置: | 角色 | 含义 | 何时获取 | |------|------|---------| | **groupHead** | 当前这一组的第一个节点(反转后变成尾巴) | 进入本组时 | | **groupTail** | 当前这一组的最后一个节点(反转后变成头部) | 遍历 k 步找到 | | **nextGroup** | 下一组的起点 = groupTail.Next | 先存下来再反转 | ### 方法一:迭代法 ⭐(最优 — O(1) 空间) 用一个**虚拟头节点** `dummy` 统一边界处理,维护一个 `prev` 指针指向**已处理部分的尾节点**。每一轮做三件事: 1. **探路**:从 `prev.Next` 出发走 k 步,看是否有完整的 k 个节点 2. **反转**:如果有,反转这一段(利用第 23 题的单次反转模板) 3. **衔接**:将上一段的尾巴 `prev` 接到新头,再把旧头(现在变尾巴)连到下一段 ```mermaid flowchart TD subgraph Prepare["准备工作"] D["dummy → head"] --> PREV["prev 初始指向 dummy"] end subgraph Phase1["阶段一:完整反转"] P1["第 1 组: [1,2,3,4,5], k=2"] --> R1["反转 (1,2) → [2,1,4,3,5]\ndummy→2→1→4→3→5\nprev=1"] R1 --> P2["第 2 组: [4,3,5], k=2"] P2 --> R2["反转 (4,3) → [2,1,4,3,5]\nprev=3"] R2 --> CheckFull{"剩余节点 ≥ k?\n[5], k=2 → 否"} end subgraph Phase2["阶段二:不足 k 个,跳过"] CheckFull -- 否 --> SKIP["直接退出\n保留剩余 [5] ✅\ndummy→2→1→4→3→5"] end Prepare --> Phase1 --> Phase2 style Prepare fill:#bbf,stroke:#333 style R1 fill:#f9d,stroke:#333 style R2 fill:#f9d,stroke:#333 style SKIP fill:#d4edda,stroke:#28a ``` **逐步展开(以 `head = [1,2,3,4,5], k = 2` 为例):** ```mermaid flowchart LR subgraph Init["① 初始状态"] D1["dummy"] --> N1["1"] N1 --> N2["2"] N2 --> N3["3"] N3 --> N4["4"] N4 --> N5["5"] PREV1["prev=dummy"] end subgraph Probe1["② 探路: 从 prev.Next=1 走 k=2 步\n找到 groupTail=2"] P1["1→2 共 2 个节点 ✅ 满足 k"] end subgraph Reverse1["③ 反转 (1,2): 先保存 nextGroup=3\n反转后: 2→1, 然后 1.Next=3"] REV1["2→1→3→4→5"] end subgraph Connect1["④ 衔接: prev.Next=2(新头), prev=1(旧头)\ndummy→2→1→3→4→5"] CONN1["dummy→2→1→3→4→5\nprev=1"] end subgraph Probe2["⑤ 探路: 从 prev.Next=3 走 k=2 步\n找到 groupTail=4"] P2["3→4 共 2 个节点 ✅ 满足 k"] end subgraph Reverse2["⑥ 反转 (3,4): 先保存 nextGroup=5\n反转后: 4→3, 然后 3.Next=5"] REV2["dummy→2→1→4→3→5"] end subgraph Connect2["⑦ 衔接: prev.Next=4(新头), prev=3"] CONN2["dummy→2→1→4→3→5\nprev=3"] end subgraph Done["⑧ 探路: 从 prev.Next=5 走 k=2 步\n只剩 1 个节点 < k ❌ 退出"] DONE["dummy→2→1→4→3→5 ✅"] end Init --> Probe1 --> Reverse1 --> Connect1 --> Probe2 --> Reverse2 --> Connect2 --> Done classDef initStyle fill:#bbf,stroke:#333 classDef probeStyle fill:#ffd700,stroke:#333 classDef reverseStyle fill:#f9d,stroke:#333 classDef connectStyle fill:#9df,stroke:#333 classDef doneStyle fill:#d4edda,stroke:#28a class Init initStyle class Probe1,P2 probeStyle class Reverse1,Reverse2 reverseStyle class Connect1,Connect2 connectStyle class Done,DONE doneStyle ``` > [!question] 💡 引导思考:为什么 prev 前进到旧头(groupHead)? > 反转完成后,旧的 groupHead 变成了这段的**末尾节点**,它的 Next 已经指向下一组的开头。所以 `prev = groupHead` 恰好让它在下一轮可以访问 `prev.Next`(下一组的第一个节点)。这和「两两交换」的思路完全一致——只不过这里每组有 k 个节点而不是 2 个。 **代码流程:** ``` dummy = &ListNode{Next: head} prev = dummy for { // 步骤 1:找第 k 个节点 kth = prev for i = 1; i <= k; i++ { kth = kth.Next if kth == nil { // 不足 k 个节点 return dummy.Next // 剩余部分保持原序,直接退出 } } // 步骤 2:记录两组之间的连接点 nextGroup = kth.Next // 下一组起点 groupHead = prev.Next // 当前组起点(反转后变末尾) // 步骤 3:切断与下组的联系,然后反转 [prev.Next, kth] kth.Next = nil revHead := reverseList(prev.Next) // 步骤 4:重新拼接 prev.Next = revHead // 上段尾巴 → 新头 groupHead.Next = nextGroup // 旧头(现尾巴)→ 下一段 // 步骤 5:prev 走到已处理段的末尾,准备下一轮 prev = groupHead } ``` 其中 `reverseList(head)` 就是 23 题的单链表反转(头插法或三指针法): ``` func reverseList(head *ListNode) *ListNode { var prev *ListNode curr := head for curr != nil { nextTemp := curr.Next curr.Next = prev prev = curr curr = nextTemp } return prev } ``` > [!info] 🧠 串联的"断链再续"策略 > 核心技巧是:**先在 kth.Next 处断开(`kth.Next = nil`),形成一个独立的子链表交给 reverseList,反转完再把两头接回主链**。这样做的好处是——反转函数不需要感知主链的存在,保持了函数的纯度和复用性。 **时间复杂度:O(n)** — 每个节点被 visit 常数次(一次探路、一次反转) **空间复杂度:O(1)** — 只用了有限个指针变量 --- ### 方法二:递归法 递归的核心思想更简洁:**假设第 2 组及以后的部分已经处理好了,我只管搞定第一组,然后把它们串起来。** **递归框架:** ```mermaid flowchart LR S1["① 从 head 开始遍历 k 步\n确认剩余节点 ≥ k?"] --> S2["② 是: 反转第一组\ntail.next = reverseKGroup(nextGroup, k)\nreturn newHead"] --> S3["③ 否: 剩余不足 k 个\n直接返回 head, 不做任何改动"] style S1 fill:#bbf,stroke:#333 style S2 fill:#f9d,stroke:#333 style S3 fill:#fff4e6,stroke:#f90 ``` **以 `head = [1,2,3,4,5], k = 2` 为例:** ```mermaid flowchart TD subgraph Explore["递:一路往下探,同时数节点"] E1["countNodes(1) = 5 ≥ 2\n继续"] --> E2["countNodes(3) = 3 ≥ 2\n继续"] --> E3["countNodes(5) = 1 < 2\n❌ 停止"] end subgraph Build["归:一层层往回构造"] B1["L2: 反转(5), 只剩1个 B2["L1: 反转(3,4)→4→3\n4.next = L2返回值(5)\n结果: 4→3→5\n返回 4"] B2 --> B3["L0: 反转(1,2)→2→1\n2.next = L1返回值(4)\n结果: 2→1→4→3→5\n返回 2 ✅"] end Explore --> Build style Explore fill:#eee,stroke:#999 style B1 fill:#fff4e6,stroke:#f90 style B2 fill:#bbf,stroke:#333 style B3 fill:#d4edda,stroke:#28a ``` 逐步展开: | 递归层级 | 传入 head | 剩余节点数 | action | 返回值 | |---------|-----------|----------|--------|--------| | L0 | `1→2→3→4→5` | 5 ≥ 2 | 反转 (1,2),next = L1 | `2→1→...` | | L1 | `3→4→5` | 3 ≥ 2 | 反转 (3,4),next = L2 | `4→3→...` | | L2 | `5` | 1 < 2 | 不足 k,直接返回 | `5` | **关键连接点:** ```mermaid flowchart TD AfterL2["L2 返回: 5(未变化)"] --> Link1["L1: tail=3, 3.Next = 5\n得到 4→3→5"] Link1 --> Link0["L0: tail=1, 1.Next = 4\n得到 2→1→4→3→5 ✅"] style AfterL2 fill:#eee,stroke:#999 style Link1 fill:#bbf,stroke:#333 style Link0 fill:#d4edda,stroke:#28a ``` > [!warning] ⚠️ 不要手动断链! > 递归法和迭代法不同:**不应该设置 `kth.Next = nil`**。递归的反转需要在子链表上进行,但我们希望反转后每个节点仍然能顺着 Next 找到同一组内的后继(这样才能用 `tail.Next = reverseKGroup(...)` 自然衔接)。具体做法是在反转之前保存好每对相邻节点的关系,详见下方代码。 **时间复杂度:O(n × n/k) = O(n²/k)** — 每层递归调用 `countNodes` 遍历剩余节点 **空间复杂度:O(n/k)** — 递归栈深度为 n/k --- ## 代码提示 ### 迭代法伪代码 ``` func reverseKGroup(head, k): dummy = &ListNode{Next: head} prev = dummy for { // 探路:找第 k 个节点 kth = prev for i = 1; i <= k; i++ { kth = kth.Next if kth == nil: return dummy.Next // 不足 k 个,退出 // 记录边界 nextGroup = kth.Next // 下一组起点 groupHead = prev.Next // 当前组起点 // 切断、反转、接回 kth.Next = nil newHead = reverseList(prev.Next) prev.Next = newHead // 上段接新头 groupHead.Next = nextGroup // 旧头(现尾)接下一段 // 移动 prev prev = groupHead } func reverseList(head): prev = nil curr = head while curr != nil: nextTemp = curr.Next curr.Next = prev prev = curr curr = nextTemp return prev ``` ### 递归法伪代码 ``` func reverseKGroup(head, k): // 第一步:检查是否有足够节点 curr = head for i = 0; i < k && curr != nil; i++: curr = curr.Next if curr == nil: return head // 不足 k 个,保持原序 // 第二步:反转前 k 个节点(注意不全断链) prev = nil curr = head for i = 0; i < k; i++: nextTemp = curr.Next curr.Next = prev // 反向指 prev = curr curr = nextTemp // 第三步:head 现在是这一组的末尾,接到递归结果 // 此时 head.Next 还没被破坏(curr = head.Next 在先) head.Next = reverseKGroup(curr, k) return prev // prev 是反转后的新头 ``` --- ## 技巧 > [!tip] 🔑 "断链再续"模式(迭代法精髓) > 碰到需要原地修改链表中某一段的场景,可以先在这段的**两端分别断开**,得到一个独立子链表,做完操作后再把首尾接回主链。这个模式反复出现在: > - 本题:按 k 组切分 > - 反转链表的一部分(LeetCode 92) > - 旋转链表(LeetCode 61) > [!tip] 🔑 递归的"贪心假设" > 递归解法的核心自信来自一句假设:**"后面的部分交给我,一定能处理好。"** 你只需要验证两件事:① 这一层自己做得对不对;② 传给下一层的参数是不是正确的起始位置。不用想全貌。 > [!warning] ⚠️ 常见错误 1:忘记处理不足 k 个的情况 > 当最后剩余节点少于 k 个时,题目要求保持原序不变。如果在探路时发现 `kth == nil` 没有及时 return,后面 `kth.Next = nil` 会把有效节点裁掉,导致链表丢失后半段。 > [!warning] ⚠️ 常见错误 2:递归中错误断链 > 递归解法里千万不要设 `kth.Next = nil`。因为递归依靠 `head.Next = reverseKGroup(curr, k)` 来拼接,如果中间断了,递归回来的结果就连不上了。 > [!warning] ⚠️ 常见错误 3:迭代中 prev 走错位置 > 每组处理完后,prev 必须走到**旧头节点**(即这一组反转前的第一个节点,现在是最后一个),因为只有它才有正确的 Next 指向下一组。很多同学在 prev = groupHead 这一步会混淆新旧关系。 > [!note] 🐹 Go 中的链表定义 > LeetCode 的 Go 环境内置如下结构体定义: ```go type ListNode struct { Val int Next *ListNode } ``` 不需要手动定义,直接在解题中使用即可。 > [!info] 📊 两种方法对比 | 维度 | 迭代法 ⭐ | 递归法 | |------|---------|--------| | 时间复杂度 | O(n) | O(n²/k) | | 空间复杂度 | O(1) | O(n/k)(递归栈) | | 代码行数 | 循环体约 15 行 | 核心逻辑约 15 行 | | 直观程度 | 中等(需理解"断链再续") | 较高("搞一组,接尾巴") | | 面试推荐 | ⭐⭐⭐ 首选 | ⭐⭐ 可作为辅助展示 | | 适用场景 | O(1) 空间约束时的唯一解 | 教学和理解分组思想 | > [!success] ✅ 相关题目串联 > - 这是链表分组操作的**终极题型**,它建立在所有前置链表题目的基础之上: > - [23-反转链表](./23-反转链表.md) — 单次反转是基本操作 > - [30-两两交换链表中的节点](./30-两两交换链表中的节点.md) — K=2 的特例 > - [29-删除链表的倒数第 N 个结点](./29-删除链表的倒数第 N 个结点.md) — 快慢指针的距离控制 > - 同系列的进阶题目还有: > - [25-K 个一组翻转链表](./) — 本题本身 > - [92-反转链表 II](https://leetcode.com/problems/reverse-linked-list-ii/) — 反转链表中的一段 [left, right] > - [24-两两交换链表中的节点](./23-反转链表.md) — 特殊情况 K=2 --- ## 代码 ### 迭代法(虚拟头节点 + 断链再续)⭐ ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ // reverseList 反转一个单链表,返回新头节点 // 这是 23 题的标准解法——三指针法(头插法的变种) func reverseList(head *ListNode) *ListNode { var prev *ListNode // 前驱,初始为空 curr := head // 当前节点 for curr != nil { nextTemp := curr.Next // 先保存下一个节点,避免断链后丢失 curr.Next = prev // 反向指向前驱 prev = curr // prev 前进一步 curr = nextTemp // curr 前进一步 } return prev // prev 最终停在原链表的尾节点,即新头 } func reverseKGroup(head *ListNode, k int) *ListNode { // 虚拟头节点:消除"第一组前面没有 prev"的边界情况 dummy := &ListNode{Next: head} prev := dummy // prev 始终指向已处理部分的尾节点 for { // 步骤 1:探路——从 prev.Next 出发,向前走 k 步 kth := prev for i := 1; i <= k; i++ { kth = kth.Next if kth == nil { // 不足 k 个节点,剩余部分保持原序,直接返回 return dummy.Next } } // 步骤 2:记录关键位置 groupHead := prev.Next // 当前组的第一个节点(反转后变末尾) nextGroup := kth.Next // 下一组的起点 // 步骤 3:在 kth 处"断链",形成独立子链表 kth.Next = nil // 步骤 4:反转这段子链表,拿到新头 newHead := reverseList(groupHead) // 步骤 5:"接回"主链 prev.Next = newHead // 上一段的尾 → 当前组的新头 groupHead.Next = nextGroup // 当前组的旧头(现尾)→ 下一段 // 步骤 6:prev 前进到已处理段的末尾,准备下一轮 prev = groupHead } } ``` ### 递归法 ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func reverseKGroup(head *ListNode, k int) *ListNode { // 第一步:检查是否有至少 k 个节点 cur := head for i := 0; i < k && cur != nil; i++ { cur = cur.Next } if cur == nil { // 不足 k 个节点,保持原序不变 return head } // 第二步:反转前 k 个节点(注意不全断链) var prev *ListNode cur = head for i := 0; i < k; i++ { nextTemp := cur.Next // 保存下一个节点 cur.Next = prev // 反向指 prev = cur // prev 前进一步 cur = nextTemp // cur 前进一步 } // 第三步:head 是这一组反转前的第一个节点,反转后变成了末尾 // 把它接到递归处理的后半段 head.Next = reverseKGroup(cur, k) // prev 是反转后的新头 return prev } ``` > [!success] ✅ 运行验证 > 这是 LeetCode 第 25 题,通过率约 50%+,属于硬实力区分题。面试官选它的目的很明确——考察候选人对链表操作的综合掌控力:能否在多个反转片段之间精准拼接、能否处理边界条件而不依赖特殊判断。迭代法是面试的首选答案,因为它同时满足 O(1) 空间和清晰的线性逻辑。递归法则展示了你对"问题分解"的理解深度。建议掌握以下三点作为面试亮点:① 用 dummy 节点统一所有边界;② 用"断链再续"保持函数纯净度;③ 能说清楚每一步的不变量是什么。 > > > [!TIP] 💬 面试加分话术 > > "这道题本质上是把 n/k 个子问题(每个子问题是 O(k) 的单链表反转)串联起来。递归的自然写法会导致 O(n²/k) 的复杂度,因为每层递归都要重数节点;但如果预先把整条链扫一遍、收集所有分组头尾,就能降到 O(n)。不过在实际面试中,简洁的递归版本通常已经够用——关键是你能清晰解释每一步的连接关系。"