ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

K个一组翻转链表:递归与迭代双解法详解

K个一组翻转链表:递归与迭代双解法详解 1. 题目拆解先说清楚这题到底在考什么1.1 从题目描述看真实意图力扣hot100第31题“K个一组翻转链表”题面看起来很短给你一个链表每K个节点一组进行翻转不足K个保持原样最后返回翻转后的链表。但你要是真把这题当成“每隔K个翻一次”来做大概率会栽。我见过不少刷题群里的朋友第一次写这题时都把它等同于“单链表逆序”结果写完才发现翻完一组之后跟上一组和下一组的连接全是坑。它真正考的东西在我看来是三件事链表的指针操作熟练度。这题不像数组题可以随意索引每一步都得靠指针“穿针引线”一个指向错了就断链。递归思维或者迭代拆解能力。这题用递归来写非常优雅但你得能想明白“把前K个翻转之后剩余部分天然是一个更小规模的同一个问题”。对边界条件的敏感度。剩余节点数不足K时保持原样这个条件看起来简单实际写起来很容易判断错。这题在hot100里的编号是31难度标的是困难但说实话它属于“困难题里比较友好的那一档”。它不像动态规划那样需要你凭空想出状态转移方程也不太考数学推导只要你链表基本功扎实、递归写得熟就是一个可以稳定拿下的题目。1.2 为什么这道题能进hot100刷过力扣hot100的人应该都有个感觉这100道题不是随便挑的困难题而是按“算法思维覆盖度”来选的。K个一组翻转链表这道题能入选是因为它一个题目同时覆盖了链表操作里最高频的两类思维模型局部翻转 递归分解。而且它在实际面试中出现频率也不低。很多公司面试官喜欢拿它当“中等偏上难度”的题目来考又能考察代码风格又能追问各种变体。你把这题吃透了等于同时掌握了链表的头插法、尾插法、递归思想、迭代指针操作一举多得。我自己刷这题的时候第一遍用的是迭代第二遍用递归重写后来又用这题的思路去解了几道类似的链表题感觉收获远大于题目本身。下面把这两条路都走一遍你按照自己的偏好选一条主攻就行但建议两条都写一遍。2. 解题思路选型递归和迭代两条路怎么选2.1 递归解法“先翻一组剩下的交给函数”递归的思路是我先把链表前K个节点翻转翻转完之后原来的第K个节点变成了这一段的新头原来的头节点变成了这一段的新尾。此时新尾的next应该指向谁应该指向“剩余链表以同样规则翻转后的结果”。这个“剩余链表以同样规则翻转后的结果”就是递归调用本身。你不需要手动去迭代处理后面所有的组只需要把子问题定义清楚reverseKGroup(head, k)表示“以head为头节点的链表按K个一组翻转后返回新头”。基于这个定义递归的逻辑就是先检查从head开始是否还有K个节点。如果不够K个直接返回head。如果够K个就翻转这K个节点。翻转完后原来的head变成了这K个节点的尾部此时让head.next指向reverseKGroup(下一组的头, k)。返回这K个节点的新头。很多初学者会卡在第3步搞不清楚“下一组的头”到底是谁。这里关键在于翻转前你要先走到第K个节点把它的next存下来作为nextGroup翻转中prev会从head一路走到第K个节点。翻转完成后prev就是新头head就是新尾而nextGroup就是下一组的开始。递归函数要不要返回值要返回值就是翻转后这一段的新头。这个思路的妙处在于每一层递归只处理K个节点剩下的部分完全交给下一层逻辑极度清晰。你不需要维护一堆prev、next指针来跨组连接因为跨组连接天然由递归的返回值完成。2.2 迭代解法三指针原地翻转递归虽然优雅但有些面试官会追问“你能不用递归写吗”或者你会担心递归栈溢出。这时就需要迭代解法。迭代解法的整体框架是先遍历链表计算长度算出总共需要翻转多少组然后用一个循环一组一组地翻转每一组翻转后要把组头和组尾跟前后组正确连接。单组翻转怎么做其实就是单链表逆序的三指针法pre指向当前组的前一个节点也就是上一组的最后一个节点cur指向当前组的第一个节点在组内用一个循环把每个节点的next指向前一个节点但这里有个关键区别如果只是简单地把组内节点逆序你会发现组头和上一组的连接、组尾和下一组的连接都需要额外维护。所以迭代写法通常要用到四个指针pre上一组尾部、start当前组头部、end当前组尾部、nextGroup下一组头部。翻转完之后pre.next要指向翻转后的新头也就是原来的endstart.next要指向nextGroup然后移动pre到start注意此时start已经是翻转后的尾部移动cur到nextGroup继续下一组。这个写法信息量比较大光看文字很难一次理清我建议你动手画个图。画图方法我后面会讲。2.3 递归 vs 迭代时间、空间、可读性对比维度递归解法迭代解法时间复杂度O(n)每个节点访问一次O(n)每个节点访问一次空间复杂度O(n/k)递归栈深度O(1)只用常数个指针代码可读性高逻辑清晰中等指针多容易绕晕面试风险容易被追问栈溢出不容易出错但写起来长推荐程度作为主解法作为补充掌握时间复杂度两者一样都是O(n)。因为不管哪种写法你都得把每个节点至少走一遍。空间上迭代明显占优但递归也远不算差毕竟递归深度是组数而不是节点数对于K个一组翻转来说即使有十万个节点、K2递归深度也只有五万层在大多数情况下都够用。我的建议是以递归为主解法因为代码短、逻辑清晰、不容易写错面试时讲思路也更好讲。但迭代你至少要看懂因为有的面试官会明确要求写非递归版本。下面两种写法都给你完整代码。3. 手把手实现两种写法的完整代码与边界处理3.1 递归写法C 和 Python 双版本先用C写一版。C写链表题需要特别注意指针的语义别把指针指向搞混了。class Solution { public: ListNode* reverseKGroup(ListNode* head, int k) { if (head nullptr) return nullptr; // 先检查剩余节点是否够 k 个 ListNode* tail head; for (int i 0; i k; i) { if (tail nullptr) { return head; // 不足 k 个保持原样 } tail tail-next; } // 此时 tail 指向第 k1 个节点也就是下一组的头 // 翻转前 k 个节点标准三指针法 ListNode* pre nullptr; ListNode* cur head; while (cur ! tail) { ListNode* nxt cur-next; cur-next pre; pre cur; cur nxt; } // 翻转完成后 // pre 指向翻转后的新头 // cur 指向 tail即下一组开头 // head 变成翻转后的尾部 head-next reverseKGroup(tail, k); return pre; } };这版代码有个特别容易让人困惑的地方while (cur ! tail)这个循环条件。注意tail已经提前走到了第k1个节点所以循环只需要翻到第k个节点就停不会多翻。这也是为什么先检查“够不够k个”如此重要——检查的过程顺便拿到了下一组的头一箭双雕。再看Python版本Python写链表题指针语义和C类似但语法更简洁class Solution: def reverseKGroup(self, head: Optional[ListNode], k: int) - Optional[ListNode]: # 检查剩余节点是否够 k 个 tail head for _ in range(k): if tail is None: return head tail tail.next # 翻转前 k 个节点 pre None cur head while cur ! tail: nxt cur.next cur.next pre pre cur cur nxt # 递归处理剩余部分 head.next self.reverseKGroup(tail, k) return pre两个版本的逻辑完全一样。你如果会C就仔细看C那版会Python就看Python版核心就一个检查够不够k个够了就翻然后递归处理剩下的。注意递归写法里head-next reverseKGroup(tail, k)这一行顺序不能写反。一定要先翻转再递归再连接。如果你先递归再翻转递归返回后的链表状态会跟你预期的不一致很容易绕晕。3.2 迭代写法四个指针的完整推导迭代写法我同样用C来写因为指针逻辑用C表述最清晰。核心思路是先把链表长度算出来算出一共有多少组需要翻然后一组一组翻。class Solution { public: ListNode* reverseKGroup(ListNode* head, int k) { // 第一步计算链表长度 int length 0; ListNode* node head; while (node ! nullptr) { length; node node-next; } // 建立虚拟头节点统一处理头节点被翻转的情况 ListNode* dummy new ListNode(0); dummy-next head; ListNode* pre dummy; // 上一组的尾部 ListNode* cur head; // 当前组的头部 while (length k) { // 找到当前组的尾部 end ListNode* end cur; for (int i 1; i k end ! nullptr; i) { end end-next; } if (end nullptr) break; // 保存下一组的头 ListNode* nextGroup end-next; // 翻转当前组的 k 个节点 ListNode* prev nullptr; ListNode* curr cur; while (curr ! nextGroup) { ListNode* nxt curr-next; curr-next prev; prev curr; curr nxt; } // 翻转完成后 // prev 指向翻转后的新头原来的 end // curr 指向 nextGroup // 连接上一组的尾部指向翻转后的新头 pre-next prev; // 当前组翻转后的尾部原来的 cur指向下一组的头 cur-next nextGroup; // 移动 pre 和 cur进入下一组 pre cur; cur nextGroup; length - k; } return dummy-next; } };迭代写法的核心在于组内翻转完成后pre-next prev连上前半部分cur-next nextGroup连上后半部分。这两个连接缺一不可。我在第一次写迭代版时就是忘了cur-next nextGroup这一行导致整个链表后半段全丢了debug了半天。最后pre cur这一步是很多人的困惑点。这里要特别注意此时cur还是原来的组头但这个节点经翻转后已经是整组节点的尾部了。所以pre更新为cur实际上是让pre指向这一组的尾部也就是下一组的前驱节点。这一步千万别写成pre prev否则下一组翻转完连接时就会出错。3.3 虚拟头节点为什么必须用它迭代写法里我引入了一个dummy虚拟头节点这一步绝对不是可有可无的。原因很简单如果整个链表长度大于等于K那么第一次翻转之后链表的头节点会变。比如链表是1-2-3-4-5K2翻转第一组后变成2-1-3-4-5头从1变成了2。如果你没有虚拟头节点每次翻转完都得特判“如果这是第一组就更新返回的头节点”代码会变得支离破碎。有了虚拟头节点之后不管哪一组翻转它的前驱节点都存在不需要做任何特殊判断最后统一返回dummy-next就行。这个技巧不仅在这题有用几乎所有涉及“头节点可能会变”的链表题都应该想到用它。递归写法不需要虚拟头节点因为递归每一层只管自己这一段新头直接由返回值搞定不需要额外的统一入口。这也是递归写法的优势之一。4. 常见错误与调试技巧实录4.1 你一定会犯的错指针更新顺序搞反我把刷题群里出现频率最高的报错场景列一列你看看有没有你自己的影子for循环里的tail tail-next没有提前判空。这种情况在测试用例里有不足K个节点的链表时会直接空指针异常。翻转循环里把nxt cur-next这一行漏了或者写在了cur-next pre之后。一旦先改了cur-next原来的cur-next就找不回来了链表直接断掉。迭代翻转完成后忘记让cur指向nextGroup导致下一次循环时还在原地打转。我自己的经验是链表题的指针操作不要靠脑子想一定拿纸笔画。每一轮循环开始前把三个指针pre、cur、nxt指向哪个节点画出来循环结束后再画一遍。能画出两张图代码就不会错。注意组内翻转的循环条件while (cur ! tail)和while (curr ! nextGroup)是等价的区别只是提前把tail/nextGroup存下来了。如果你写的是while (cur ! tail)那tail必须是在翻转前就确定好的如果写的是while (curr ! nextGroup)那nextGroup同样得提前保存。这两个值都不能在翻转过程中临时去找因为翻转会破坏原来的next关系。4.2 递归写法最容易让人懵的两个点第一个点是reverseKGroup(tail, k)返回的到底是什么。注意tail是第k1个节点也就是下一组的头。这个递归调用会处理“从tail开始、以同样规则翻转”的链表并返回处理后的新头。所以head-next reverseKGroup(tail, k)的意思就是让当前组翻转后的尾部接上后续处理完的结果。第二个点是为什么翻转完第一组后head就是新尾因为翻转前head是这一组的第一个节点翻转后它变成了最后一个节点。这个节点的next原本指向第二个节点但翻转过程中它已经被改成指向pre初始是nullptr所以翻转完后要专门给它设置next。如果不设置这个节点就成了链表终点后面的组全丢了。这两个点想通了递归版基本就没障碍了。我见过不少人递归版代码写对了但问他“head-next为什么要这样接”答不上来。面试时如果答不上来考官还是会认为你没掌握。4.3 测试用例设计怎么验证你的代码是对的刷题最忌讳的就是代码一跑过样例就提交结果WAWrong Answer了才开始慌。我建议你每写完一道链表题固定用下面这组测试用例来验证测试用例输入链表K期望输出验证点用例1[]2[]空链表用例2[1]2[1]不足一组用例3[1,2]2[2,1]恰好一组用例4[1,2,3]2[2,1,3]有一组翻余下不足用例5[1,2,3,4]2[2,1,4,3]能翻两组用例6[1,2,3,4,5]3[3,2,1,4,5]K大于一半长度用例7[1,2,3,4,5]1[1,2,3,4,5]K等于1不翻转最后一个用例很关键。K1时代码里的翻转循环会直接跳过链表保持不变。如果你没考虑K1有些写法会陷入死循环或者出现空指针。还有一个很多人会忽略的细节当K非常大比如K1000000而链表只有几个节点时你的代码不能崩。递归写法里那个“检查够不够K个”的循环要能快速返回迭代写法里初始化长度也是O(n)都没问题。但如果你在翻转循环里用了for (int i 0; i k; i)又没判空就会直接空指针。5. 面试追问与扩展思考5.1 面试官常问的“变体”怎么答这道题在面试中经常被扩展成几种变体提前想一想会有很大优势。第一种不足K个也要翻转。逻辑变化是最小的你只需要把递归版本里第一段“检查够不够K个”的提前返回去掉并在翻转循环里加上cur ! nullptr的判断。迭代版本里把while (length k)改成do...while或者干脆不用长度判断直接翻到底。第二种让你用“交换相邻节点”的方法而不是K个一组翻转。这对应的是力扣另一道题“两两交换链表中的节点”本质上就是K2的特例。你如果这题的递归写法写熟了那道题就是一行递归的事。第三种要求你返回每一组翻转后的中间状态也就是不只返回最终结果还要能打印每一轮翻转后的链表。这种问题考察的是你对翻转过程中中间状态的理解画图熟练的话这也难不倒你。第四种只翻转链表中第a到b个节点。这个思路跟本题一模一样只是你先把指针走到第a-1个节点然后以它为“pre”翻转K个改成翻转(b-a1)个。你会发现K个一组翻转这个写法本质上是把这个局部翻转操作重复执行了多组。5.2 从一道题到一类题递归模型的迁移K个一组翻转链表背后其实是一个非常通用的递归模型我给它起了个名字叫“分组处理模型”拿到链表头部先处理前一小段后一小段交给递归。在力扣hot100里你可以用这个思路去解好几道题合并两个有序链表比较头部大小小的节点指向剩余两个链表的递归合并结果。反转链表递归版就是先翻后面所有再把当前节点接到尾部后面。两两交换链表中的节点K2的分组翻转思路一模一样。有序链表转换二叉搜索树找到中点的前驱断开链表左右两部分递归建树。所以你可以把这道题当作一个模板题来刷。把这题的递归逻辑吃透了上面那几道题你至少有一半能秒解。另外说一句链表题里那种“画图、定义进出、写循环、跑例子”的节奏是通用的。无论你是刷力扣还是面试这套方法都适用。我在刷题群带过几个朋友每次他们链表题卡住我就说一句话“画图把每个指针的指向标出来。”这一步做到了80%的错误都能自己发现。6. 最后再做一个小分享我自己刷这题的经历比较特别第一次是在某厂面试前夜临时抱佛脚看的递归解法当时看懂了但完全没消化第二天面试官让我手写我画了五分钟图写了一版迭代最后过了。后来在刷hot100时又刷到这一题才真正把递归和迭代都吃透。所以我的体会是不要怕一开始看不懂也不要在没画透指针关系的情况下硬写代码。这题就是典型的“画图两分钟代码两分钟”的题目如果你十分钟没写出来大概率不是你不懂算法而是你没把图理清。先把草稿纸拿出来画三张图——翻转前、翻转中、翻转后——再落笔。还有一个很实用的小技巧刷题的时候把每道链表题的测试用例都固定带几组比如“空链表、单个节点、全部翻转、部分翻转”这四类。以后不管遇到哪道链表题直接套这套用例能省掉大量调试时间。我自己现在刷题已经养成了这个习惯效率提升非常明显。这题之后建议你紧接着把“两两交换链表中的节点”和“反转链表II”一起刷了。三题放在一起对比你对链表递归和迭代的理解会直接上一个台阶。
返回列表