刷链表题的时候,我总喜欢先问自己一句:这道题是在考操作,还是在考思维?LeetCode 234题《回文链表》两样都占。题目本身很直白:给定一个单链表,判断它是不是回文链表。1 -> 2 -> 2 -> 1 是回文,返回 true;1 -> 2 不是回文,返回 false。可一旦面试官追加一句“能不能只用 O(1) 额外空间”,很多人就卡住了。这道题非常适合所有正在刷链表专题的人,尤其是校招、社招准备算法面试的朋友。它不涉及复杂的数学推导,也没有各种高深数据结构,但把快慢指针、链表反转、递归、空间复杂度分析这些高频考点全串了起来。说白了,它是链表操作里最典型的“组合拳”题目。我最早刷这道题的时候,第一反应也是“转成数组再用双指针”,后来在模拟面试中被问了一句“如果链表有十万个节点呢”,我才意识到这题真正要练的不是判断回文,而是如何在 O(1) 空间里解决逆序访问问题。这篇文章我就从最朴素的解法讲起,一步步拉到标准解法,再分享一些我在实际调试中踩过的坑。1. 题目定位与朴素解法1.1 回文链表的本质难点先明确一下什么是回文链表。从概念上说,“回文”指的是正着读和倒着读都一样,比如 1 -> 2 -> 3 -> 2 -> 1。数字数组做这个判断非常简单,两个下标从两端往中间走就行。但单链表不一样,它只有 next 指针,没有 prev 指针,更不支持随机访问。换句话说,你拿到了头节点,可以一路往后走,但想从尾节点往前走,在单链表里是做不到的。这就是回文链表的本质难点:判断回文天然需要“从两端往中间”或“从中间往两端”比较,而单链表只给了一个方向的遍历能力。所以我们需要用一些额外手段,比如先遍历一遍存到额外空间中,或者把链表的后半段反转,让后半段也能从尾到头方向访问。把这个本质想清楚,刷题方向就明确了。后面不管用什么解法,本质上都在解决同一个问题:如何在不额外占用大量内存的前提下,让链表后半段可被“从后往前”访问。1.2 用数组辅助判断(笔试够用,面试不够)最简单、也最容易想到的解法,是先把链表所有节点的值依次放进一个数组,然后对数组做双指针判断。这个思路完全不碰链表反转,也没有任何空指针风险。def isPalindrome(head): values = [] cur = head while cur: values.append(cur.val) cur = cur.next left, right = 0, len(values) - 1 while left < right: if values[left] != values[right]: return False left += 1 right -= 1 return True这段代码没有任何坑,时间复杂度 O(n),空间复杂度 O(n)。你只需要额外存一份链表值的数组。我为什么说它“笔试够用”?因为很多在线判题环境不会专门限制你使用 O(n) 额外空间,尤其是 LeetCode 这类平台,只要不超时、不超内存,提交上去都能过。但如果是在面试里,这只能算“起手式”,而不是最终答案。面试官往往会追问:能不能做到 O(1) 额外空间?你可能还会想到另一个思路:把整个链表反转一份,然后与原链表逐节点比较。这也是 O(n) 空间,因为单链表没有前驱指针,你为了反转后还能拿到原链表,必须复制一份完整链表,或者先构造一个新串,实际操作比数组更繁琐。所以相比之下,数组方案已经是最清晰的暴力解法。2. 标准解法:快慢指针 + 反转后半段2.1 核心思路:为什么是快慢指针和反转题目的进阶要求,是做到 O(n) 时间和 O(1) 额外空间。既然不能用额外数组,我们只能从链表结构本身想办法。把回文链表的特征拆开看,会发现一个规律:链表的前半段和后半段是“镜像对称”的。如果能找到中间位置,再把后半段反转,那么后半段就会从原来的“尾部”变成“头部”,这时候从链表头开始遍历前半段,同时从反转后的后半段头部开始遍历,两个指针一个往前一个往“相当于从尾部向前”的方向移动,就能逐项比较。所以整个解法拆成四步:用快慢指针找到链表的中间节点。反转中间节点之后的后半段链表。同时遍历前半段和反转后的后半段,逐节点比较数值。如果愿意,把后半段再反转回来,恢复原链表。这里面最难理解的就是第一步为什么用快慢指针,以及第二步反转之后链表会变成什么样。我逐个说。2.2 找中点:奇偶长度不能踩坑找链表中点,最常见的做法就是快慢指针:快指针每次走两步,慢指针每次走一步。当快指针走到链表末尾时,慢指针正好走到中间。slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next循环结束后,slow 的位置有两种情况:链表长度是奇数,slow 正好指向正中间那个节点。比如 1 -> 2 -> 3 -> 2 -> 1,slow 会指向 3。链表长度是偶数,slow 会指向中间偏右的那个节点。比如 1 -> 2 -> 2 -> 1,slow 会指向第二个 2。这个行为可以快速推导一下。假设链表有 4 个节点,fast 初始在节点 1,走两步后到节点 3,slow 走一步到节点 2;再判断一次,fast 在节点 3 时有 next,所以继续:fast 走两步后为 null,slow 走一步后到节点 3,也就是第二个 2。也就是说,对于偶数长度,slow 停在“右中点”。有一个非常常见的错误写法,是用while fast.next and fast.next.next。这个条件在某些链表长度下会让慢指针停在错误位置,尤其是只有两个节点的时候,循环一次都不会执行,slow 停在 head,反转后半段时就会把整个链表后半段搞错。所以一定要用while fast and fast.next。还有一个边界要提前处理:如果链表为空,或者只有一个节点,那一定是回文,直接返回 true。否则后面 fast.next 可能会空指针。2.3 反转链表与逐对比较找到 slow 之后,下面要反转以 slow 为头节点的后半段链表。反转操作是链表题的基本功,每次都必须保证先保存 next 再改指针,否则会断链。def reverse(head): prev = None cur = head while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev这个函数返回的是反转后新的头节点,也就是原链表的尾节点。调用完second_half = reverse(slow)后,second_half 就是从后往前排列的后半段链表。比较阶段我推荐这样写:first, second = head, second_half while second: if first.val != second.val: return False first = first.next second = second.next return True为什么不写成while first?因为当链表长度是奇数时,后半段反转后长度比前半段少一个节点。比如 1 -> 2 -> 3 -> 2 -> 1,后半段反转前是 3 -> 2 -> 1,反转后是 1 -> 2 -> 3,而前半段从 1 开始是 1 -> 2 -> 3。比较过程:first 从 1,second 从 1,相等。first 到 2,second 到 2,相等。first 到 3,second 到 3,相等;此时 second.next 为 null,循环结束。如果用while first,比较完 null 后会尝试访问 first.val,直接空指针。用 second 作为循环条件,正好避开中间的重复节点,因为后半段永远不比前半段长。2.4 复杂度与恢复链表这个解法的额外空间只有几个指针变量,不随链表长度增长,因此空间复杂度是 O(1),时间复杂度因为每个节点最多访问常数次,整体是 O(n)。这才是 LeetCode 234 题真正想考察的点。但这里有个隐藏问题:我们修改了原链表。LeetCode 的判题系统不会检查原链表是否被还原,所以很多题解直接不恢复。但面试时如果面试官说“我不希望链表被破坏”,你就需要多写一步恢复操作。恢复操作其实很简单:比较结束后,再调用一次reverse(second_half)即可。reverse(second_half)为什么这样就够了?因为反转前半段时,我们并没有断开前半段和 slow 之间的连接。前半段最后一个节点仍然指向 slow,而第一次反转后 slow.next 变成了 null,但 slow 后半段所在的链表被反转成了“尾到头”的顺序。第二次反转后,后半段又重新变回“头到尾”,并且头节点又是 slow。所以前半段末尾节点指向 slow 的关系依然成立,链表自动恢复原样。你可能会觉得这个细节很难想明白。我第一次推导的时候也花了不少时间,建议拿一张纸画一个奇数长度和偶数长度的链表,把每个节点的 next 变化标出来,跑两遍就通了。这块理解之后,你对链表反转的掌控力会明显提升。3. 递归解法:想象链表是一个回文结构3.1 递归回溯的核心写法除了反转后半段,还有一种非常巧妙的思路:利用递归天然的先深入后回溯,把链表从尾部开始依次访问。这个思路不修改链表结构,代码非常简洁,但空间复杂度很高。实现方式是这样的:在类里定义一个成员变量front,指向链表头。递归函数先一路递归到链表末尾,然后回溯时依次比较当前节点和 front 指向的节点,每比较一次,front 向后移动一步。class Solution: def isPalindrome(self, head): self.front = head def check(node): if node is None: return True if not check(node.next): return False if node.val != self.front.val: return False self.front = self.front.next return True return check(head)这段代码的核心逻辑是:递归到最深处时 node 是链表尾节点,此时 front 指向头节点,两个最外层的节点先比较;然后递归回溯到倒数第二个节点,front 已经移动到第二个节点,再比较;以此类推。这种解法对理解“链表的递归性质”很有帮助。你可以把 check(node.next) 这行理解成“先把后面的链表判断完,再处理当前节点”,本质上是用调用栈保存了每个待比较节点,以此实现倒序遍历。3.2 递归的空间代价与适用场景递归解法的空间复杂度是 O(n),因为递归调用栈的深度等于链表长度。如果链表有几万个节点,递归会非常危险,轻则内存占用大,重则直接栈溢出。所以在生产环境中,我不推荐这种写法;但在 LeetCode 题目本身的数据范围下,它可以通过。另外有一个细节特别容易踩坑:成员变量 self.front 必须在每次调用 isPalindrome 时重新赋值为 head。如果同一个 Solution 对象被多次调用,上一次调用结束时 front 已经指向链表末尾,下一次调用不重置,结果一定不对。还有人说,既然递归会保存调用栈,那我用显式的栈不也一样吗?确实,可以用一个栈把所有节点值压进去,再弹栈和原链表比较,空间也是 O(n)。这类做法在思维上是朴素的,也没有反转链表那么复杂,但面试时不太加分,因为和数组辅助法的空间复杂度没有本质区别。所以递归解法更适合作为“思路拓展”讲给面试官听,证明你有多种角度理解链表;但真正要写 O(1) 空间时,还是得回到快慢指针 + 反转的迭代方案。4. 边界条件、测试用例与现场排查4.1 一定要跑的测试用例清单链表题最怕空指针和边界错误。回文链表这道题,我非常建议你至少把下面这组用例跑一遍。不要只看“能不能通过”,要边跑边调试,观察 slow 的位置和反转后的链表长什么样。测试用例预期结果说明[]true空链表,直接判 true[1]true单节点链表,天然回文[1, 2]false偶数非回文[1, 1]true偶数回文,最简单的双节点回文[1, 2, 1]true奇数回文,中间节点不用管[1, 2, 2, 1]true经典偶数回文[1, 2, 3, 2, 1]true经典奇数回文[1, 2, 3, 4, 5]false非回文,用来验证返回值[-1, 2, -1]true负数也可以正常比较[1, 0, 0, 1]true存在 0 值,不影响比较超长链表,比如 10 万节点全为 1true验证迭代解法不会爆栈[1, 2, 3, 3, 2, 1]true另一种偶数回文[1, 2, 3, 4, 3, 1]false后半段和前半段形状类似但不完全一致这些用例看起来简单,但每一条都有针对性。比如 [1, 2] 用来验证两个节点时快慢指针是否正确;[1, 2, 1] 用来验证奇数长度下反转后半段不会把中间节点算进去;超长链表则专门攻击递归解法的栈空间问题。4.2 典型错误的定位方法我多次重写这道题,总结出几个最典型的错误,你可以照着自查。第一,快慢指针条件写错。while fast.next and fast.next.next在偶数长度的短链表中会让 slow 停在头节点,后续反转和比较全部错乱。解决办法就是固定用while fast and fast.next。第二,反转时丢节点。写反转链表时,如果先执行cur.next = prev,再取nxt = cur.next,拿到的 nxt 已经是 prev 了,链表直接断掉。这个错误非常隐蔽,尤其在你手忙脚乱的时候。建议把反转函数单独提取出来,先保存 nxt,再改指针,三步顺序别乱。第三,比较时用错循环条件。用while first在奇数长度回文上会越界访问 first.val。因为反转后的后半段比前半段少一个节点,比较到后来 second 已经变为 null,first 还指向中间节点。用while second才是安全的。第四,忘记恢复链表。如果面试里被问到,口头解释“我可以在最后再反转一次恢复原链表”,然后能写出对应代码,就很加分。但如果你根本不知道为什么要恢复,或者恢复时把指针接错,反而暴露了对链表结构的理解不足。排查这类问题有一个很实用的方法:写一个printList辅助函数,在找完中点后、反转后、比较前各打印一次链表。观察 slow 的位置和 second_half 的第一个节点值是否跟预期一致。我刷链表题时几乎都会用这个调试手段,比单步调试更快。5. 从刷题到面试:讲清楚比做出来更重要5.1 面试官想听到的思路演进这道题在模拟面试中出现的频率很高,因为它适合考查“从暴力到优化”的思维过程。如果你一上来就甩出快慢指针 + 反转的最优解,面试官其实不好判断你是背题还是真懂。更好的表达方式是分层推进。你可以先说:“这道题最直观的做法是转成数组,空间 O(n)。”然后补一句:“但既然链表本身不能随机访问,我可以把后半段反转,这样就能 O(1) 空间解决。”接着讲快慢指针怎么找中点、反转函数怎么写、比较时为什么用 second 作为循环条件。最后主动说:“如果不允许修改链表,我还可以再反转一次恢复。”这个过程中,考官最想听到的是你对边界情况的理解,比如奇数偶数长度、空链表、单节点。所以不需要着急写完所有代码,先把思路分点说出来,再动手写,会显得更从容。我在模拟面试中发现,很多候选人代码能过,但一问“为什么偶数长度时 slow 在中间偏右”就解释不清。这个点是这道题的命门,一定要练到能用一两句话讲明白:快指针走两步,慢指针走一步,快指针能走的步数决定了慢指针最终的位置;偶数长度时快指针最终停在 null 前,慢指针自然落在右中点。5.2 延伸题与通用能力这道题做完之后,不要急着删掉,它后面还关联着一串高频题:反转链表 II,要求反转链表中某一段,核心就是本题所用的反转函数。删除链表的倒数第 N 个节点,用快慢指针找位置,和找中点异曲同工。环形链表 II,也是快慢指针,只是多了一步数学推导。回文数,这个用字符串或取余都可以,和链表的解法思路不同,但“镜像对称”的判断逻辑相似。如果你把回文链表这道题彻底吃透,再去做这些延伸题,会明显感觉链表题之间是相通的。所谓“刷题量”,不是单纯刷了多少道,而是能否把一道题里的操作抽象成可复用的能力。回文链表刚好把“快慢指针”和“链表反转”两个基础能力揉在一起,值得多写几遍,直到能无脑写对。我个人实际操作中的体会是:写链表题千万不要只在脑子里想,一定要画图。画得不精致没关系,重点是标出每一步的指针指向变化。这道题我第一次在纸上画的时候,用了十几分钟,但画完之后整个状态就通透了。后面不管是写递归还是恢复链表,都能顺着指针方向一步步推出来。如果你也经常被链表题卡住,不妨试试这个办法,比一味刷题更有效。
刷链表题的时候,我总喜欢先问自己一句:这道题是在考操作,还是在考思维?LeetCode 234题《回文链表》两样都占。题目本身很直白:给定一个单链表,判断它是不是回文链表。1 -> 2 -> 2 -> 1 是回文,返回 true;1 -> 2 不是回文,返回 false。可一旦面试官追加一句“能不能只用 O(1) 额外空间”,很多人就卡住了。这道题非常适合所有正在刷链表专题的人,尤其是校招、社招准备算法面试的朋友。它不涉及复杂的数学推导,也没有各种高深数据结构,但把快慢指针、链表反转、递归、空间复杂度分析这些高频考点全串了起来。说白了,它是链表操作里最典型的“组合拳”题目。我最早刷这道题的时候,第一反应也是“转成数组再用双指针”,后来在模拟面试中被问了一句“如果链表有十万个节点呢”,我才意识到这题真正要练的不是判断回文,而是如何在 O(1) 空间里解决逆序访问问题。这篇文章我就从最朴素的解法讲起,一步步拉到标准解法,再分享一些我在实际调试中踩过的坑。1. 题目定位与朴素解法1.1 回文链表的本质难点先明确一下什么是回文链表。从概念上说,“回文”指的是正着读和倒着读都一样,比如 1 -> 2 -> 3 -> 2 -> 1。数字数组做这个判断非常简单,两个下标从两端往中间走就行。但单链表不一样,它只有 next 指针,没有 prev 指针,更不支持随机访问。换句话说,你拿到了头节点,可以一路往后走,但想从尾节点往前走,在单链表里是做不到的。这就是回文链表的本质难点:判断回文天然需要“从两端往中间”或“从中间往两端”比较,而单链表只给了一个方向的遍历能力。所以我们需要用一些额外手段,比如先遍历一遍存到额外空间中,或者把链表的后半段反转,让后半段也能从尾到头方向访问。把这个本质想清楚,刷题方向就明确了。后面不管用什么解法,本质上都在解决同一个问题:如何在不额外占用大量内存的前提下,让链表后半段可被“从后往前”访问。1.2 用数组辅助判断(笔试够用,面试不够)最简单、也最容易想到的解法,是先把链表所有节点的值依次放进一个数组,然后对数组做双指针判断。这个思路完全不碰链表反转,也没有任何空指针风险。def isPalindrome(head): values = [] cur = head while cur: values.append(cur.val) cur = cur.next left, right = 0, len(values) - 1 while left < right: if values[left] != values[right]: return False left += 1 right -= 1 return True这段代码没有任何坑,时间复杂度 O(n),空间复杂度 O(n)。你只需要额外存一份链表值的数组。我为什么说它“笔试够用”?因为很多在线判题环境不会专门限制你使用 O(n) 额外空间,尤其是 LeetCode 这类平台,只要不超时、不超内存,提交上去都能过。但如果是在面试里,这只能算“起手式”,而不是最终答案。面试官往往会追问:能不能做到 O(1) 额外空间?你可能还会想到另一个思路:把整个链表反转一份,然后与原链表逐节点比较。这也是 O(n) 空间,因为单链表没有前驱指针,你为了反转后还能拿到原链表,必须复制一份完整链表,或者先构造一个新串,实际操作比数组更繁琐。所以相比之下,数组方案已经是最清晰的暴力解法。2. 标准解法:快慢指针 + 反转后半段2.1 核心思路:为什么是快慢指针和反转题目的进阶要求,是做到 O(n) 时间和 O(1) 额外空间。既然不能用额外数组,我们只能从链表结构本身想办法。把回文链表的特征拆开看,会发现一个规律:链表的前半段和后半段是“镜像对称”的。如果能找到中间位置,再把后半段反转,那么后半段就会从原来的“尾部”变成“头部”,这时候从链表头开始遍历前半段,同时从反转后的后半段头部开始遍历,两个指针一个往前一个往“相当于从尾部向前”的方向移动,就能逐项比较。所以整个解法拆成四步:用快慢指针找到链表的中间节点。反转中间节点之后的后半段链表。同时遍历前半段和反转后的后半段,逐节点比较数值。如果愿意,把后半段再反转回来,恢复原链表。这里面最难理解的就是第一步为什么用快慢指针,以及第二步反转之后链表会变成什么样。我逐个说。2.2 找中点:奇偶长度不能踩坑找链表中点,最常见的做法就是快慢指针:快指针每次走两步,慢指针每次走一步。当快指针走到链表末尾时,慢指针正好走到中间。slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next循环结束后,slow 的位置有两种情况:链表长度是奇数,slow 正好指向正中间那个节点。比如 1 -> 2 -> 3 -> 2 -> 1,slow 会指向 3。链表长度是偶数,slow 会指向中间偏右的那个节点。比如 1 -> 2 -> 2 -> 1,slow 会指向第二个 2。这个行为可以快速推导一下。假设链表有 4 个节点,fast 初始在节点 1,走两步后到节点 3,slow 走一步到节点 2;再判断一次,fast 在节点 3 时有 next,所以继续:fast 走两步后为 null,slow 走一步后到节点 3,也就是第二个 2。也就是说,对于偶数长度,slow 停在“右中点”。有一个非常常见的错误写法,是用while fast.next and fast.next.next。这个条件在某些链表长度下会让慢指针停在错误位置,尤其是只有两个节点的时候,循环一次都不会执行,slow 停在 head,反转后半段时就会把整个链表后半段搞错。所以一定要用while fast and fast.next。还有一个边界要提前处理:如果链表为空,或者只有一个节点,那一定是回文,直接返回 true。否则后面 fast.next 可能会空指针。2.3 反转链表与逐对比较找到 slow 之后,下面要反转以 slow 为头节点的后半段链表。反转操作是链表题的基本功,每次都必须保证先保存 next 再改指针,否则会断链。def reverse(head): prev = None cur = head while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev这个函数返回的是反转后新的头节点,也就是原链表的尾节点。调用完second_half = reverse(slow)后,second_half 就是从后往前排列的后半段链表。比较阶段我推荐这样写:first, second = head, second_half while second: if first.val != second.val: return False first = first.next second = second.next return True为什么不写成while first?因为当链表长度是奇数时,后半段反转后长度比前半段少一个节点。比如 1 -> 2 -> 3 -> 2 -> 1,后半段反转前是 3 -> 2 -> 1,反转后是 1 -> 2 -> 3,而前半段从 1 开始是 1 -> 2 -> 3。比较过程:first 从 1,second 从 1,相等。first 到 2,second 到 2,相等。first 到 3,second 到 3,相等;此时 second.next 为 null,循环结束。如果用while first,比较完 null 后会尝试访问 first.val,直接空指针。用 second 作为循环条件,正好避开中间的重复节点,因为后半段永远不比前半段长。2.4 复杂度与恢复链表这个解法的额外空间只有几个指针变量,不随链表长度增长,因此空间复杂度是 O(1),时间复杂度因为每个节点最多访问常数次,整体是 O(n)。这才是 LeetCode 234 题真正想考察的点。但这里有个隐藏问题:我们修改了原链表。LeetCode 的判题系统不会检查原链表是否被还原,所以很多题解直接不恢复。但面试时如果面试官说“我不希望链表被破坏”,你就需要多写一步恢复操作。恢复操作其实很简单:比较结束后,再调用一次reverse(second_half)即可。reverse(second_half)为什么这样就够了?因为反转前半段时,我们并没有断开前半段和 slow 之间的连接。前半段最后一个节点仍然指向 slow,而第一次反转后 slow.next 变成了 null,但 slow 后半段所在的链表被反转成了“尾到头”的顺序。第二次反转后,后半段又重新变回“头到尾”,并且头节点又是 slow。所以前半段末尾节点指向 slow 的关系依然成立,链表自动恢复原样。你可能会觉得这个细节很难想明白。我第一次推导的时候也花了不少时间,建议拿一张纸画一个奇数长度和偶数长度的链表,把每个节点的 next 变化标出来,跑两遍就通了。这块理解之后,你对链表反转的掌控力会明显提升。3. 递归解法:想象链表是一个回文结构3.1 递归回溯的核心写法除了反转后半段,还有一种非常巧妙的思路:利用递归天然的先深入后回溯,把链表从尾部开始依次访问。这个思路不修改链表结构,代码非常简洁,但空间复杂度很高。实现方式是这样的:在类里定义一个成员变量front,指向链表头。递归函数先一路递归到链表末尾,然后回溯时依次比较当前节点和 front 指向的节点,每比较一次,front 向后移动一步。class Solution: def isPalindrome(self, head): self.front = head def check(node): if node is None: return True if not check(node.next): return False if node.val != self.front.val: return False self.front = self.front.next return True return check(head)这段代码的核心逻辑是:递归到最深处时 node 是链表尾节点,此时 front 指向头节点,两个最外层的节点先比较;然后递归回溯到倒数第二个节点,front 已经移动到第二个节点,再比较;以此类推。这种解法对理解“链表的递归性质”很有帮助。你可以把 check(node.next) 这行理解成“先把后面的链表判断完,再处理当前节点”,本质上是用调用栈保存了每个待比较节点,以此实现倒序遍历。3.2 递归的空间代价与适用场景递归解法的空间复杂度是 O(n),因为递归调用栈的深度等于链表长度。如果链表有几万个节点,递归会非常危险,轻则内存占用大,重则直接栈溢出。所以在生产环境中,我不推荐这种写法;但在 LeetCode 题目本身的数据范围下,它可以通过。另外有一个细节特别容易踩坑:成员变量 self.front 必须在每次调用 isPalindrome 时重新赋值为 head。如果同一个 Solution 对象被多次调用,上一次调用结束时 front 已经指向链表末尾,下一次调用不重置,结果一定不对。还有人说,既然递归会保存调用栈,那我用显式的栈不也一样吗?确实,可以用一个栈把所有节点值压进去,再弹栈和原链表比较,空间也是 O(n)。这类做法在思维上是朴素的,也没有反转链表那么复杂,但面试时不太加分,因为和数组辅助法的空间复杂度没有本质区别。所以递归解法更适合作为“思路拓展”讲给面试官听,证明你有多种角度理解链表;但真正要写 O(1) 空间时,还是得回到快慢指针 + 反转的迭代方案。4. 边界条件、测试用例与现场排查4.1 一定要跑的测试用例清单链表题最怕空指针和边界错误。回文链表这道题,我非常建议你至少把下面这组用例跑一遍。不要只看“能不能通过”,要边跑边调试,观察 slow 的位置和反转后的链表长什么样。测试用例预期结果说明[]true空链表,直接判 true[1]true单节点链表,天然回文[1, 2]false偶数非回文[1, 1]true偶数回文,最简单的双节点回文[1, 2, 1]true奇数回文,中间节点不用管[1, 2, 2, 1]true经典偶数回文[1, 2, 3, 2, 1]true经典奇数回文[1, 2, 3, 4, 5]false非回文,用来验证返回值[-1, 2, -1]true负数也可以正常比较[1, 0, 0, 1]true存在 0 值,不影响比较超长链表,比如 10 万节点全为 1true验证迭代解法不会爆栈[1, 2, 3, 3, 2, 1]true另一种偶数回文[1, 2, 3, 4, 3, 1]false后半段和前半段形状类似但不完全一致这些用例看起来简单,但每一条都有针对性。比如 [1, 2] 用来验证两个节点时快慢指针是否正确;[1, 2, 1] 用来验证奇数长度下反转后半段不会把中间节点算进去;超长链表则专门攻击递归解法的栈空间问题。4.2 典型错误的定位方法我多次重写这道题,总结出几个最典型的错误,你可以照着自查。第一,快慢指针条件写错。while fast.next and fast.next.next在偶数长度的短链表中会让 slow 停在头节点,后续反转和比较全部错乱。解决办法就是固定用while fast and fast.next。第二,反转时丢节点。写反转链表时,如果先执行cur.next = prev,再取nxt = cur.next,拿到的 nxt 已经是 prev 了,链表直接断掉。这个错误非常隐蔽,尤其在你手忙脚乱的时候。建议把反转函数单独提取出来,先保存 nxt,再改指针,三步顺序别乱。第三,比较时用错循环条件。用while first在奇数长度回文上会越界访问 first.val。因为反转后的后半段比前半段少一个节点,比较到后来 second 已经变为 null,first 还指向中间节点。用while second才是安全的。第四,忘记恢复链表。如果面试里被问到,口头解释“我可以在最后再反转一次恢复原链表”,然后能写出对应代码,就很加分。但如果你根本不知道为什么要恢复,或者恢复时把指针接错,反而暴露了对链表结构的理解不足。排查这类问题有一个很实用的方法:写一个printList辅助函数,在找完中点后、反转后、比较前各打印一次链表。观察 slow 的位置和 second_half 的第一个节点值是否跟预期一致。我刷链表题时几乎都会用这个调试手段,比单步调试更快。5. 从刷题到面试:讲清楚比做出来更重要5.1 面试官想听到的思路演进这道题在模拟面试中出现的频率很高,因为它适合考查“从暴力到优化”的思维过程。如果你一上来就甩出快慢指针 + 反转的最优解,面试官其实不好判断你是背题还是真懂。更好的表达方式是分层推进。你可以先说:“这道题最直观的做法是转成数组,空间 O(n)。”然后补一句:“但既然链表本身不能随机访问,我可以把后半段反转,这样就能 O(1) 空间解决。”接着讲快慢指针怎么找中点、反转函数怎么写、比较时为什么用 second 作为循环条件。最后主动说:“如果不允许修改链表,我还可以再反转一次恢复。”这个过程中,考官最想听到的是你对边界情况的理解,比如奇数偶数长度、空链表、单节点。所以不需要着急写完所有代码,先把思路分点说出来,再动手写,会显得更从容。我在模拟面试中发现,很多候选人代码能过,但一问“为什么偶数长度时 slow 在中间偏右”就解释不清。这个点是这道题的命门,一定要练到能用一两句话讲明白:快指针走两步,慢指针走一步,快指针能走的步数决定了慢指针最终的位置;偶数长度时快指针最终停在 null 前,慢指针自然落在右中点。5.2 延伸题与通用能力这道题做完之后,不要急着删掉,它后面还关联着一串高频题:反转链表 II,要求反转链表中某一段,核心就是本题所用的反转函数。删除链表的倒数第 N 个节点,用快慢指针找位置,和找中点异曲同工。环形链表 II,也是快慢指针,只是多了一步数学推导。回文数,这个用字符串或取余都可以,和链表的解法思路不同,但“镜像对称”的判断逻辑相似。如果你把回文链表这道题彻底吃透,再去做这些延伸题,会明显感觉链表题之间是相通的。所谓“刷题量”,不是单纯刷了多少道,而是能否把一道题里的操作抽象成可复用的能力。回文链表刚好把“快慢指针”和“链表反转”两个基础能力揉在一起,值得多写几遍,直到能无脑写对。我个人实际操作中的体会是:写链表题千万不要只在脑子里想,一定要画图。画得不精致没关系,重点是标出每一步的指针指向变化。这道题我第一次在纸上画的时候,用了十几分钟,但画完之后整个状态就通透了。后面不管是写递归还是恢复链表,都能顺着指针方向一步步推出来。如果你也经常被链表题卡住,不妨试试这个办法,比一味刷题更有效。