18 KiB
tags, create time
| tags | 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 <= 50000 <= Node.val <= 1000
进阶: 你可以设计一个只用 O(1) 额外内存空间的算法解决此问题吗?
思路
[!question] 💡 核心洞察 这是一道组合题——它把两个经典操作叠加在了一起:分组 + 反转。分解来看:
- 如果只有"反转链表",那是 23 题(基础)
- 如果只有"分组拼接",那只需要追踪头尾即可
- 现在要把两者结合起来,难点在于每次反转一段后如何正确衔接回主链
关键问题:如何定位一段的起止?
[!warning] ⚠️ 最大陷阱 反转一个区间
[a, b]时,反转完成后 a 会变成这段的尾节点,b 会变成头节点。如果你不提前保存 a.Next(即下一段的起点),反转完就找不到路了。
所以每组反转前,需要明确三个位置:
| 角色 | 含义 | 何时获取 |
|---|---|---|
| groupHead | 当前这一组的第一个节点(反转后变成尾巴) | 进入本组时 |
| groupTail | 当前这一组的最后一个节点(反转后变成头部) | 遍历 k 步找到 |
| nextGroup | 下一组的起点 = groupTail.Next | 先存下来再反转 |
方法一:迭代法 ⭐(最优 — O(1) 空间)
用一个虚拟头节点 dummy 统一边界处理,维护一个 prev 指针指向已处理部分的尾节点。每一轮做三件事:
- 探路:从
prev.Next出发走 k 步,看是否有完整的 k 个节点 - 反转:如果有,反转这一段(利用第 23 题的单次反转模板)
- 衔接:将上一段的尾巴
prev接到新头,再把旧头(现在变尾巴)连到下一段
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 为例):
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 组及以后的部分已经处理好了,我只管搞定第一组,然后把它们串起来。
递归框架:
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 为例:
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 |
关键连接点:
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 环境内置如下结构体定义:
type ListNode struct {
Val int
Next *ListNode
}
不需要手动定义,直接在解题中使用即可。
[!info] 📊 两种方法对比
| 维度 | 迭代法 ⭐ | 递归法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n²/k) |
| 空间复杂度 | O(1) | O(n/k)(递归栈) |
| 代码行数 | 循环体约 15 行 | 核心逻辑约 15 行 |
| 直观程度 | 中等(需理解"断链再续") | 较高("搞一组,接尾巴") |
| 面试推荐 | ⭐⭐⭐ 首选 | ⭐⭐ 可作为辅助展示 |
| 适用场景 | O(1) 空间约束时的唯一解 | 教学和理解分组思想 |
[!success] ✅ 相关题目串联
- 这是链表分组操作的终极题型,它建立在所有前置链表题目的基础之上:
- 23-反转链表 — 单次反转是基本操作
- 30-两两交换链表中的节点 — K=2 的特例
- [29-删除链表的倒数第 N 个结点](./29-删除链表的倒数第 N 个结点.md) — 快慢指针的距离控制
- 同系列的进阶题目还有:
- 25-K 个一组翻转链表 — 本题本身
- 92-反转链表 II — 反转链表中的一段 [left, right]
- 24-两两交换链表中的节点 — 特殊情况 K=2
代码
迭代法(虚拟头节点 + 断链再续)⭐
/**
* 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
}
}
递归法
/**
* 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)。不过在实际面试中,简洁的递归版本通常已经够用——关键是你能清晰解释每一步的连接关系。"