先抛一个问题:给你一个数组,[1,2,2,1],判断它是不是回文,你大概率一秒钟就想到左右指针往中间夹。可同样的需求放到单链表上,事情立刻变味——链表只能从头到尾单向访问,你想让一个指针从尾部往前走,普通链表原生做不到。力扣 hot100 第24题“回文链表”考的就是这个转换的过程:输入一个单链表的头节点 head,判断它的节点值序列是否回文。题目看起来很朴素,但答案可以从 O(n) 空间一路优化到 O(1) 空间,中间藏着链表中点、链表反转两个高频考点。这篇不打算只贴一个最优解,我希望能把从第一直觉到最终答案的完整思考链写出来,包括那些让我自己都翻过车的边界条件。
1. 审题:这题到底在考什么
1.1 “回文”在数组里很简单,在链表里却有点棘手
回文,就是正着读和反着读一样。链表版本里,我们要判断 head 指向的整条链,从头到尾的节点值序列和从尾到头读是否一致。比如 1->2->3->2->1 是回文,1->2->3->1 不是。
数组里一左一右两个下标,往中间收着比较就行。链表的问题是:你拿到的只有头节点,每个节点只认识自己的 next,没有任何一种指针能倒退。没有长度、没有下标、没有 prev,想从尾部开始比较,必须先绕开“只能往前走”这条限制。
这个限制就是这题真正的题眼。
很多初学者拿到题目第一反应“这不就是双指针吗”,把数组双指针直接套上去,写到一半发现链表走不了回头路。也有人立刻想到“那我用额外空间存下来”,这当然能解,但进阶要求往往是在 O(n) 时间、O(1) 空间内完成。所以这道题表面在问“是否回文”,实际在问:你能不能在不借助数组、栈、哈希表的情况下,让后半段变成可反向遍历的形态。
提示:单链表和数组最大的区别不是“有没有顺序”,而是“能不能随机访问”。所有链表题的难点,几乎都从这里衍生出来。
1.2 为什么 hot100 会给这道题一个位置
hot100 是很多人在准备面试时优先刷的列表,里面放题目不只是看难度,更要看考点覆盖密度。“回文链表”难度不算高,但它一次打包了好几个链表的底层基本功:快慢指针、链表反转、奇偶边界判断、空间复杂度分析。
面试时这道题也很适合做“追问题”素材。我见过不少面试流程是:候选人先用数组解法写出来,面试官点头,然后追问一句“空间能压到 O(1) 吗”;候选人完成 O(1) 解法后,面试官可能还会加一个“那你现在能不能把链表恢复成原来的样子”。每追问一次,题目难度就往上跳一档。
所以别把它当成一道普通的背答案题。真正值得带走的是那套思考方式:知道哪些空间可以用,哪些空间必须省;知道一个单向结构如何通过局部反转制造“反向遍历”的能力。这些东西换一道题依然成立。
2. 解法一:复制到数组,双指针一击命中
2.1 代码与执行过程
先来看最稳、最不容易写错的解法:把链表节点值复制进数组,然后用数组的左右双指针从两端往中间走。代码如下。
class Solution: def isPalindrome(self, head: Optional[ListNode]) -> bool: vals = [] cur = head while cur: vals.append(cur.val) cur = cur.next left, right = 0, len(vals) - 1 while left < right: if vals[left] != vals[right]: return False left += 1 right -= 1 return True这段代码的思路不复杂:链表给不了随机访问,数组给。第一遍遍历把节点值全部搬进一个 list,第二步的 left 和 right 就是两个可以自由移动的游标,往中间夹逼。任何一个位置不等,立刻返回 False。
这个解法的正确性不难验证:回文性质只依赖序列本身,跟链表的节点对象、指针走向没有任何关系。我们复制的就是完整序列,所以数组双指针的结果和原链表完全等价。
复杂度上,时间 O(n),因为只做了两次线性扫描;空间 O(n),因为存了一个同样长的数组。在大多数面试场景里,这个答案是“对”的,但不是“最优的”。
2.2 为什么十行代码解决不了所有问题
数组解法最大的问题不是错误,而是占用。如果一个链表的长度是几百万,你就要额外开一块几百万的容器。力扣判题通常不在意,但面试官会顺着这条线继续问:如果内存很紧张,或者链表本身是流式产生的、只知道头节点,你能不能在不开数组的情况下判断?
这时候就需要重新审视目标:我们真正缺的,是让“从尾到头”的访问变成可能。数组是方式之一,递归栈是方式之二,反转链表是方式之三。真正的高效解法,不应该依赖额外容器,而应该在链表本身的结构上做文章。
所以数组解法更像一道“安全网”:它可以让你在紧张时先拿到正确答案,同时给后续优化留出空间。我自己的习惯是,面试里如果遇到这道题,先快速把数组解法说出来,然后主动补充一句“如果面试官希望空间 O(1),我可以用快慢指针加反转来做”。这样既展示了思路的层次,又不会因为一上来就写复杂解法而卡壳。
3. 进阶解法:快慢指针 + 反转后半段,把空间压到 O(1)
3.1 快慢指针为什么能找到中点:一个不靠长度的办法
要在 O(1) 空间里判断回文,第一件事是确定中点。链表没有 length 字段,不能像数组那样直接算索引。快慢指针就是专门解决这个问题的:让 slow 每次走一步,fast 每次走两步,当 fast 走到链表末尾时,slow 刚好走了一半。
用一个生活化的类比:两个人同时从同一起点出发,A 的速度是 B 的两倍。A 到达终点时,B 刚好在路程的中点。这里的 fast 就是那个快的人,slow 是那个慢的人,而“fast 到达终点”对应的是 fast 走到空指针或最后一个节点。
代码里的循环条件固定写成:
while fast and fast.next: slow = slow.next fast = fast.next.next为什么一定要检查 fast 和 fast.next 两个条件?因为 fast 一次跳两步,它可能在跳出链表后变成 None,也可能停在最后一个非空节点。只有同时确认 fast 非空、fast.next 非空,才能安全地往前跳。这个条件的记忆成本不高,但写错的人非常多。
循环结束后 slow 的位置很关键:
- 链表长度为偶数 2k:循环执行 k 次,slow 移动 k 步,slow 指向后半段的第一个节点;
- 链表长度为奇数 2k+1:循环执行 k 次,slow 移动 k 步,slow 指向正中间那个节点。
所以如果我们从 slow 开始反转链表,偶数时反转的是整个后半段,奇数时反转的是“中间节点 + 后半段”。这两种情况都可以正常比较,最多只是奇数时中间节点会被自己比较一次,不影响结果。这个处理方式比先找中点再区分奇偶再决定从哪个节点反转要省心得多。
3.2 反转链表:链表题必备的“反向遍历”手段
找到中点后,我们要把后半段反向,这样原本只能从中间往尾部访问的后半段,就变成了从尾部往中间访问。反转单向链表的迭代写法三指针算是基本功:
def reverse(node: ListNode) -> ListNode: prev = None cur = node while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev理解这段代码就抓住一件事:在改变 cur.next 之前,必须先用 nxt 保存 cur 原来的下一个节点。否则指针一改,后面的节点就找不到了。这和你搬家前先拍照留证是一个道理:先把门牌号记下来,再把门牌摘下来换到新位置。
反转完成后,原后半段变成了一个新的链表,这个链表的头节点是原链表的尾节点。我们拿着这个头和原始 head 一前一后往中间走,就能逐个比较节点值。
3.3 完整代码与奇偶长度的处理逻辑
把找中点和反转组合起来,主解法如下:
class Solution: def isPalindrome(self, head: Optional[ListNode]) -> bool: def reverse(node: ListNode) -> ListNode: prev = None cur = node while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next tail = reverse(slow) left, right = head, tail while right: if left.val != right.val: return False left = left.next right = right.next return True我建议比较循环用while right而不是while left。原因是:快慢指针结束时,right 这一段的长度最多和 left 相等。如果 left 比 right 长(比如奇数长度时选择跳过中间节点的写法),用while left就会访问到 None。用while right则天然以较短的那一段结束,代码更安全。
拿两个例子验证:
- 链表 1->2->2->1:循环结束后 slow 指向第二个 2,反转后半段得到 1->2,与前半段 1->2 逐点相等,返回 true。
- 链表 1->2->3->2->1:循环结束后 slow 指向 3,反转后半段得到 1->2->3,比较时 left 走三步、right 走三步,1 对 1、2 对 2、3 对 3,都相等,返回 true。
要注意,这个写法会修改原链表的结构,但力扣判题只检查返回值,不检查链表是否被改坏。后面我会专门说怎么在判断完再恢复链表。
3.4 面试加分写法:边找中点边反转前半段
还有一类写法不需要“先找中点再反转”两个阶段,而是边移动边把已经走过的前半段就地反转。代码如下:
class Solution: def isPalindrome(self, head: Optional[ListNode]) -> bool: prev = None slow = fast = head while fast and fast.next: fast = fast.next.next nxt = slow.next slow.next = prev prev = slow slow = nxt if fast: slow = slow.next while prev and slow: if prev.val != slow.val: return False prev = prev.next slow = slow.next return True循环做的事是:slow 每前进一步,就把已经走过的节点原地反转,于是 prev 始终是“前半段已经反转好的链表头”。fast 走到末尾时,slow 停在中点附近,prev 正是前半段反转后的结果。最后两句对照 prev 和 slow 就能完成比较。
这个写法时间复杂度同样是 O(n),空间 O(1),看起来更帅,但理解成本和写错概率也更高。尤其是奇数长度时为什么要补一句if fast: slow = slow.next,很多人一紧张就会漏掉。我建议先把两阶段版本写熟练,再考虑这种边找边反转的写法。面试现场如果对某个指针的中间状态没有十足把握,就别强行秀这个写法。
4. 边界条件、恢复链表与实测踩坑
4.1 空链表、单节点、双节点这些边界怎么处理
链表题最怕边界,回文链表也不例外。我习惯在写代码之前先过一遍下面的表格:
| 输入 | 结果 | 说明 |
|---|---|---|
| 空链表 | true | 空序列天然回文 |
| 只有一个节点 | true | 单一元素自然回文 |
| [1, 2] | false | 左右不对称 |
| [1, 1] | true | 两个相同节点 |
| [1, 2, 1] | true | 奇数长度,中间节点不用比较 |
| [1, 2, 2, 1] | true | 偶数长度,标准回文 |
| [1, 2, 3, 1] | false | 中间错位 |
这些用例不是随便列的。空链表和单节点是很多解法最容易崩溃的地方;奇数长度和偶数长度的处理差异决定了循环条件的写法;最后一组非回文用例用来确认“只要中途出现一次不相等就返回 false”的逻辑是有效的。
用上面的 O(1) 解法跑到这些用例上都没问题,但要注意一个前提:head 非空时,快慢指针循环结束后 slow 一定不会空;反转 slow 后 tail 也一定非空。这就是为什么while right能安全跑完。
4.2 “输入链表被改坏了”怎么办:恢复链表
两阶段反转解法为了省空间,会改变原链表。力扣通常不会校验链表内容,但工程上我们写函数一般不应该破坏输入数据;面试官如果仔细,也可能追问“你改了链表,能恢复吗”。
恢复的思路很简单:比较之前把后半段反转后的头节点记下来,比较结束后再反转一次,接回原来的中点上。给出一个既判断又恢复的完整版本:
class Solution: def isPalindrome(self, head: Optional[ListNode]) -> bool: def reverse(node: ListNode) -> ListNode: prev = None cur = node while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next mid = slow tail = reverse(mid) result = True left, right = head, tail while right: if left.val != right.val: result = False break left = left.next right = right.next mid.next = reverse(tail) return result这里最容易被绕晕的是最后一行:tail 已经在比较过程中被遍历过,为什么reverse(tail)还能把后半段恢复成原顺序?因为 tail 这个变量始终保存的是反转后后半段的头节点,指针的移动只改变了局部 next 关系,并没有改变 tail 本身的引用。所以再次反转这个头节点,得到的就是原后半段顺序,接到 mid 后面,链表恢复原样。
不过说实话,面试时能把“判断”做对已经很不错,恢复链表属于加分项。如果没把握,可以先口述思路,再动手写。
4.3 我自己本地跑挂过的两个细节
细节一:快慢指针的循环条件写过while fast.next and fast.next.next。这在 fast 已经变成 None 的偶数长度链表上会直接空指针报错。要记住 fast 可能为 None,必须先判断while fast and fast.next。
细节二:反转链表时先写了cur.next = prev,忘记用nxt保存下一个节点。本地跑起来链表直接断成两截,后面的节点丢失。这三行顺序看起来简单,但每次写都必须保持一样的肌肉记忆:先存 nxt,再改 cur.next,最后移动 prev 和 cur。
细节三:比较阶段用while left。在某些实现里 right 先走完,left 还停在中间节点上,此时会漏掉中间节点之后是否有不相等的情况,或者干脆访问 None。统一用while right可以让循环边界自动对齐。
这些小坑单个看都很小,但组合在一起足以让一次面试变得很难看。所以我在本地练习时,会在同一份代码里跑完奇偶长度、全部相同、最后一位不同这类用例,确认无误后再去提交。
5. 从这道题延伸出去:链表题型的三条主线
5.1 找中点、反转、合并:链表三大基本操作
链表题很多,但拆开看基本操作其实高度集中。第一是找中点或找环相关的快慢指针;第二是反转链表的各种变体;第三是合并、交错两个链表时的双指针移动。回文链表刚好踩中前两个,这也是它值得反复刷的原因。
我把它们分开看:
- 找中点:判断回文要用,找链表中间节点要用,判断环形链表要用。快慢指针本质上是在“不知道长度”的前提下按比例定位位置。
- 反转链表:回文判断要用,部分区间反转要用,K 个一组翻转要用。反转是制造反向遍历能力的最直接手段。
- 合并链表:合并两个有序链表要用,重排链表要用。双指针负责在两个链之间轮流切换。
如果你能把这三个操作从“背代码”变成“理解为什么”,很多中等难度的链表题其实都能拆成它们的组合。
5.2 围绕这些基本操作的同类题目
刷完回文链表之后,按这三条主线继续往后铺开比较顺:
- 和找中点相关的:判断链表是否有环、找环的入口、找链表中间节点。这些题共用同一套快慢指针心智。
- 和反转相关的:反转指定区间链表、K 个一组翻转链表。这些题在反转细节上做了更多限制,但核心还是三指针。
- 和合并相关的:合并两个有序链表、两两交换相邻节点、重排链表。双指针移动的节奏感可以借用回文链表里“left 和 right 同时前进”的感觉。
看到共同点了吗?它们很少直接考某个高级数据结构,更多是在考你能不能把“顺序访问”这个限制处理好。回文链表就是一套很好的入门组合拳,刷完它再去碰更复杂的链表题,至少不会被“中点在哪里”“怎么反转一段”这种基础问题卡住。
5.3 实际应用里的“回文”影子
你可能会想,工程里哪有那么多链表让我判断回文?确实,直接判断链表回文的场景不多,但“判断一个只能顺序读取的序列是否对称”的需求并不少见。
举个例子,流式日志或者传感器数据到达时,你只有往前走的能力,没有随机访问能力。如果想知道前半段和后半段是否镜像对称,常见思路就是用栈先缓存前半段,或者在某个中点处把后半段整体反转后比较。这跟链表题里的做法本质相同。
再比如某些协议字段、校验码设计,都会出现“回文式”的约束;基因序列里也经常要检查回文片段。当你理解了“单向访问 + 对称性判断”的组合之后,换一根数据结构或者换一个输入源,底层逻辑都是一样的。
这道题刷完之后,我最深的体会不是快慢指针有多妙,而是:面对一个只能单向访问的结构,与其硬着头皮倒着走,不如考虑怎么把其中一半“翻过来”。一旦你能给后半段造出一个反向视图,比较就变成了两个正向指针从两端往中间靠拢的事。