Files
leetcode-go/链表/31-K 个一组翻转链表.md

511 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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个<k → 返回 5\nreverseKGroup(5)=5"] --> 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)。不过在实际面试中,简洁的递归版本通常已经够用——关键是你能清晰解释每一步的连接关系。"