很多人刷链表题,刷到回文这道题时都会有同一个感觉:明明题目很好懂,判断一个链表是不是回文嘛,但真上手写代码,总会在“怎么做到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, val=0, next=None): 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++,思路一模一样,只是数组用ArrayList<Integer>或vector<int>来装而已。需要注意的一点是:如果链表节点值是负数、字符串或者对象,比较的时候要确认你用的是“值相等”而不是“引用相等”,这在 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 相似题串联:一道题带出一类题
回文链表做完,它不是孤立的。你会发现 “快慢指针找中点” 这个技巧在“链表的中间节点”这道题里是标准解法;“反转链表”直接对应“反转链表”这道基础题;“找到中点后拆分再比较”的思路,又能迁移到“重排链表”这道题——那题需要你先找到中点,再反转后半段,再交错合并,几乎就是回文链表的三步变体。
所以我建议你按这样的顺序刷:先刷“反转链表”,把反转模板练熟;再刷“链表的中间节点”,把快慢指针练熟;最后回头做回文链表,你会发现很多年前觉得难的题,现在就是几行模板拼出来的结果。这就是刷题里常说的“串题”——知识点是会复用的。
我个人在实际操作中的体会是,回文链表这道题最大的价值,不在于你记住了一个答案,而在于你建立了一个“链表问题的解题框架”。以后遇到任何需要从中间拆链表、从尾部反向遍历的题目,你都能条件反射地想到快慢指针和反转这两个工具。踩过几次坑之后,你也会慢慢养成一个习惯——先画图、再写码、最后测边界,这套节奏才是刷题真正的收获。