ARTICLE DETAIL

资讯详情

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

单链表反转彻底搞懂:迭代法、递归法与边界处理

单链表反转彻底搞懂:迭代法、递归法与边界处理 单链表反转这道题我最早不是在算法书里碰到的而是给一个嵌入式数据缓冲模块做逆序输出时遇到的。当时我第一反应是开一个临时数组把所有节点值倒着拷出来结果被同事一句话问住了“你倒的是值不是链表本身。”后来我才彻底明白反转单链表的核心不是把数据反过来而是把每个节点的next指针方向整体翻转——这恰恰是绝大多数初学者的第一道坎。再后来我去面试别人几乎每次都会把单链表反转当作第一道手写题。它代码量不大却能一眼看出你对指针操作的理解会不会断链、会不会丢节点、边界条件想得全不全。网上也经常看到“单链表的反转怎么写”“python单链表逆序怎么做”这类提问如果你想把这道题彻底吃透而不是靠背代码模板应付这篇内容值得看完。1. 为什么“单链表反转”值得单独拿出来写很多人觉得链表反转不就是改几个指针吗能有什么花头。但真正上手写一遍你就会发现这里面的弯弯绕绕比想象中多得多。先搞清楚“为什么这个问题值得单独讨论”后面理解代码会顺畅很多。1.1 链表和数组在“反转”上的根本差异数组反转是典型的随机访问操作用一对前后指针从两端往中间走交换元素的值。因为数组是一块连续内存元素本身不用动交换值就达到了“顺序反转”的效果。整个过程不改变数组的物理结构额外空间O(1)逻辑简单到几乎不需要思考。链表就不一样了。单链表的每个节点在内存里是零散分布的节点之间只靠next指针串联。你要反转它不能像数组那样交换两个节点的“位置”因为节点的内存地址根本不是你能控制的。你必须做的是把每个节点next指针的方向全部改变让原来的头节点变成尾节点原来的尾节点变成头节点。用一个生活化的比喻数组像一排列好编号的储物柜把1号柜和5号柜里的东西互换柜子本身不动单链表像一列火车每节车厢只认识挂在它后面的那节车厢要让整列火车掉头你得把每一节车厢之间的挂钩全部解开、换个方向重新挂。这就是为什么很多人第一次写链表反转时明明“值看起来对了”结构却全乱了。1.2 一个很多人踩过的最初误区搜索“单链表逆序”的时候你会发现相当一部分Python解答是这么写的先遍历一遍原链表把节点值存进一个列表然后从后往前遍历再新建一个链表或者给原链表节点重新赋值。比如def reverse_list_value(head): vals [] cur head while cur: vals.append(cur.val) cur cur.next # 再逆序遍历把值赋回原链表 cur head for v in reversed(vals): cur.val v cur cur.next return head这段代码跑起来打印链表确实变成了逆序。但它反转的只是“节点里存的值”节点的物理连接顺序一点没变。如果你接下来对链表做删除、插入、局部反转这类操作很快就会出问题——因为结构还是原来的正序结构只是外表数值看起来反了。面试里考官问“反转链表”默认要的就是结构级反转遍历从新头开始一步一步走到的确是原来的尾节点。如果只做值反转属于没理解题意。当然在某些只关心“最终打印结果长什么样”的简单场景值反转够用但作为基本功你必须掌握原地结构反转。1.3 现实场景什么时候真的需要反转链表有人觉得这题就是面试八股工程里没人这么干。实际上反转链表在很多地方是底层基础操作。第一种场景是逆序遍历。单链表只有next指针想从尾到头访问节点只能靠递归或显式栈空间复杂度都是O(n)。如果环境对内存极其敏感你又不允许用额外缓冲区那就只能原地把链表反转遍历完成后再决定要不要恢复。第二种场景是回文判断。判断一个链表是不是回文经典做法就是用快慢指针找到中点把后半段反转然后从两头同时比较。虽然严格来说这是“部分反转”但用的技能和整链反转完全同源。第三种场景是各种进阶题的基础。比如“反转链表中从left到right的部分”“每K个节点一组反转”“两两交换相邻节点”这些题目本质上都是在做局部反转唯一区别是边界处理更复杂。可以说单链表反转是一个“原子操作”类似排序里的交换很多高阶题都是它的组合。2. 迭代法核心三根指针怎么把链“倒过来”理解了为什么需要结构反转之后我们现在来看最经典、最推荐的解法迭代法也叫三指针法。它只需要O(1)的额外空间代码好写逻辑也直接。2.1 指针流转全过程拆解迭代法的思路只有一句话从头开始遍历链表把每个节点的next指向前一个节点同时用一个临时指针保存原链表的下一个节点防止断链后找不回来。文字描述比较抽象我们直接看代码。用C写一个最标准的版本struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* reverseList(ListNode* head) { ListNode* pre nullptr; ListNode* cur head; while (cur ! nullptr) { ListNode* nextNode cur-next; // 先保存下一个节点 cur-next pre; // 当前节点指向前一个节点 pre cur; // pre前进 cur nextNode; // cur前进 } return pre; }这里有三个指针很多人一开始搞不清它们各自扮演什么角色pre前驱指针永远指向当前节点“反转之后应该指向的那个节点”。初始值为nullptr因为反转后原来的头节点会变成尾节点尾节点的next必须为空。cur当前指针每次循环要处理的那个节点。它的next需要被改成指向pre。nextNode后继暂存指针防止修改cur-next之后丢失原链表后续部分的临时指针。关键点在于循环体里的四行代码顺序不能换。我们用一个三节点的链表1 - 2 - 3 - null完整走一遍你就明白为什么这个顺序是死的。第一轮循环cur指向节点1pre是null。先保存nextNode 节点2然后执行节点1-next null节点1就脱离了原来的链表“孤零零”站在最前面。此时pre移到节点1cur移到节点2。链表状态1 - null2 - 3 - null。第二轮循环cur指向节点2pre指向节点1。保存nextNode 节点3执行节点2-next 节点1。现在节点2反过来挂到节点1前面。pre移到节点2cur移到节点3。链表状态2 - 1 - null3 - null。第三轮循环cur指向节点3pre指向节点2。保存nextNode null执行节点3-next 节点2。pre移到节点3cur变成null。链表状态3 - 2 - 1 - null。循环结束返回pre也就是节点3。整个链表成功反转。下面这张表能更直观地看到每一轮三个指针的位置变化轮次操作前pre操作前cur保存的nextNode操作后pre操作后cur当前链表已有状态初始null节点1-null节点11-2-3-null1null节点1节点2节点1节点21-null2节点1节点2节点3节点2节点32-1-null3节点2节点3null节点3null3-2-1-null每次看到有人手写这道题翻车十有八九就是这张表没在脑子里过一遍。2.2 为什么“先存next”这一步是生死线初学者最容易犯的错误是一上来就写cur-next pre把“保存nextNode”这行漏掉。我当年也犯过。设想一下如果第一轮循环没有保存节点2直接执行节点1-next null那么原链表里节点1 - 节点2的这条引用就被覆盖了。此时你手里的cur虽然知道自己是节点1但它已经无法找到节点2。链表的后半段相当于“蒸发”了程序马上就会出问题。用倒车入库来类比你要把车头调转必须先看清楚车后面有没有墙、有多少空间。链表反转里的nextNode就是那个“后视镜”你必须在打方向盘之前确认后面的路况否则一打方向就撞墙。所以四行代码的正确顺序是铁律先存nextNode cur-next再改cur-next pre后移prepre cur后移curcur nextNode如果顺序颠倒要么断链要么死循环。2.3 终止条件和返回值为什么是它们迭代法的终止条件是cur ! null不是nextNode ! null。这里有个很容易忽略的细节当cur走到最后一个节点节点3时它仍然需要完成“把next指向pre”的操作。只有当cur变成null时才说明所有节点都已经处理完。如果错误地把循环条件写成while (nextNode ! null)那最后一轮循环会在处理节点3之前就退出导致节点3的next没有被改成指向节点2反转结果不完整。循环结束后cur是nullpre停留在原链表的尾节点也就是反转后链表的头节点。所以返回值必须是pre。如果返回head那拿到的还是原来的头节点它现在已经是尾节点了遍历它只能走到null整个反转白做。2.4 不同语言照着写Python和JavaScript版本很多学Python的同学搜过“python单链表逆序”特别是刷LeetCode的时候。Python版逻辑和C完全一样只是写法上更简洁class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def reverse_list(head): pre None cur head while cur: next_node cur.next # 先保存 cur.next pre # 反向 pre cur # pre前进 cur next_node # cur前进 return preJavaScript/TypeScript版本长得也差不多function reverseList(head) { let pre null; let cur head; while (cur ! null) { const nextNode cur.next; cur.next pre; pre cur; cur nextNode; } return pre; }你会发现一旦理解了三个指针的流转把这套逻辑翻译到任何语言都只是换了一层语法糖。真正需要思考的不是语言而是指针操作的顺序。3. 递归解法从“递推公式”理解反转的本质迭代法适合动手递归法适合动脑。递归版的代码比迭代版还短但理解门槛高不少。很多人能背下来代码却解释不清为什么对。这一节我们一步步拆。3.1 递归出口和子问题划分递归版本最常见的写法是这样的ListNode* reverseListRecursive(ListNode* head) { if (head nullptr || head-next nullptr) { return head; } ListNode* newHead reverseListRecursive(head-next); head-next-next head; head-next nullptr; return newHead; }先看终止条件head nullptr处理空链表head-next nullptr处理单节点链表。这两种情况反转后都是它自己直接返回。再看子问题reverseListRecursive(head-next)做的事情是——把“以head-next为头节点”的那段子链表反转并返回反转后的新头。我们用1 - 2 - 3 - null来递推reverseListRecursive(节点1)会先调用reverseListRecursive(节点2)reverseListRecursive(节点2)会先调用reverseListRecursive(节点3)reverseListRecursive(节点3)发现节点3的next是null直接返回节点3到这里递归开始“回溯”。很多人就是卡在这个回溯过程里因为不清楚每一层返回之后链表到底长什么样。3.2 回溯时 head-next-next head 到底在干什么这是递归版最核心的一行代码。我们来仔细分析回溯到节点2那一层时的情况。当reverseListRecursive(节点3)返回节点3之后程序回到reverseListRecursive(节点2)这一层。此时head是节点2head-next是节点3而节点3现在是什么状态节点3的next还是null吗不是节点3作为子问题传入时它的next原本是null所以它返回时自己不需要修改但如果链表更长比如1-2-3-4那节点4反转回来之后会让节点3的next指向4的后面……这里容易绕晕。我们先把简单例子的状态理清。回到节点2这一层时节点3的next还是null因为节点3是原链表的尾节点它自身没有需要改的方向。此时执行head-next-next head也就是节点3-next 节点2。注意这一步让节点2被挂到了节点3后面形成一个小的反向结构3 - 2。但问题来了节点2的next现在还指向节点3。如果不去改它那么节点2和节点3就会互相指向形成环。所以紧接着执行head-next nullptr把节点2的next清空。于是子链表变成3 - 2 - null而newHead是节点3。随后把newHead返回给上一层。再回到节点1这一层。此时head是节点1head-next还是节点2吗是的节点1的next从未被修改过。而节点2的next在上一步已经被改成null了。现在链表的状态是1 - 2 - null加上已经反转好的3 - 2但2的next已经被切断……实际上此刻结构是节点1指向节点2节点2指向null节点3指向节点2。如果你从节点1开始走1走到22走到null节点3暂时“游离”但其实3指向2。这时候执行head-next-next head也就是节点2-next 节点1节点2被接到节点1前面。再执行head-next nullptr清掉节点1的next。最终得到3 - 2 - 1 - nullnewHead是节点3返回给调用者。一句话总结这行代码的作用把当前节点的后继节点反过来指向当前节点然后把当前节点的next断掉。它就相当于迭代法里的cur-next pre只不过pre在这个场景下是通过递归层层带回来的“上一个节点”。3.3 递归的代价代码短不表示一定更好递归版的时间复杂度是O(n)没得说每个节点访问一次。但空间复杂度是O(n)因为每一层递归都要占用栈帧。对一个几万甚至几十万节点的链表执行递归反转很容易把程序栈压爆。所以在工程代码里我几乎不用递归版除非明确知道链表长度很短。递归版的另一个问题是不好调试。迭代版中间出错打个断点看pre、cur、nextNode三个变量就够了递归版要一层层回溯脑子和调试器都容易跟丢。面试时如果你写递归版面试官很可能会追着问“为什么head-next-next head是对的”讲不清楚反而减分。3.4 迭代和递归怎么选一张表说清对比维度迭代法递归法时间复杂度O(n)O(n)空间复杂度O(1)O(n)递归栈代码长度略长约6-8行更短约5-6行调试难度低变量状态清晰高函数调用栈复杂链表很长时稳定可能栈溢出面试建议推荐优先写能讲清原理再加分我的个人习惯是面试里默认先写迭代版写完可以提一句“这个题也可以用递归核心是head-next-next head但递归版有O(n)的栈开销”。这句话本身就能展示你不仅会写代码还对时空复杂度有意识。4. 边界情况和必踩的坑从空链表到循环链表单链表反转代码量少但边界情况比很多中等难度题还多。很多人leetcode一提交发现报错不是算法问题而是边界没处理。这一节集中把坑都过一遍。4.1 空链表和单节点一定要判空空链表反转结果还是空链表这没争议。单节点链表反转后还是那个节点。迭代法其实天然兼容这两种情况空链表时while循环不执行直接返回pre也就是null单节点时只走一轮循环返回原节点。递归法就不一样了必须有显式的终止条件if (head nullptr || head-next nullptr) { return head; }很多初学者把head nullptr判空当成“防御性编程”略过结果在空链表上调用head-next直接空指针异常。两种情况都得写缺一不可。我自己写链表相关代码时有个习惯任何操作函数第一个要考虑的就是“如果链表是空的会怎样”然后才是“如果只有一个节点”。先把这两种极端情况在开头处理掉后面的主逻辑会清爽很多。4.2 三个经典翻车现场断链、死循环、成环翻车现场一断链。这个前面详细说过就是忘记保存nextNode直接cur-next pre。检查方法很简单写完后手推一遍如果发现某个节点从“可达状态”变成了“无人指向”那基本就是断链了。翻车现场二死循环。典型写法是循环体末尾忘记执行cur nextNode导致cur一直停在原头节点每轮都把cur-next指向pre但cur从来不前进。程序会陷入死循环跑都跑不完。我的排查技巧是在循环体末尾打印cur-val如果打印出来一直是同一个值那基本就是忘了推进指针。翻车现场三成环。迭代法里如果最后没有让原头节点现在的新尾节点的next指向null比如多节点情况下cur-next pre之前pre不是null而是原头节点就会形成循环引用。标准写法里pre初始化成null天然规避这个问题。但如果你写成ListNode* pre head就会出大问题——第一个节点的next指向head自己形成自环遍历根本停不下来。很多人以为“头节点next置null”是多余的反正到头自然就是null。但别忘了原链表的头节点在反转前的next指向的是第二个节点如果不显式改掉它反转后指向的仍然是第二个节点直接就成环了。变量名叫pre还是prev倒在其次关键是初始值必须是null。4.3 循环单链表的反转先断环再接环搜索热词里频繁出现“循环单链表”这也是“单链表的基本操作实验”里的常见考点。循环单链表的尾节点不指向null而是指向头节点整体构成一个环。反转这样的链表最稳妥的思路是先把环断开按普通链表反转最后再接回成环。具体步骤如下从头节点出发找到尾节点也就是满足tail-next head的那个节点。同时把尾节点记录下来。把tail-next nullptr暂时破坏环结构。现在链表变成了一个普通单链表起点是head终点是tail。对这个普通单链表执行标准反转得到newHead也就是原链表的尾节点。此时原链表的头节点head已经变成了反转后的尾节点它的next为null。执行head-next newHead把反转后的尾节点重新接回头节点恢复循环结构。返回newHead作为反转后循环链表的新头。一个只有A、B、C三个节点的循环链表A-B-C-A断开环后是A-B-C-null反转后是C-B-A-null最后执行A-next C得到C-B-A-C。这样就完成了循环链表的整体反转。这个流程的关键点在于一定要在反转前记录好尾节点并且最后通过head-next newHead来恢复环而不是试图在反转过程中保持环不断。后者逻辑非常绕容易在终止条件上翻车。先断、反转、再接三步走既不容易错也方便写实验报告。4.4 反转完怎么验证结果是对的写完代码不能只是“看着对”至少要跑两个层面的验证。第一层结构验证。从头遍历一遍检查每个节点的next关系是否构成一条完整的链以及最后一个节点的next是否为null普通链表或者指向新头循环链表。可以写个辅助函数def print_list(head): cur head values [] while cur: values.append(str(cur.val)) cur cur.next print( - .join(values))第二层值序列验证。反转前先收集原链表的值序列反转后再收集一次断言第二个序列正好是第一个序列的逆序def get_values(head): vals [] cur head while cur: vals.append(cur.val) cur cur.next return vals assert get_values(reversed_head) list(reversed(get_values(original_head)))这一步能在测试用例里自动检查比肉眼看打印结果可靠得多。特别是链表很长或者节点值有重复的情况打印结果很难人肉校对断言直接给出对错。5. 进阶只反转一部分、K个一组反转与反转思想的延伸能把整链反转写对只是掌握了基本功。真正让反转技能产生价值的是它的进阶形态。这一节从区间反转讲起逐步过渡到K个一组反转最后聊聊反转思想的延伸场景。5.1 区间反转哨兵节点让边界处理变简单问题给定left和right反转链表中从第left个节点到第right个节点的部分。比如1-2-3-4-5left2right4结果是1-4-3-2-5。思路并不复杂找到left位置的前驱节点pre然后对pre-next开始的一段子链表做长度为right-left1的反转反转结束后接回原链表。这里面最容易出错的地方是left1的边界情况。如果头节点也要参与反转那么“前驱节点”根本不存在所有逻辑都要为头节点单独写判断。解决办法是引入哨兵节点也叫dummy nodeListNode* reverseBetween(ListNode* head, int left, int right) { ListNode* dummy new ListNode(0); dummy-next head; ListNode* pre dummy; for (int i 1; i left; i) { pre pre-next; } ListNode* cur pre-next; // 迭代反转 right-left 次 for (int i 1; i right - left 1; i) { ListNode* nextNode cur-next; cur-next nextNode-next; nextNode-next pre-next; pre-next nextNode; } return dummy-next; }这里用了一个比较小巧的“头插法”思路每次把cur后面的那个节点摘下来插到pre的后面。执行完right-left次后区间内的节点顺序就完全反转了。因为多了一个dummy节点即使left1pre也永远存在不需要对头节点做任何特殊处理。这个技巧在链表类题目里特别好用——我在很多进阶题里都用dummy来规避头节点的“特殊公民”问题。5.2 K个一组反转四个关键指针的维护K个一组反转可以看作区间反转的循环版每K个节点一组各自反转组内反转后还要把这一组接到整体链表上。题目描述一般是“给你一个链表每K个节点一组进行翻转请你返回修改后的链表。K是一个正整数它的值小于或等于链表的长度。如果节点总数不是K的整数倍那么请将最后剩余的节点保持原有顺序。”核心做法是用pre记录“上一组反转后的尾节点”初始为一个dummy节点方便处理第一组。不断检查当前组是否有至少K个节点。没有的话直接把剩余部分接到尾部返回。对当前组的K个节点做区间反转得到新的组内头节点。把反转后的组挂到已处理部分的尾部。更新pre为当前组的原头节点反转后它变成了组内尾节点然后处理下一组。直接上代码ListNode* reverseKGroup(ListNode* head, int k) { ListNode* dummy new ListNode(0); dummy-next head; ListNode* pre dummy; while (head) { ListNode* tail pre; // 检查剩余节点数是否 k for (int i 0; i k; i) { tail tail-next; if (!tail) return dummy-next; } ListNode* nextGroup tail-next; // 反转从 head 到 tail 这一段 ListNode* reversedHead reverseBetween2(head, tail); // 接回原链表 pre-next reversedHead; head-next nextGroup; // 更新 pre 为当前组原头节点 pre head; head nextGroup; } return dummy-next; }这里reverseBetween2是实现“反转从node1到node2之间所有节点”的辅助函数返回反转后的头节点。实际写的时候可以直接复用5.1区间的逻辑也可以单独写一个只接受两个节点的版本。K个一组反转最费心的地方在于每个组的边界指针前一组尾、本组头、本组尾、下一组头四个指针一个都不能乱。画图手推一次1-2-3-4-5K2你就能体会这四个指针的重要性。5.3 反转思想的延伸回文判断与逆序输出把反转技能铺开看它能延伸出不少经典算法。第一个延伸是回文链表判断。先通过快慢指针找到链表的中点然后把后半段反转再用两个指针分别从头部和反转后的后半段头部出发逐一比较节点值。全部相等就是回文。这个解法空间复杂度是O(1)不考虑递归栈复杂度很优。而且它并不要求“反转后保持结构不变”——判断完回文后如果业务需要把后半段再反转回来即可。第二个延伸是逆序输出单链表。如果只是要打印逆序结果最省事的是递归利用函数调用栈天然倒序输出也可以显式用一个栈。这两种做法空间复杂度都是O(n)。如果面试官要求O(1)空间那就得先把链表反转遍历输出最后再反转回去。这比开栈省空间但多两次O(n)遍历属于用时间换空间。第三个延伸是字符串反转。虽然字符串不像链表要改指针但“双指针从两端往中间交换”的思想和链表反转很像。很多人搜“字符串反转怎么打印出来c”答案是std::reverse(s.begin(), s.end())或者自己写双指针左边一个i右边一个jij时swap(s[i], s[j])然后i、j--。思想上和链表反转的“指针逐步靠拢”是一致的只是数据结构简单不需要担心断链。当你把一个知识点拆到这种程度再去用就不会再是“背题”状态了而是真的在工具箱里多了一把趁手的扳手。6. 从“会做题”到“能落地”复杂度分析与测试用例设计最后这部分不写具体算法了聊点更实际的东西怎么权衡复杂度怎么写测试用例以及我在这道题上积累的实操习惯。6.1 复杂度为什么是O(n)和O(1)迭代反转每个节点只访问一次每次循环做常数次指针操作所以时间复杂度是O(n)。这个复杂度已经是最优的——你至少要遍历一遍所有节点才能把每个next改掉不存在更快的可能。空间复杂度方面迭代法只用了三个指针变量不随链表长度变化所以是O(1)。递归法因为函数调用栈而变成O(n)这就是我反复强调工程场景优先选迭代法的原因。如果你在写一个低资源环境的服务一个一百万元素的链表递归反转很可能直接stack overflow迭代版则毫无压力。对比一下其他“看起来也能做”的方案如果用数组先存节点再逆序重建链表时间复杂度O(n)空间复杂度也是O(n)而且新建的节点和原节点不是同一个引用。在设计缓存、对象池这类对“对象身份”敏感的系统时这种方案会引入额外的内存分配和对象复制开销往往不可接受。原地反转之所以是标准答案不只是因为它能过面试题而是它在真实约束下确实是最合理的方案。6.2 测试用例设计别只测happy path给单链表反转写测试很多人的用例就是一个较长的正常链表。这远远不够。有经验的工程师会把用例分成几类空链表null反转结果应该是null。单节点链表1 - null反转结果还是1 - null。两个节点1 - 2 - null反转结果2 - 1 - null。这是最小规模的“需要真正反转”的用例能暴露很多实现问题。多个节点1 - 2 - 3 - 4 - 5 - null反转结果5 - 4 - 3 - 2 - 1 - null。节点值全部相同5 - 5 - 5 - null验证反转后值和结构都正确。非常长的链表比如10万个节点验证迭代版能正常完成且时间可接受。也可以用这个用例对比递归版是否会栈溢出。已经反转过的链表比如输入本身就是5 - 4 - 3 - 2 - 1 - null再反转一次应该恢复成1 - 2 - 3 - 4 - 5 - null。循环链表如果有扩展需求反转后要保证闭环关系存在。测试代码可以用断言来写比如在Python里def test_reverse_list(): # 空链表 assert reverse_list(None) is None # 单节点 head ListNode(1) assert reverse_list(head) is head # 多节点 head ListNode(1, ListNode(2, ListNode(3, ListNode(4)))) reversed_head reverse_list(head) assert get_values(reversed_head) [4, 3, 2, 1]注释里标注好每类用例想验证什么万一以后重构代码测试能帮你兜底。6.3 手写代码时最容易翻车的几个细节这些年我看过很多人手写单链表反转也亲手踩过不少坑总结几个实操细节。第一个细节写代码前先在纸上画一个三列表格列名分别是pre、cur、nextNode手工把1-2-3跑一遍。这个动作花不了一分钟但能拦住至少一半的“断链”错误。很多人上来就敲代码敲完发现逻辑不对再回头调试花费的时间远超过画表的时间。第二个细节迭代版写完以后不要急着跑完整链表。先用两个节点的链表测再用三个节点的链表测。两节点能暴露指针方向改没改对三节点能暴露中间的指针推进有没有问题。这是我自己写链表操作类题目时雷打不动的习惯。第三个细节遇到“反转链表的一部分”“K个一组反转”这类题优先想想能不能用dummy节点把头节点“普通化”。很多边界问题都是因为头节点没有前驱而dummy节点可以完美解决这个矛盾。这个思路不是我发明的但确实是链表题里最实用的工程技巧。dummy不是刷题专用的小聪明在真实项目里处理链表头节点同样好用。第四个细节面试时如果和面试官聊到了递归法一定要主动说出空间复杂度的代价。很多人觉得递归代码短显得“高级”是加分项。但在工程视角下O(n)的栈开销在长链表场景是实打实的风险。能说出“递归版简洁但迭代版更稳”这句话面试官大概率会觉得你是有经验的人而不是只会背题的学生。单链表反转这道题现在回看真算是我接触算法和数据结构的“破冰题”。它足够简单基本没有复杂的逻辑分支适合作为理解指针操作的第一课它又足够深刻能把“边界处理”“复杂度分析”“结构思维”这些工程能力全串起来。我在面试别人的时候经常把这道题作为第一题不是为了难倒对方而是想看看候选人在最基础的地方是否稳。能把单链表反转一次写对的人后面做LRU缓存、合并K个有序链表时思路通常会清楚很多。如果你正在准备面试或者刚学完链表这一章我的建议很简单先别看题解自己拿张纸画出1-2-3用手把三根指针拨一遍然后写出迭代版。等迭代版闭着眼都能写对了再回头研究递归版那句head-next-next head。这套基本功打通之后你会发现链表的很多难题本质上都只是反转的变体。
返回列表