手撕 K 个一组反转链表,这些细节你必须知道!
前言
大四双非春招学习记录
LeetCode 第 25 题「K 个一组翻转链表」是链表类题目中的困难题,也是面试中的高频考题。很多同学看到「困难」标签就望而却步,但实际上,只要掌握了核心思路,这道题并没有想象中那么难。
本文将带你从零开始,逐步攻克这道题,并总结出通用的解题模板。
一、题目理解
1.1 题目描述
给你一个链表,每 k 个节点一组进行反转,返回反转后的链表。如果节点总数不是 k 的整数倍,最后剩余的节点保持原有顺序。
示例 1:
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
图解:
1 -> 2 -> 3 -> 4 -> 5
↓ 反转第一组
2 -> 1 -> 3 -> 4 -> 5
↓ 反转第二组
2 -> 1 -> 4 -> 3 -> 5
示例 2:
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
1.2 关键信息提取
- ✅ 每 k 个节点一组进行反转
- ✅ 不足 k 个的节点保持原样
- ✅ 需要原地修改链表(不能只改值)
- ✅ 空间复杂度要求 O(1)
二、前置知识
在攻克这道题之前,你需要掌握以下基础:
2.1 链表的基本操作
javascript
// 链表节点定义
function ListNode(val, next) {
this.val = val === undefined ? 0 : val;
this.next = next === undefined ? null : next;
}
// 遍历链表
let cur = head;
while (cur) {
console.log(cur.val);
cur = cur.next;
}
2.2 反转整个链表
javascript
function reverseList(head) {
let prev = null;
let cur = head;
while (cur) {
let next = cur.next; // 保存下一个节点
cur.next = prev; // 反转指针
prev = cur; // 移动 prev
cur = next; // 移动 cur
}
return prev; // 返回新的头节点
}
图解反转过程:
初始: null <- 1 -> 2 -> 3
第一步: null <- 1 <- 2 -> 3
第二步: null <- 1 <- 2 <- 3
完成: 3 -> 2 -> 1 -> null
2.3 虚拟头节点技巧
虚拟头节点(dummy node)是链表题目中常用的技巧,可以统一处理边界情况。
javascript
// 没有虚拟头节点
let newHead = head;
if (条件) {
newHead = head.next; // 需要特殊处理
}
// 使用虚拟头节点
let dummy = new ListNode(0);
dummy.next = head;
let prev = dummy; // 统一处理,不需要特殊判断
三、核心思路
3.1 整体流程
K 个一组反转链表的核心思路可以概括为 4 步:
┌─────────────────────────────────────┐
│ 1. 找到当前组的头节点和尾节点 │
│ 2. 保存下一组的起始节点 │
│ 3. 反转当前组 │
│ 4. 连接回原链表 │
└─────────────────────────────────────┘
↓
重复以上步骤直到结束
3.2 图解整体流程
以 1->2->3->4->5, k=2 为例:
初始状态:
dummy → 1 → 2 → 3 → 4 → 5 → null
↑
prev
第1组(节点1-2):
dummy → 1 → 2 → 3 → 4 → 5 → null
↑ ↑
head tail
反转后:
dummy → 2 → 1 → 3 → 4 → 5 → null
↑ ↑
head tail
连接并移动指针:
dummy → 2 → 1 → 3 → 4 → 5 → null
↑ ↑
prev head(下一组起点)
第2组(节点3-4):
dummy → 2 → 1 → 3 → 4 → 5 → null
↑ ↑
head tail
反转后:
dummy → 2 → 1 → 4 → 3 → 5 → null
↑ ↑
head tail
连接并移动指针:
dummy → 2 → 1 → 4 → 3 → 5 → null
↑
prev
head(null,结束)
四、代码实现
4.1 完整代码(迭代法)
javascript
var reverseKGroup = function(head, k) {
// 边界情况
if (!head || k === 1) return head;
// 创建虚拟头节点
let dummy = new ListNode(0);
dummy.next = head;
let prev = dummy; // prev 指向每组的前一个节点
let cur = head;
// 先计算链表长度
let len = 0;
let p = head;
while (p) {
len++;
p = p.next;
}
// 需要反转的组数
let groups = Math.floor(len / k);
// 反转每一组
for (let i = 0; i < groups; i++) {
// 反转当前组的 k 个节点
for (let j = 1; j < k; j++) {
let next = cur.next; // 要移动的节点
cur.next = next.next; // 跳过 next 节点
next.next = prev.next; // next 指向当前组的头
prev.next = next; // prev 指向新的头
}
// 移动指针到下一组
prev = cur;
cur = cur.next;
}
return dummy.next;
};
4.2 更易理解的版本(带辅助函数)
javascript
var reverseKGroup = function(head, k) {
// 创建虚拟头节点
let dummy = new ListNode(0);
dummy.next = head;
let prev = dummy;
while (head) {
// 1. 找到当前组的尾节点
let tail = prev;
for (let i = 0; i < k; i++) {
tail = tail.next;
if (!tail) return dummy.next; // 不足 k 个,直接返回
}
// 2. 保存下一组的起始节点
let nextGroup = tail.next;
// 3. 反转当前组 [head, tail]
[head, tail] = reverseList(head, tail);
// 4. 连接回原链表
prev.next = head;
tail.next = nextGroup;
// 5. 移动指针到下一组
prev = tail;
head = nextGroup;
}
return dummy.next;
};
// 反转链表的一部分 [head, tail]
function reverseList(head, tail) {
let prev = tail.next; // 关键:prev 指向 tail 的下一个节点
let cur = head;
while (prev !== tail) {
let next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return [tail, head]; // 返回新的头尾
}
4.3 递归解法(简洁优雅)
javascript
var reverseKGroup = function(head, k) {
// 找到第 k+1 个节点
let tail = head;
for (let i = 0; i < k; i++) {
if (!tail) return head; // 不足 k 个,不反转
tail = tail.next;
}
// 反转前 k 个节点
let newHead = reverseFirstK(head, k);
// 递归处理后续节点
head.next = reverseKGroup(tail, k);
return newHead;
};
// 反转前 k 个节点,返回新的头节点
function reverseFirstK(head, k) {
let prev = null;
let cur = head;
for (let i = 0; i < k; i++) {
let next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
五、关键点详解
5.1 为什么需要虚拟头节点?
javascript
// 没有虚拟头节点时,第一组需要特殊处理
let newHead = head;
if (第一组需要反转) {
newHead = 反转后的头;
}
// 使用虚拟头节点,统一处理
let dummy = new ListNode(0);
dummy.next = head;
let prev = dummy; // prev 始终指向"前一个节点"
5.2 反转函数中的 prev 为什么要指向 tail.next?
javascript
function reverseList(head, tail) {
let prev = tail.next; // 关键点!
let cur = head;
while (prev !== tail) {
let next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return [tail, head];
}
图解说明:
反转前:head → ... → tail → nextGroup → ...
↓
反转时:prev 应该指向 nextGroup
这样反转后,tail 的 next 自然就指向 nextGroup
5.3 为什么反转后要返回 [tail, head]?
javascript
// 反转前
[head] → ... → [tail] → nextGroup
// 反转后
[tail] → ... → [head] → nextGroup
// ↑新头 ↑新尾
// 所以返回 [新头, 新尾] = [tail, head]
六、常见错误与调试
错误 1:指针丢失
javascript
// ❌ 错误写法
let next = cur.next;
cur.next = prev;
prev = cur;
cur = cur.next; // 此时 cur.next 已经被修改!
// ✅ 正确写法
let next = cur.next;
cur.next = prev;
prev = cur;
cur = next; // 使用之前保存的 next
错误 2:边界判断错误
javascript
// ❌ 错误:提前 break 会导致逻辑混乱
for (let i = 0; i < k; i++) {
tail = tail.next;
if (!tail) break;
}
// ✅ 正确:直接返回
for (let i = 0; i < k; i++) {
tail = tail.next;
if (!tail) return dummy.next;
}
错误 3:连接顺序错误
javascript
// ❌ 错误
prev.next = tail; // tail 是反转前的尾
head.next = nextGroup;
// ✅ 正确
prev.next = head; // head 现在是反转后的头
tail.next = nextGroup;
七、测试用例
javascript
// 辅助函数:数组转链表
function arrayToList(arr) {
let dummy = new ListNode(0);
let cur = dummy;
for (let val of arr) {
cur.next = new ListNode(val);
cur = cur.next;
}
return dummy.next;
}
// 辅助函数:链表转数组
function listToArray(head) {
let result = [];
while (head) {
result.push(head.val);
head = head.next;
}
return result;
}
// 测试用例1:正常情况
let head1 = arrayToList([1,2,3,4,5]);
console.log(listToArray(reverseKGroup(head1, 2))); // [2,1,4,3,5]
// 测试用例2:k=1
let head2 = arrayToList([1,2,3,4,5]);
console.log(listToArray(reverseKGroup(head2, 1))); // [1,2,3,4,5]
// 测试用例3:k 大于链表长度
let head3 = arrayToList([1,2,3]);
console.log(listToArray(reverseKGroup(head3, 5))); // [1,2,3]
// 测试用例4:正好整数倍
let head4 = arrayToList([1,2,3,4]);
console.log(listToArray(reverseKGroup(head4, 2))); // [2,1,4,3]
// 测试用例5:空链表
console.log(listToArray(reverseKGroup(null, 2))); // []
// 测试用例6:单节点
let head6 = arrayToList([1]);
console.log(listToArray(reverseKGroup(head6, 2))); // [1]
八、复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 迭代法 | O(n) | O(1) |
| 递归法 | O(n) | O(n/k)(递归栈) |
- 时间复杂度:每个节点被访问常数次,总体 O(n)
- 空间复杂度:迭代法 O(1),递归法 O(n/k)(递归深度)
九、相关题目推荐
掌握了这道题,下面这些题目会变得简单很多:
| 题目 | 难度 | 相似度 | 核心区别 |
|---|---|---|---|
| 206. 反转链表 | 简单 | ⭐⭐⭐ | 整体反转 |
| 92. 反转链表 II | 中等 | ⭐⭐⭐⭐ | 反转指定区间 |
| 24. 两两交换链表中的节点 | 中等 | ⭐⭐⭐⭐⭐ | k=2 的特例 |
| 61. 旋转链表 | 中等 | ⭐⭐ | 链表旋转 |
十、面试技巧
面试官可能会问的问题:
Q1:能否用递归实现?时间复杂度是多少?
可以,递归实现更简洁,但空间复杂度 O(n/k)(递归栈深度)。如果 k 很大,可能会导致栈溢出。
Q2:如果 k=0 怎么办?
k 是正整数,题目保证 k>0。但可以和面试官讨论边界处理。
Q3:如何测试你的代码?
可以从以下角度测试:
- 空链表
- 单节点链表
- k=1
- k=链表长度
- 链表长度正好是 k 的整数倍
- 链表长度不是 k 的整数倍
Q4:能优化吗?
可以先遍历一次计算长度,避免在循环中重复检查边界。
十一、总结口诀
创建虚拟头,prev 指向它
循环条件 head 不空
找够 k 个点,不够就回家
保存下一组,反转当前它
连接前后段,指针往后拉
重复以上步,直到结束啦
写在最后
K 个一组反转链表虽然标记为「困难」,但它本质上是「反转链表」+「分组处理」的组合。只要掌握了基础的反转算法,理解了指针的操作,这道题就能迎刃而解。
记住核心心法:
- 先找到一组
- 反转它
- 接回去
- 找下一组
建议多画图、多调试,把指针的变化过程在脑海中过一遍。当你能够清晰地画出每一步的指针变化,代码自然就写出来了。
如果这篇文章对你有帮助,欢迎点赞收藏!也欢迎在评论区交流讨论~