ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

回文链表LeetCode 234全解:快慢指针与反转实现O(1)空间判断

回文链表LeetCode 234全解:快慢指针与反转实现O(1)空间判断

刷过一阵子算法题的读者,对“回文链表”这道题应该不陌生。LeetCode 第 234 题,题面很短:给定一个单链表头节点 head,判断它是否为回文链表,是则返回 true,否则返回 false。输入 1->2->2->1 返回 true,输入 1->2 返回 false。看起来人畜无害,但它在面试里出现的频率一直居高不下,原因在于这道题几乎把链表题的基础考点全串起来了:遍历、找中点、反转、双指针对比,还顺带考察空间复杂度控制。很多人在笔试现场能快速写出一版“转成数组再判断”的解法,但面试官一句“能不能用 O(1) 空间完成”,就足以筛掉一批只会背答案的人。这篇文章我围绕这道题做一次系统的拆解,从最直观的栈辅助法讲起,到快慢指针加反转后半段的经典双指针方案,再补上递归思路和数组兜底方案,最后把面试现场怎么组织回答、哪些细节最容易翻车一起整理出来。无论你是刚开始刷题的新手,还是准备跳槽想查漏补缺的老手,这篇都值得认真读一遍。

1. 题目到底在考什么:先说清楚问题本质

1.1 回文链表 vs 回文字符串:难度为什么不一样

回文这个概念本身并不新,字符串版本很多人张口就来:左右双指针从两端往中间收,遇到不同字符就返回 false。但链表版本麻烦在单链表只能单向行走,你没有随机访问能力,甚至不提前遍历都不知道一共有多少个节点。想要“从右往左”遍历,只能借助额外存储,或者把链表本身改造成可反向访问的结构。这一点就是整道题的钥匙。

输入范围也要留意,节点数可以到 10^5 这个量级,这意味着 O(n) 时间基本是必须的,空间上则看你选什么方案。节点值本身取值范围倒不影响核心算法,因为判断回文只看节点之间的相等性,无论是数字还是其他可比较对象,逻辑都一样。

写代码之前还得把边界情况想清楚:空链表(如果题目允许这种输入)和单节点链表都直接返回 true,两个节点时只要值相等就是 true。很多翻车现场不是栽在算法主逻辑,而是栽在这些“看起来太简单”的输入上。面试里先花 30 秒确认边界,往往比急着写代码更让面试官加分。

1.2 把回文判断翻译成链表操作

一个序列是回文,意味着第 i 个元素和倒数第 i 个元素相等。对链表来说,这等价于“前半段正序”与“后半段逆序”完全一致。于是问题就拆成两块:

  • 怎么拿到后半段的头节点:用快慢指针找中点。
  • 怎么让后半段变成可以正向遍历的“逆序”:要么压栈,要么原地反转。

这两个动作的组合,基本就是这道题所有主流解法的骨架。栈辅助法的本质是用 O(n) 空间把逆序遍历问题转换成栈的后进先出特性;双指针加反转的做法则是直接改变节点指针方向,用 O(1) 空间完成同样的对比。理解了这两个分支,很多变体写法一眼就能看穿。

1.3 空间复杂度才是一道“隐藏题眼”

可以做个对比实验:如果允许 O(n) 空间,这道题甚至算不上中等难度,复制到数组后一分钟内能写完。面试官把它放在题库里,真正的考验是你能不能意识到空间可以压到 O(1),以及敢不敢在面试中临时修改链表结构。后面的章节我会一步步展开这两条路径,先看最直观的栈辅助法。

2. 栈辅助法:最容易上手的实现

2.1 思路与最小实现

栈方案的思路一句话就能说清:第一遍完整遍历链表,把所有节点的值依次入栈;第二遍再从头遍历,同时不断弹出栈顶元素做比对。因为栈弹出的顺序恰好是链表从尾到头的顺序,所以两遍遍历对比的就是正序和逆序是否一致。

def isPalindrome(head): stack = [] cur = head while cur: stack.append(cur.val) cur = cur.next cur = head while cur: if cur.val != stack.pop(): return False cur = cur.next return True

这个版本的时间复杂度是 O(n),空间复杂度是 O(n)。代码没有任何复杂的边界判断,非常适合作为你拿到题目之后的第一版答案。哪怕后面要改成更优解,也可以先用它跑通测试用例,确认自己对题意的理解没有偏差。

2.2 优化版本:只把前半段压栈

上面把所有节点都塞进栈,空间上有些浪费。其实可以用快慢指针先找到中点,只把前半段的节点值压栈,然后从后半段开始遍历,边遍历边弹栈对比。这样栈的深度最多只有 n/2。

def isPalindrome(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next stack = [] cur = head while cur != slow: stack.append(cur.val) cur = cur.next # fast 不为空,说明是奇数长度,跳过中间节点 if fast: slow = slow.next while slow: if slow.val != stack.pop(): return False slow = slow.next return True

这里需要解释两个细节。第一个是快慢指针循环结束后,slow 停在哪里。偶数长度时,slow 停在后半段的起点;奇数长度时,slow 停在正中间。第二个细节是 fast 变量的作用:循环结束后如果 fast 不为空,说明链表是奇数长度,中间节点不需要参与对比,直接让 slow 往后走一步。测试几个例子:1->1 返回 true,1->2 返回 false,1->2->1 返回 true,1->2->2->1 返回 true,逻辑都是吻合的。

2.3 栈方案的适用边界

栈方案最大的优点是直观、不容易写错,尤其适合面试刚开始、心态还比较紧张的时候快速拿分。缺点也很明显:空间复杂度不达标。如果面试官接下来的问题就是“能不能用 O(1) 空间”,你必须有第二套方案接上。

还有一个使用细节想单独提一下:如果你用编程语言自带的栈接口,比如 Java 的 Deque,要注意 push/pop 和 offer/poll 这些方法容易混。用数组模拟栈反而最不容易出问题,数组的 append 和 pop 就是天然的栈操作,这也是我在示例里直接用数组的原因。

3. 双指针法:快慢指针配合反转后半段

3.1 三步走的整体设计

如果要把空间压到 O(1),就必须在原地反转链表。先把整条链表拆成两段,反转后半段,然后从头节点和反转后的后半段头节点同时出发,逐个比较节点值。整个过程只需要若干个指针变量,不申请任何新节点。

具体三个步骤:

  1. 用快慢指针找到前半段的末尾节点。
  2. 从后半段头节点开始,原地反转后半段。
  3. 两个指针同步前进,遇到值不相等就返回 false;全部匹配就返回 true。

这里“找前半段末尾”是核心动作。快指针每次走两步,慢指针每次走一步,快指针到达末尾时,慢指针正好落在链表中间位置附近。细节不同,slow 落点也不同,下面代码采用的是面试题解里最常见的写法,让 slow 落在前半段末尾。

3.2 具体代码实现:Java 版本

public boolean isPalindrome(ListNode head) { if (head == null || head.next == null) { return true; } // 1. 快慢指针找前半段末尾 ListNode slow = head, fast = head; while (fast.next != null && fast.next.next != null) { slow = slow.next; fast = fast.next.next; } // 2. 反转后半段 ListNode secondHead = reverse(slow.next); // 3. 同步比较 ListNode p1 = head, p2 = secondHead; boolean result = true; while (p2 != null) { if (p1.val != p2.val) { result = false; break; } p1 = p1.next; p2 = p2.next; } // 4. 恢复原链表(不是题目要求,但建议养成习惯) slow.next = reverse(secondHead); return result; } private ListNode reverse(ListNode head) { ListNode prev = null, cur = head; while (cur != null) { ListNode next = cur.next; cur.next = prev; prev = cur; cur = next; } return prev; }

再把同样的逻辑用 Python 写一遍,结构几乎一模一样:

def isPalindrome(self, head): if head is None or head.next is None: return True slow = fast = head while fast.next and fast.next.next: slow = slow.next fast = fast.next.next second_head = self._reverse(slow.next) p1, p2 = head, second_head result = True while p2: if p1.val != p2.val: result = False break p1, p2 = p1.next, p2.next slow.next = self._reverse(second_head) return result def _reverse(self, head): prev = None cur = head while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev

两个版本的比较循环都选择“p2 不为空”作为终止条件,原因放在后面问题排查章节详细说。

3.3 反转链表本身的细节拆解

反转这段代码值得多花一点笔墨,因为它单独就是一道高频题。核心思路是准备三个指针:prev 记录前一个节点,cur 记录当前节点,next 提前保存当前节点的下一个引用。每次循环只做一件事:让 cur.next 指向 prev,然后把三个指针整体往后移动一格。

很多初学者写反转会丢链表,原因是 cur 的 next 指针被改写之后,原本的下一个节点就找不到了,所以必须先执行next = cur.next,再执行cur.next = prev。这个顺序绝对不能反。整个过程遍历一次,时间复杂度 O(n),且只用了三个额外指针变量。

反转后半段之后,原链表被分割成两部分。头部这一半仍然是从 head 出发的正常链表,尾部这一半变成从 secondHead 出发的逆序链表。比较时 p2 走到 null,就意味着所有对应位置都比较完了,返回结果即可。

3.4 为什么选择反转后半段而不是前半段

理论上反转前半段也能比较,但会出现一个麻烦:前半段反转后,head 这个入口就失效了,你需要额外记住反转后的新头节点。更麻烦的是,比较完之后链表是交叉坏掉的,恢复原状需要多做不少工作。反转后半段就舒服很多:前半段不变,head 仍然可用,比较结束之后,只要把后半段再反一次,就能恢复到完整链表。这也是为什么几乎所有标准解法都是反转后半段。

4. 递归法与其他实现角度

4.1 递归回溯:用函数调用栈做“从尾部回到头部”

递归的思路和栈辅助法异曲同工,但它不是显式申请一个栈,而是借用函数调用栈天然的后进先出特性。先递归到链表末尾,然后在归的过程中从尾到头访问节点;同时用一个外部指针从头到尾访问节点。两者一对比,就能判断回文。

def isPalindrome(head): front = head def check(node): nonlocal front if node is None: return True if not check(node.next): return False if front.val != node.val: return False front = front.next return True return check(head)

这段代码的执行轨迹是这样的:check 不断深入,一直到最后一个节点的 next,也就是空节点,然后开始一层层返回。第一次返回时 node 是最后一个节点,front 指向第一个节点,两者比较;第二次返回时 node 是倒数第二个节点,front 已经移动到第二个节点,两者比较。就这样从两端向中间收敛。

递归解法的代码很简洁,也能展示你对函数调用栈的理解,面试时偶尔可以作为补充方案提一嘴。但它有两个明显短板:一是空间复杂度同样是 O(n),并没有比栈方案好;二是链表很长时容易爆栈,比如 10^5 个节点,在许多语言里会直接抛栈溢出异常。所以它适合用来讲述思路,不适合作为主推方案。

4.2 数组拷贝兜底方案

还有一种最保守的思路,就是把链表所有节点值复制到数组里,然后用数组双指针从两端夹逼。这个方案在面试中不算加分,但胜在极其稳定,几乎不可能写错。

def isPalindrome(head): 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

我自己的习惯是把它当成“基准实现”。遇到链表相关的题目,如果一时想不起更优解,先用这种最简单的方法保证正确通过测试;如果面试官追问空间优化,再切换到双指针加反转的方案。这样至少能保证你不会在高压环境下交白卷。

4.3 各种思路在真实场景中的定位

把四种思路放在一起看,你会发现它们并不是孤立的知识点。栈辅助法是最直白的思维映射,递归法是对函数调用栈的灵活运用,数组拷贝是工程里最稳妥的兜底,快慢指针加反转则是面试官想看到的“最优解”。平时练习时建议四种都写一遍,不要只背最优解。真到面试现场,面试官更看重的是你能不能解释清楚取舍关系,而不是单纯默写代码。

5. 复杂度对比与面试现场的取舍

5.1 方案复杂度快速对照

四种主流方案的复杂度差异可以用一张表看得很清楚:

实现方案时间复杂度空间复杂度是否修改链表适合定位
数组拷贝 + 双指针O(n)O(n)否兜底方案,先跑通
全部节点入栈O(n)O(n)否直观,但空间浪费
前半段入栈O(n)O(n)否省一半空间,思路加分
递归回溯O(n)O(n) 递归栈否展示理解,但不建议主用
快慢指针 + 反转后半段O(n)O(1)临时修改,可恢复面试标准答案

表格里唯一达到 O(1) 空间的就是快慢指针加反转。这也是面试官最想听到的方案。需要特别说明的是,反转操作本身仍然是 O(n) 时间,只不过没有申请新节点,所以空间复杂度才是 O(1)。

5.2 面试回答顺序与话术

我的建议是面试时从最简单方案讲起,逐步优化,而不是一上来就甩出最优解。直接写反转版本显得像背题,而先给数组方案、再在追问下优化到 O(1),反而能显示出你的思考链路完整。

可以参考这样的回答节奏:先表达“我先给你一个最简单、空间 O(n) 的方案”,写出数组拷贝,确认用例通过;然后主动说“如果链表很长,这个方案占内存,我可以改成栈辅助法,只压前半段”;最后补一句“如果面试官希望空间严格 O(1),那就用快慢指针定位中点,原地反转后半段对比,结束后可以把链表恢复原样”。整个过程中,你的每个方案之间都有明确递进关系,面试官也能感受到你不是在背模板,而是真的理解。

还有一个容易被忽略的加分点:主动提到“恢复原链表”。虽然题目只要求返回布尔值,但很多实际工程场景不允许方法对被传入的数据结构做副作用修改。主动恢复链表,往往会让面试官对你的代码习惯高看一眼。

5.3 这道题的高频追问与扩展思考

面试官常常会在原题基础上做变形。常见的有几种:

  • 如果节点值不是整数而是自定义对象怎么办?比较相等时就要用对象层面的 equals 方法,或者逐个字段比较。算法骨架不受影响。
  • 如果给你的是双向链表怎么办?那就更简单了,直接头尾双指针往中间走,连反转都省了。
  • 如果链表带环怎么判断回文?这已经不是原题的范畴,需要先考虑环检测,判断回文前得先想办法拿到一条线性序列,复杂度会明显上升。
  • 如果要求“判断后必须保持链表结构不变”怎么办?使用栈方案天然满足,使用反转方案则务必补上恢复步骤。

这些追问本质上都是在测试你能否把“核心算法”和“输入特性”解耦。平时多给自己出几个变形题,面试时遇到类似问题就不会慌。

6. 常见问题与排查技巧实录

6.1 空指针、断链与遍历终止条件

链表题最常见的报错就是空指针异常,回文链表这道题也躲不开。比较常见的错误写法是直接判断fast.next.next而不先检查fast.next。比如:

while (fast.next.next != null) { // 错误示范

当 fast 已经指向最后一个节点时,fast.next是 null,再取.next就直接空指针。正确的写法要利用短路求值,先把fast.next != null放在前面,或者采用fast != null && fast.next != null的形式。这两种条件在不同实现里对应不同的 slow 落点,写之前先想清楚。

6.2 奇偶长度错位:你最可能踩的坑

奇偶长度处理错位是另一大类问题。栈辅助法的奇数长度版本,如果忘记跳过中间节点,就会让中间节点和栈里弹出的第一个元素比较,导致误判。双指针反转版本如果反转的起点错了,奇数长度时会把正中间的节点算进后半段,好在多数写法下这不会影响最终结果,但会让恢复链表时逻辑变复杂。

核心判断依据只有一个:用快慢指针找中点时,循环结束条件是让 fast 停在最后一个节点还是停在空节点。停在最后一个节点说明是奇数长度,停在空节点说明是偶数长度。你选的循环条件不同,slow 的落点就不同,后续跳过和反转的节点设置都要跟着调整。建议选定一种写法后,用我之前给的几组测试用例逐个走一遍。

6.3 恢复原链表:一个很容易翻车的小动作

反转方案里,恢复链表有一个经典坑。你先把secondHead = reverse(slow.next)保存了反转后的新头节点,此时slow.next其实还指向原来的后半段首节点,只是这个节点现在变成了后半段的尾节点,它的 next 已经是 null。如果恢复时写成reverse(slow.next),你反转的就只有一个节点,整条链表再也接不回去。

正确的恢复方式是slow.next = reverse(secondHead),也就是基于保存下来的 secondHead 做反转,而不是基于 slow.next。很多人在这一行踩坑,就是因为脑子里想的是“刚才反转了什么,现在再反转回去”,但指针的实际指向已经在反转过程中被改变了。这个细节我也写进了前面的代码里,建议你亲手跑一遍,观察每一步的指针变化。

6.4 自测用例清单与测试脚本

无论用哪种实现,我都建议你在本地准备一套自动化测试脚本,而不是每次手工构造链表。这里提供一个简单的 Python 辅助函数:

class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def build_linked_list(nums): dummy = ListNode() move = dummy for val in nums: move.next = ListNode(val) move = move.next return dummy.next def linked_list_to_list(head): res = [] while head: res.append(head.val) head = head.next return res

用这个脚本,可以快速验证下面这组用例:单节点[1]应是 true;双节点[1, 1]是 true,[1, 2]是 false;奇数长度[1, 2, 1]是 true;偶数长度[1, 2, 2, 1]是 true;非回文[1, 2, 3]是 false。实测下来,只要这些用例全过,你的实现基本就是正确的。

我自己刷这道题时的体会是:链表的边界条件并不可怕,可怕的是没有一套可重复的验证流程。有了测试脚本兜底,你就可以放心大胆地尝试不同写法,比如把反转起点从slow.next改成slow,对比结果差异,这样练一遍,对快慢指针各种落点的理解会非常扎实。最后再提醒一句,恢复链表虽然不强制,但养成这个习惯之后,面试里很多“链表能不能保持原样”的追问你都能从容接住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进