K个一组翻转链表:链表反转与指针操作的进阶实战指南
2026/9/7 19:00:38 网站建设 项目流程

1. 写在前面:这道题我为什么建议每个学链表的人都刷三遍

先直接亮明观点:LeetCode 25(K个一组翻转链表)不是一道普通的链表题,它是检验你链表基本功是否扎实的试金石。刷LeetCode到中后期,很多朋友会发现,面试官特别喜欢拿链表题来考察候选人,原因很现实:链表操作不像数组那样可以靠下标随便跳,每一步指针调整都得你手动维护,稍微一个顺序写错,整个链表就成环了。而K个一组翻转链表,恰好把指针操作中最容易踩坑的几个点——局部反转、断链重连、哨兵节点——全部集中在一起考。你如果能把这道题写出一个无bug的版本,并且能讲清楚每一行代码存在的理由,链表面试这一块基本就稳了。

这道题解决的是这样一个场景:你有一个单链表,从头节点开始,每K个节点为一组,把每一组内部的节点顺序完全翻转,然后组与组之间保持原来的先后顺序。例如1->2->3->4->5,当K=2时结果是2->1->4->3->5;当K=3时结果是3->2->1->4->5。注意最后剩余的节点如果不足K个,就保持原样不动。听起来好像不难,但真上手写的时候,你会发现翻转一组容易,翻转完一组还要和前面、后面的节点正确连接,这就不是一回事了。

本篇文章就是围绕这个核心场景展开的,适合正在刷LeetCode的求职者、复习数据结构的大学生,以及对链表操作一直模模糊糊、想彻底搞懂反转类题型的开发者。我会把这道题的思路拆解、完整代码、易错点、变体题目,包括我实际调试过程中踩过的坑,全部整理出来,尽量让你看完之后能一次性吃透。

2. 题目拆解:K个一组翻转到底在考什么

2.1 核心需求解析与关键词定位

我们先说透题目的几个关键词。第一个是“K个一组”,它的意思是分组动作,从链表头部开始连续数K个节点算一组。这里要注意,不是让你把链表按K个一组先划分好再翻转,而是边遍历边分组边翻转,因为链表节点不可能像数组那样提前知道长度,所以你得先探测这K个节点够不够,不够就直接结束。第二个关键词是“翻转”,也就是把节点的next方向完全反转。第三个关键词是“组间保持原顺序”,意思是第一组翻转完之后,第二组仍然要跟在第一组后面,不能把整条链表全部翻成逆序。

这道题最经典之处在于,它同时考察了两件互相冲突的事情:分组需要顺序遍历,而翻转又需要逆序操作。你必须在同一个指针移动过程中完成这两个动作,而且不能弄丢任何一个节点。很多第一次做这道题的朋友,卡就卡在“翻转完一组之后,怎么把指针重新定位到下一组的起始位置”这个问题上。

2.2 常见误区:先统计长度与原地翻转的取舍

有一个很自然的思路是先遍历一遍链表,统计出总长度,然后算出共有多少组,再按组去翻转。这个思路本身没有错,但如果你真的先统计长度,就会多一次O(n)的遍历,总的时间复杂度依然是O(n),空间复杂度也是O(1),其实是可以接受的。问题在于,很多人在统计完长度之后,就开始写反转逻辑,写到一半发现还要维护一个“当前组的前一个节点”和“当前组的第一个节点”这两个指针,手忙脚乱之下就把代码写复杂了。

我个人建议不要走“先统计长度”这条路,原因有两个。一是它会让代码多一层嵌套逻辑,你需要在循环里维护“还剩几组”的计数器,组与组之间的连接一旦写错,排查起来很费劲。二是这道题本来就有一个更优雅的解法:边遍历边检查剩余节点是否够K个,够就翻转,不够就停止。这样做的好处是,你不需要知道链表的长度,只需要在一个while循环里重复执行“检查、翻转、连接”这三个动作。

2.3 进阶约束:O(1)额外空间意味着什么

题目还有一个进阶要求,只能使用常数额外空间。这意味着你不能把节点值拷贝到一个数组或者List里,翻转完再放回去。说实话,如果允许用O(n)空间,这道题会简单很多,你把链表转成数组,按K个一组反转数组,再重建链表就行了。但采用这种方式,你的代码可能过得了测试,却练不到链表指针操作的核心能力,面试中如果追问一句“能不能不用额外空间”,就会露馅。

O(1)空间要求你只能通过调整节点的next指针来达成翻转,这就强制你深入理解链表指针的语义。链表和数组最大的区别也在这里,数组可以通过下标随机访问任何元素,链表则必须从head开始逐个遍历;数组交换元素可以直接用临时变量,链表交换节点则要操作多个前驱和后继的指针。搞懂这道题之后,你对链表数据结构的理解会上一个台阶。

3. 核心思路:从整体到局部的反转策略

3.1 链表的局部反转模板

在写K个一组翻转之前,你要先掌握一个更基础的模板:反转单链表的一个区间。反转整条链表你可能已经写过无数次了,核心代码一般是三行:

ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; }

这个模板的逻辑是:用prev记录已反转部分的前驱,用curr指向当前要处理的节点,提前用next保存curr原本的下一个节点,然后把curr.next指向前驱,最后三个指针整体向后移动。理解了这个基础模板,反转一个区间就很简单,只要把终止条件从curr != null改成“已经处理了K个节点”就行。K个一组翻转的核心,其实就是把这个基础模板在链表的每一段上重复执行,同时处理好段与段之间的连接。

3.2 分组的四个阶段:找段、断连、反转、重连

我把整个K个一组翻转的过程拆成四个阶段,你按这个思路去写代码,思路会非常清晰。

第一个阶段是“找段”。你需要用一个指针从当前组的起始位置开始,向后走K步,看看能不能走满K步。如果途中遇到了null,说明剩余节点不足K个,直接结束整个流程。如果走满了K步,你就知道了这一组的头节点和尾节点。

第二个阶段是“断连”。你不需要真正把这段链表从整体上剪下来,但你要在思维上把它当做一个独立的子链表来处理。这里有一个关键操作:先记录下当前组的头节点start,以及当前组的头节点的前一个节点prev,这样翻转完之后,你才知道应该把谁接到谁后面。

第三个阶段是“反转”。对start开始的K个节点执行标准的链表反转。反转完成后,原本的start节点变成了这一组的尾节点,原本的尾节点变成了这一组的头节点。

第四个阶段是“重连”。把prev.next指向翻转后的新头节点,再把start.next指向下一组的起始节点。这步很容易被忽略,如果不重连,链表就会在这里断开或者形成错误的指向。

这四个阶段画出来就是一个循环,只要还有足够K个节点,就不断重复。每次循环结束时,把prev更新为start(也就是翻转后这一组的尾节点),把下一组的起始节点作为新的start继续处理。

3.3 为什么需要虚拟头节点

这里我要重点强调一个经验:处理链表类题目,尤其是头部可能发生变化的题目,你最好创建一个虚拟头节点,让它的next指向真正的链头。这个虚拟头节点通常命名为dummy,它本身不存储有效数据,唯一的作用是帮你统一处理边界情况。

在K个一组翻转中,链表头节点也可能被翻转,比如1->2->3->4,K=2时结果变成2->1->4->3,原链表的头节点1变成了第二个节点。如果你不引入dummy节点,翻转之后你要额外判断“当前组的prev是不是null”,然后单独更新head,代码会多出很多分支,非常容易出错。有了dummy之后,整个过程统一为:dummy.next始终指向新的头节点,最后一并返回dummy.next即可。尤其要注意,dummy节点的值随便初始化,它只是占位,你永远不会真正读到它的值。

4. 手写实现:完整代码与关键步骤逐段解析

4.1 一个标准的迭代实现(Java版)

直接给出我多次调试后认为最清晰的一个实现,你本地跑LeetCode用例可以直接通过。

class Solution { public ListNode reverseKGroup(ListNode head, int k) { if (head == null || k <= 1) { return head; } ListNode dummy = new ListNode(0); dummy.next = head; ListNode prev = dummy; while (true) { // 1. 检查剩余节点是否足够K个 ListNode check = prev.next; int count = 0; while (count < k && check != null) { check = check.next; count++; } if (count < k) { break; // 剩余节点不足K个,不翻转 } // 2. 此时prev.next是这一组的头节点,记为start ListNode start = prev.next; ListNode curr = start; ListNode prevNode = null; // 3. 反转从start开始的K个节点 for (int i = 0; i < k; i++) { ListNode next = curr.next; curr.next = prevNode; prevNode = curr; curr = next; } // 4. 连接:prev.next指向翻转后的新头节点,start.next指向下一组的头 prev.next = prevNode; start.next = curr; // 5. 移动prev到下一组的前驱位置 prev = start; } return dummy.next; } }

4.2 逐段解读这段代码在做什么

我们一行一行过一遍。首先处理两个特殊的边界条件:链表为空,或者K小于等于1。如果K等于1,翻转一组等于没翻转,直接返回原链表即可。然后创建dummy节点,让prev指针最开始指向dummy。这里prev的意义是“当前要翻转的这一组的前一个节点”,因为在最开始时,第一组的前一个节点就是dummy。

进入while (true)循环后,第一件事是检查剩余节点够不够K个。我用check指针从prev.next出发,走K步,如果中途遇到null,说明剩余节点不足K个,直接break退出循环。这里有一个细节,check指针走了K步之后,它指向的是这一组尾节点的下一个节点,也就是下一组的头节点。这个信息在后面重连时非常有用,因为翻转完之后,curr指针恰好也指向这个位置,所以curr可以直接作为“下一组的头节点”来使用。

确认够K个之后,记录start为prev.next,也就是这一组的第一个节点。然后开始标准的K个节点的反转循环,这个循环和反转整个链表的模板完全一样,只是循环次数从“直到节点为null”变成了固定的K次。反转结束后,prevNode指向的是这一组的新头节点,也就是原组尾节点,curr指向的是下一组的头节点。

重连阶段是这段代码的精华:先执行prev.next = prevNode,把前一个节点指向翻转后的新头节点,再执行start.next = curr,把这一组翻转后的尾节点(也就是原start)指向下一组的头。这两行代码的顺序不能反,否则你会丢失指向下一组头节点的引用。最后把prev更新为start,因为start现在是这一组翻转后的尾节点,而它就是下一组的前驱。整个循环继续,直到剩余节点不足K个为止。

4.3 另一种思路:定义辅助函数反转区间

如果你觉得上面的写法在一个大循环里同时做检查和反转,看着有点乱,也可以把它拆分成两个方法。一个方法负责“找到从指定位置开始、长度为K的子链表”,另一方法负责“反转从begin到end的子链表”。这种方法在可读性上更好,面试时可以边写边讲,思路更清楚。

class Solution { public ListNode reverseKGroup(ListNode head, int k) { if (head == null || k <= 1) return head; ListNode dummy = new ListNode(0); dummy.next = head; ListNode prev = dummy; while (true) { ListNode start = prev.next; ListNode end = start; int count = 1; while (count < k && end != null) { end = end.next; count++; } if (end == null) break; ListNode nextGroup = end.next; reverseRange(start, end); prev.next = end; start.next = nextGroup; prev = start; } return dummy.next; } private void reverseRange(ListNode start, ListNode end) { ListNode prev = null; ListNode curr = start; while (prev != end) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } } }

这段代码的思路是先把这一组的头节点start和尾节点end找出来,把反转部分封装成一个独立方法。注意reverseRange的循环终止条件是prev != end,也就是说,当curr移动到end后面的第一个节点时,反转正好结束。使用这种方式时,你不需要关心K具体是几,也不需要一层层数节点,只要你确保传入的start到end之间恰好有K个节点就行。

我自己在实际刷题过程中推荐你先写第一种迭代版本,因为它的变量更少、状态更集中,一旦你完全理解了prev和start的移动规律,再看第二种版本只是编码风格上的差异。两种版本的时间复杂度都是O(n),空间复杂度都是O(1),面试时写哪一种都可以。

5. 常见问题与排查技巧实录

5.1 指针更新顺序混乱导致的死循环

我见过最多的问题,是在反转K个节点的循环里没有提前保存curr.next。你写反转链表时,必定要先写一句ListNode next = curr.next,否则当执行完curr.next = prev之后,原来的下一个节点就找不到了。这个问题看起来低级,但只要你连续写了很多道题、状态疲劳,很容易漏掉。漏掉之后,链表会形成一个环,程序进入死循环,LeetCode会报超出时间限制。

我的建议是,任何时候写链表节点的指针更新,都养成一个固定习惯:先保存后继,再改指针。这个习惯怎么写都不会错,因为链表节点的next属性是唯一的、确定指向某个节点的引用,一旦修改就再也找不回原来的节点了,除非你提前保存。

5.2 最后一组不足K个时如何正确结束

另一个高频错误是在最后一组不足K个时仍然强行翻转,导致输出结果完全不对。这里的关键在于“检查剩余节点是否足够K个”这一步的位置。你必须把它放在每次循环开始的时候,也就是在翻转之前检查,而不是翻转之后再判断。如果翻转完之后再判断,你都已经把节点反转了,再想恢复原状就很麻烦。

还有一个细节,检查节点的循环中,check指针需要从prev.next开始走K步。有些朋友会把check初始化为prev,然后走K+1步,结果也是对的,但容易造成困惑。统一从prev.next开始走K步是最直观的写法,你数一下代码里check指针每走一步移动到了哪里,就能确定它指向的是下一组的头节点还是null。

5.3 常见错误速查表

我把这道题最常见的几个错误整理成了一张表,你可以对照检查自己要写的代码里有没有这些问题。

错误类型具体表现解决方案
漏存后继执行curr.next = prev后找不到下一节点第一行先写ListNode next = curr.next
组间不连接翻转完第一组,第二组接不上重连阶段执行start.next = curr
检查时机错误先翻转后检查,最后一组被误翻转每次while循环先检查再翻转
返回节点错误从头节点而非dummy.next返回结果返回dummy.next,dummy用于统一边界
指针更新遗漏翻转完没把prev移动到新尾节点每轮循环结束时prev = start
没有处理k为1翻转逻辑混入,代码变复杂开头判断k <= 1直接return head

这张表你可以贴在本地笔记里。我做面试模拟的时候发现,面试官最常问的一个问题就是你如何保证最后一组不足K个时不翻转,这时你只要说清楚“循环开头检查,不足则退出”,基本就能让面试官满意。

5.4 复杂度解析:为什么是O(n)时间、O(1)空间

这道题的时间复杂度非常有意思。表面上看,你有一个外层循环,里面还有一个走K步的检查循环和一个走K步的反转循环,好像复杂度是O(nK),但实际上每个节点最多被访问两次:一次在检查时,一次在反转时。所以总的时间复杂度是O(n)。这里n是链表的节点总数。

空间复杂度方面,如果你用迭代写法,只额外创建了dummy节点和几个指针变量,它们占用的空间是固定不变的,所以空间复杂度是O(1)。这也是这个算法的最大优势,你不需要把节点数据拷贝到额外数组或栈里,完全靠指针操作完成所有翻转。如果你用递归写法,递归栈会消耗O(n/K)的空间,严格来说就不满足O(1)的进阶要求了,所以面试时如果要体现对空间复杂度的把控,优先写迭代版本。

6. 扩展与进阶:一道题吃透链表反转类题型

6.1 递归写法:思路简洁但空间不达标

既然说到递归,我简单提一下递归版本,因为它的代码写法确实很漂亮。递归的核心思想是:先把前K个节点看作一组,反转这一组,然后递归处理剩余链表。具体写法是,先找到第K+1个节点newNext,递归调用reverseKGroup(newNext, k),得到后续已经翻转好的链表,然后反转前K个节点,把它们和递归返回的结果连接起来。整个过程非常简洁,像数学归纳法一样,base case就是剩余节点不足K个时直接返回原链表。

class Solution { public ListNode reverseKGroup(ListNode head, int k) { ListNode start = head; int count = 0; while (count < k && start != null) { start = start.next; count++; } if (count < k) return head; ListNode prev = null; ListNode curr = head; for (int i = 0; i < k; i++) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } head.next = reverseKGroup(curr, k); return prev; } }

递归版代码长度更短,思路也更直接,但它的空间复杂度是O(n/K),因为每一层递归都会占用栈空间。在实际生产环境中,如果链表特别长,递归有栈溢出的风险,因此我更推荐迭代版本作为主解法,递归版本作为理解辅助。

6.2 变体题目:LeetCode 24两两交换链表中的节点

这道题还有一个非常经典的简化版变体,K=2,也就是LeetCode 24。两两交换链表中的节点。它的做法几乎和K个一组翻转一模一样,本质上是K个一组翻转在K=2时的特例。很多朋友先做了LeetCode 24再来看LeetCode 25,会觉得很亲切;反过来,如果你先做通了LeetCode 25,再回头去看LeetCode 24,几乎可以秒杀。

LeetCode 24的迭代写法可以直接套用K个一组翻转的骨架,唯一的区别是K固定为2。你可以自己把上面的泛型写法中的k改成2,然后运行,会发现代码依然成立。这就是泛型算法的好处,当你写一个适配任意K的版本时,一些特定的K值题目就成了你的囊中之物。

6.3 链表反转题的通用方法论

刷完这道K个一组翻转之后,我建议你总结一套自己的“链表题方法论”。根据我个人经验,链表反转类问题无论怎么变,核心就两件事:一是明确要操作的区间,二是明确区间前后的连接点。区间内部的翻转永远是那三行核心代码,不会变;区间与外部连接时,你始终要关注“前驱节点”和“后继节点”这两个锚点。

为了便于记忆,我抽象出五个步骤,你做题时可以按这个顺序思考:

  1. 判断是否需要虚拟头节点,只要头节点可能变,就用dummy。
  2. 找到区间的前驱prev和区间的起始起点start。
  3. 判断区间是否存在,不存在就退出。
  4. 用标准反转模板翻转区间内的节点。
  5. 连接prev和start,把指针移动到下一轮循环。

这五个步骤适用于所有类似“按组反转”“区间反转”“隔节点反转”的题目。比如LeetCode 92反转链表II,其实就是给定m和n,反转一个区间,同样可以套用这套方法论。你在刷题串讲时如果能把这几道题放在一起对比练习,效果会非常好。

7. 说点题外话:这道题对我的实际影响

最后说一个我个人练习时的体验。我第一次做这道题的时候,写出来第一版代码跑了半天也没跑对,最终发现自己在一个愚蠢的地方卡了很久:我没有在反转循环之前检查剩余节点数,导致最后一组只有两个节点时,我强行反转变成了错误顺序。那时候我就意识到,链表题考的不只是你会不会写反转,更考你在动手之前有没有把整个流程推演清楚。

从那以后,我养成了一个习惯:所有链表题,不管简单困难,先画一张指针移动的示意图,把每一轮循环结束之后各指针的位置标出来,再开始写代码。虽然这个习惯在笔试时可能有点浪费时间,但它能有效避免那种“代码看起来没问题但运行结果一团糟”的情况。尤其是K个一组翻转这种需要多个指针配合的题,提前推演一遍,代码基本一遍过。

如果你现在正在准备面试,我真心建议你把这篇文章里的代码手写三遍。第一遍照着抄,第二遍合上文章默写,第三遍尝试自己讲给别人听。三遍之后,你会发现链表反转类题目再也没有什么可怕的地方了。等你熟练掌握这道题,你甚至可以尝试挑战它的进阶改版:交替翻转、K个一组逆序输出等,那时候你的链表操作已经进入了高级应用的层次。刷题不是目的,真正的目的是通过一道题,掌握一类题的通用解法,这才是LeetCode题目设计的价值所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询