先把结论放这儿这道题是链表类算法题里性价比极高的一道。不夸张地说我先后在三家公司的算法面试里都遇到过它不是原题就是变体。字节、阿里、腾讯的题库里都有它的身影LeetCode 上编号 25名字叫“K 个一组翻转链表”很多刷题网站把它标记为 Hard但我个人觉得它更像是一个“会用链表 vs 真懂链表”的分水岭。能用它做什么一句话就能说明白给你一个单链表每 K 个节点一组组内做反转组与组之间保持原有相对顺序如果最后一组不足 K 个就保持原样。听起来像一个简单的“反转”加“分组”组合题但真正动手写的时候绝大多数人都会在组与组的边界上栽跟头。这篇文章我会把这道题从题目拆解、前置知识、迭代实现、递归实现、边界条件、复杂度分析一直到面试变形全部讲透。无论你是刚开始刷题的大三学生还是准备跳槽的社招选手只要跟着思路走一遍把代码自己默写两遍这道题基本就能稳稳拿下。1. 题目拆解先搞懂“每 K 个一组”到底在考什么1.1 题目要求与一个直观例子原题描述很简洁给你链表的头节点head每k个节点一组进行翻转返回修改后的链表。k是一个正整数它的值小于或等于链表的长度。如果节点总数不是k的倍数那么最后剩余节点保持原有顺序。拿最常见的例子来说链表1 - 2 - 3 - 4 - 5如果k 2结果应该是2 - 1 - 4 - 3 - 5如果k 3结果应该是3 - 2 - 1 - 4 - 5。注意这个行为逻辑不是让你把整个链表翻转也不是每 K 个翻转后还保持原来的连接方式而是“先分组再组内翻转最后把各组重新串起来”。题目对最后一组提出了明确要求不足 K 个就不动它。这其实是在考察你处理“不完整分组”的边界能力。从数据结构的角度看链表和数组最大的差别在于数组可以通过下标直接定位任意区间而链表想定位到第 K 个节点必须从头走一遍。这道题的所有操作——定位边界、组内反转、组间衔接——都建立在“链表只能顺序访问”这个特性之上所以它本质上是“链表遍历 指针操作”的综合体操。1.2 这道题为什么能拉开面试差距很多人面对这道题的第一反应是“我会反转链表那我先写个 reverse 函数然后循环调用不就行了”。但实际写的时候会发现几个问题第一个问题反转一组之后当前组的前驱指针该指向谁是原来的第一个节点还是原来的最后一个节点很多人在这里就写错了因为组内反转后原来的第一节点变成了当前组的末尾原来的末尾变成了新的头部如果不理清这个对应关系下一组的衔接就会断裂。第二个问题链表的头节点会变。第一组反转后整个链表真正的头节点不再是原来的 head如果你还是无脑返回 head结果就是返回了一个指向中间节点的指针面试官一眼就能看出你没有理解“头节点动态变化”的本质。第三个问题最后一组不足 K 个时怎么处理。有些人直接无脑翻转结果和题目要求不符有些人想判断长度但没想清楚怎么在遍历过程中优雅地判断导致代码里塞满了 if 分支。这三个问题恰好对应了链表操作里的三个基本功边界定位、指针更新、条件判断。面试官特别喜欢用这道题来分辨一个人是“背过题解”还是“真的能把链表玩明白”。我后面讲的每一种写法都会围绕这三个问题展开。2. 前置知识链表操作的三个基本功2.1 链表结构定义与遍历在开始写这道题之前最好先确认自己能把下面这几个操作闭着眼睛写出来定义节点结构、遍历链表、在指定位置插入节点、反转链表。不同语言的节点定义大同小异。C 语言里一般这么写struct ListNode { int val; struct ListNode *next; };Java 里是类public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val val; } ListNode(int val, ListNode next) { this.val val; this.next next; } }Python 里最简单一个类加一个构造函数class ListNode: def __init__(self, val0, nextNone): self.val val self.next next链表的遍历是所有操作的地基。一个核心原则遍历时永远不直接移动头节点指针而是用一个临时变量cur从头开始走每次执行cur cur.next。这个习惯在大二数据结构实验“单链表的基本操作”里就该养成但很多人刷题时一紧张就写成head head.next最后把原始头节点弄丢了导致整个算法崩溃。2.2 哑节点处理头节点变化的最佳实践这道题里有一个绕不开的问题头节点会被翻转到第二位甚至翻到更后面的位置。也就是说整个链表真正的头节点是动态变化的。如果每次变化后都要单独写一套逻辑来更新“真正的头指针”代码会变得极其繁琐且容易出错。行业内的通用解法是引入一个“哑节点”Dummy Node也叫哨兵节点。做法很简单在真正的头节点前面再挂一个不存储有效值的节点让它的 next 指向 head。哑节点最大的价值是让头节点的处理和中间节点完全统一。不管你翻转多少组链表头部的前驱永远是dummy你只需要维护dummy.next就能随时拿到最新的头节点。这就像给一个常常会替换队长的队伍配了一个固定的“联络官”你只管跟联络官对接不需要每次重新找人。结合热词里提到的“不带头结点的单链表”如果你非要用不带头结点的写法也不是不行但你的代码里必然会出现类似if (isFirstGroup) { head newHead; }这样的分支判断。多一个分支就多一个犯错的机会。我用过带哑节点的写法之后就再也不想写不带哑节点的版本了。2.3 理解“断链”与“重连”链表反转的本质是什么说白了就是不断改变每个节点的 next 指向。你要把a - b - c变成c - b - a需要做的是把a.next指向空把b.next指向a把c.next指向b。问题是当你执行第一步之后b就再也找不到了因为a不再指向b。所以反转链表的第一步永远是先保存当前节点的下一个节点。用代码表达就是nxt cur.next。这个“保存 next”的细节就是链表题里最著名的坑之一。很多人在 K 个一组翻转时写崩不是不会反转而是反转一组之后下一步要拿next_group去继续遍历结果发现这个指针早就被反转操作给弄丢了。核心心法其实就一句话链表操作的本质是“断链”和“重连”断之前必须先留下后路也就是把要访问的下一个节点用变量先存起来。掌握了这一点K 个一组翻转的大方向就不会跑偏。3. 迭代法完整实现哑节点 三段式翻转3.1 整体流程定位、反转、接线、移动迭代法的核心思路不复杂可以拆成三个阶段从当前组的起点出发向后走 K 步找到当前组的末尾节点tail。这一步同时也是在用“走不走得到 K 步”来判断剩余节点够不够一组。对当前组内的 K 个节点做反转。把反转后的当前组重新接回链表。前驱节点prev的 next 指向当前组的新头也就是原来的tail当前组的新尾也就是原来的group_head的 next 指向下一组的起点。等这三步做完把prev更新为当前组的新尾然后进入下一轮循环。从整体流程上你可以把这套方法理解成“流水线作业”找到一段翻一段接一段再找下一段。每一轮之间靠prev指针衔接它永远指向“上一组翻转后的末尾节点”也是下一组的前驱节点。3.2 组内反转复用经典单链表逆序组内反转其实就是在写“单链表逆序”的经典代码。单独拎出来解法是这样的def reverse_list(head): prev None cur head while cur: nxt cur.next cur.next prev prev cur cur nxt return prev注意这个函数prev最后会成为反转后链表的头节点head则变成反转后链表的尾节点。在 K 个一组翻转的场景里我们也可以用同样的逻辑但需要额外指定“反转的终止边界”。在完整实现里我不会写一个单独的reverse_list函数再传区间而是直接在循环里做反转。这样做的好处是不需要多次遍历链表也不用额外处理函数返回的头尾节点代码更紧凑。3.3 完整代码与逐行解析下面是我推荐使用的迭代版本Pyhton 书写语言无关思路可以直接翻译成 Java 或 Cdef reverseKGroup(head: ListNode, k: int) - ListNode: if not head or k 1: return head dummy ListNode(0) dummy.next head prev dummy while True: # 1. 判断剩余节点是否够 k 个同时定位当前组末尾节点 tail tail prev for _ in range(k): tail tail.next if tail is None: return dummy.next # 2. 记录当前组的起始节点和下一组的起始节点 group_head prev.next next_group tail.next # 3. 组内反转把 group_head 到 next_group 之间的节点反转 node group_head prev_node None while node ! next_group: nxt node.next node.next prev_node prev_node node node nxt # 4. 接线前驱指向反转后的新头原组头变成新尾并指向下一组 prev.next tail group_head.next next_group # 5. 移动 prev 到当前组的新尾准备处理下一组 prev group_head这段代码里最有意思的是第一步。我让tail从prev开始走而不是从group_head开始走。为什么因为第一步同时做了两件事检查剩余节点是否够 K 个以及定位当前组的末尾节点。如果从group_head开始走当链表剩余节点恰好等于 K 个时走完 K 步后tail会变成空指针你还需要额外判断“tail 是否为空并且 group_head 是否存在”逻辑就分裂了。而从prev开始走第一步天然兼容“当前组就是第一组”的情况因为prev初始化为哑节点prev.next就是真正的链表头。再看第三步反转。循环条件用的是while node ! next_group这和普通的反转整个链表有所不同普通反转是遍历到None为止这里是把next_group当作“哨兵边界”表示只处理当前组内的节点。由于前面已经判断了剩余节点够 K 个这里循环一定会在有限的 K 次内结束不会出现访问空指针的问题。第四步接线是整个算法最容易写错的地方。很多人写到这里会把prev.next tail和group_head.next next_group的先后顺序搞混。这里我说明一下这两条赋值语句互不冲突因为它们操作的是两个不同节点的 next 字段先写哪条都不会影响结果。真正要注意的是prev和group_head这两个变量在反转前后的语义变化prev是当前组前驱group_head是当前组原来的头节点反转完成后prev应该指向当前组的新头也就是tailgroup_head变成了当前组的新尾它的 next 应该接回next_group。3.4 关键误区为什么下一轮的前驱是 group_head 而不是 tail我见过很多初学者在第五步把prev更新成tail理由是“tail 是当前组的新头啊下一组的前驱应该是它”。这个想法是错的。我们仔细想一下当前组反转完成后这个组在链表中的顺序是“原来的 tail - ... - 原来的 group_head”也就是group_head才是当前组的最后一个节点。下一组再接上来时要接到当前组的尾部也就是group_head的 next而不是tail的 next。如果错误地把prev更新为tail下一轮的第一步就会从tail开始找 K 个节点而不是从group_head后面找逻辑直接乱掉。这也是这类题典型的“想当然”错误。记住一句话prev 永远指向“已经处理完的最后一组的末尾节点”而不一定是“最后一组的新头”。4. 递归写法用“先处理后递归”简化思考4.1 递归设计思路迭代版本已经能解决所有问题为什么还要研究递归写法主要有两个原因一是面试官可能会要求你用递归实现考察你对递归结构的理解二是递归思路本身就非常优雅能帮你从另一个角度看清这道题的结构。递归的核心思想是先处理当前这一组剩下的节点交给递归函数处理。把“每 K 个一组翻转整个链表”这个大问题拆成“翻转头部 K 个节点”加“对剩余链表做同样的操作”两个子问题这就是标准的“分而治之”式递归。递归的终止条件有两个如果节点数量不足 K 个直接返回当前链表的头节点不做任何翻转如果节点为空同样返回空指针。这两个条件也可以合并从当前节点出发走 K 步如果遇到空指针说明剩余节点不足 K 个直接返回当前节点。4.2 递归完整代码def reverseKGroup(head: ListNode, k: int) - ListNode: if not head: return head # 1. 判断剩余节点是否足够 k 个 cur head count 0 while cur and count k: cur cur.next count 1 if count k: return head # 2. 翻转前 k 个节点 prev None cur head for _ in range(k): nxt cur.next cur.next prev prev cur cur nxt # 3. 当前组的原始头节点 head 已经变成了组内最后一个节点 # 递归处理剩余链表并把结果接到 head.next 上 head.next reverseKGroup(cur, k) return prev这段代码最精华的部分是第 3 步。翻转前 K 个节点后prev是当前组的新头head是当前组的新尾cur是下一组的起点。此时我需要递归处理从cur开始的剩余链表把处理结果交给head.next。注意这里的head不再是整个链表的头它已经变成了当前组的尾部所以对head.next赋值就是在为整个链表“接上新的一段”。很多人在递归版本里写错是因为他们习惯性地把head当作整个链表的头部。实际上在递归函数中head这个变量只是“当前调用入参的头节点”经过组内翻转后它的角色变成了“当前组的尾节点”语义变了代码逻辑就得跟着变。4.3 递归 vs 迭代如何选择两种写法的核心逻辑完全等价但适用场景不同。迭代版的优点是空间复杂度低不需要额外的调用栈适合对内存敏感的场景缺点是代码状态变量多初学时容易在prev、group_head、tail之间迷路。递归版的优点是思路直观代码量少从“把问题缩小”的角度理解起来更自然缺点是在极端情况下可能栈溢出。假设链表有一万个节点k 2递归深度大约是五千层大多数编程语言的默认栈空间都撑不住这么大的深度。如果面试中时间充裕我建议把两种写法都讲一遍先给递归版展示思路再补一句“如果担心栈溢出我可以用迭代版实现空间复杂度 O(1)”这反而是加分项。5. 边界条件与高频易错点实测总结5.1 边界情况速查表以1 - 2 - 3 - 4 - 5为例各种边界情况应该输出什么我整理了一张表输入情况期望输出说明k 11 - 2 - 3 - 4 - 5每组只有一个节点翻转等于没翻head NoneNone空链表无需处理链表长度 k如k 61 - 2 - 3 - 4 - 5不足一组保持原样链表长度恰好是 k 的倍数如k 55 - 4 - 3 - 2 - 1所有节点都被翻转链表长度 k 的倍数 余数如k 22 - 1 - 4 - 3 - 5最后一组不足 k 个不翻转这些情况看起来简单但很多人在写代码时一个分支没覆盖就挂掉了。我的建议是写完成第一件事不是提交而是对照这张表逐个手动模拟确认输出符合预期。5.2 我踩过的五个大坑第一个坑忘记保存next指针。这是链表反转的经典错误不用多说。凡是写链表反转进入循环第一行永远是nxt cur.next没有例外。第二个坑把“当前组的边界”弄混。在迭代版中我用了while node ! next_group作为反转终止条件但有人会写成for i in range(k)。这两种写法在大部分情况下等价但有一个细微差异如果当前组恰好是链表最后一组next_group为None循环条件node ! next_group依然能正常终止而for i in range(k)也完全可以。问题不在终止条件而在于有些人反转完一组后把next_group又拿来计算下一组的边界导致next_group的值已经被反转操作覆盖了。正确的做法是next_group必须在反转之前保存并且反转过程中绝不能修改它。第三个坑接线顺序混乱导致链表出现环。比如先执行group_head.next next_group再执行prev.next tail理论上结果是一样的因为这两个节点没有交集。但如果你在反转过程中不小心把tail.next改成了某个组内节点然后再接线时就会形成环。我的习惯是反转完成后先接后面的线group_head.next next_group再接前面的线prev.next tail每一步都检查有没有形成环。第四个坑递归版本里把cur传给了递归调用但自己又在当前层使用了cur。递归调用会在返回之前完成所有操作所以理论上cur在当前层不会再被修改。但我见过有人递归之前把cur.next改了导致递归函数接收到的cur已经不是原来的下一组头部。这个问题的根源是递归前一定要保证cur是“下一组真正的起点”并且从cur开始的所有节点都没有被当前层改动过。第五个坑拿到题目就动手写代码没有先和面试官确认k的取值范围。如果k可能为 0 或负数代码就会陷入死循环如果k非常大超过了链表长度按题目要求应该保持不变。我在面试中养成的习惯是先把手动模拟的 1-2 个例子讲给面试官听确认自己的理解和他一致再开始写代码。这一步能避免很多无效沟通。6. 复杂度分析与面试延伸6.1 时间与空间复杂度怎么算迭代版的时间复杂度是 O(n)。为什么不是 O(n*k)因为虽然每组内部要反转 K 个节点但每个节点在整个算法中只会被访问常数次定位边界时访问一次组内反转时访问一次。所以总的操作次数大约为 2n属于线性级别。空间复杂度方面迭代版是 O(1)只用了几个指针变量不随链表长度增长。递归版的时间复杂度同样是 O(n)因为每个节点也只会被常数次操作。空间复杂度是 O(n/k)也就是递归的深度。在最坏情况下当 k 2 时n/k 约等于 n/2空间复杂度仍为 O(n)这就是我在前面提醒过的“递归可能导致栈溢出”的场景。这个复杂度结论我记得非常清楚因为有一次面试里面试官追问“你能把空间复杂度降到 O(1) 吗”我当时第一反应是“递归已经 O(1) 了啊”被他指出来递归栈也算额外空间。这个教训让我学会了把“代码里显式分配的变量”和“函数调用栈隐式占用的空间”分开计算。6.2 面试现场的沟通与扩展变形面试时这道题最常见的变体有三种第一种k 2的情况。这就是 LeetCode 24 题“两两交换链表中的节点”可以说是本题的一个特例。如果你能流畅地写出 K 个一组翻转面试官大概率会让你顺手写一下 k2 的特化版本考察你是否理解了题目之间的关联。第二种从链表尾部开始每 K 个一组翻转。这个变体有个很聪明的通用做法先把整个链表反转一次然后用常规的 K 个一组翻转处理处理完再整体反转回来。这个思路的核心是题目要求“尾部优先”但链表的遍历方向只有从头到尾所以用一个额外反转把尾部变成头部。第三种不足 K 个也要翻转。这个更简单只需要删掉“判断剩余节点是否够 K 个”的逻辑直接无脑反转即可。这种变体在业务代码里比较常见比如某些批量处理场景中不足一批的数据也需要处理而不是丢弃。除了变体本身面试官还喜欢在实现细节上追问。比如“如果这个链表是一个循环单链表怎么处理”思路是先判断是否有环再考虑翻转后尾节点的 next 应该指向哪里。又比如“如果链表很长怎么做内存优化”那就应该选择迭代版本并且尽量用局部变量而不是创建新的链表节点。6.3 相关题型与延伸学习如果你把这道题吃透了以下几个题都是你的“射程范围”LeetCode 206反转链表本题的基础功底LeetCode 92反转链表 II限定区间的反转LeetCode 24两两交换链表中的节点本题的特例LeetCode 25本题原题LeetCode 143重排链表综合运用找中点、反转后半段、合并链表。我个人刷题的习惯是每做一道链表中等难度以上的题都会把相关的基础操作重新手写一遍。链表这个数据结构就是“会者不难难者不会”关键不在看多少题解而是把指针变化的每一步都画在纸上直到形成肌肉记忆。最后分享一点个人体会。这道题我前前后后刷了不下十遍每一次重新写都有新的理解。第一次写的时候我在prev和group_head的更新上卡了整整一个下午后来在纸上画了五六遍才真正搞懂。第二次面试遇到它的时候我已经能先跟面试官把思路讲清楚再花五分钟把代码一次写对。如果你现在正被这道题折磨说明你正处于“从看懂到写对”的突破期。这种时期最好的突破方式不是反复看题解而是把代码合上自己在白纸上手动模拟一遍假设链表是1 - 2 - 3 - 4 - 5k 2每一步prev、group_head、tail、next_group这四个指针分别指向哪里写下来再对照代码验证。这个过程走通了这道题才算真正是你的而不是题解的。