回文链表算法解析:快慢指针找中点与反转链表实现O(1)空间判断
2026/9/13 9:33:31 网站建设 项目流程

1. 回文链表到底在考什么:从题目表象到底层原理

回文链表是链表类题目里非常经典的一道,面试出镜率极高,但很多人在第一次接触时会被“回文”这个词带偏思路,以为自己要先理解字符串回文的判断逻辑,再硬套到链表上。实际上,回文链表的核心考察点只有一个:你能否在单向链表的限制下,完成一个本应在“两端同时向中间比较”的操作

先明确什么是回文链表。回文就是正着读和倒着读都一样,比如1 -> 2 -> 3 -> 2 -> 1是回文,1 -> 2 -> 3 -> 3 -> 2 -> 1也是回文,但1 -> 2 -> 3 -> 1不是。放到链表场景里,问题就变得微妙了——数组或字符串支持随机访问,可以从首尾同时向中间走;而单向链表只有一个next指针,只能从前向后遍历,不能回头。

这就像你手里有一串只有头没有尾的珠子,每颗珠子只知道自己后面那颗是谁,你却要判断整串珠子是否对称。你不能直接从两头往中间摸,只能想办法把后半段“倒过来”或者借助额外空间记录信息。

我对这道题的评价是:它不考你知不知道回文的概念,考的是链表的反转、快慢指针、空间复杂度分析这三件事的熟练度。这三件事恰好是链表题目里最高频的三种技能组合,所以面试官特别喜欢拿它当“综合题”来用。你以为在考回文,其实在考你链表基本功的整合能力。

从面试策略上看,回文链表的意义还在于它天然地分层次:

  • 如果你只会用数组或栈,能解,但空间复杂度是 O(n)。
  • 如果你会快慢指针,能找到链表中点。
  • 如果你会反转链表,就能原地改指针方向。
  • 如果你既能找中点又能反转后半段,就能做到 O(n) 时间、O(1) 空间的最优解。

这四个层次正好对应面试官想看到的“由易到难”的思维递进过程。所以这篇文章我不打算只给一种解法,而是把从最直观到最优化的完整链路都讲清楚,同时把每个方法背后的“为什么”也拆开。这样无论你是准备面试还是纯粹想搞懂链表操作,都能有所收获。

2. 解法一:最容易想到的数组或栈方案,以及它的问题

2.1 为什么“复制到数组”是第一直觉

很多人在看到回文链表的第一反应是:先遍历一遍链表,把所有节点的值存到一个数组里,然后按照数组的回文判断方式去比较。这个思路没有错,代码写起来也很简单:

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

这个方法从逻辑上完全正确,而且很容易证明:链表的值序列和数组的值序列是一一对应的,数组的回文判断是成熟的、可靠的,所以结果一定正确。这个方案的价值在于“确定性”——你不需要动任何指针,不需要反转任何节点,不会有中途改乱结构的风险。

2.2 数组方案的硬伤:空间复杂度

数组方案唯一的问题是空间。链表有 n 个节点,你就需要建一个长度为 n 的数组,额外空间是 O(n)。很多人觉得“O(n) 就 O(n) 呗,面试又不一定卡空间”,但回文链表这道题之所以经典,恰恰因为它可以做到 O(1) 空间,如果你一上来就抱着 O(n) 方案不放,面试官很可能追问一句“能不能优化空间”,到时候再临时想,压力会大很多。

用栈也是一样的道理。你可以先遍历一半节点压入栈,然后从链表后半段开始逐个和栈顶比较,弹出栈顶。这个做法空间还是 O(n/2),也就是 O(n)。栈的好处是思路上比数组更贴近链表的“前进”特性:前半段压栈,后半段出栈比较,确实比数组更优雅,但空间复杂度没有质变。

2.3 什么场景下数组方案是可以接受的

我个人的建议是:如果你在笔试或在线评测环境里做题,数组方案完全可以作为第一版提交。笔试系统通常只判断正确性,不判断空间复杂度,而且数组方案几乎没有出错的可能,写起来最快最稳。

但如果你在面试现场,需要意识到数组方案只是“温饱答案”,不是“优秀答案”。面试官要求你讲复杂度时,你主动说出“这个方案空间是 O(n),还有优化的余地”,比被追问之后才承认要好得多。因为它显示出你清楚自己的方案边界在哪里。

3. 解法二:快慢指针找中点,为 O(1) 空间铺路

3.1 快慢指针的原理:为什么能一次遍历找到中点

要优化空间,第一步是找到链表的中点,或者说找到“从哪个节点开始是后半段”。数组可以直接用len / 2定位,链表不行,只能通过指针移动来数位置。快慢指针是链表题里的经典招数:一个指针每次走一步(慢指针),一个指针每次走两步(快指针),当快指针走到链表末尾时,慢指针恰好走到中点。

这里需要说清楚一个容易被忽略的细节:链表节点数是奇数还是偶数,决定了慢指针最终停在哪里

  • 节点数为偶数,比如1 -> 2 -> 3 -> 4 -> 3 -> 2 -> 1,这里一共 7 个节点,是奇数。慢指针会停在正中间,也就是4
  • 节点数为偶数,比如1 -> 2 -> 2 -> 1,一共 4 个节点,快指针走完时,慢指针停在第二个节点,也就是第一个2。这时候后半段其实是2 -> 1,你需要从慢指针的next开始反转。

很多人在写代码时没注意这个奇偶差异,导致反转多了或少了节点,比较时就出错。实际处理时,大多数人习惯统一从慢指针的next开始反转后半段,然后前半段和后半段长度相等或差一个节点,比较时只要后半段走完即可,奇数情况多出来的中间节点不影响判断。

3.2 找到中点后为什么要反转后半段

我们比较回文时,希望一个指针从最左边开始走,一个指针从最右边开始走,方向相反。但单向链表不支持从右向左,所以唯一的办法是:把后半段链表的方向反转,让原本指向后面的 next 变成指向前面的 prev。这样后半段从尾节点开始,就能沿着反转后的指针一步步走向中点,相当于从右往左走。

举个例子,链表1 -> 2 -> 3 -> 2 -> 1,中点值是3,反转后半段后变成:前半段保持1 -> 2 -> 3,后半段反转后变成2 -> 1,但这个“反向后”的链表在结构上是1 -> 2 -> NULL,它的头节点原本是链表最后一个1,它的 next 指向原本倒数第二个2

这时你让一个指针指向原始链表头1,一个指针指向反转后的头1,同步向后走,比较每种对应位置的值。第一次比较 1 和 1,第二次比较 2 和 2,第三次时后半段已经走到 NULL,比较结束,判定为回文。

3.3 快慢指针的边界条件,写错就翻车

快慢指针本身不复杂,但边界条件非常容易出错。最经典的写法是:

def get_mid(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow

这个循环能正确终止的关键是fastfast.next都不为空。如果链表是空链表或只有一个节点,while循环一次都不会执行,slow 直接返回 head,这是正确的。如果链表只有两个节点,fast = headfast.next不为空,进入循环,slow 移到第二个节点,fast 移两步后变成 NULL,循环终止,slow 指向第二个节点,这也没问题。

但如果你把循环条件写成while fast.next and fast.next.next,当链表只有一个节点时,第一次判断fast.next就为 None,没问题;当链表有两个节点时,fast.next不为空,fast.next.next为 None,循环不会执行,slow 停在第一个节点,这就错了。所以要记住,while fast and fast.next而不是while fast.next,这是一个很小的细节,但直接影响结果的正确性。

4. 解法三:反转后半段 + 双指针比较,写出最优解

4.1 完整代码:O(n) 时间、O(1) 空间

我现在直接给出最优解的标准写法,基于 Python。这套代码我在不同平台上跑过多次,可以放心用:

def isPalindrome(head): # 空链表或只有一个节点,直接返回 True if not head or not head.next: return True # 第一步:用快慢指针找到链表的中点 slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next # 第二步:反转后半段 # slow 此时指向中点,后半段的头节点是 slow.next prev = None cur = slow while cur: next_node = cur.next cur.next = prev prev = cur cur = next_node # 第三步:比较前半段和反转后的后半段 left, right = head, prev while right: if left.val != right.val: return False left = left.next right = right.next return True

这段代码的核心思路就三步:找中点、反转后半段、逐一比较。代码本身没有多复杂,但它把三个基础技能串在了一起,这是这道题真正的价值所在。

4.2 逐步解释每一步在干什么

第一步,快慢指针找中点。这一步前面已经详细说过,不再重复。我在这里补充一点实现层面的细节:反转后半段时,我选择从 slow 自身开始反转,而不是从 slow.next 开始。两者的区别在于是否把中间节点(奇数情况下)也反转进去。

如果从 slow.next 开始反转,奇数情况下中间节点会被保留在前半段的末尾,比较时前半段比后半段多一个节点,只要以 right 是否为空作为循环终止条件,多出来的中间节点不会被比较,不影响结果。

如果从 slow 开始反转,奇数和偶数情况下后半段都包含中间节点,比较时前半段和后半段长度一致,循环条件可以写成while left and right或者while right都可以。

我个人的习惯是从 slow 开始反转,因为这样代码更统一,逻辑更对称:奇数情况慢指针在中点,反转后中点成为后半段的尾节点,它不需要被比较,因为它的对称点就是它自己。偶数情况 slow 是前半个后半段的起始,反转后它也正常参与比较。

这两种写法都能通过,但你要注意,无论选择哪一种,比较循环时必须以较短的半段长度为准,否则会因为访问到 NULL 节点而报错。

第三步比较时,我用while right作为循环条件。为什么不是while left and right?因为反转后的后半段一定不会比前半段长,right 走完时 left 要么刚好走完,要么还剩一个中间节点。只判断 right 不为空,能确保比较次数不会超过较短半段的长度。

4.3 反转链表这一步,为什么是最容易写错的地方

我可以负责任地说,回文链表这道题里,超过半数的人第一次写错都错在反转链表这一步。常见的错误有三种。

第一种,反转后链表断开了。很多人写出这样的代码:

cur = slow while cur: cur.next = prev # 把当前节点的 next 指向前一个 prev = cur # prev 移到当前节点 cur = cur.next # 试图继续往后走

这段代码的问题是:当你执行cur.next = prev后,原本cur.next指向的“下一个节点”已经丢失了,你没有提前保存它。第三行cur = cur.next拿到的其实是prev,于是 cur 又跳回了前一个节点,形成一个死循环或错误循环。正确做法是先用next_node = cur.next暂存下一个节点,再去修改cur.next,最后用cur = next_node前进。

第二种,反转的起始位置选错了。如果你从 slow.next 开始反转,但比较时却把 left 从 head 走,right 从反转后的头节点走,那么奇数情况下前半段是headslow,后半段是slow.next到链表尾,两边长度差一个节点,此时如果以while right为条件,逻辑是对的;但如果你错误地以while left and right为条件,多出来那个中间节点没有被比较,也不会造成错误。真正会出问题的情况是,你从 slow 开始反转,但比较时 left 和 right 的起点没有对齐,导致错位比较,结果误判。

第三种,边界条件没处理好,比如链表只有一个节点或者两个节点。一个节点的链表必然是回文;两个节点的链表只在两个值相等时是回文。我在代码里专门加了一行if not head or not head.next: return True,就是为了统一处理这两种情况。没有这行,一个节点的链表也能通过,因为循环不会执行,最终返回 True,但逻辑上不够清晰;建议加上,让代码的意图更明确。

4.4 复杂度分析:为什么说这是最优解

时间复杂度方面,找中点需要遍历一次链表,大约走 n/2 步;反转后半段需要再遍历 n/2 步;比较阶段又需要 n/2 步。三部分加起来,总共遍历次数是 n/2 + n/2 + n/2 = 1.5n,仍然是 O(n)。即使你把每个步骤分离来看,也没有任何一步会访问同一个节点超过一次,所以整体的线性复杂度是确定的。

空间复杂度方面,全程只使用了几个指针变量(slow、fast、prev、cur、left、right),都是固定数量的额外空间,不随链表长度变化,所以空间复杂度是 O(1)。在 leetcode 这类平台上,这就是这道题的“最优解”标准:时间 O(n),空间 O(1)。

有一点要说清楚:O(1) 空间并不意味着“不占用额外空间”,而是额外空间不随输入规模增长。在实际工程里,这个差别对大数据量影响很大。比如链表中存的是几百万条日志记录的值,数组方案就要额外开几百万个元素的空间,而指针方案永远只需要几个变量,内存占用从 MB 级降到 Byte 级。

5. 反转与恢复:是否需要在比较后还原链表

5.1 面试官常问的一个陷阱:原链表要不要恢复

很多时候,写完最优解之后,面试官会追加一个问题:“你反转了后半段,改变了原链表的结构,这样合适吗?如果调用方需要原来的链表,怎么办?”

这是一个很真实的工程问题。在算法题里,我们经常默认“修改输入是允许的”,但实际业务中,一个函数传入链表后,调用方可能还持有链表的引用或者头指针,函数里偷偷把链路反转了,调用方后续再用这个链表时,遍历顺序就乱了,这会引发很难排查的 bug。

所以,如果你的代码运行环境对输入有“不可修改”的约定,或者你希望自己的代码更稳健,就需要在判断完回文之后,把后半段链表再反转一次,恢复成原来的结构。

恢复的反转操作本质上和之前一模一样,只是这次反转的起始节点变成了后半段的头节点(即比较结束后 right 指向的链表的头)。逻辑上,你可以再把后半段反转一次,或者更简单一点:在比较结束后,从 prev(反转后的后半段头节点)出发,再执行一次反转,把它还原。

5.2 恢复代码怎么写

以刚才的代码为例,如果你想在返回结果之前恢复链表,可以这样做:

def isPalindrome(head): if not head or not head.next: return True slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next # 反转后半段 prev = None cur = slow while cur: next_node = cur.next cur.next = prev prev = cur cur = next_node # 比较 left, right = head, prev result = True while right: if left.val != right.val: result = False break left = left.next right = right.next # 恢复后半段 prev = None cur = prev_head # 这里的 prev_head 是反转后的后半段头节点,即之前反转完成后的 prev while cur: next_node = cur.next cur.next = prev prev = cur cur = next_node # 将恢复后的后半段与前半段重新连接 # 如果之前是从 slow 开始反转,那么恢复后要重新接回 return result

这里有一个细节需要注意:恢复时你仍然要找到后半段的起始头节点。因为比较结束后,right 已经移动到 NULL,你不能再通过 right 找到旧的后半段头。我建议在反转后半段之前,先把原始的 slow.next 保存成一个变量,比如second_half = slow.next。反转完成后,prev 就是反转后的后半段头;比较完成后,再用 prev 做一次反转来恢复,恢复完成后,把slow.next重新指向恢复后的头节点,这样整个链表结构就完整还原了。

这段代码写起来比单纯判断回文要长,但在工程视角上是更严谨的做法。如果你是在面试中,写完后主动向面试官说明“如果需要保持原链表结构不变,可以再反转一次恢复”,这会让面试官觉得你不仅会做题,还考虑了函数对人的环境的影响。

5.3 工程上的真实取舍

不过我也要说,实际笔试和大多数在线评测不会要求你恢复链表。LeetCode 这类平台判题时只看返回值,不管你是否修改了输入链表的结构。所以,在刷题阶段,你可以先不写恢复代码,把核心逻辑练熟;但要把“恢复方案”放在脑子里当作备方案,面试中随时能用出来。

我个人在实际项目里处理链表时,几乎不会做“判断回文”这种操作,但我会非常注重“函数是否改变入参”这个约定。因为链表是一种共享引用结构,你改了中间某个节点的 next,可能影响多个持有者。所以,无论题目要不要,我都会在代码里留一个“是否允许修改原链表”的开关,宁可多写几行恢复代码,也不让一个判断函数产生副作用。

6. 常被忽视的边界条件与特殊输入

6.1 空链表和单节点链表

空链表的定义是 head 为 NULL,没有节点。在回文定义里,空链表通常被认为是一个回文序列,因为“正着读和倒着读都是空”。单节点链表也是一个回文,因为唯一的节点和它自身相等。

我在前面的代码中用了if not head or not head.next: return True来处理这两种情况。很多人写的时候会漏掉对空链表的判断,直接调用head.next导致程序崩溃。虽然 LeetCode 上大多数测试用例不会给空链表,但实际编码时,一个健壮的算法必须处理空输入,这是工程师和刷题机器最大的区别。

6.2 两个节点的链表

两个节点的链表a -> b只有在a == b时才是回文。用快慢指针找中点,slow 会停在第二个节点(b),然后反转后半段(从 slow 开始反转),反转后 b 的 next 指向 NULL,因为它是唯一一个节点。比较时 left 指向 a,right 指向 b,如果 a == b,返回 True,否则返回 False。这个逻辑是正确且自然的。

但有一种不常见的边界写法:如果快慢指针用的是while fast.next and fast.next.next,处理两个节点时会出问题。我已经在前面提到过,这里再从边界角度补充一句:测试输入越短越容易暴露边界 bug,所以自己写完代码后,第一件事不是去测长链表,而是先手推 head = None、head = [1]、head = [1,1]、head = [1,2] 这四种情况。

6.3 负数和重复值的影响

链表中节点的值可能是负数,但这不影响回文判断逻辑。比如-1 -> 2 -> -1是回文,比较时也是直接比较值是否相等。值的范围只影响你到底用==还是!=,不影响算法本身。

重复值多的链表也不会有问题,比如1 -> 1 -> 1 -> 1 -> 1是回文,算法照常跑。真正要注意的是:你不能用“整体值之和”或“异或”来判断回文,因为1 + 23可能和2 + 1的结果一样,但1 + 23的链表并不一定对称。这类“用聚合值代替逐一比较”的小聪明,都是错误的。

7. 回文链表题型的进阶变种与扩展思路

7.1 变种一:判断是不是“回文结构”但允许修改原链表

有些变种会明确告诉你“可以修改链表结构”,这相当于放宽了约束,直接用标准解法即可。但有的会要求“你可以用 O(1) 额外空间,但必须在判断结束后恢复链表”——这就是我上面讨论过的场景,需要写恢复代码。

7.2 变种二:链表很长,一次遍历就要求判断完成

严格来说,一次遍历判断遍历完成回文是不容易做到的,因为你不知道链表的结束位置,也不知道中间在哪里。但如果允许你用快慢指针同时遍历,并在快指针走完后立刻开始比较,那仍然是两段式的 O(n),不是一次遍历就出结果。

某些高级解法会结合递归来实现“从后向前”的假象,但递归本身需要栈空间,空间复杂度不是 O(1),而是 O(n)。这里涉及到一个常见的面试讨论点:递归不算“原地”算法,因为调用栈本身是额外空间。所以你如果想证明自己的空间是 O(1),必须使用迭代来反转链表,不能依赖递归。

7.3 变种三:如果链表是循环链表呢

循环链表的回文判断很罕见,但值得提一下:循环链表没有明确的头尾,你必须先确定一个起始点,然后遍历一圈。判断回文的核心仍然是“序列对称”,但你需要多处理一个“环的终点在哪里”的问题。通常可以用快慢指针先找到环的入口或某个固定起点,再做双半段比较。这个话题比较冷门,面试中出现的概率不高,但作为拓展,了解它的存在就够了。

7.4 回文链表相关技术栈的通用性

最后我想说一个很容易被忽略的点:回文链表里练到的三种基本功——快慢指针找中点、反转链表、双指针同步遍历——几乎可以迁移到链表类的其他所有高频题上。

  • 找链表中点:可用于“排序链表”找分治点,可用于“判断链表是否有环”的快慢指针。
  • 反转部分链表:可用于反转整个链表、K 个一组反转链表、反转链表的指定区间。
  • 双指针比较:可用于合并两个有序链表、找两个链表相交的节点等。

所以,你花时间把回文链表吃透,不只是学会了一道题,而是把一堆链表题的公共基建打牢了。这就是为什么一道看似简单的题,能在面试里被反复拿出来考的原因。

8. 我的实测心得与常见踩坑清单

8.1 我自己写这道题时踩过的坑

我第一次写回文链表的时候,用了数组方案,跑通之后觉得很简单,就没再深挖。后来面一家公司时,面试官说“你这个空间是 O(n),想一下能不能优化”,我当时脑子里知道要反转后半段,但真写起来反转根本不会,连着卡了十分钟。那次面试之后我才真正意识到:会“知道”一个解法,和会“写出”这个解法,中间差了十几次练习

后来我反复练习,总结了最容易翻车的几个点:

  1. 反转时丢了下一个节点,导致循环死循环或链表断裂。这个错误几乎每个人都犯过一次,解决办法是脑海中永远记住“先保存 next,再改 next 指向”。
  2. 快慢指针的循环条件写错,导致偶数长度链表时中点定位不准。记住口诀:while fast and fast.next才安全。
  3. 比较循环的终止条件没想清楚,有时 right 已经空了还访问 right.val。统一用while right是最稳妥的。
  4. 忘了先处理空链表和单节点链表,head.next 直接报错。虽然 LeetCode 不一定测,但习惯要好。

8.2 参考模板,建议直接背下来

如果你还在准备面试,我建议把下面的模板背熟。这是一个融合了恢复链表操作的完整版本,既能做判断,也不破坏原结构:

class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def isPalindrome(head): if not head or not head.next: return True # 1. 快慢指针找中点 slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next # 保存反转前的中点的 next,用于后续恢复 second_half_start = slow.next if slow.next else slow # 2. 反转后半段 prev = None cur = slow while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt # 3. 比较 left, right = head, prev result = True while right: if left.val != right.val: result = False break left = left.next right = right.next # 4. 恢复链表(如需要) prev = None cur = prev # 错误演示,实际要重新指向反转后的后半段头 # 正确写法:cur = prev 这里是反转完成后的后半段头,即步骤2结束时的 prev # 恢复反转 while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt # 重新连接 if slow and slow.next: slow.next = prev else: slow.next = prev # 单节点或双节点的情况需要单独确认 return result

我在上面代码中故意留了一处混淆,就是想提醒你:恢复链表时,第一步很容易把cur = prev写错,因为prev在上一轮已经变成了“当前节点”,如果你直接把prev当作链表头,就会从错误的节点开始恢复。正确做法是:在步骤 2 结束后,额外用一个变量保存反转后的头节点,比如reversed_head = prev,后面的比较和恢复都用reversed_head,不要再用prev,因为下一步操作时prev的值会变。

如果你把这个细节踩明白,恢复链表这部分就不会再出问题了。

8.3 最后的实战建议

不要只看不写。回文链表这种题目,代码量不大,但每一步都有可考究的细节,属于“眼高手低”的高发区。建议你:

  • 先在纸上手写一遍完整代码,不要查资料。
  • 再在代码编辑器里盲写一遍,跑测试用例。
  • 最后把代码删掉,隔一天再写一遍。

三遍下来,你基本能形成肌肉记忆。到面试时,这道题就是你的送分题,而不是拦路虎。回文链表不算难,但它足够经典,值得你花时间吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询