如果你刷过牛客的高频题单,或者在 LeetCode 上把链表题按热度排过序,K 个一组翻转链表大概率就躺在“必刷”那一栏。这道题编号 25,标签是链表、递归、双指针,难度标了 Hard,但真正动手做过的同学都知道,它的算法思想并不复杂——难的是你把指针写对、把边界条件理顺。面试官爱出这道题,是因为它能用十分钟看出你到底是真的写过链表题,还是只背过模板。
先说一个反直觉的点:K=1 时链表完全不变,K 等于链表总长度时就是整条链表全反转,所以这道题本质上是“反转链表”和“反转链表 II”的组合升级。这篇我按自己面试和辅导别人的经验,把这题的思路、两套主流写法、踩坑点一次讲清楚。适合正在准备算法面试、或者刷题刷到链表专题想一口气吃透反转类题目的同学。就算你现在只会遍历链表,读完也能照着把代码写出来。
1. 题目理解与面试定位
1.1 题干到底在说什么
题目要求一句话总结:把链表按每 K 个节点分成一组,组内做反转,组与组之间的相对顺序不变,最后如果剩余节点不足 K 个,保持原样不动。
举个例子,链表1->2->3->4->5:
- 当 K=2 时,结果是
2->1->4->3->5。 - 当 K=3 时,结果是
3->2->1->4->5。
第二个例子很多人会错,因为末尾的4->5只有两个节点,不足 K=3,所以不翻转,保持原顺序接在后面。
这里有两个约束缺一不可:一是“组内反转”,二是“末尾不足一组不反转”。很多人在 LeetCode 上提交报错,不是反转逻辑写错,而是第二点没处理好,把最后那截不够长度的也翻了。面试里如果犯这种错,比写不出代码更减分,因为说明你没有把题目条件读完整。
1.2 这道题在面试里的地位
你可能会问:链表题那么多,为什么偏偏这道题是高频考点?
我自己的体会是,这题考察的面非常全:
- 它需要你维护多个指针,并且保证每一步都不丢节点;
- 它需要虚拟头节点的技巧,否则头节点翻转后无法返回新头;
- 它需要先遍历统计长度,或者在高潮处判断剩余节点是否够一组;
- 它还需要你在纸上把指针画清楚,靠纯脑补很容易翻车。
所以面试官只要看你写这题的过程,基本就能判断你的链表基本功属于什么水平。背过答案的人写起来磕磕绊绊,指针变量一多就开始乱;真正做过的人会先画图、再说思路、再动手,整个过程有条不紊。
另外,这道题和 LeetCode 24(两两交换链表中的节点)、LeetCode 92(反转链表 II)之间是强关联。面试官经常会把这题作为基础题,然后现场改成“只反转第 m 到第 n 段”或者“K=2 怎么优化”。一道题能不能举一反三,往往比题目本身 AC 不 AC 更重要。
2. 思路拆解:两套核心方案
2.1 为什么要用虚拟头节点
链表反转类问题,第一个要养成的习惯就是:构造一个虚拟头节点 ,让反转后的新头有地方挂。
你可以想象一下,链表1->2->3->4,K=4,翻转后变成4->3->2->1,原来的头节点 1 变成了新链表的尾节点。如果你一开始只持有head指针,翻转完成后这个指针指向的节点已经变了位置,你怎么拿到新头?
你当然可以用一个变量专门记录新头,但这样逻辑上要多开一个分支,代码也容易乱。虚拟头节点的做法是:不管链表怎么翻转,dummy->next永远指向新链表的头节点,最后直接return dummy->next就行。这就像系鞋带的时候先打一个活结,后面怎么拉都不会把鞋带拉散。
2.2 迭代头插法:面试首选的完整思路
迭代方案里最推荐的是“头插法”,因为它只涉及指针的交换,不需要额外的数组空间。
思路分四步:
- 先遍历一遍链表,统计总长度
len; - 只要
len >= K,说明还能凑出一组可翻转的区间; - 在组内执行
K-1次头插,把后面的节点依次挪到组的最前面; - 一组处理完后,把前置指针
pre移动到这一组的末尾,同时len -= K,继续下一组。
很多人不理解为什么是 K-1 次而不是 K 次。这里解释一下:当一组待翻转的节点是1->2->3时,节点 1 最终会变成这一组的最后一个节点,它不需要再往前插,真正需要插到前面的只有 2 和 3 两个节点。每次头插,都会让其中一个节点变成组内第一个节点。所以 K 个节点需要 K-1 次头插。
每次头插的细节是:先把当前节点cur的下一个节点存为nxt,然后把cur->next直接跨过nxt指向后面,再把nxt插到pre->next的位置。这个过程中,cur一直指向组内第一个节点(同时也是翻转后组内的最后一个节点),它从头到尾没有移动过,所有被插过来的新节点都插在它前面。一组结束后,cur自然就是这一组的末尾,下一组的pre就是它。
2.3 递归法:思路优雅但容易绕晕的方案
递归的思路更直观一些:每一层只处理一组 K 个节点,剩下的交给函数自己处理。
具体来说:
- 从头节点开始,往后走 K 步,得到第 K+1 个节点;
- 如果不足 K 步,说明当前不够一组,直接返回头节点;
- 如果够 K 个节点,先递归处理第 K+1 个节点之后的链表,拿到后面处理完的新头;
- 翻转当前这 K 个节点,把翻转后的尾节点接到递归返回的结果上。
递归代码写出来比迭代短很多,但有一个代价:递归深度是n/K,如果链表特别长,会占用额外栈空间。面试时如果追问空间复杂度,迭代版可以理直气壮地说 O(1),递归版得老实承认 O(n/K)。
另外递归版有个容易搞混的点:翻转完当前组后,返回的新头是组内原来的最后一个节点,而不是当前层的head。很多人在这一步绕进去,所以我建议面试时优先写迭代法,递归法作为思路扩展讲给面试官听。
3. 手撕代码与核心细节
3.1 迭代法完整实现(C++)
先上可以直接用的 C++ 代码,注释我写得比较细:
/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* reverseKGroup(ListNode* head, int k) { // 虚拟头节点,统一处理头节点变化的情况 ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* pre = dummy; ListNode* cur = head; // 第一遍遍历,统计链表长度 int len = 0; ListNode* p = head; while (p != nullptr) { len++; p = p->next; } // 只要剩余节点还够一组,就继续处理 while (len >= k) { cur = pre->next; // 当前组的第一个节点 // 头插 k-1 次,把后面的节点依次插到 pre 后面 for (int i = 1; i < k; i++) { ListNode* nxt = cur->next; // 保存要移动的节点 cur->next = nxt->next; // 跨过 nxt,先把链表连好 nxt->next = pre->next; // nxt 指向组内第一个节点 pre->next = nxt; // 把 nxt 插到 pre 后面 } pre = cur; // 当前组的最后一个节点成为下一组的前驱 len -= k; // 减去已处理的一组 } return dummy->next; } };逐行说几个关键点。
dummy和pre的关系:pre永远指向当前待处理组的前一个节点。第一组的前驱就是dummy,所以头节点被反转后也能顺利接回来。
cur = pre->next这行不要省略或挪位置。很多人写的时候喜欢在循环外用变量一直记录cur,但每组开始前重新从pre->next取当前组第一个节点,能避免上一组结束后的指针错位问题。
内层循环里,nxt = cur->next一定要先保存。头插法最容易犯的错就是先改了cur->next,然后发现原来的下一个节点找不到了。
pre = cur这个赋值也值得说一下:一组处理完后,cur指向的是这一组最初的第一个节点,现在它因为一直被“往后面顶”,已经变成了这一组的最后一个节点,所以它天然就是下一组的前驱。这个性质很优雅,不需要额外去数位置。
3.2 递归法完整实现与对比
递归版代码长这样:
class Solution { public: ListNode* reverseKGroup(ListNode* head, int k) { ListNode* cur = head; int count = 0; // 走 k 步,看看够不够一组 while (cur != nullptr && count < k) { cur = cur->next; count++; } // 不够 k 个,保持原样 if (count < k) { return head; } // 够 k 个,先递归处理后面的链表 ListNode* newHead = reverseKGroup(cur, k); // 翻转当前 k 个节点 while (count-- > 0) { ListNode* tmp = head->next; head->next = newHead; newHead = head; head = tmp; } return newHead; } };这段代码里最绕的是最后的while (count-- > 0)翻转过程。如果你看不太懂,可以把它想象成“一个一个摘下来,头插到新链表上”:
tmp = head->next先把后面的节点缓存起来;head->next = newHead让当前节点指向已翻转好的部分;newHead = head更新“新链表头”为当前节点;head = tmp继续处理下一个节点。
这样循环 K 次,就能把当前组的 K 个节点彻底反过来,并且接上后续部分。
两个版本对比的话,我个人的建议是:面试写迭代,交流讲递归。迭代法代码量稍多,但每一步都看得见摸得着;递归法代码简短,但对思维要求高,而且空间复杂度不如迭代。如果你递归基础一般,不建议现场挑战这个写法,写岔了比迭代更难排查。
3.3 复杂度分析与 Java/Python 实现说明
复杂度方面:
- 时间复杂度:O(n),每个节点被遍历常数次。第一遍统计长度遍历一次,后续翻转时每个节点最多被访问两次(一次保存,一次移动),整体线性。
- 空间复杂度:迭代版 O(1),只用到了几个指针变量;递归版 O(n/K),因为每一层递归要保存当前栈帧。
如果你用的是 Java,逻辑和 C++ 完全一样,只是把ListNode*换成ListNode,引用类型天然就是“指针”的效果。需要注意的点是:Java 里没有手动释放内存的问题,但你要避免无意中把pre和cur指向同一个对象,导致链表成环。Python 同理,定义节点类之后操作 next 的规则完全一致。
很多同学面试时用 C++ 写链表题,写完忘了delete dummy,这个看面试官习惯,有的会提一句内存管理。我的建议是,面试场合以代码可读性为先,主动说明“这里如果考虑内存释放,还需要 delete 虚拟头节点”,比真的去 delete 更有加分感,因为面试官知道你有这个意识。
4. 边界测试与易错点排查
4.1 必测的几组用例
写完之后不要急着交,先在脑子里跑一遍测试用例。我整理了一份我刷这题时常用的用例清单,你可以直接抄:
| 用例 | 输入 | 期望输出 | 说明 |
|---|---|---|---|
| 空链表 | [],k=2 | [] | 任何代码都不能崩 |
| 单节点 | [1],k=1 | [1] | k=1 是最平凡的情况 |
| 不足一组 | [1,2],k=3 | [1,2] | 尾组不足保持原序 |
| K 等于长度 | [1,2,3],k=3 | [3,2,1] | 相当于整表反转 |
| 常规多组 | [1,2,3,4,5],k=2 | [2,1,4,3,5] | 最常见的场景 |
| 尾组不足 | [1,2,3,4,5],k=3 | [3,2,1,4,5] | 最容易错的场景 |
你可能会觉得这些都是废话,但我在带人刷题时真见过有人把空链表测漏,结果返回了dummy节点本身而不是nullptr的。虚拟头节点是new出来的一块内存,不是dummy->next,最后返回错了检查半天。
4.2 高频 bug 现场还原
这里把我在实际调试中见过最多的问题集中列一下:
Bug 1:没统计长度,最后一组不足 K 也被反转。这是最典型的问题。解决办法就是先遍历一遍拿到len,每次处理完len -= k,用while (len >= k)控制循环。另一种写法是在每组开始前尝试走 K 步,走不到就 break,也可以,但我觉得先统计长度的写法循环条件更清晰。
Bug 2:头插顺序写错,导致丢节点。正确顺序是:先存nxt,再改cur->next,再改nxt->next,最后改pre->next。有同学先改了pre->next,结果nxt->next指向的pre->next已经变成了nxt,链表当场成环。记住一个口诀:先摘下来,再接前后,最后挂上去。
Bug 3:忘了更新pre指针,导致死循环。如果一组处理完不执行pre = cur,下一组的头插就会把节点插到上一组里,链表越排越乱,最后死循环。你如果调试时发现链表长度完全没减少,多半就是这个原因。
Bug 4:return head而不是return dummy->next。链表一旦发生翻转,原来的head就不再是新头了。这是虚拟头节点使用中最经典的误区。看到这里你可以自查一下,是不是总在最后下意识返回 head。
Bug 5:递归版里count被 while 改了,后面想再用它做别的事。递归版的count在翻转循环里会递减到 0,如果你之后还拿它做判断就会被坑。所以递归版里要么用局部变量把 K 的值先存起来,要么就别在翻转之后依赖 count。
4.3 和相似题目的关系
这题刷完之后,我建议你顺手把下面三道题一起刷了,因为它们的解法几乎就是从这题变形出来的:
- LeetCode 206 反转链表。整条链表反转,等价于本题 K=链表长度时的特例。
- LeetCode 92 反转链表 II。固定区间反转,核心是利用虚拟头节点找到区间前驱,再对区间内做头插。
- LeetCode 24 两两交换链表中的节点。就是本题 K=2 时的特例。你可以把本文的迭代代码里
k换成 2,代码照样能跑,只是两两交换有更简洁的写法。
把它们放在一起刷,你会发现一个规律:链表反转类的题,本质上就是“找到区间 -> 头插法翻转 -> 重接边界”这三板斧。掌握这三板斧,比单独背一道题的答案有用得多。
5. 面试现场:思路展示与变化题
5.1 建议在现场如何表达思路
面试和做题不一样,光写出代码不够,你还要让面试官看到你的思考过程。我建议拿到题之后,先跟面试官说这样几句话:
“我先遍历一遍链表拿到总长度,这样就能知道一共需要处理几组。然后我用一个虚拟头节点来统一处理头节点变化的情况。每一组内部我用头插法做翻转,组内头插 K-1 次,处理完把 pre 移到组尾,继续下一组。最后剩余不足 K 个的节点保持原序不动,直接返回虚拟头节点的 next。”
这段话既交代了方案,又点出了边界条件,面试官一听就知道你不是在边写边猜。如果你直接闷头开始敲代码,即使最后 AC 了,印象分也会打折扣。
面试官接下来大概率会追问几个问题,这里预判一下:
- 为什么要先统计长度?答:为了保证最后一组不足 K 个时不翻转,同时让主循环条件更清晰。
- 空间复杂度是多少?答:迭代版 O(1),只用了常数个指针。
- 如果 K 非常大接近链表长度会怎样?答:最多只会处理一组,时间复杂度依然是 O(n)。
5.2 怎么写代码能一次过
在面试那种紧张环境下,想一遍把代码写对,有几个实操技巧:
第一,先在白板上把链表画出来。画一个1->2->3->4->5,K=3 的例子,手动模拟两次头插,写代码时照着图来,而不是凭空想。
第二,变量名要有区分度。我用pre表示前驱,cur表示当前组的第一个节点,nxt表示要移动的下一个节点。名字清晰,思路就清晰一半。见过有人写a、b、c、d,写到最后自己都分不清谁是谁。
第三,写完之后,用一个小例子在代码里“走一遍”。不需要真的调试,沿着循环,把指针一步步标出来,确认三次循环后链表形态正确,再提交或交给面试官。
第四,注意代码风格。循环里的变量声明尽量靠近使用处,不要一上来把一堆指针全部声明好,这会让代码读起来很难受。
5.3 变化题与延伸练习
这题最常见的变体就是前面提到的 K=2 和区间反转,这里再说几个进阶方向,供已经能 AC 的同学查漏补缺:
- 如果题目改成“最后一组也要翻转”,代码只需要把循环条件从
len >= k改成计数到组数为止,或者递归时不足 K 也照单反转。 - 如果考察双向链表,核心思路不变,但要额外维护
prev指针,头插时同步更新四个方向的引用,难度会高一个台阶。 - 如果要求“输出每组翻转后的链表中间值”,就需要把本题和快慢指针结合,这种跨知识点组合题也是大厂面试喜欢玩的花样。
练习顺序我建议这么安排:先裸写本题,再改 K=2 看代码能不能简化,再去做 92 题区间反转,最后试一下递归版。这个顺序是从具体到抽象,等你能用自己的话把四种变体都讲明白,链表反转这个专题基本就过关了。
我记得带过的一位同学,最初看这题答案都看不懂,后来按“画图 -> 模拟 -> 复写 -> 总结”这四步练了三天,再遇到 92 题十分钟就写出来了。可见这个专题的特点就是:想通一次,全部贯通。
最后再分享一个个人习惯。我刷这题时,会在草稿纸上把“头插法”的每一步用箭头画三遍:第一遍照着题解画,第二遍遮住题解自己画,第三遍把文字描述换成口头表达讲给自己听。三遍过后,这个解法基本就长在脑子里了。你可以试试,这比刷十遍相同题目管用。