1. 这道题到底在考什么:从题意到隐藏在背后的考点链
我在按 LeetCode Hot 100 的刷题路线准备面试时,排到第 19 题位置的是 25 题「K 个一组翻转链表」。这是链表专项里少见的 Hard,但刷完你会发现它是典型的纸老虎——没有高级算法,没有复杂状态,本质就考一件事:你会不会操作指针,能不能在改掉一堆引用之后,依然清楚记得谁指向谁。这篇我会把自己从暴力想法、前置技能、两种主流解法到真实翻车经历的全部过程写下来,面试遇到它,你能少走很多弯路。
1.1 一句话说清题目,以及最常见的错误答题姿势
题目要求其实很短:给一个单链表和一个正整数 k,从头部开始每 k 个节点为一组做原地反转,组与组之间的顺序保持不变;如果最后一组不足 k 个节点,保持原样。举个例子,1 -> 2 -> 3 -> 4 -> 5,k = 2 时答案是2 -> 1 -> 4 -> 3 -> 5;k = 3 时答案是3 -> 2 -> 1 -> 4 -> 5。
很多人(包括我身边一些准备面试的朋友)第一反应是:把链表遍历成数组,每隔 k 个做一次区间翻转,再把数组重建回链表。这种做法在 LeetCode 上能跑通,但面试基本是要被追问的。原因很简单,LeetCode 判题只看结果对不对,面试官看的是你处理引用的能力。既然题目名字叫"翻转链表",考察重点就明确指向指针操作,用数组辅助属于绕开了考点,属于"能过但没分"的典型答案。
1.2 考察维度拆解:基本功、边界感、双解法
我把这道题的考察点拆成三层,正好对应面试评分的三个维度:
第一层是基本功,也就是单链表反转。你需要闭着眼都能写出迭代版翻转,这是 206 题的内容,没有这个地基,后面一切免谈。
第二层是边界感。每一组翻转完,要把组的尾部接上"下一组翻转后的头",前一组翻转后的尾部又要指向当前组的新头。任何一个连接没接上,链表要么断掉,要么成环。这种"改完引用后重新搭桥"的能力,不是背代码能练出来的。
第三层是递归与迭代的转换。面试官大概率会在你写完递归版后追加一句"能不能不用递归、空间 O(1)?" 这一问直接筛掉那些只会抄题解的人。所以我对这道题的建议很明确:递归版和迭代版必须都练熟,单独任何一种都不能算过关。
1.3 在刷题路线里的位置:和 24 题、92 题一起刷
Hot 100 里和这道题相关的其实是一串:206 反转链表、92 反转链表 II、24 两两交换链表中的节点、25 K 个一组翻转链表。我实际刷题时没有按题号顺序来,而是把这四道放在同一个时间段里对比刷,效果比孤立刷 25 题好得多。
24 题其实就是 25 题在 k = 2 时的特例。如果你能把 24 题不看题解写出来,那么到 25 题只差一个抽象:把"固定翻转两个节点"泛化成"翻转动态的 k 个节点"。92 题则帮你建立"区间翻转"的概念——它要求翻转链表中某个给定区间,这就已经把 25 题需要的前置技能覆盖了一半。所以下面我先讲两个前置技能,再看这道题的两套解法。
2. 前置底子:单链表翻转的迭代与递归两种手感
2.1 迭代翻转:三指针模型的每一步都在干什么
迭代翻转单链表的代码很短,但每次都会有人写出死循环或丢节点。核心是三个指针:prev表示已经翻转好的部分的头,cur指向当前要处理的节点,next提前保存 cur 的下一个节点。每轮做的事是:把cur.next指向prev,然后三个指针整体右移。
private ListNode reverse(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; // 先保存下一个节点,这一步是防断链的关键 cur.next = prev; // 反转当前节点的指针 prev = cur; // prev 右移 cur = next; // cur 右移 } return prev; // 翻转后,原链表的尾部成为新头部 }这里最容易出错的点是顺序。next必须在修改cur.next之前取到,因为一旦执行cur.next = prev,原本的后继就找不回来了。你可以把cur.next理解成一张"地址纸条",改掉之前不先抄下来,后面整个链条就断了。这个习惯放在 25 题里同样适用,后面迭代解法中nextGroup的保存就是这个思路的延续。
2.2 递归翻转:洋葱剥开的视角
递归翻转的代码更短,但理解门槛更高。它的逻辑是:先把 head 后面整条子链表翻转好,再把 head 接到这条子链表的末尾。听起来绕,实际上就是"层层剥洋葱,剥到最后一层再一层层往回穿"。
private ListNode reverseRecursive(ListNode head) { if (head == null || head.next == null) { return head; } ListNode newHead = reverseRecursive(head.next); head.next.next = head; // head.next 翻转过,现在指向子链表的尾 head.next = null; return newHead; }这里最关键的一行是head.next.next = head。因为递归调用返回后,head.next已经变成子链表的尾节点,让这个尾节点的 next 指向 head,就完成了"把 head 接到末尾"。最后head.next = null是为了避免成环。理解这一行之后,后面递归版 25 题的那句head.next = reverseKGroup(tail, k)你会觉得特别顺。
2.3 真正的前置技能:会翻"区间段",才是 25 题的地基
很多人卡在 25 题,不是因为不会翻转整条链表,而是不会翻转"链表中的一段"。25 题需要的不是reverse(head)那种从头翻到尾,而是翻转一个起点闭区间、终点开区间[start, end)的片段。
private ListNode reverseRange(ListNode head, ListNode tail) { // 翻转 [head, tail) 这一段,tail 不参与翻转 ListNode prev = null; ListNode cur = head; while (cur != tail) { ListNode next = cur.next; cur.next = prev; prev = cur; cur = next; } return prev; }细心的话你会发现它和reverse(head)几乎一样,唯一的区别是循环终止条件从cur != null变成了cur != tail。这个写法天然适合 25 题:因为每组翻转前,我们必然已经找到了下一组的头节点,也就是这一组的 tail,直接用 tail 做循环边界,不需要额外把当前组的末尾节点的 next 置空。
这也是我建议你先刷 92 题的原因:92 题练的就是"区间端点怎么记录、翻转后怎么接回去",到 25 题你只需要在这个基础上加一个"每组是否有 k 个节点"的判断。
3. 解法一:递归切割,把"每 k 个一组"变成"翻一组+翻剩下的"
3.1 思路三步走:探边界、翻一段、接剩余
递归版的思考顺序非常重要,很多人一开始就卡在"我怎么知道第 k 个节点在哪"。其实可以反过来想:不需要一开始就知道全部组,只需要处理当前这一组的 k 个节点,然后把剩余链表交给递归函数,剩下的全是递归的事。
所以每一步只做三件事:
- 从 head 出发走 k 步,找到第 k+1 个节点,记为 tail。tail 同时是下一组的起始节点。
- 如果走不到 k 步(tail 为 null),说明剩余不足 k 个,直接返回 head,保持原样。
- 翻转
[head, tail)这一段,翻转后 head 变成这段的尾节点,让它指向reverseKGroup(tail, k)的结果,最后返回这段的新头。
写成代码就是这样:
public ListNode reverseKGroup(ListNode head, int k) { ListNode tail = head; for (int i = 0; i < k; i++) { if (tail == null) { return head; // 剩余不足 k 个节点,保持原样 } tail = tail.next; } ListNode newHead = reverseRange(head, tail); // 翻转当前组 [head, tail) head.next = reverseKGroup(tail, k); // 当前组尾接上后续翻转结果 return newHead; }注意看,这里用的是[head, tail)前闭后开区间。tail 走完 k 步之后指向的是第 k+1 个节点,正好是下一组的开头。用 tail 当边界,翻转函数不用关心当前组末尾节点的 next 是什么,因为它在翻转过程中会被循环条件自动排除在外。
3.2 用 k = 2 跑一遍完整递归过程
纸上跑代码是理解递归的最好方法。链表1 -> 2 -> 3 -> 4 -> 5,k = 2:
第一层调用:head 是 1。走 2 步,tail 先到 2、再到 3。翻转[1, 3),得到2 -> 1,局部返回的 newHead 是 2。此时 head(节点 1)的 next 被指向什么?是第二层调用的返回值,但第二层还没执行完,所以先压栈。
第二层调用:入参是 tail,也就是节点 3。它自己再走 2 步,tail 到 5。翻转[3, 5),得到4 -> 3,newHead 是 4。节点 3 的 next 指向第三层调用。
第三层调用:入参是节点 5。走第 1 步到 5,走第 2 步 tail 变成 null,于是直接返回节点 5。第二层拿到返回值后,把节点 3 的 next 指向节点 5,整合成4 -> 3 -> 5。第一层拿到这个结果,把节点 1 的 next 指向它,整合成2 -> 1 -> 4 -> 3 -> 5。
整个过程你可以理解成"先把每一组翻转好,然后在返回的路上逐层接回去"。这也是递归题比迭代题更容易写的核心原因:你不用手动维护所有连接状态,只需要保证当前层该做的事做对了。
3.3 递归版的三个易错点
第一个易错点是:head.next = reverseKGroup(tail, k)这行必须在翻转之后写。因为翻转[head, tail)时,tail 是不动的,head 的 next 会被改成 null。如果先调用递归再接 next,结果没差别;但如果你在翻转之前就尝试用 head.next 去记录什么,那存下来的大概率是错的。
第二个易错点是:tail 是引用,不是值。tail = tail.next走 k 步之后,tail 指向的节点本身在后续翻转中也可能被改指针。但没关系,我们需要的只是"这个节点是谁"这个身份,而不是它的 next 指向哪。理清"引用身份"和"指针指向"的区别,这类题就不会蒙。
第三个易错点是:递归深度。假设链表有 10 万个节点,k 又很小,递归层数可能达到 O(n/k),在极端情况下会栈溢出。面试时如果链表规模不确定,主动提一句"这个写法空间开销在递归栈,数据量大时可能爆栈"会让面试官觉得你有工程意识,然后顺势切换到迭代版。
4. 解法二:迭代 + 哨兵节点,面试中最稳的空间 O(1) 写法
4.1 为什么非要有 dummy:单链表换头的前驱问题
迭代时需要维护"上一组的尾节点",用它来接当前组翻转后的头。问题在于第一组:第一组翻转后,新的链表头是谁?是原来第二组的尾节点,也就是第一组的末尾翻转后的新头。但单链表没有前驱节点,你没有办法在一个pre指向链表头之前去修改它的 next。解决方式是加一个哨兵节点 dummy,让dummy.next = head,所有组的处理逻辑就完全统一了,最后返回dummy.next就是新链表头。
这个技巧在链表题里出现频率极高,不只是翻转链表,删除节点、合并链表里都能看到它的身影。它的本质是"用人为的前驱消除头节点的特殊性",值得单独记住。
4.2 主循环的六个状态转移动作
迭代版的主循环里,我习惯把每一步压缩成六句话,写代码时按顺序执行,基本不会错:
- 用 end 指针从上一组末尾出发,走 k 步,尝试找到当前组末尾节点。
- 如果走不满 k 步,说明剩余节点不足,直接跳出循环。
- 记录 start = pre.next,也就是当前组的第一个节点。
- 记录 nextGroup = end.next,也就是下一组的第一个节点。这必须在翻转前保存。
- 翻转当前组,然后让 pre.next 指向翻转后的新头。
- 让当前组的尾节点(翻转前是 start)指向 nextGroup,完成和下一组的接驳,把 pre 和 end 移到正确位置。
这六步的顺序不是随便写的,第 4 步尤其关键。翻转会改变 end.next 的指向,如果翻转之后再想拿下一组头,你得到的可能是 null 或者已经翻转过的中间节点。
4.3 完整代码与"先剪断再翻转"的工程视角
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) { end = pre; for (int i = 0; i < k; i++) { end = end.next; if (end == null) { break; } } if (end == null) { break; // 剩余不足 k 个 } ListNode start = pre.next; ListNode nextGroup = end.next; end.next = null; // 先把当前组从主链上剪下来 pre.next = reverse(start); // 翻转后接回 pre 后面 start.next = nextGroup; // 组尾接回下一组 pre = start; // pre 移到当前组尾部 } return dummy.next; } private ListNode reverse(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; cur.next = prev; prev = cur; cur = next; } return prev; } }这里我用的是"剪断再翻转"的工程思路:end.next = null把当前组和后面的链表隔离,然后调用普通的reverse(start)。这比我前面写的reverseRange更简单,代价是多了一次断开和重连的指针操作。两种做法都能过,我日常更喜欢剪断版,因为普通reverse函数写起来最不容易出错。
每次循环结束前,pre = start这一步是很多人的盲区。start 在翻转前是当前组的头节点,翻转后它变成了当前组的尾节点,也正是下一组需要的前驱。而 end 下一轮会被重置到 pre,重新向后探索 k 步。这两个变量的语义一定要记牢:pre 是"上一组尾",end 是"预探针"。
4.4 递归与迭代的复杂度对比
写代码之前最好先跟面试官把复杂度讲清楚,这是不加分但绝不能丢分的环节。
| 维度 | 递归版 | 迭代版 |
|---|---|---|
| 时间复杂度 | O(n),每个节点被翻转一次 | O(n),每个节点被翻转一次 |
| 空间复杂度 | O(n/k),递归栈深度 | O(1),只使用常数个指针 |
| 代码简洁度 | 代码短,逻辑集中 | 代码略长,状态变量多 |
| 面试建议 | 先讲这个,展示思路清晰 | 追问后给出,展示工程能力 |
我的个人建议是面试时说:"我先给出递归版本,思路是把当前组翻转交给一个函数、剩余部分交给递归;如果要求 O(1) 空间,我再改成迭代加哨兵节点。"这句话既展示了理解深度,又给你自己争取了一次展现第二方案的机会,比悶头甩代码强得多。
5. 边界用例、翻车复盘与面试追问
5.1 必测的七组用例与每组的考察点
链表题不测边界等于没写。我刷 25 题时固定会跑下面这组用例:
| 用例 | 输入 | 期望输出 | 主要验证点 |
|---|---|---|---|
| 空链表 | null, k = 2 | null | 判空逻辑 |
| 单节点 | 1, k = 2 | 1 | 不足 k 个保持原样 |
| k = 1 | 1->2->3, k = 1 | 1->2->3 | 每组 1 个节点,翻转等于不翻 |
| 长度 = k | 1->2->3, k = 3 | 3->2->1 | 整表恰好一组翻转 |
| 长度 = k+1 | 1->2->3->4, k = 3 | 3->2->1->4 | 最后一组不足 k 个保持不动 |
| 长度 = 2k | 1->2->3->4, k = 2 | 2->1->4->3 | 多组之间的衔接 |
| 长度 = 2k+2 | 1->2->3->4->5->6, k = 2 | 2->1->4->3->6->5 | 完整组之后接半组 |
我通常会在本地 IDE 里维护一个arrayToList(int[])的小工具,把数组转成链表,再用断言检查结果。没有这个工具前我都是手搓 ListNode,效率极低,后来花十分钟写了这个工具方法,刷链表题的速度直接翻倍。
5.2 我实际写错的三个瞬间
第一个错误发生在递归版开头。我把找 tail 的循环写成了"走一步看一眼",类似for (int i = 0; i < k; i++) { if (tail == null) break; tail = tail.next; },然后if (tail == null) return head。表面看起来合理,但链表刚好 3 个节点、k = 3 时,tail 走到第三个节点后,下一步才会变成 null。我的写法在第三轮循环开始时就 break 了,导致 tail 并没有指向空,程序接着去翻转一个根本不完整的组,结果链表的最后一段被错误翻转,测试直接标红。这个教训是:循环退出条件里的"dp"必须精确,走 k 步就是走 k 步,中间任何提前 break 都会破坏语义。
第二个错误在迭代版,我翻转前忘了存nextGroup。当时我的写法是先pre.next = reverse(start),再想拿end.next作为下一组头,结果 end 的 next 早就在翻转过程中被改成了 null。链表后半截直接丢失,输出的链表只有前半部分,长度硬生生少了一半。从那以后我在任何需要修改指针的操作前,都会先把所有后续需要访问的节点存到局部变量,这个习惯救了我很多次。
第三个错误是 pre 和 end 的重置顺序。我第一次写的是pre = end; end = pre;,下一轮循环时 end 直接指向了当前组最后一个节点,而不是它的 next,于是每组都少处理一个节点。正确写法是pre = start; end = pre,你只需要记住一句话:pre 永远指向"上一组的尾",翻转后 start 正是这一组的尾。
5.3 面试官最爱追问的四个变种
第一个追问是"最后一组不足 k 个也翻转怎么做"。改动非常小:递归版把出口判断去掉,让 tail 为 null 时也执行reverseRange(head, null),等价于整段翻转;迭代版把if (end == null) break改为不 break,而是继续翻转剩余的[start, null)。你能在两分钟内改出来,说明是真的理解了结构,而不是背模板。
第二个追问是"k = 2 和 24 题的关系"。其实 24 题就是这道题在 k = 2 时的特例,你把这段代码传 k = 2 就能直接通过 24 题的所有用例。我刷题时先做了 24 题,再做 25 题,等于同一套思想复习了两遍。
第三个追问是"能不能用头插法做"。可以,每组内部做 k-1 次头插,每次把当前组的第二个节点插入到组头之前,迭代次数少一些但指针操作更琐碎。面试时提一嘴思路就够了,不用现场写完,重点是证明你知道第二种实现路径。
第四个追问是"如果要求每组的翻转方向交替呢,比如第一组正序、第二组逆序?"这其实就从纯粹链表题变成了带状态切换的题,需要额外维护一个布尔变量控制是否启用区间翻转。这属于进阶变种,平常刷题不用刻意准备,但碰到过一次之后你会对"翻不翻转"这个动作本身有更深的体感。
5.4 刷题路线的后续衔接
如果你也在走 Hot 100 速刷路线,我的建议是把链表反转这串题集中处理:206 反转链表打底,92 反转链表 II 建立区间概念,24 两两交换处理成对节点,最后再来 25 K 个一组翻转。四道题之间的关系是层层递进的,前一道没练熟直接跳后面,容易在指针细节上反复卡壳。
做完 25 题之后,后续遇到"合并 K 个有序链表""复制带随机指针的链表"这类 Hard 链表题时,你会发现自己看指针引用的眼光明显变了——你不再背每一步代码,而是能一眼看出每个引用的"身份"是什么。我觉得这才是刷这道题最大的收获,它给你的不是一个孤立解法,而是一整套链表操作的思考范式。
最后分享一个实操习惯:每次写完链表题的代码,我都不会急着提交,而是先在草稿纸上走一遍长度为 4、k = 2 的最小完整用例,把所有 pre、end、start、nextGroup 的指向变化列出来,确认最后一轮循环不会越界才提交。这个习惯让我在上周连续几道链表题里都没有因为边界问题返工,刷题和面试都很受用。