反转链表全解析:从迭代递归到K个一组翻转
发布时间:2026/10/11 17:11:20 锦皓数字建站

作为一名刷了几年题的老开发我始终觉得链表操作是数据结构里最“手熟”的一类问题——代码量不大边界条件却极多而且特别考验指针思维。反转链表这道题几乎每个面试题库里都有被称作链表操作的经典题一点都不夸张。它看着简单但能在一分钟内写出无 bug 版本的人比例比你想象的低得多。我见过不少候选人能口述思路一到白板就频频空指针说到底还是对节点引用关系缺少肌肉记忆。这篇内容我打算按我带新人时的讲法来写先把反转的本质掰开揉碎再给迭代和递归两套实现然后延伸到区间反转、K 个一组反转这些变体最后整理一份我在实际刷题和面试中反复踩过的坑。无论你是刚接触链表的初学者还是准备冲刺大厂算法关的求职者这篇都值得花二十分钟读完并亲手敲一遍。毕竟链表的题看十遍不如手写一遍。1. 反转链表到底在考什么1.1 核心需求拆解不要把它当成“翻转数组”题目描述通常很简单给定单向链表的头节点反转链表返回新的头节点。但很多人会用数组的惯性来理解——以为把值存下来再倒着赋回去就行。这种做法虽然能通过部分判题却完全没有触及链表操作的精髓。反转的核心不是交换值而是改变节点之间的指向关系。数组因为内存连续可以通过索引原地交换元素。链表则靠每个节点的 next 指针串联节点本身在内存里是离散的所以要反转本质上就是让每个节点的 next 从指向后一个节点改为指向前一个节点。头节点变成尾节点尾节点变成头节点。如果你只是交换 val那只是“看起来倒序”一旦题目要求你别改变节点值比如后面要加的“反转部分链表”变体这种思路就彻底行不通了。理解了这一点你就该明白链表的题操作指针才是灵魂。整个反转过程可以拆成三步保存后继、反转指向、移动指针。这三个动作循环到底就能完成整条链表的反转。我经常跟朋友开玩笑说链表操作其实就是“先拉住别丢再转身改方向最后往前挪一步”。1.2 为什么这道题值得反复练习反转链表被叫做“链表操作的经典题”是因为它几乎覆盖了链表题目最核心的三种能力。第一是指针操作的精确性你必须清楚每一行代码执行后内存里到底有几个引用指向同一个节点稍有不慎就会丢节点或形成环。第二是边界条件的敏感度空链表、单节点链表、两个节点链表每种情况都要确保逻辑一致。第三是空间复杂度的权衡能力迭代法只需要常数级额外空间递归法虽然代码更简洁但栈深度与链表长度成正比在长链表上存在栈溢出风险。我经常把反转链表比作“算法题里的俯卧撑”——动作简单但标准做组却不容易。它也是在为后面一堆变体题打地基反转链表 II反转区间、K 个一组翻转链表、回文链表判断、链表内两两交换节点无一不是从基础反转扩展出来的。毫不夸张地说吃透反转链表就等于解锁了链表类问题的半壁江山。2. 迭代法三指针漂移的底层逻辑2.1 为什么用三指针而不是双指针网上讲反转链表的文章很多有的用双指针prev 和 cur有的用三指针prev、cur、nextTemp。其实双指针方案也够用因为 cur 的 next 在反转前可以先保存到临时变量。但为什么我推荐先理解三指针版本因为在教学阶段三指针把“先保存后继”这一步显式体现出来了不容易产生空指针。想象一下你在一列火车上想把整列车厢的挂钩方向全部掉头。你不能直接改第一节车厢的挂钩因为改了之后第二节车厢就找不到了。所以正确步骤是先伸手抓住第二节车厢保存 next然后断开第一节和第二节的连接把第一节的挂钩转向原来它前面的那节车厢反转指向最后你和第一节车厢一起向前走一步换到下一节继续操作。这就是三指针漂移prev是当前节点原来前面的节点cur是当前正在处理的节点temp是 cur 原来的下一个节点。用代码表示就是下面的循环体// 伪代码展示核心三步 ListNode temp cur.next; // 1. 先抓住后继防止节点丢失 cur.next prev; // 2. 当前节点指向前驱完成反转 prev cur; // 3. 前驱前移 cur temp; // 4. 当前节点前移这四步缺一不可。少第一步cur.next prev执行完原链表后半段就彻底丢了少第三步或第四步指针永远在原地打转甚至会进入死循环。代码逻辑正确之后剩下的问题就是初始值和终止条件。2.2 迭代法的完整实现与边界剖析头节点前面没有节点所以prev初始为nullcur初始为head。循环终止条件的判断很关键是cur ! null还是cur.next ! null答案是cur ! null。因为我们要让每个节点的 next 都反转包括最后一个节点——原来的尾节点反转后 next 应为null而它自己变成新链表的头。以经典的 C# 实现为例public ListNode ReverseList(ListNode head) { ListNode prev null; ListNode cur head; while (cur ! null) { ListNode temp cur.next; // 保存后继这是防止丢链的关键 cur.next prev; // 反转指针 prev cur; // prev 前移 cur temp; // cur 前移 } return prev; // 循环结束时prev 就是原链表的尾节点即新链表的头 }这里有个容易混淆的点为什么返回prev而不是cur因为循环结束时cur已经变成nullprev才停留在原链表最后一个节点上。我见过很多初学者在这里卡住写成了return cur结果返回了空指针。要记住一个口诀反转完成后prev 就是新世界的头。边界条件上空链表和单节点链表都无需特殊处理空链表进不了循环直接返回null单节点链表循环一次后prev指向该节点cur为null返回该节点本身。这个方案的优点是空间复杂度 O(1)时间复杂度 O(n)只需要遍历一次缺点是会修改原链表的结构如果后续还需要用原链表顺序就得提前备份或改用其他方式。3. 递归法用函数调用栈代替指针漂移3.1 递归的思维转换先走到黑再一路回头递归法写出来往往只有几行看起来非常优雅但理解起来却比迭代法更抽象。我自己的经验是如果迭代法是“从上到下改绳子”递归法就是“先走到绳子末端再倒着把每一段重新编回去”。常用的递归写法有两种。第一种是先找到新的头节点再在回归过程中反转指针。它的核心思路是假设ReverseList(head.next)已经帮我把后面的链表全部反转好了那我只需要把后面的新尾节点指向当前头节点并把当前头节点的 next 置空。换句话说递归函数只负责“把子链表反转”主函数负责“把当前节点拼接上去”。第二种写法是带前驱参数的尾递归本质上就是用递归模拟迭代public ListNode Reverse(ListNode cur, ListNode prev) { if (cur null) return prev; ListNode temp cur.next; cur.next prev; return Reverse(temp, cur); }这种写法虽然也是递归但没有任何“回归”阶段要做的额外操作也就是尾递归很多编译器能直接优化成循环。不过在纯教学场景下我更喜欢第一种因为它更能体现“递归定义”的美感。3.2 递归反转的代码推导别被 return 绕晕以第一种递归法为例代码是这样的public ListNode ReverseList(ListNode head) { if (head null || head.next null) { return head; } ListNode newHead ReverseList(head.next); head.next.next head; // 让后继节点反过来指向自己 head.next null; // 断开原来的正向连接 return newHead; }很多人盯着head.next.next head这一行看半天想不明白为什么。我来拆解一下递归返回后head.next是原链表第二个节点而它在这时候已经变成了反转后子链表的最后一个节点或者说它是“新链表靠近尾部的那一段”的末尾。head.next.next head的意思就是让这个“末尾节点”的 next 指向原来的前驱head。这样一来两个节点之间的方向就反转过来了。举个例子链表 A - B - C - D。递归调用ReverseList(B)返回后B 后面已经被反转为 D - C - B此时head是 Ahead.next是 B。执行B.next A即head.next.next head链表变成 A 和 B 双向相连不过原链在 A 处还有一个指向 B 的引用。最后执行head.next null把 A 和后面断开于是得到D - C - B - A完成反转。递归法的难点在于你必须信任“子问题已经解决了”。很多人在脑子里并不敢做这个假设总想手动模拟每一层调用结果把自己绕进死胡同。我的建议是用“最小例子 信念”的方式来理解先验证两个节点的链表能正确反转然后相信递归函数对 n-1 个节点成立剩下的推导就顺了。不过也要记住递归方法在链表很长时会因为调用栈过深引发栈溢出实际工程中若要反转大量节点迭代法是更安全的选择。4. 变体与延伸当反转不从头开始4.1 区间反转哑节点是救命稻草反转链表的进阶版本是“反转从位置 left 到 right 的链表段”。这道题通常叫反转链表 II要求只反转区间内的部分区间外的部分保持原样。难点在于当 left 不等于 1 时我们需要记录区间前一个节点并在反转完成后把区间的新头接回去。实现思路分四步找到第 left 个节点同时记录它的前驱节点pre。从第 left 个节点开始用标准迭代法反转 right - left 个节点。把反转后的新头接到pre后面。把反转后的新尾接到原来的第 right 1 个节点上。这里最经典的坑就是 left 等于 1 的情况。如果 left 为 1头部没有前驱节点单独处理会很麻烦。解决办法是引入一个哑节点dummy node在真正头节点前面加一个哨兵让 pre 初始指向哑节点这样无论 left 是不是 1前驱节点都不为空代码逻辑就可以统一处理。public ListNode ReverseBetween(ListNode head, int left, int right) { ListNode dummy new ListNode(0); dummy.next head; ListNode pre dummy; for (int i 0; i left - 1; i) { pre pre.next; } ListNode cur pre.next; // 头插法把 cur.next 不断移到 pre 之后 for (int i 0; i right - left; i) { ListNode next cur.next; cur.next next.next; next.next pre.next; pre.next next; } return dummy.next; }上面这个“头插法”版本比常规的三指针漂移要更高效些不需要先定位 right 节点而是在一次遍历中持续把后面的节点头插到 pre 之后。你可以把 pre 想象成区间前面的固定锚点每次循环都把“当前节点的后继”拎到锚点后面迭代完成时区间内已经逆序。这个方法实测写起来也更容易记我特别喜欢推荐给面试前临时抱佛脚的朋友。4.2 K 个一组反转把大问题拆成小组装如果说区间反转是“一段一组”那 K 个一组翻转链表就是“连续多段每段都要反转并且连接起来”。这种题通常会额外给你一个参数 k要求从头开始每 k 个节点一组做反转如果最后一组不足 k 个就保持原样。它考查的其实是对“局部反转 全局拼接”的综合能力。思路可以分成五步用一个指针start指向当前组的第一个节点。从start出发走 k 步若能走到第 k 个节点说明这组完整若不足 k 个说明是尾巴直接结束。记录组的后继节点nextGroupStart。对这一组执行标准的链表反转得到新头和新尾。把上一组的尾节点接到新头新尾接到nextGroupStart然后更新指针进入下一组。这类题写起来容易乱因为要同时维护“上一组末尾”“当前组反转前首个节点”“下一组起点”三个引用。我写的时候习惯在纸上先画三个方框把指针关系标清楚再动手比硬想代码快得多。这里还有一个很实用的优化你不用真的去数 k 次可以直接用一个计数循环预判剩余节点数是否足够提前结束避免无谓的操作。// 伪代码示意先判断剩余长度是否够一组 ListNode node start; for (int i 0; i k; i) { if (node null) return dummy.next; // 不够一组保持原样 node node.next; }这段判断代码的妙处在于它把“是否够一组”和“定位组尾”合二为一了。如果遍历完 k 个节点后 node 不为 null说明这组是完整的如果中途 node 变为 null说明已经是最后不足 k 个的尾巴了。实测下来这种写法比先单独写一个计算长度的函数更节省代码量也不容易在边界上出错。5. 常见错误与调试技巧实录5.1 我在刷题中踩过的三类典型错第一类错误是丢了链表后继也就是在改cur.next之前没有先保存cur.next。这个问题通常发生在手写代码时过于急切把顺序写反。症状是运行到后面出现空指针异常或者链表从某个节点开始突然断裂。排查方法很简单在循环体的第一行打日志打印cur.val和cur.next.val如果第二次循环时cur变成 null说明上一次保存后继的位置有问题。第二类错误是指针更新顺序混乱。典型写法是把prev和cur的更新顺序写反了结果就是prev永远停在第一个节点循环变成了死循环或错位反转。这个问题在 IDE 里调试时会发现进程卡住但在白板面试时只能靠自己在脑子里模拟。我的自查口诀是先改 next 方向再动 prev最后动 cur三步顺序永远不能调换。第三类错误是返回值选错。不少人在迭代法中返回了cur而不是prev在递归法里返回了head而不是newHead。这类错误往往在链表长度为 0 或 1 时发现不了因为返回值正好相等在长度大于等于 2 时立刻爆雷。为此我养成了一个习惯写完任何链表反转代码第一反应就是用三个节点的链表跑一遍完整流程这是覆盖所有边界情况的最小有效样本。错误类型典型表述排查思路丢后继没有保存temp cur.next就反转检查循环体第一行是否保存后继更新顺序错cur和prev更新顺序颠倒按“保存-反转-前移-当前”口诀自查返回值错返回cur而非prev用三个节点链表模拟确认最后指向递归忘记断链没把head.next置空检查反转后原头是否还指向原第二个节点5.2 手写代码时的自查清单结合我带人和自己面试的经验我整理了一份自己每次刷链表题都会过的自查清单。先把链表画成一条横线标注 head、cur、prev、temp 的位置再写代码。写完后照着清单走一遍能省下大量调试时间。空链表和单个节点链表是否能直接返回循环能否正常终止还是会出现无限循环每个节点的 next 指针最终是否都指向了正确的前驱/后继原链表的头节点是否已经变成了新链表的尾节点且其 next 为 null返回值是否指向了新链表的头节点而不是 null 或原头节点我还会刻意测试两个极端的输入一个全是相同值的链表一个全部逆序好的链表。同值链表经常能暴露“比较值而不是比较引用”的错误已经逆序好的链表则能测试反转后是否变成了正序。这两个测试用例在任何链表题目里都通用几乎是万能试金石。另外如果你用的是带有调试器的编辑器我强烈建议你在循环体的四个关键行各设一个断点逐步观察prev、cur、temp三个引用的变化。这个习惯比任何文字解释都来得直观。我第一次彻底理解反转链表不是靠看文章而是靠单步调试三指针变迁看完那一遍后续所有链表变体都顺理成章。6. 一些额外的题外话我个人在实际操作中是建议把反转链表当作“睡前十分钟”来练的不看题解直接在编辑器里默写迭代版再默写递归版。连续写三天你会发现自己对指针的掌控感会明显提升——这种肌肉记忆在真正面试时特别重要因为面试官通常不会只问基础反转他们会在你写完基础版后立刻追加一个变体比如“那如果只反转奇数位置的节点呢”这时候如果你对基础版足够熟悉变体其实就是在基础版框架上改条件而已。还有一点值得说链表的题目往往不要求你背代码而是要求你画图。每道题动手前把链表画出来用箭头标注每一步变化比任何高级分析都管用。我见过有些同学在纸上画一遍之后就豁然开朗也见过光看代码模拟半天还在纠结 next 指向的。这轮练习过后你对链表操作的信心会高出一截再去刷环形链表、合并链表、删除倒数第 N 个节点这类题目时你会发现底层思路都是同一套把节点引用关系理清楚边界条件做扎实代码自然就稳了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。