news 2026/9/28 6:18:26

LeetCode 25 K个一组翻转链表:递归与迭代边界详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 25 K个一组翻转链表:递归与迭代边界详解

1. 为什么很多人栽在LeetCode 25:这道题真正考的并不是反转

刷到LeetCode 25「K个一组翻转链表」之前,很多人其实已经能把反转链表、两两交换写得挺顺了。但到了这道题,突然就卡住:反转我会啊,K个一组我也懂啊,怎么拼在一起就不对?说实话,这个题是链表题目里的分水岭,它真正考你的不是"能不能写出反转",而是你能不能把一个连续的动作按组切分成可独立执行的单元,再把这些单元安全地串回一条完整的链。

先看题目要求:给你一个链表,每K个节点一组进行翻转,最后不足K个的保持原顺序。这里的难点藏在两处。第一,"每K个一组"意味着你脑子里不能只有"头节点"的概念,必须同时管住每一组的前驱、头、尾、下一组的头,这四个位置漏掉任何一个,链表就会断在中间。第二,"不足K个不翻转"这个条件,需要在循环或递归的每一轮开始前做一次长度判断——这个判断写错,要么把最后一小段也翻了,要么整条链根本走不到终点。

很多人的失败路径是:直接套用单链表反转的代码,然后在外层套一个while,结果发现反转完一组之后找不到下一组的位置了。这是典型的"工具正确、方法错误"——你把整条链的反转函数用在一个子段上,它会把子段之后的所有节点全部卷进去,这就是断链的最常见原因。所以我在本文反复提示一个关键认知:这道题的核心操作不是"反转链表",而是"reverse N"——精确地反转前N个节点,并且让反转后的尾巴恰好停在下一组的入口,不多吞一个节点,也不少连一个节点。

这篇文章的结构也围绕这条主线展开:先讲清楚为什么"反转整条"和"反转前N个"是两个难度级别,再给出分组的心智模型,最后分别用递归和迭代把它落地。我会把边界条件和断链现场单独拿出来讲,因为我把这些年刷题、面试、看别人代码的典型翻车姿势都汇聚在这里。不管你是准备面试还是想彻底吃透链表操作,顺着这条主线走,应该能比单纯背答案多得一些真正的掌控感。

2. 地基工程:从反转整条链表到「反转前N个」,差别只在一条指针

要理解LeetCode 25,得先确认地基稳不稳。链表反转这个动作本身不复杂,但很多人的代码只是"背下来了",并没有真正理解每一步指针的移动规律。这里先把最基础的单链表反转写出来,然后在此基础上升级出"反转前N个"的两种形态。

2.1 基础操作:三指针迭代反转整条链表

def reverse_list(head): prev = None curr = head while curr: nxt = curr.next curr.next = prev prev = curr curr = nxt return prev

这段代码的核心思路,用一句话说就是:每走一步,把当前节点的next从"后面的节点"改成"前面的节点"。prev先指向None,表示新链表的终点;curr从head出发,nxt暂存curr原本的下一个节点,防止改完next之后找不到路;然后prev和curr整体右移。循环结束时,prev恰好停在原链表的最后一个节点上,也就是新链表的头。

这个函数没问题,但它有一个隐含的"坏毛病":它会把整条链从头反转到None为止。如果你只打算反转前半段,它会把后半段也一并卷走。所以在K个一组翻转的场景里,绝对不能直接把这个reverse_list用在子段上,除非你先手动切断子段与后续节点的连接。这个"切断"的需求,就是引出"reverse N"概念的开关。

2.2 第一次升级:反转前N个,并把尾巴接回第N+1个节点

现在需求变了:只反转前N个节点,后面的节点原封不动。例如链表1 -> 2 -> 3 -> 4 -> 5,N=3,目标是3 -> 2 -> 1 -> 4 -> 5。这时你会发现,代码和reverse_list很像,只是在收尾时多了一步:需要找到原来的第N+1个节点curr,并把反转后的尾部(也就是原来的head)接到它身上。

def reverse_first_n(head, n): prev = None curr = head for _ in range(n): nxt = curr.next curr.next = prev prev = curr curr = nxt head.next = curr # head现在是反转段的尾部,把它和后续链表接上 return prev

这里最关键的一行是head.next = curr。循环结束后,curr刚好站在第N+1个节点上(如果链表的长度恰好等于N,那curr就是None)。head经反转后已经变成这段子链的最后一个节点,只有把它的next指向curr,才能保证"后面的节点保持原顺序"。这一步是"反转整条"和"反转前N个"的本质区别——你必须明确告诉这段子链:你的尾巴连向哪里。

提示:这里的隐藏前提是,调用前必须确定head之后至少有N个节点。在LeetCode 25的主函数里,我们会先做"数K步"的判断,确认数量够,再调用这个函数,所以可以放心。

2.3 第二次升级:以边界节点收尾的tail-bounded reverse

上面reverse_first_n的写法已经能够完成K个一组翻转,但还有一个更优雅的变种,我强烈建议你掌握,因为它能让你彻底摆脱"切断与重接"的额外操作。思路很简单:既然已经知道第N+1个节点是谁,不如直接把它作为反转的边界传进去。

def reverse_between(head, tail): prev = tail # prev从边界节点开始,而不是从None开始 curr = head while curr != tail: # 走到边界就停 nxt = curr.next curr.next = prev prev = curr curr = nxt return prev

这个函数反转区间[head, tail),注意tail本身不参与反转,它是下一组的起点。为什么prev要从tail开始?因为第一个被反转的节点是head,它的next会被改成prev,而prev初始时正好指向tail,这一改就天然完成了"反转后的尾巴指向下一组起点"的连接。后面每处理一个节点,都是同样的逻辑,直到curr走到tail停止。最终返回的prev是反转后的新头。

对比一下你就明白了:reverse_first_n需要在循环结束后额外写一行head.next = curr,而reverse_between把这一步化进了prev = tail的初始化里。这个在LeetCode 25里可以少写一行容易错的代码。我之前面试时问过不少候选人,大多数都能写出第一种,但第二种能顺手写出来的人,说明是真的理解了"反转一个子段"的边界本质。所谓reverse N的成熟形态,就是让反转操作在正确的边界处停止,并让边界两侧的指针关系保持完整。

3. 分组心智模型:每一轮循环只要做四件事

在写递归或迭代代码之前,先把"K个一组翻转"的整体流程在脑子里过一遍。我建议把每一组的处理抽象成四件事:数出K步、暂存后继、反转子段、重接前后。

用个生活化的类比:想象一列火车有若干节车厢,现在要按每K节一组重新编组,每组内部的车厢顺序倒过来排,但组与组之间的顺序不变。你要做的其实是:先数出这一组有几节车,把队伍从第K+1节那里拦腰断开,让这一组的车厢原地掉头,然后再把掉头后的组拼回整列火车。

对应到链表操作,每一轮需要盯住的节点有四个:

pre 上一组的最后一个节点 start 本组的第一个节点 end 本组的最后一个节点 next_group 下一组的第一个节点

比如链表1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7,K=3。第一组里start=1,end=3,next_group=4。反转完第一组,链表变成3 -> 2 -> 1 -> 4 -> 5 -> 6 -> 7。此时pre变为原来的start,也就是新的1节点,它是上一组的尾部;第二组的start=4,end=6,next_group=7。反转后变成6 -> 5 -> 4 -> 7。第三组只有一个节点7,不足K个,保持原序。

这个模型的强大之处在于:所有组都遵循同一套规则,唯一需要单独处理的只有"不足K个"这个终止条件。判断方法很朴素:从当前起点出发,尝试走K步,走不到第K步就遇到了None,说明剩余节点不够一组,直接收尾。这个判断在递归版和迭代版里几乎一样,只是放的位置不同。

接下来我分别给出递归版和迭代版的完整实现。两者共享同一个"子段反转"的函数,区别只在于如何处理组与组之间的推进:递归靠函数调用的参数传递自动推进,迭代靠手动维护pre指针推进。

4. 递归实现:先反转当前组,再把后续直接交给函数自己

递归版很推荐作为第一个能跑通的版本,因为它的代码结构非常短,而且思路高度贴合"分组"这个概念。核心想法:先数出K个节点,如果不够K个直接返回head;如果够K个,就反转这K个节点,然后把"从这个K个节点之后的部分"递归处理,最后把两段接起来。

4.1 完整代码

class Solution: def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]: cur = head cnt = 0 while cur and cnt < k: cur = cur.next cnt += 1 if cnt < k: return head new_head = self.reverse_between(head, cur) head.next = self.reverseKGroup(cur, k) return new_head def reverse_between(self, head, tail): prev = tail curr = head while curr != tail: nxt = curr.next curr.next = prev prev = curr curr = nxt return prev

4.2 逐段拆解:四个步骤各司其职

第一步,cur从head出发走K步。循环退出时有两种可能:要么cur变成了None(说明head后面不足K个节点),要么cur恰好指向第K+1个节点。如果cnt < k,说明这一组凑不满,按照题目要求保持原序,直接返回head即可。这里有一个很容易疏忽的细节:cnt < k的判断不能省,否则当剩余节点不足时,你依然会对一个短子段强行反转,返回的新头会让整条链错乱。

第二步,调用reverse_between(head, cur)反转[head, cur)这一段。前面已经讲过,这个函数把prev初始化为cur,所以反转完之后,原head(现在变成了这一段的新尾巴)会自动指向cur。这省去了手工连接尾部与下一组的操作。

第三步,head.next = self.reverseKGroup(cur, k)。这一行是递归版的精华。head是当前组反转后的末尾,它现在虽然已经指向了cur,但cur之后还没处理。递归调用传进去的参数cur,正是下一组的起点。递归返回后,下一组内部已经完成翻转,返回值是下一组的新头,把它接到当前组的末尾上。如果你把这行误写成new_head.next = self.reverseKGroup(cur, k),就会丢掉new_head这个头部,因为new_head是当前组反转后的头,它应该作为整条结果链的进入点返回给上层,而不是挂在下面当尾巴。

第四步,返回new_head,也就是当前组反转后的新头。对于最外层调用来说,这就是整条链表翻转后的头节点。整个递归过程像接力棒一样:每一层只负责自己的K个节点,剩下的交给下一层,最终一层一层接回来,形成完整链表。

4.3 用一个具体例子走一遍递归

拿1 -> 2 -> 3 -> 4 -> 5,K=2来演算。

  • 第一层:cur走两步,指向3。调用reverse_between(1, 3),把1 -> 2反转为2 -> 1,此时1.next指向3。然后调用self.reverseKGroup(3, 2)处理3 -> 4 -> 5。
  • 第二层:从3出发走两步,cur指向5。调用reverse_between(3, 5),把3 -> 4反转为4 -> 3,3.next指向5。再调用self.reverseKGroup(5, 2)处理5。
  • 第三层:从5出发走两步,但只走一步就碰到None,cnt < 2,直接返回5。
  • 回到第二层:第三层返回5,于是3.next = 5,本层返回4。
  • 回到第一层:第二层返回4,于是1.next = 4,本层返回2。

最终结果2 -> 1 -> 4 -> 3 -> 5,和题意完全一致。注意第三层只返回了5,因为它不足K个,保持原序。如果你把这个例子自己动手画一下指针箭头的变化,对递归的理解会深很多。

4.4 递归版的时间与空间复杂度

时间复杂度是O(n),因为每个节点最多被访问两次:一次在数K步的循环里被cur经过,一次在被反转时被curr经过,整体是线性扫描。空间复杂度方面,递归深度等于组的数量,大致是n/k层,每层只有常数个变量,所以空间O(n/k)。最坏情况下如果K接近1,深度会退化为O(n)。这在实际工程里通常没什么问题,但如果面试官明确要求O(1)空间,你就需要切到迭代版。

5. 迭代实现:哨兵节点加pre指针,把每一组串成流水线

递归版虽然清晰,但很多面试官会追问一句:"你能用迭代写吗?"迭代版的优势是空间O(1),而且代码执行更直接。代价是你必须手动管理一个哑元节点(dummy),并且每一步的指针更新都不能乱。

5.1 完整代码

class Solution: def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]: dummy = ListNode(0) dummy.next = head pre = dummy while True: end = pre for _ in range(k): if end.next is None: return dummy.next end = end.next start = pre.next next_group = end.next end.next = None pre.next = self.reverse_list(start) start.next = next_group pre = start def reverse_list(self, head): prev = None curr = head while curr: nxt = curr.next curr.next = prev prev = curr curr = nxt return prev

5.2 为什么必须先有一个dummy哨兵节点

链表的第一个节点没有前驱,翻转第一组时,你需要把新头挂回某个位置。如果直接用head指针,第一组翻转后head到底指向谁是模糊的。dummy节点的作用就是给整个链表一个统一的"前驱",让所有组都遵循同一套处理逻辑:本轮翻转后,把结果挂在pre.next上。最后返回dummy.next,才拿到真正的链表头。

哨兵节点这个技巧在链表题里极其常用。不只是"K个一组翻转",凡是要操作链表头部、或者头节点可能变化的问题,先加一个dummy都能省掉大量的特殊判断。面试时说出"用dummy统一头节点的边界处理",本身就是加分项。

5.3 每轮循环的四步操作

第一步,从pre出发走K步找end。这里的pre初始是dummy,走K步之后end指向第一组的最后一个节点。如果中途end.next已经是None,说明剩余节点不足K个,此时整条链表已经处理完毕,返回dummy.next。为什么不是走完K步再判断?因为如果链表刚好只剩K个节点,end会走到最后一个节点上,end.next为None,这属于"正好够一组"的合法情况,也应该翻转;所以判断必须在下一步之前进行,但必须在for循环的每一次检查end.next,理由留到下一条再讲。

第二步,记录start = pre.next和next_group = end.next,然后把end.next置为None。这一步容易被忽略,但它非常关键。start是本组第一个节点,也是反转后将成为本组最后一个节点的节点;next_group是下一组的头。end.next = None的作用是隔离本组,让接下来调用的reverse_list只作用于[start, end]这一段。如果没有这行,reverse_list会沿着end.next一路反转下去,把下一组甚至后面所有组都吞进去,最终链表变成一团乱麻。

第三步,pre.next = self.reverse_list(start)。reverse_list返回反转后本组的新头,也就是原来的end。挂到pre.next上,相当于接通了上一组与本组的连接。此时start变成了本组的尾巴,且它的next是None(因为上面断开了)。

第四步,start.next = next_group,把本组尾巴和下一组的头接上。到这里,这一组才算完整地焊回了链表。最后更新pre = start,因为start现在是本组的最后一个节点,对下一组来说,它正是"上一组的尾部"。

你可能已经注意到,每轮循环重复的动作完全一致,不会因为位置不同而改变。这就是迭代版的漂亮之处:dummy + pre指针让所有组看起来都一样。

5.4 迭代和递归怎么选

从我自己的刷题经验来看,第一遍理解用递归更快,但最终上考场或者面试手写,我优先写迭代。原因有三:一是迭代空间O(1),面试官不用为递归栈的深度操心;二是迭代的执行路径是直线型的,不容易因为递归层数太深而晕;三是面试中面试官经常在迭代代码基础上提问"如果尾段不足K也要翻转怎么办"、"能不能只改一行实现某种变体",迭代的改动点更集中。

但递归也值得掌握,因为它能锻炼你"把规模缩小的问题扔给函数自己"的思维方式,这种思维在其他链表递归题里非常有用。如果你时间有限,我建议至少做到:看着题目的输入,能立刻写出迭代版,并且能解释清楚每一行指针操作的意图。

6. 边界条件与断链现场:最常见的五个错误和排查思路

代码写对一遍不算本事,能自己排查断链才是真正理解。下面我把K个一组翻转最常见的几个错误场景整理出来,每个都对应一段真实的踩坑经历。

6.1 错误一:迭代版忘记断开end.next,下一组被整段吞掉

这个坑我见过太多次了。把end.next = None注释掉,跑测试时你会发现,链表后面所有的组全部被反转了,而且结果链出现环形引用,最终打印链表时会死循环或报错。原因前面说过:reverse_list是"反转整条链"的工具,它会一直走到None才停,如果end.next还连着下一组,它自然把下一组也算进去了。

排查思路很简单:在调用reverse_list之前打断点看start到end这段的长度是不是恰好K个;如果长度超过K,多半就是没断开。修复就是补上end.next = None这一行,没有别的花样。

6.2 错误二:递归版里把head.next错写成new_head.next

递归版最经典的手误。很多人在第三步会不假思索地写new_head.next = self.reverseKGroup(cur, k),理由是"新头后面应该接递归处理的结果"。这一写,当前组的新头就被扔到了链表深处,整个链表从第一组开始就缺了头,返回结果自然不对。

我建议在递归版里始终记住一句话:new_head是结果,head现在是尾巴。上层需要拿到的是"这一组翻转后的头",也就是new_head;而head作为尾巴,它的next要接上后续处理完的链表。先想清楚这两个指针的新身份,再写赋值语句,就不会犯这个错。

6.3 错误三:不足K个的末尾被无差别翻转

稍微改一下题目要求,比如"不足K个也翻转",实现上很简单:把递归版中if cnt < k: return head去掉,或者把迭代版中的if end.next is None: return dummy.next改成某种强制翻转的逻辑。但如果题目要求的是"不足K个保持原序",而你忘了这个终止条件,末尾就会被翻乱。

这里我提供一个调试技巧:写一个print_linked_list(head)函数,每处理完一组就打印一次当前链表状态。运行一个小规模输入比如1-2-3-4-5,K=2,如果打印出来末尾是1 -> 4而不是1 -> None,说明指针重接有问题;如果末尾变成3但顺序不对,说明终止条件有误。通过打印状态来定位,比盯着代码空想高效得多。

6.4 错误四:k=1导致递归栈溢出或无效循环

K=1时,每个节点单独成组,"翻转"一个节点等于什么都不做。结果应该直接返回原链表。递归版对这个case也能工作,但会无意义地递归N层,浪费空间。迭代版走K步检查时,end从pre出发,因为pre.next存在,能走一步,end = start,然后反转单节点,逻辑上没错,只是空转一轮。

如果你在意极端性能,可以在函数开头加一行if k == 1: return head。这不算特殊处理,而是一个合法的剪枝,面试时主动写出来能体现你对边界条件的敏感度。

6.5 错误五:空链表和单节点链表的静默失败

空链表即head为None,此时任何K的取值都不应该报错。迭代版里dummy.next = head,然后pre从dummy出发走K步时,第一步就会遇到end.next is None,直接返回dummy.next,也就是None,正确。但如果你没有用dummy,直接操作head,空链表会带来一堆额外的空指针判断。这也是为什么我特别推荐dummy写法。

单节点链表、K>1,直接返回该节点;K=1,也返回该节点。用上面的代码跑一遍都能通过。如果发现某些case特别容易出错,我建议列一个快速自测清单:空链表、单节点、K=1、K等于链表长度、K大于链表长度、链表长度恰好是K的整数倍。这个清单在面试前花两分钟过一遍,比反复通读代码更让人安心。

7. 面试延伸:从两两交换到任意区间反转的底层能力

LeetCode 25不是孤立的一道题,它和你刷过的其他链表题目(如LeetCode 24、LeetCode 92)共享同一套底层能力。如果你把本文的reverse_between彻底吃透,后面几个变体基本是顺手的事。

先说LeetCode 24,两两交换相邻节点。这就是K=2的K个一组翻转。你可以把K=2代入上面的递归版或迭代版,跑一下会发现结果完全正确。这也算是一种"用通用解干掉特例"的验证方法。面试时如果先被问到两两交换,你可以说"这道题等价于K=2的K个一组翻转",然后直接给出本文的迭代代码,会让面试官对你的抽象能力印象不错。

再说LeetCode 92,反转从位置left到right的若干节点。这道题的思路其实也是分组反转的变体:先把链表切成三段,中间那段用reverse_between反转,然后把三段接回去。很多人在92上头疼,原因和25是一样的——搞不定"反转一段之后怎么把前后接上"。如果你已经理解了reverse_between的边界设计,92的代码其实就是额外维护两个指针,非常直观。

还有一个面试官很喜欢改的条件:"如果末尾不足K个也要求翻转,怎么改?"递归版删掉if cnt < k: return head即可;迭代版需要在不足K个时,仍然把start到链表末尾这一段反转。不要小看这个改动,它考察的正是你对"终止条件"和"边界"的理解。

我个人在实际刷题中的体会是:链表题的价值不在于写出某一道题的答案,而在于把"指针边界感"训练出来。所谓边界感,就是你看到一段子链时,能立刻说出它的前驱是谁、后继是谁、反转之后谁是新头谁是尾巴。LeetCode 25恰好把这些要素全部揉在一起,所以我才劝你不要只背答案,而是把reverseN这个子能力单独拆出来练习。它不是一道题的技巧,而是一类题的地基。

最后再分享一个我常用的扩展练习:在纸上画出1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7,K=3的完整指针变化过程,每完成一组反转就画一张图。画完三张图,你会发现自己对链表指针的掌控力有明显的提升——这比多刷十道同类题更管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 6:18:00

验证栈序列:P4387栈模拟算法与线性复杂度解析

开头P4387【深基15.习9】验证栈序列&#xff0c;这个题号背后的“深基15”指的是洛谷《深入浅出基础篇》题单第15章“栈”的配套习题&#xff0c;而“验证栈序列”只有一句话&#xff1a;给定一个入栈序列和一个出栈序列&#xff0c;判断这个出栈序列是否真的能由入栈序列靠着栈…

作者头像 李华
网站建设 2026/9/28 6:17:37

FMQL45T900开发实战:ARM+FPGA协同设计与Procise工具链深度解析

1. 为什么选FMQL45T900——不是“国产替代”口号&#xff0c;而是真实工程约束下的技术收敛复旦微FMQL45T900开发板最近在工业控制、边缘智能终端和国产化信创项目里出现频率陡增&#xff0c;但很多人拿到板子第一反应是&#xff1a;这颗芯片到底算ARM还是FPGA&#xff1f;它和…

作者头像 李华
网站建设 2026/9/28 6:17:29

夏季饮品品牌联名视觉操盘全攻略:策略、执行与避坑

做一个品牌联名视觉项目&#xff0c;最怕的不是创意不够&#xff0c;而是所有人都喊着“我们要再掀夏日新风暴”&#xff0c;却没人说得清风暴从哪卷起、往哪卷。看到“椰语堂 X 尚谦设计”这个项目名&#xff0c;我的第一反应不是去找效果图&#xff0c;而是顺着一串问题把整个…

作者头像 李华
网站建设 2026/9/28 6:15:56

Android Studio环境搭建避坑指南:安装、Gradle与模拟器全流程

1. 动手前先想清楚&#xff1a;你缺的不是教程&#xff0c;而是正确的环境认知说实话&#xff0c;移动开发入门这件事&#xff0c;卡住大部分新手的往往不是代码&#xff0c;而是第一步的环境搭建。Android Studio 装到一半报错、SDK 下载卡住、模拟器起不来、Gradle 同步失败—…

作者头像 李华
网站建设 2026/9/28 6:15:51

Kingscada日报表与趋势曲线:基于任意变量查询的完整实现

最近接触了不少做产线数字化改造的同行&#xff0c;大家不约而同地在问Kingscada里日报表和趋势曲线的做法。很多人一上来就想着接数据库、写脚本&#xff0c;其实Kingscada自带的实时历史库完全够用&#xff0c;而且用起来比你想的简单得多。标题里那句话“通过查询ks任意一变…

作者头像 李华