回文链表最优解:快慢指针+反转链表,O(1)空间套路详解
很多人刷链表题刷到回文这道题时都会有同一个感觉明明题目很好懂判断一个链表是不是回文嘛但真上手写代码总会在“怎么做到O(1)空间”这一步卡住。这道题是力扣Hot 100里的第24题也是链表问题里的一道分水岭——能独立把最优解写出来的人基本说明双指针和链表反转这两块基本功已经过关了。这篇文章就从最暴力的解法开始一步步拆到最优解顺带把我自己调试这道题时踩过的坑、面试现场容易翻车的地方一起写出来。先说结论方便你对照自己的进度这道题的核心不是“判断回文”本身而是三种层级的解法递进——转数组是保底递归是思维训练快慢指针加反转链表才是面试官真正想看到的答案。读完这篇你至少能把三种解法都写出来还能说清楚每一步为什么要这么写。1. 题目到底在考什么三个维度的能力拆解1.1 先读题回文链表的定义与边界条件回文链表简单说就是正着读和倒着读完全一样的链表。比如1 - 2 - 3 - 2 - 1是回文1 - 2 - 2 - 1也是回文而1 - 2 - 3不是。题目的输入输出很直接给你一个单链表的头节点返回一个布尔值判断这个链表是否是回文链表。力扣上的定义是链表节点数在[1, 10^5]范围内节点值在[0, 9]之间。这里有一个很容易忽略的边界单节点链表和空链表都算回文。很多人写代码时只处理了正常情况一提交发现边界用例挂了就是漏了这两点。提示写任何链表题第一步永远是先问自己三个边界——空链表、单节点、两个节点。这三个用例过了代码的骨架基本就稳了。1.2 考察的三个核心能力这道题表面上在考“回文判断”实际上把链表题最常考的三种能力全串起来了。第一是链表遍历的基本功。你要能熟练地在链表上移动指针知道什么时候用head.next什么时候用head.next.next什么时候可能空指针。这听起来基础但很多人在快慢指针那一步写错本质就是遍历的边界感不够。第二是双指针思想。回文判断在数组里非常简单一个左指针一个右指针往中间走就行。但链表是单向的不能从尾部往前访问所以你需要用快慢指针找到中点或者用递归强行制造“从后往前”的效果。这个思维的转换才是这道题真正的考点。第三是空间复杂度优化的意识。同样的逻辑转成数组做代码干净利落但空间复杂度是O(n)。面试官通常会追问一句“能不能用O(1)空间再做一遍”这时候你不仅需要懂回文判断还得掌握链表反转把后半段就地反转后比较这就是题目的进阶要求。1.3 为什么这道题值得反复做Hot 100里链表题不少但像回文链表这样把“查找中点 反转链表 双指针比较”三个考点揉在一道题里的并不多。很多人在面试中遇到链表题写得出基础解法但一被追问复杂度就卡壳。回文链表这道题刚好能把这块短板补上——它逼着你去想怎么在一个单向数据结构上做反向操作而这种思路在做“重排链表”“环形链表”等题目时都能复用。我认识的很多开发者包括我自己都是通过这道题真正理解了“快慢指针”和“链表反转”的组合用法。刷一遍可能只是会背代码刷三遍、五遍把每种写法都自己推导一遍那才是真的吃透了。2. 解法一转数组双指针——先保证对再追求好2.1 核心思路把链表变成数组问题就降维了这道题最直接的思路就是遍历一遍链表把每个节点的值存进一个数组然后对数组做回文判断。等于把“链表回文”转化成“数组回文”后者是个人都会写。为什么说这是降维因为数组支持随机访问可以直接用两个下标从两端向中间靠拢而链表只能从头往后走要想从后往前要么递归要么反转都麻烦。所以转数组这个方案代码逻辑简单几乎不会写错是你在时间紧张或思路混乱时最可靠的保底手段。从工程角度讲这个解法也不是一无是处。如果链表的长度已知且很小或者你只是要快速验证一个想法把数据复制到数组里再判断反而是最不容易出 bug 的方案。复杂度虽然是O(n)空间但胜在稳。2.2 完整代码与复杂度分析Python 里可以直接把链表值收集到列表再用双指针判断。代码如下class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class Solution: def isPalindrome(self, head: ListNode) - bool: 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)因为额外开了一个数组存全部节点值。在大多数在线判题平台上这个解法都能通过因为题目给的数据量是十万级别存一个十万长度的数组完全没压力。如果你用的是 Java 或 C思路一模一样只是数组用ArrayListInteger或vectorint来装而已。需要注意的一点是如果链表节点值是负数、字符串或者对象比较的时候要确认你用的是“值相等”而不是“引用相等”这在 Java 里特别容易踩坑。2.3 这个解法什么时候用我的建议是把转数组解法当作你的第一反应但不要只停留在这一步。笔试或者在线测评时时间紧张先写这个保证通过拿到分这是最务实的策略。但如果是面试现场写完这个解法之后一定要主动说一句“这个解法空间复杂度是O(n)如果要优化到O(1)空间可以用快慢指针找中点反转后半段链表再比较。”这句话一说面试官就知道你还没到天花板会继续往下聊。反过来说如果你面试时只会这个解法大概率会被追问到卡壳。因为O(n)空间的方案太常规了面链表题的人主要想听的就是你怎么处理空间复杂度。所以转数组是起点不是终点。注意不要把转数组解法当作“作弊”。它是完全正确的解法只是不是最优解。先对再优这是写代码的通用原则。3. 解法二递归双指针——思路优雅但别在面试里踩坑3.1 递归怎么模拟双指针递归解法的思路很巧妙用递归天然的函数调用栈把链表的访问顺序反转过来。你可以想象有一个递归函数它先把链表走到最后一个节点然后在回溯的过程中让一个外部指针从头节点开始同步往后走。这样递归回溯的顺序等价于“从尾部往前”配合外部指针从头部往后就凑出了一对“双指针”。这个思路本质上是用系统的调用栈来替代显式的数组用O(n)的递归深度换空间只不过这个O(n)空间是栈帧空间而不是堆上的数组空间。理解了这一点你就能明白为什么这个解法虽然优雅但并不适合在工程或面试中大规模使用。3.2 代码实现与栈溢出风险递归解法通常需要一个辅助函数用一个成员变量或者闭包里的外部引用来保存左指针。Python 实现如下class Solution: def isPalindrome(self, head: ListNode) - bool: self.front head def check(node: ListNode) - bool: if node: if not check(node.next): return False if self.front.val ! node.val: return False self.front self.front.next return True return check(head)这段代码的关键点在于check(node.next)先把链表递归到最后一个节点然后回溯时node是从尾部逐步往前的而self.front是从头部逐步往后的两者一一对应比较。只要有一对节点值不相等就直接返回 False。但这个解法有一个非常现实的问题递归深度等于链表长度。题目给的最长链表是十万级别Python 的默认递归深度上限通常是一千左右超了就报RecursionError。就算你用 Java 或 C递归深度十万层对函数调用栈也是巨大的压力很容易栈溢出。所以我的结论是递归解法适合用来理解思路、写写小规模的测试不适合作为这道题面试或判题的标准答案。如果面试官问你“有没有其他思路”你可以提递归但最好主动补一句“递归深度等于链表长度数据量大的时候有栈溢出风险所以工程上我更倾向用迭代式的快慢指针方案”这样既展示了你知识面的广度也暴露不了这个方案的致命弱点。3.3 复盘为什么递归不是最优选复盘一下递归方案它的优点是代码极短逻辑非常优雅读起来像数学归纳法理解了之后很爽。缺点是明显的第一空间复杂度依然是O(n)没有达到最优只是把显式数组换成了隐式栈第二有栈溢出的风险在十万级甚至百万级链表上根本不实用第三递归本身有额外的时间开销压栈退栈是在浪费指令周期。所以递归在回文链表这道题里的定位是“思维的补充”不是“最优解”。你可以在聊解法的时候提它展示你理解递归的调用机制但真正写代码时还是优先选下一种方案。4. 解法三快慢指针加反转链表——O(1)空间的黄金解4.1 三步走的整体框架最优解的核心思想是把链表拆成两半反转后半段然后从头比较。整个过程分三步第一步用快慢指针找到链表的中间节点第二步从中间节点开始反转后半段链表第三步用两个指针分别从头部和反转后的尾部开始逐个比较节点值直到遍历完后半段。为什么要拆成这三步因为链表是单向的你无法直接从尾部向前遍历但你把后半段反转之后它就变成了一个从“原尾部”指向“原中间”的新链表这样你就可以从头部往中间、从尾部往中间同时走逐一比较。等比较完了如果你愿意还可以把后半段再反转回来恢复链表原状——这是很多面试官的加分问点。这个方案的厉害之处在于它实现了O(1)的额外空间复杂度全程只用了几个指针变量没有开任何和链表长度相关的存储。时间复杂度依然是O(n)找中点是O(n)反转后半段是O(n)比较也是O(n)加在一起还是线性。4.2 找中点快慢指针的边界细节快慢指针找中点是第一步也是最容易写错的一步。基本写法是慢指针每次走一步快指针每次走两步快指针走到链表末尾时慢指针恰好停在中间位置。slow head fast head while fast and fast.next: slow slow.next fast fast.next.next这里有个关键的边界问题链表长度是奇数还是偶数慢指针停的位置不一样。比如1 - 2 - 3 - 2 - 1慢指针会停在中间的3上而1 - 2 - 2 - 1上面的循环结束时慢指针会停在第二个2上也就是后半段的起点。这个差异会影响你接下来反转的起点。我的经验是不需要刻意区分奇偶统一让慢指针作为“后半段的头节点”来处理。实际上在回文判断里我们真正需要反转的是“从慢指针开始的链表后半段”。奇数情况下中间的那个节点只有一个反转后它自己和自己比较不影响结果偶数情况下慢指针正好是后半段的起点。所以代码可以统一处理。提示快慢指针循环结束后slow指向的位置可以作为后半段的起点fast则可能为 None偶数长度或指向最后一个节点奇数长度。你不必关心停下来时 fast 在哪只需要确认 slow 的位置正好是你的反转起点。另一个容易踩的坑是初始化的写法。slow head, fast head这种写法适用于“慢指针需要停在中间偏右”的场景也是回文链表这道题最方便的写法。如果你写成fast head.next那慢指针会停在中间偏左对偶数长度链表来说反转的后半段就少了一个节点比较就会出错。所以这道题建议就用fast head初始化。4.3 反转后半段链表反转的两种写法找到中点后下一步就是反转从slow开始的链表。链表反转有两种常见写法迭代法和递归法。这里推荐迭代法因为递归法在长链表上同样有栈溢出风险。迭代法的标准模板是这样的def reverse_list(head: ListNode) - ListNode: prev None cur head while cur: nxt cur.next cur.next prev prev cur cur nxt return prev这段代码的核心逻辑是每次把当前节点的next指向前一个节点然后三个指针整体后移。prev一开始是None所以反转后的最后一个节点的next是None刚好作为新链表尾部。返回值是prev也就是原链表的最后一个节点反转后它成了新链表的头节点。对于回文链表这道题我们不需要单独写一个函数可以直接在isPalindrome方法里内联这段逻辑把slow当作链表头传进去prev None cur slow while cur: nxt cur.next cur.next prev prev cur cur nxt反转结束后prev就是反转后的后半段头节点。这里务必注意保存nxt很多新手写链表反转时先执行cur.next prev再想取cur.next就取不到了因为原来的next已经被覆盖了。这是链表题最常见的 bug 之一。4.4 逐个比较与最终判断反转完成后我们得到两个链表从head开始的原始前半段以及从prev开始的反转后半段。接下来就是简单的一一比较了left head right prev while right: if left.val ! right.val: return False left left.next right right.next return True这里有一个细节循环条件用while right而不是while left因为反转后的后半段长度一定不超过前半段。奇数长度时中间节点会被包含在反转后的后半段里此时前半段比后半段多一个节点用right作为循环条件刚好能遍历完需要比较的部分又不会因为 left 先走到头而越界。偶数长度时两者长度相同用谁当循环条件都行。完整的最优解代码拼起来是这样的class Solution: def isPalindrome(self, head: ListNode) - bool: if not head or not head.next: return True # 第一步快慢指针找中点 slow head fast head while fast and fast.next: slow slow.next fast fast.next.next # 第二步反转后半段 prev None cur slow while cur: nxt cur.next cur.next prev prev cur cur nxt # 第三步逐个比较 left head right prev while right: if left.val ! right.val: return False left left.next right right.next return True这段代码就是面试时最理想的一版答案O(n)时间O(1)空间代码短逻辑清楚。我建议你把它背下来但不是死记而是能一边写一边讲出每一步在干什么。4.5 面试加分项比较完把链表还原很多面试官在看完上面的代码后会习惯性地追问一句“如果要求不修改原链表你的代码有问题吗”这时候如果你能在比较完之后把后半段链表再反转回来恢复链表的原始结构就能稳稳拿到这个加分项。实现起来也很简单——比较结束后再对prev指向的链表做一次反转把它恢复成原来的顺序然后重新接回中间节点即可。代码大概长这样# 可选的链表还原步骤 def restore(head): prev None cur head while cur: nxt cur.next cur.next prev prev cur cur nxt return prev # 在原解法中调用 right restore(right)虽然在线判题不会检查链表是否被修改因为判题只看返回值但在工程实践中函数的副作用是要尽量规避的。你能主动考虑到“不破坏输入数据”会让面试官觉得你不仅会刷题还有良好的编码习惯。这是我非常推荐在面试中展示的一点。5. 刷题实战心得与调试技巧5.1 我调试这道题踩过的三个坑第一个坑是快慢指针的初始化。我第一次写的时候用了fast head.next结果在偶数长度链表上慢指针停在了中间偏左的位置反转的后半段少了一个节点比较结果错误而且错的用例很隐蔽——只有特定长度的链表才会触发。后来我习惯在草稿纸上画一遍节点移动过程才彻底搞明白这个初始化的区别。第二个坑是链表反转时忘记保存nxt。这个太经典了我在很多链表题里都犯过。你一旦先执行了cur.next prev原来的下一个节点就丢了链表直接断掉。现在我的习惯是反转循环里第一行永远先写nxt cur.next写完之后再动cur.next顺序固定不容易错。第三个坑是循环结束后right可能不是空指针。有的写法里反转后半段后会用right从头往后走但没想清楚循环终止条件导致比较到一半left已经到尾了还在访问left.val直接空指针异常。用while right做条件就完全规避了这个问题。5.2 测试用例设计的通用模板这道题刷完我把测试用例总结成了一个模板刷其他链表题也能复用。你在写任何链表解题代码时至少要先跑这几组用例空链表[]预期返回 True。单节点[1]预期返回 True。奇数长度回文[1, 2, 3, 2, 1]预期 True。偶数长度回文[1, 2, 2, 1]预期 True。奇数长度非回文[1, 2, 3, 4, 5]预期 False。偶数长度非回文[1, 2, 3, 4]预期 False。只有两个元素[1, 1]预期 True[1, 2]预期 False。为什么这组用例够用因为它们覆盖了所有边界场景和奇偶组合。你可以把这段测试逻辑写成一个辅助函数每次在本地跑完再提交能省下很多因为边界条件导致的“红色提交记录”。5.3 相似题串联一道题带出一类题回文链表做完它不是孤立的。你会发现 “快慢指针找中点” 这个技巧在“链表的中间节点”这道题里是标准解法“反转链表”直接对应“反转链表”这道基础题“找到中点后拆分再比较”的思路又能迁移到“重排链表”这道题——那题需要你先找到中点再反转后半段再交错合并几乎就是回文链表的三步变体。所以我建议你按这样的顺序刷先刷“反转链表”把反转模板练熟再刷“链表的中间节点”把快慢指针练熟最后回头做回文链表你会发现很多年前觉得难的题现在就是几行模板拼出来的结果。这就是刷题里常说的“串题”——知识点是会复用的。我个人在实际操作中的体会是回文链表这道题最大的价值不在于你记住了一个答案而在于你建立了一个“链表问题的解题框架”。以后遇到任何需要从中间拆链表、从尾部反向遍历的题目你都能条件反射地想到快慢指针和反转这两个工具。踩过几次坑之后你也会慢慢养成一个习惯——先画图、再写码、最后测边界这套节奏才是刷题真正的收获。