🔥个人主页: Milestone-里程碑
❄️个人专栏: <<力扣hot100>> <<C++>><<Linux>>
🌟心向往之行必能至
题目回顾
LeetCode 25. K 个一组翻转链表
给定一个链表的头节点 head 和一个正整数 k,每 k 个节点一组进行翻转,返回修改后的链表。如果节点总数不是 k 的整数倍,请将最后剩余的节点保持原有顺序。
示例:
- 输入:
head = [1,2,3,4,5],k = 2→ 输出:[2,1,4,3,5] - 输入:
head = [1,2,3,4,5],k = 3→ 输出:[3,2,1,4,5]
要求:
- 不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
- 空间复杂度要求为 O(1)O(1) 。
🐛 遇到的"坑":一段看似正确的代码
在尝试解题时,我写下了如下代码(已简化):
cpp
编辑
1class Solution {
2public:
3 ListNode* reverseKGroup(ListNode* head, int k) {
4 // ... 计算长度 n ...
5
6 // ❌ 错误点 1:拷贝构造函数陷阱
7 ListNode dummy = ListNode(0, head);
8 ListNode* p0 = &dummy;
9 ListNode* pre = nullptr;
10 ListNode* cur = head;
11
12 while (n >= k) {
13 n -= k;
14 // ❌ 错误点 2:pre 未重置
15 for (int i = 0; i < k; ++i) {
16 ListNode* nxt = cur->next;
17 cur->next = pre;
18 pre = cur;
19 cur = nxt;
20 }
21 // ... 连接逻辑 ...
22 p0 = p0->next;
23 }
24 return dummy.next;
25 }
26};
这段代码在逻辑上似乎很通顺:先算长度,再分组翻转,最后拼接。但在实际提交时,却遇到了两个严重问题。
💥 错误一:被删除的拷贝构造函数
代码行:
cpp
编辑
1ListNode dummy = ListNode(0, head);
在 LeetCode 的环境中,ListNode 结构体通常被定义为不可拷贝(Deleted Copy Constructor),以防止浅拷贝带来的指针混乱。因此,这行代码会直接报 Compile Error。
✅ 修正方案 :
直接使用构造函数初始化,避免赋值拷贝:
cpp
编辑
1ListNode dummy(0, head); // 正确:直接在栈上构造
💥 错误二:致命的指针逻辑漏洞
更隐蔽的错误在于变量 pre 的使用。
在第一次循环(第一组翻转)结束后,pre 指向了第一组的新头节点。
进入第二次循环(第二组翻转)时,pre 没有被重置为 nullptr。
后果推演 :
当翻转第二组的第一个节点时,执行 cur->next = pre。此时 pre 还是第一组的尾节点(即原第一组的头)。
这导致:第二组的第一个节点指回了第一组,链表瞬间成环或顺序大乱!
✅ 修正方案 :
必须在每组翻转开始前,将 pre 重置为 nullptr。
💡 核心解法:迭代 + 哨兵节点
修复上述错误后,我们得到一个标准的迭代解法 。其核心思想是:利用哨兵节点简化头节点处理,分组进行局部翻转,再重新连接。
算法步骤详解
- 统计长度 :遍历链表获取总长度
n,用于判断剩余节点是否足够k个。 - 引入哨兵 :创建
dummy节点指向head,设p0指向dummy(p0始终指向待翻转组的前一个节点)。 - 循环翻转 :
- 检查剩余长度
n >= k。 - 重置指针 :
pre = nullptr,cur = p0->next。 - 局部翻转 :执行
k次标准链表翻转操作(cur->next = pre...)。 - 重新连接 :
- 记录当前组的旧头节点(翻转后变为尾):
tail = p0->next。 p0->next = pre:前驱连上新头。tail->next = cur:旧头(现尾)连上后继节点。
- 记录当前组的旧头节点(翻转后变为尾):
- 移动游标 :
p0 = tail,准备处理下一组。
- 检查剩余长度
- 返回结果 :
dummy.next。
✅ 最终 AC 代码
cpp
编辑
1class Solution {
2public:
3 ListNode* reverseKGroup(ListNode* head, int k) {
4 if (!head || k == 1) return head;
5
6 // 1. 计算链表总长度
7 ListNode* cur = head;
8 int n = 0;
9 while (cur) {
10 cur = cur->next;
11 ++n;
12 }
13
14 // 2. 初始化哨兵节点 (注意:直接构造,不要拷贝)
15 ListNode dummy(0, head);
16 ListNode* p0 = &dummy;
17
18 // 重置 cur 指向真正的头节点
19 cur = head;
20
21 while (n >= k) {
22 n -= k;
23
24 // 【关键修复】每组翻转前,pre 必须重置为 nullptr
25 ListNode* pre = nullptr;
26
27 // 3. 局部翻转 k 个节点
28 for (int i = 0; i < k; ++i) {
29 ListNode* nxt = cur->next;
30 cur->next = pre;
31 pre = cur;
32 cur = nxt;
33 }
34
35 // 4. 重新连接链表
36 // p0 -> [旧头...旧尾] -> cur
37 // 翻转后:p0 -> [新头(pre)...新尾(旧头)] -> cur
38
39 ListNode* tail = p0->next; // 记录当前的尾节点(即翻转前的头)
40
41 p0->next = pre; // 上一组尾 -> 当前组新头
42 tail->next = cur; // 当前组尾 -> 下一组头
43
44 p0 = tail; // 更新 p0 为当前组的尾,准备处理下一组
45 }
46
47 return dummy.next;
48 }
49};
📊 复杂度分析
- 时间复杂度 : O(N)O(N)
- 第一次遍历计算长度耗时 O(N)O(N) 。
- 后续每组翻转,每个节点仅被访问和反转一次,总耗时 O(N)O(N) 。
- 整体为线性时间。
- 空间复杂度 : O(1)O(1)
- 仅使用了
dummy,p0,cur,pre,tail等常数个指针变量。 - 没有使用递归栈或额外数组,满足进阶要求。
- 仅使用了
🧠 总结与思考
这道题是链表操作的集大成者,它考察了以下几个关键点:
- C++ 基础细节:对象构造与拷贝控制(Rule of Three/Five),在刷题时也不能忽视语言特性。
- 指针状态管理 :在循环中,临时变量(如
pre)的状态是否需要在每轮重置?这是很多 Bug 的根源。 - 哨兵节点技巧 :
dummy节点能极大简化对头节点的特殊处理,使代码逻辑更统一。 - 分组处理模式 :
while(n>=k)配合内部for循环,是处理"每 K 个操作一次"类问题的通用模板。
避坑指南:
- 看到
ListNode定义,下意识避免拷贝构造。 - 写链表翻转循环时,时刻问自己:"这个指针在下一轮循环开始时,应该是多少?"