资讯动态

K个一组翻转链表:递归与迭代解法深度剖析

发布时间:2026/10/20 15:35:03 来源:尧图企业网站定制
1. 题目解读这题到底在考什么1.1 题目要求里藏着的四个约束把 25 题完整读一遍给你链表的头节点head每k个节点一组进行翻转最后返回修改后的链表。k是正整数小于等于链表长度。如果节点总数不是k的整数倍最后剩余的节点要保持原有顺序。另外强调一句不能只改变节点内部的值必须实际进行节点交换。这句话看着简单实际上藏着四个约束。第一k可以比链表短很多意味着可能存在多组连续翻转。第二剩余不足k个时不能翻这一点最容易写错因为很多人会用剩余节点数是否小于 k来判断但如果一开始没有求链表长度就需要在遍历过程中判断。第三实际进行节点交换直接否掉了那种把链表节点值取出来放进数组、翻转数组再回写的偷懒方案。第四k等于 1 时链表不变k等于链表长度时就是整条链表翻转这两个极端情况是自测时的必测用例。很多人第一眼看到这题会把注意力放在怎么翻一组上但真正决定成败的是怎么让相邻两组接起来。单组翻转是 206 题的基础能力几乎所有学过链表的人都会而 25 题把组内翻转和组间连接两个任务叠在一起要求你同时处理两套逻辑。这就是为什么它被称为链表题的分水岭会翻单个链表不算会能把若干个翻转后的片段重新缝合成一条完整链表才算真正的理解。1.2 面试官最想看到的三个能力我模拟面试时拿这题考过不少人观察下来做得好的候选人通常在三个维度上表现稳定而这三点恰恰是链表题想检验的东西。第一个是基本功能不能熟练操作next指针翻转时不丢节点。第二个是边界意识链表长度不够k、长度刚好是k的倍数、k1这些情况是否都能正确处理。第三个是工程判断在递归和迭代之间选哪个能不能说清楚各自的复杂度和风险。这三个能力对应着岗位日常开发中对细节敏感度和防御式编程习惯的要求所以出题率一直不低。一个常见的失败模式是候选人很快写出翻转单链表的代码然后卡在翻转完一组之后pre 应该指向哪。这恰好暴露了问题——他只是在背 206 题的模板并没有理解翻转这个操作对组间连接的影响。如果你能想明白pre、start、end、next这四个指针在每一轮开始前和结束后的语义整道题的代码就是顺水推舟的事。1.3 数组翻转的思路为什么在这里翻车先看一个直观对比。数组翻转可以用双指针i、j对向移动逐次交换元素整个过程依赖下标随机访问长度也随时可知。链表没有下标只能通过next逐节点走长度通常不知道想知道就得先遍历一遍。这就导致判断剩余是否够 k 个这个动作在数组里是一行代码在链表里必须老老实实走指针。再看交换的本质。数组swap(arr[i], arr[j])交换的是内存里的值数组节点的物理位置不变链表节点的交换是改变next指向让节点的逻辑顺序发生变化。如果你试图把链表节点的值收集到一个数组里翻转数组后再回写确实能得到一个数值顺序正确的链表但节点的指针结构完全没有变化这在题目里是明文禁止的。退一步说就算题目允许这种做法在工程上也没有意义。真实的链表节点往往携带复杂字段比如带权重、带时间戳、带依赖关系只回写val等于丢弃了节点身上其他所有信息。链表题训练的本质是在只有 next 的世界里小心地重新接线数组思维在这里不仅违规而且会让你完全错过这道题的训练价值。2. 核心思路拆解分组翻转的两种主流解法2.1 递归解法让后续小组自己处理自己递归的思路很适合描述这类每 k 个一组的问题我只需要处理当前这一组后面的组让函数自己去处理。递归写法的核心逻辑是这样的从head出发数出k个节点数不完就直接返回head因为剩余部分要保持原序。数完k个节点后记录next_group这个变量指向下一组的第一个节点。翻转从head到第k个节点这一段得到新的组头。把这一段翻转后的尾节点也就是原来的head的next指向reverseKGroup(next_group, k)。返回这一组的新头。理解这个逻辑的关键在于翻转当前组的时候需要一个明确的终点边界。next_group就是边界翻转循环走到这个节点就停不会把下一组卷进来。翻转完成后原来的组头变成了组尾它需要知道下一组处理完之后长什么样而这个信息只有递归调用能给。所以head.next reverseKGroup(next_group, k)这行代码承担了缝合组间连接的任务。递归写起来简单但有一个代价递归深度等于组数也就是n/k。当k很小、链表很长时比如n 10^6、k 1调用栈会深到让程序在真实环境中直接栈溢出。所以递归更适合作为思路阐述和笔试快速实现在需要严格控制空间的场景下迭代才是更稳的选择。2.2 迭代解法四个指针的接力赛迭代解法没有递归那个让函数帮你处理后续的魔法所有连接都得自己手动维护。它的核心是通过四个指针完成每一轮的操作。dummy虚拟头节点用于统一处理头节点变化的情况。pre当前翻转段的前驱节点初始指向dummy。start当前翻转段的第一个节点初始指向head。end当前翻转段的最后一个节点通过走k步确定。next_group当前翻转段的后继节点也就是end.next。每一轮的完整流程是先让end从pre出发走k步走到第k个节点如果走的过程中遇到了None说明剩余节点不足k个直接返回dummy.next。然后记录start pre.next、next_group end.next把end.next暂时设为None目的就是把这一段独立出来翻转。翻转完成后会得到一个新的组头把pre.next指向它再把start.next指向next_group这样当前组就接回了链表。最后把pre移动到start的位置因为翻转完成之后start已经变成了当前组的尾节点而它正是下一组的前驱。这个流程里最容易被忽略的是pre的移动位置。很多人会直觉地写成pre pre.next或者pre end但两者都不对。翻转结束后pre.next是新组头end已经变成了一段游离链表的中间节点只有start因为翻转变成了段尾所以下一轮的前驱必须是start。记住这个位置整个迭代写法就顺了。2.3 两种思路的取舍面试时先写哪个面试场景下我个人的建议是先写迭代。理由很直接迭代空间复杂度是O(1)不需要向面试官额外解释递归栈的问题同时迭代写法把手动管理指针连接的能力展露无遗而这正是链表题想考察的重点。但这不是说递归不能写。如果你对递归更顺手先写递归也完全可以只要你能回答一个问题这段代码最深的递归深度是多少答案是n/k因为在每一层只处理一组然后下探到下一组。你还可以主动补一句如果链表长度较大这个写法会有栈溢出的风险所以生产环境我倾向迭代这句话会让面试官看到你的工程意识。我在实际模拟面试中见过两种表现写迭代的人如果指针乱了我提示一下画一下 pre 和 start 的关系就能快速找到问题写递归的人如果乱了通常是当前组翻转完到底返回给谁没想清楚。无论先写哪种建议在动手前先花 30 秒口头说清整体思路让面试官确认你理解题目再开始写代码。3. 实操过程手写一个能跑的 AC 版本3.1 迭代版代码逐行注释class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def reverse_k_group(head: ListNode, k: int) - ListNode: # 特殊情况直接返回空链表或者每组的长度是 1 if head is None or k 1: return head # dummy 节点统一处理头节点变化的情况 dummy ListNode(-1) dummy.next head pre dummy # pre 是当前翻转段的前驱 while True: # end 从 pre 出发走 k 步走到当前组的最后一个节点 end pre for _ in range(k): end end.next if end is None: # 剩余节点不足 k 个保持原序直接返回结果 return dummy.next start pre.next # 当前组的第一个节点 next_group end.next # 当前组的后继节点也就是下一组的头 # 把当前组从链表中临时断开方便独立翻转 end.next None # 翻转当前组new_head 是这一组翻转后的新头 new_head reverse_linked_list(start) # 接回链表新头接上前驱原 start 变成组尾接上下一组 pre.next new_head start.next next_group # pre 移动到当前组的尾节点也就是 start 翻转后的位置 pre start def reverse_linked_list(node: ListNode) - ListNode: prev None curr node while curr is not None: next_temp curr.next # 先保存后继否则翻转后会丢失 curr.next prev prev curr curr next_temp return prev这段代码里有一个细节值得展开end pre配合for _ in range(k)走完k步恰好停在当前组的第k个节点上。初始时pre是dummy从dummy走 1 步到第 1 个节点走k步到第k个节点。翻转完一组后pre移动到了组尾从组尾出发走k步同样会到下一组的第k个节点。这样写比end pre.next再走k-1步更不容易出现差一错误是我在实践里更推荐的形式。另外注意start这个变量在翻转前后语义的变化。翻转前它是当前组的第一个节点翻转后它变成了最后一个节点。所以最后一行pre start是移动到组尾而不是回到组头。很多人在这一步写错本质上是因为没有意识到翻转操作会反转start的角色。3.2 递归版代码逐行注释def reverse_k_group_recursive(head: ListNode, k: int) - ListNode: # 空链表直接返回 if head is None: return None # 先数出 k 个节点如果不够 k 个保持原序 next_group head count 0 while count k and next_group is not None: next_group next_group.next count 1 if count k: return head # 到这里next_group 指向第 k1 个节点也就是下一组的头 # 翻转当前组的 k 个节点翻转的终点是 next_group prev None curr head while curr ! next_group: temp curr.next curr.next prev prev curr curr temp # 翻转完成后head 变成了当前组的尾节点 # 把它接到递归处理后的下一组上 head.next reverse_k_group_recursive(next_group, k) # prev 是当前组翻转后的头节点 return prev递归版的思路可以用一句话概括我只负责把当前组翻好并把当前组的尾巴接到下一组翻好的结果上。这句话浓缩了整段代码的全部逻辑。关键的一行是head.next reverse_k_group_recursive(next_group, k)。这个调用要等下一组完全处理完才会返回所以当前组的尾节点能正确地接到整个后续链表的头部。也正因为有这样的等待递归深度才会累积到n/k。如果你把这个调用去掉直接把head.next指向next_group那么下一组就没有被翻转结果会与题目要求不符。还有一个容易混淆的点最后返回值是prev不是head。prev在翻转循环结束时指向当前组的新头也就是第k个节点head此时已经是尾节点了。返回head是写递归版最常见的错误会导致整个链表头变成最后一个节点。3.3 一定要跑通的那几组测试用例链表题的 bug 通常藏在边界里所以自测用例必须覆盖所有分类。下面是我每次写完这道题都会跑的测试表输入链表k期望输出重点验证点[]2[]空链表[1]2[1]不足一组[1,2]2[2,1]恰好一组[1,2,3]2[2,1,3]翻一组后剩一个[1,2,3,4]3[3,2,1,4]剩余节点不是 1 个[1,2,3,4,5,6]3[3,2,1,6,5,4]整倍数全部翻转[1,2,3,4,5]5[5,4,3,2,1]k 等于链表长度[1,2,3,4,5]1[1,2,3,4,5]k1链表不变这些用例里最容易被忽视的是[1,2,3,4]、k3这个组合因为剩余节点是 1 个而上一轮翻转后的组尾必须正确接到这个节点上。如果start.next设置晚了或者next_group记录错了输出很容易变成[3,2,1]丢失最后的4这个 bug 在只测k2的情况下是发现不了的。如果你在本地调试建议准备一个简单的链表打印函数方便逐段观察输出def print_list(head: ListNode) - None: values [] curr head while curr is not None: values.append(str(curr.val)) curr curr.next print( - .join(values))写测试用例时不要满足于跑一遍对上了。把head、pre、start、end在每一轮翻转前后的值都打印出来能帮你确认组间连接是否正确这个习惯在调链表题时非常有用。4. 常见问题与排查技巧实录4.1 我在调试时踩过的五个坑第一个坑翻转时忘记保存后继节点。这是所有链表翻转问题的经典错误。如果你写curr.next prev之前没有先用临时变量记住curr.next那么当节点走到下一个位置时原后继已经丢了链表后半段直接失联。这个错误在普通翻转里容易察觉在分组翻转里更容易出现因为同时还要处理end.next None这种断链操作。第二个坑end判断不足 k 个的时候位置放错了。我见过有人写成这样end pre for _ in range(k): end end.next if end is None: return dummy.next乍一看好像没问题但细看就发现判断发生在循环结束之后。如果end在第k步之前就已经变成Noneend end.next会在下一次循环直接抛出空指针异常。正确的做法是实现每走一步就判断一次也就是把if end is None写在循环体内。第三个坑翻转后忘记把start.next接到next_group。翻转子函数返回时new_head已经接好pre.next new_head也执行了但如果漏掉start.next next_group当前组的尾节点就指向空从下一组开始的所有节点全部丢失。这个 bug 在自测时表现非常明显输出只有前一组翻好的几个节点。第四个坑递归版返回了head而不是prev。head翻转后会变成组尾如果作为返回值返回给上一层上一层会把整个链表的头接到组尾上最后得到的链表从中间断成两段你会在合并阶段莫名丢失前半部分。每次写递归版都要盯着最后一行确认返回值。第五个坑没有做k 1或空链表特判。题目保证k是正整数且小于等于链表长度但实际写代码时防御性判断成本极低。k 1时迭代逻辑虽然也能跑通但白白走一遍全流程测试时容易让人误判性能空链表则需要单独处理否则dummy.next head虽然安全后面拿到start pre.next却是None一旦进入循环就会麻烦。4.2 链表的调试方法论链表题最有效的调试方式永远是画图。把每个节点画成一个方框next画成箭头然后拿着笔把每一轮pre、start、end、next_group的位置标出来。很多人觉得在脑子里想就能想清楚但指针多的时候人的工作记忆根本装不下那么多状态画出来一两下就明白了。第二个方法是利用日志。在关键节点前后打一行print(f翻转前: pre{pre.val}, start{start.val}, end{end.val}) new_head reverse_linked_list(start) print(f翻转后: new_head{new_head.val}, start.next{start.next.val if start.next else None})这段日志能直接告诉你段间连接是否丢失、pre是否移动正确。第三个方法是使用最小规模的输入三个节点、五个节点最合适。小输入能让每一步的影响都暴露出来一旦能跑再逐渐加节点数一般就能定位到问题集中在哪一段。我在实际调试中还有一个习惯按顺序检查三条边。第一pre.next是否指向了当前组的新头第二start.next是否指向了next_group第三pre是否移动到了本组尾部。三条边全部正确这一轮就完成了再检查下一轮。4.3 复杂度与性能分析时间复杂度方面迭代版的每组操作包含两部分数k个节点和翻转k个节点。数节点总共进行n/k次每次k步合计n翻转变种也是n/k次每次k步合计n。段间连接是常数时间。所以总时间复杂度是O(n)每个节点大约被访问常数次这是一个可以放心向面试官阐述的结论。空间复杂度方面迭代版只用了固定数量的指针变量不随输入规模增长所以是O(1)。递归版的递归树深度是n/k因为每一层只处理一组就下探到下一组所以空间复杂度是O(n/k)。这里有一个容易混淆的点有些人以为是O(n)但实际并不是。比如k1时递归深度是n那确实是O(n)但一般情况深度是n/k。面试时如果说成O(n)也不算完全错但准确说是O(n/k)会显得你理解更细。如果链表长度达到百万级别迭代版在时间和空间上都有明显优势这也是我推荐在工程化场景下使用迭代写法的原因。递归版的简洁性在面试中很讨喜但在真实系统里一个n10^6、k2的场景就会让递归栈变得非常危险。写之前先想清楚使用场景这本身就是一种工程判断力。5. 从这道题延伸出去的思考5.1 把翻转链表变成工具箱25 题不是孤立的一道题它其实是链表翻转问题族的集大成者。最基础的 206 题是整条翻转k等于链表长度即可92 题是反转从left到right的区间可以看作在组内翻转之前先定位区间边界24 题两两交换链表节点本质上就是k2的 25 题。理解了 25 题前面这些题都能靠同一个思路打通。我建议你在笔记里留下这样一个模板处理按 k 分组的问题时先想清楚三个问题。第一如何确定第k个节点答案是让一个指针走k步。第二如何处理不足k个的情况答案是让指针走到None就停止并返回。第三如何处理组间连接答案是用前驱指针和后继指针在翻转前后重新接线。这三个问题一旦固化下来以后遇到按 k 组求和、按 k 组反转后输出、按 k 组做某种聚合操作都能套用框架只是中间的处理逻辑不同而已。5.2 同族题目与准备策略准备链表题的时候可以按下面的对照表整理思路题目核心考点与 25 题的关系206 反转链表基础指针翻转25 题中组内翻转的部分92 反转链表 II区间边界定位组内翻转 区间前驱后继处理24 两两交换链表中的节点成对交换指针25 题的k2特例25 K 个一组翻转链表分组遍历 组内翻转 组间连接本题从学习顺序上讲我会建议先把 206 题写到闭着眼睛都能写对再跳到 92 题理解区间的定位最后挑战 25 题。直接拿 25 题开练不是不行但如果组内翻转都还磕磕绊绊困难来源会从组间逻辑被混淆成翻转本身反而不利于训练。还有一个实际建议面试前不要把这道题刷一遍就放下而是把它当作热身题隔几天默写一次。因为这个题包含的知识点密度很高写一遍能同时检验你对dummy节点、指针接线、边界判断、复杂度的掌握程度。默写时不要看题解写完再对照代码检查这种主动回忆的过程比被动浏览有效得多。5.3 链表的实际意义不只在面试看到这里你可能会想工作里真的会写这么复杂的链表吗说实话工业级代码里直接让你手写K 个一组翻转链表的场景几乎不存在但链表的思想无处不在。典型的就是 LRU 缓存它基于双向链表和哈希表的组合操作时同样需要小心地维护前驱和后继。编辑器里的撤销/重做栈、播放器的播放列表、图形学里的多边形顶点链都依赖类似的节点 指针结构。真正有价值的是在指针连接的世界里小心不改坏别人的思维方式。你写完 25 题之后会对断链之前先保存后继、移动指针之前先明确前后关系这类习惯有本能反应。这些习惯在写复杂数据结构代码时非常有用它让你在做并发操作、操作共享文件、维护复杂对象图时下意识地先想清楚影响范围再动手。这也是链表题在面试中经久不衰的根本原因它考察的不是记忆力而是你在混乱中保持逻辑清晰的能力。就我个人的体会来说25 题值得反复练每次都能发现自己对指针连接的理解又深了一层。如果你身边也有朋友在准备面试拿这道题互相出题是个不错的办法因为讲解过程会逼着你把模糊的感觉变成清晰的语言这种输出比自己闷头刷题更能暴露知识漏洞。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑