力扣热题100实战 | 第25期:K个一组翻转链表------从两两交换到K路翻转的进阶之路
-
- 前言
- 一、题目:每K个一组,翻转链表
- 二、第一反应:栈辅助法(直观但空间不达标)
- 三、核心解法一:递归法(代码简洁,但空间不是常数)
- 四、核心解法二:迭代分区反转(面试终极答案,O(1)空间)
- [五、递归 vs 迭代:巅峰对决](#五、递归 vs 迭代:巅峰对决)
- 六、细节剖析:面试官真正关心的问题
-
- Q1:为什么一定要用虚拟头节点(dummy)?
- Q2:迭代法中,为什么需要两个循环?一个找尾,一个反转?
- [Q3:反转子链表时,为什么要记录 `end.next` 作为停止点?](#Q3:反转子链表时,为什么要记录
end.next作为停止点?) - [Q4:如果 k=1 或 k 大于链表长度,代码怎么处理?](#Q4:如果 k=1 或 k 大于链表长度,代码怎么处理?)
- Q5:如何验证算法的正确性?
- 七、面试官追问进阶版
-
- [追问1:如果要求每 k 个一组翻转,但最后一组即使不足 k 个也要翻转,怎么改?](#追问1:如果要求每 k 个一组翻转,但最后一组即使不足 k 个也要翻转,怎么改?)
- 追问2:如何用头插法实现分组翻转?
- 追问3:如何计算链表的长度,用于提前判断分组数?
- 追问4:如何实现一个通用的"反转链表前N个节点"的函数?
- 八、实际开发:这道题到底有什么用?
- 九、总结:从一道题到一类题
- 附录:思考题
两两交换链表节点只是开胃菜,K个一组翻转才是真正的硬核挑战。这道Hard题教会我们:当翻转的粒度从"2"扩展到"K",如何用虚拟头节点、区间反转和指针衔接,化解复杂度危机。
前言
你好,我是@礼拜天没时间。
上一期我们学习了"两两交换链表中的节点",掌握了通过迭代和递归处理相邻节点交换的技巧。这一期,我们来解它的终极进阶版------K个一组翻转链表(LeetCode 第25题)。
这道题在力扣上被标记为"困难",是链表操作中公认的经典难题。它的难点不在于单个技巧有多复杂,而在于多个操作的交织:分组计数、区间反转、前后衔接、边界处理,每一步都需要精确无误。
很多同学第一次接触这道题时,要么代码写得冗长混乱,要么指针丢失导致死循环。但当你真正掌握了它,你会发现自己的链表操作能力已经上升了一个台阶。
今天,我希望能带你从递归的简洁思路出发,再到迭代的极致性能,彻底吃透这道题。
一、题目:每K个一组,翻转链表
先看题目描述(LeetCode 第25题):
给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。
k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
进阶:
- 你可以设计一个只使用常数额外空间的算法来解决此问题吗?
- 你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例 1:
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
解释:每2个一组翻转,第一组1-2变成2-1,第二组3-4变成4-3,最后一组5不足2个,保持原序。
示例 2:
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
解释:第一组1-2-3变成3-2-1,剩余4-5不足3个,保持原序。
示例 3:
输入:head = [1,2,3,4,5], k = 1
输出:[1,2,3,4,5]
解释:k=1时,每个节点自己一组,相当于不翻转。
关键点解读
-
K的范围:k 是正整数,且 ≤ 链表长度。但最后一组可能不足 k 个。
-
空间限制:进阶要求只能使用常数额外空间(O(1)),这意味着不能用递归(递归栈占用O(n/k)空间),也不能用栈辅助。
-
实际交换节点 :不能只改值,必须通过修改
next指针实现。 -
与第24题的关系:当 k=2 时,就是"两两交换链表中的节点",但 k 可以是任意正整数。
二、第一反应:栈辅助法(直观但空间不达标)
当我第一次看到这道题,我的第一反应是:用栈来辅助反转。因为栈的"后进先出"特性天然适合反转操作。
核心思想
- 遍历链表,将节点依次压入栈中,直到栈的大小达到 k
- 将栈中的节点依次弹出并连接,实现反转
- 重复这个过程,直到链表末尾
- 如果最后一组不足 k 个,则保持原序(不压栈,直接连接)
代码实现
java
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || k == 1) return head;
Deque<ListNode> stack = new LinkedList<>();
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
ListNode temp = head;
while (temp != null) {
int count = 0;
// 将k个节点压入栈
while (temp != null && count < k) {
stack.push(temp);
temp = temp.next;
count++;
}
// 如果这一组正好k个,则弹出并反转
if (count == k) {
while (!stack.isEmpty()) {
cur.next = stack.pop();
cur = cur.next;
}
cur.next = temp; // 连接后续部分
} else {
// 不足k个,保持原序
// 此时栈里可能还有元素,需要从栈底到栈顶依次连接
// 但为了保持原序,应该直接连接第一个节点
// 这里需要把栈中的节点按原序接回
// 为了简化,可以直接将temp之前的节点按原序接回
// 实际上更简单的处理是:不足k个时不压栈,直接遍历一次
break;
}
}
return dummy.next;
}
复杂度分析
- 时间复杂度:O(n) ------ 每个节点入栈出栈一次
- 空间复杂度:O(k) ------ 栈中最多存放 k 个节点
这段代码的问题在哪?
-
不符合进阶要求:空间复杂度 O(k),当 k 很大时(如 k=10⁴),栈空间占用过大。
-
代码复杂:需要处理不足 k 个时的特殊逻辑。
-
面试减分:面试官会追问"能否优化到 O(1) 空间"。
三、核心解法一:递归法(代码简洁,但空间不是常数)
递归解法是理解这道题的最佳起点,代码非常简洁优雅。
递归思想
- 先找到当前组的尾节点 :从
head出发走 k-1 步,如果中途遇到null,说明不足 k 个,直接返回head(不反转) - 标记下一组的头节点 :
nextGroup = tail.next - 断开当前组 :将
tail.next设为null,使当前组独立 - 反转当前组 :调用反转链表函数,得到新的头节点
newHead - 递归处理下一组 :
head.next = reverseKGroup(nextGroup, k),将反转后的当前组与后续部分连接 - 返回新头 :返回
newHead
图解递归过程
以 head = [1,2,3,4,5], k = 2 为例:
reverseKGroup(1→2→3→4→5, 2)
├─ tail = 2 (走1步)
├─ nextGroup = 3→4→5
├─ 断开: 1→2→null
├─ 反转: 2→1 (newHead = 2)
├─ head.next = reverseKGroup(3→4→5, 2)
│ ├─ tail = 4
│ ├─ nextGroup = 5→null
│ ├─ 断开: 3→4→null
│ ├─ 反转: 4→3
│ ├─ head.next = reverseKGroup(5, 2)
│ │ └─ 不足2个,直接返回5
│ ├─ 连接: 3.next = 5 → 4→3→5
│ └─ 返回 4→3→5
├─ 连接: 1.next = 4→3→5 → 2→1→4→3→5
└─ 返回 2→1→4→3→5
代码实现
java
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
// 1. 找到当前组的尾节点
ListNode tail = head;
for (int i = 0; i < k - 1; i++) {
if (tail == null) return head; // 不足k个,直接返回
tail = tail.next;
}
if (tail == null) return head; // 不足k个
// 2. 标记下一组的头节点
ListNode nextGroup = tail.next;
// 3. 断开当前组
tail.next = null;
// 4. 反转当前组
ListNode newHead = reverse(head);
// 5. 递归处理下一组,并连接
head.next = reverseKGroup(nextGroup, k);
// 6. 返回新的头节点
return newHead;
}
// 反转链表(迭代版)
private ListNode reverse(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
代码解析
-
找尾节点 :
for循环走 k-1 步,定位到当前组的最后一个节点。如果中途遇到null,说明不足 k 个,直接返回head。 -
断开连接 :将当前组的
tail.next设为null,这样当前组就独立出来了,便于反转。 -
反转当前组 :调用
reverse函数,返回新的头节点(原来的尾节点)。 -
递归连接 :
head此时是当前组的第一个节点,反转后它变成了最后一个节点。所以head.next应该指向递归处理下一组的结果。 -
返回值 :返回反转后的新头节点
newHead。
复杂度分析
- 时间复杂度:O(n) ------ 每个节点被处理常数次
- 空间复杂度:O(n/k) ------ 递归调用栈的深度,不是 O(1)
四、核心解法二:迭代分区反转(面试终极答案,O(1)空间)
递归解法虽然简洁,但空间复杂度不是常数。进阶要求是只能使用常数额外空间,这迫使我们必须用迭代实现 。
核心思想
- 使用虚拟头节点 :
dummy节点指向head,统一处理头节点变化的情况 - 两个关键指针 :
pre:指向当前待翻转组的前一个节点tail:指向当前待翻转组的最后一个节点(需要每走 k 步确定)
- 循环处理 :当
tail不为空时,说明当前有 k 个节点可翻转 - 区间反转 :将
[pre.next, tail]这个区间的子链表反转 - 重新连接 :反转后,
pre.next应该指向新的头(原来的尾),head(原来的头)变成了新的尾,需要连接到下一组 - 移动指针 :将
pre移动到head(当前组的尾),head移动到下一组的头
区间反转的技巧
反转一个子链表,需要知道三个关键位置:
pre:子链表的前驱节点start:子链表的第一个节点(也就是pre.next)end:子链表的最后一个节点(也就是tail)
反转过程(头插法):
- 记录
next = end.next(下一组的头) - 用标准的反转链表算法反转
start到end的部分 - 将反转后的子链表接回:
pre.next指向新的头(原来的end),start.next指向next
图解流程
以 head = [1,2,3,4,5], k = 2 为例:
初始状态:
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> null
pre head
tail
第1步:移动 tail 走 k=2 步,定位到节点2
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> null
pre head tail
第2步:反转 [head, tail] 即 [1,2]
-
反转后:2 -> 1
-
pre.next = 2
-
1.next = next (节点3)
dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
pre head
第3步:移动 pre 到 1,head 到 3
dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
pre head
第4步:移动 tail 从 pre 出发走 k 步,定位到节点4
dummy -> 2 -> 1 -> 3 -> 4 -> 5 -> null
pre head tail
第5步:反转 [head, tail] 即 [3,4]
-
反转后:4 -> 3
-
pre.next = 4
-
3.next = next (节点5)
dummy -> 2 -> 1 -> 4 -> 3 -> 5 -> null
pre head
第6步:移动 pre 到 3,head 到 5
dummy -> 2 -> 1 -> 4 -> 3 -> 5 -> null
pre head
第7步:移动 tail 从 pre 出发走 k 步,但 pre.next 只有 5 一个节点,不足 2 个,循环结束。
最终结果:2 -> 1 -> 4 -> 3 -> 5 ✅
代码实现
java
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || k == 1) return head;
// 1. 虚拟头节点,简化边界处理
ListNode dummy = new ListNode(0);
dummy.next = head;
// 2. 初始化指针
ListNode pre = dummy;
ListNode tail = dummy;
while (true) {
// 3. 移动tail,找到当前组的最后一个节点
for (int i = 0; i < k; i++) {
tail = tail.next;
if (tail == null) {
// 不足k个,直接返回结果
return dummy.next;
}
}
// 4. 记录下一组的头节点
ListNode nextGroup = tail.next;
// 5. 反转当前组 [pre.next, tail]
ListNode start = pre.next;
ListNode end = tail;
// 反转链表的标准操作
reverse(start, end);
// 6. 重新连接
pre.next = end; // pre指向新的头
start.next = nextGroup; // 当前组的尾指向下一组的头
// 7. 移动pre和tail,准备下一轮
pre = start;
tail = start;
}
}
/**
* 反转以start为头、end为尾的子链表
* 反转后,end成为新的头,start成为新的尾
*/
private void reverse(ListNode start, ListNode end) {
ListNode prev = null;
ListNode curr = start;
ListNode stop = end.next; // 反转的终点
while (curr != stop) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
}
}
另一种常见的迭代写法(更易理解)
java
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy;
ListNode end = dummy;
while (end.next != null) {
// 1. 移动end找到当前组的最后一个节点
for (int i = 0; i < k && end != null; i++) {
end = end.next;
}
if (end == null) break; // 不足k个
// 2. 记录关键节点
ListNode start = pre.next;
ListNode next = end.next;
// 3. 断开当前组
end.next = null;
// 4. 反转当前组
pre.next = reverse(start);
// 5. 重新连接
start.next = next;
// 6. 移动指针,准备下一轮
pre = start;
end = start;
}
return dummy.next;
}
private ListNode reverse(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
复杂度分析
- 时间复杂度:O(n) ------ 每个节点被遍历常数次
- 空间复杂度:O(1) ------ 只用了几个指针变量
五、递归 vs 迭代:巅峰对决
| 维度 | 递归法 | 迭代法 |
|---|---|---|
| 代码简洁度 | ⭐⭐⭐⭐⭐(极简,5行核心) | ⭐⭐⭐(指针操作稍复杂) |
| 空间复杂度 | O(n/k)(递归栈) | O(1) ✅ |
| 符合进阶要求 | ❌ | ✅ |
| 可读性 | 需要理解递归思维 | 指针变化需要图解 |
| 面试推荐 | 思路讲解可用 | ⭐⭐⭐⭐⭐(最终答案) |
面试建议
优先掌握迭代法,因为只有它满足进阶要求的 O(1) 空间 。在面试中,可以先讲递归思路(展示你对问题本质的理解),再给出迭代实现(展示你的代码能力)。
六、细节剖析:面试官真正关心的问题
Q1:为什么一定要用虚拟头节点(dummy)?
答案 :因为头节点可能会变化 。当第一组反转后,原来的头节点(比如1)可能变成了组内的尾节点,新的头节点变成了原来的尾节点(比如2)。如果没有虚拟头节点,就需要单独处理这种情况。有了 dummy,我们始终可以通过 dummy.next 返回正确的头节点,统一了所有操作 。
Q2:迭代法中,为什么需要两个循环?一个找尾,一个反转?
答案 :第一个循环用于确定当前组是否有 k 个节点,同时定位尾节点。如果不足 k 个,直接结束。第二个循环(在 reverse 函数中)用于实际反转子链表。这两个步骤必须分开,因为找尾需要遍历,反转也需要遍历,但它们的逻辑不同。
Q3:反转子链表时,为什么要记录 end.next 作为停止点?
答案 :因为我们需要反转的是一个闭区间 [start, end]。反转过程中,我们希望当 curr 走到 end.next 时停止。这样,反转后的子链表的最后一个节点(原来的 start)的 next 就会自动指向 end.next,也就是下一组的头 。
Q4:如果 k=1 或 k 大于链表长度,代码怎么处理?
答案:
- k=1 :不需要翻转,直接返回原链表。可以在开头加判断
if (k == 1) return head; - k > 链表长度 :在找尾的过程中,
tail会变成null,代码中判断if (tail == null) break;直接返回原链表。
Q5:如何验证算法的正确性?
答案:可以写一个辅助函数遍历链表,检查每个节点的值是否满足预期顺序。同时,可以计算节点总数,确保没有节点丢失。
七、面试官追问进阶版
追问1:如果要求每 k 个一组翻转,但最后一组即使不足 k 个也要翻转,怎么改?
思路:去掉"不足k个不翻转"的限制。只需将判断条件改为一直处理到链表末尾。
java
while (true) {
// 移动tail找尾
for (int i = 0; i < k; i++) {
tail = tail.next;
if (tail == null) {
// 最后一组不足k个,也要翻转
// 但此时 tail 为 null,需要特殊处理
// 可以计算剩余节点数,如果小于k,则直接返回
// 这里为了简单,可以提前计算链表长度
}
}
// ... 反转逻辑
}
追问2:如何用头插法实现分组翻转?
思路 :头插法的核心是每次将当前节点插入到 pre 之后。对于一组 k 个节点,可以依次将每个节点用头插法插入到 pre 后面,实现反转 。
java
// 头插法反转一组
ListNode nextGroup = tail.next;
ListNode cur = pre.next;
while (cur != nextGroup) {
ListNode temp = cur.next;
cur.next = pre.next;
pre.next = cur;
cur = temp;
}
追问3:如何计算链表的长度,用于提前判断分组数?
思路 :先遍历一次链表,统计节点总数。然后根据 count / k 知道有多少组需要翻转。
java
int count = 0;
ListNode p = head;
while (p != null) {
count++;
p = p.next;
}
int groups = count / k;
追问4:如何实现一个通用的"反转链表前N个节点"的函数?
思路:这是本题的核心子函数。可以用递归或迭代实现。递归实现如下 :
java
public ListNode reverseN(ListNode head, int n) {
if (n == 1) return head;
ListNode newHead = reverseN(head.next, n - 1);
ListNode tail = head.next;
head.next = tail.next;
tail.next = head;
return newHead;
}
八、实际开发:这道题到底有什么用?
很多读者会问:"K个一组翻转链表,实际工作中哪用得到?"
其实它的思想无处不在:
场景1:数据包重组
在网络通信中,数据包可能被分片传输,接收端需要按顺序重组。每收到 k 个分片,可能需要重新排序后提交给上层应用。
场景2:数据库页重组
在数据库系统中,磁盘页的物理存储可能需要重新排列以优化访问性能。分组反转的思想可以应用于页内记录的重新组织。
场景3:缓存淘汰策略
在某些缓存算法中,可能需要将最近访问的 k 个节点移到链表头部,这本质上是一种分组反转的变体。
场景4:文件块排序
在分布式存储系统中,文件被分成多个块,可能需要每 k 个块为一组进行重新排序,以优化读取性能。
场景5:面试压轴题
这道题常作为算法面试的压轴题,考察候选人对链表操作的掌握程度。能流畅写出迭代解法,说明链表基本功非常扎实。
九、总结:从一道题到一类题
回顾一下,我们从K个一组翻转链表学到了什么:
| 维度 | 收获 |
|---|---|
| 算法思维 | 递归(简洁)→ 迭代(O(1)空间),理解从特殊到一般的推广 |
| 代码技巧 | 虚拟头节点、子链表反转、指针衔接、边界处理 |
| 复杂度分析 | 时间 O(n)、空间 O(1) 是终极目标 |
| 面试要点 | 为什么要用 dummy?如何找到每组的尾?如何反转子链表? |
| 工程关联 | 数据包重组、数据库页整理、缓存策略、分布式存储 |
力扣热题100的第二十五题,不是为了难住你,而是为了告诉你:掌握了链表的基本操作后,还需要学会组合运用。当你能熟练地在链表中切分区间、反转局部、衔接整体,你就真正掌握了链表的灵魂。
下一期预告:《删除有序数组中的重复项》------双指针的经典应用
附录:思考题
看完这篇文章,你可以试着回答:
- 如果要求每 k 个一组翻转,但最后一组不足 k 个时也要翻转,代码怎么改?
- 如何用头插法实现分组翻转?头插法和标准反转有什么区别?
- 你能用这道题的思路,去解 LeetCode 92(反转链表 II)吗?
欢迎在评论区留下你的思考!