从数组拷贝到原地反转:力扣234「回文链表」的四种解题进化之路
当“龟兔赛跑”遇上“反转链表”,这道题让你彻底理解如何用O(1)空间判断链表回文。
前言
在刚刚攻克了力扣206「反转链表」之后,我们趁热打铁,来看一道它的“亲兄弟”题目——力扣234. 回文链表(Palindrome Linked List)。
这道题在LeetCode上标记为简单(Easy),但千万别被它的难度标签骗了。它在字节跳动、腾讯、美团等国内大厂的出现频率极高,而它的进阶要求直接劝退了无数人:
你能否用
O(n)时间复杂度和O(1)空间复杂度解决此题?
很多同学都会写“把链表转成数组,然后双指针”,空间复杂度 O(n) 轻松过关。但当面试官幽幽地问一句“如果不让你用额外数组呢?”,很多人就愣在了原地。
今天,我们就从最直观的“数组拷贝法”出发,一路进化到“快慢指针 + 原地反转”的终极解法。这个过程恰好串联了我们之前学过的反转链表(206)和双指针技巧,是一道极佳的“综合应用题”。读完这篇文章,你将对链表的“引用操作”产生肌肉记忆般的本能反应。
题目回顾
给你一个单链表的头节点
head,请你判断该链表是否为回文链表。如果是,返回true;否则,返回false。示例1:
输入:head = [1,2,2,1] 输出:true示例2:
输入:head = [1,2] 输出:false进阶: 你能否用
O(n)时间复杂度和O(1)空间复杂度解决此题?
核心难点:单链表的“单向性”困境
对于数组判断回文,我们有成熟的“左右双指针”模型:左指针从左往右,右指针从右往左,向中间靠拢比较。
但对于单链表,我们只能从 head 开始单向遍历。我们无法直接让一个指针从尾部“倒着走”。因此,这道题要解决的根本矛盾就是——如何在不借助外部数组的情况下,让链表具备“从两端向中间”比较的能力?
这个矛盾催生出了我们今天要讲的几种解法,它们分别从“空间换时间”、“隐式栈(递归)”、“显式反转”三个角度给出了答案。
第一层:辅助数组法 —— 最稳妥的“降维打击”
既然链表不能倒着走,那我把它变成数组总行了吧?数组支持随机访问,左右双指针直接起飞。
核心思想
- 遍历链表,将每个节点的值存入
ArrayList或数组。 - 使用双指针
left = 0,right = list.size() - 1,向中间靠拢比较。
代码实现
class Solution {
public boolean isPalindrome(ListNode head) {
List<Integer> vals = new ArrayList<>();
ListNode curr = head;
while (curr != null) {
vals.add(curr.val);
curr = curr.next;
}
int left = 0, right = vals.size() - 1;
while (left < right) {
if (!vals.get(left).equals(vals.get(right))) {
return false;
}
left++;
right--;
}
return true;
}
}
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
vals = []
curr = head
while curr:
vals.append(curr.val)
curr = curr.next
return vals == vals[::-1]
复杂度分析:
- 时间复杂度:
O(n),遍历两次(一次链表转数组,一次双指针比较)。 - 空间复杂度:
O(n),存储了链表的所有值。
点评: 这是最符合直觉的解法,代码优雅且几乎不会出错。但在面试中,面试官一定会紧接着抛出那个“进阶”问题:能优化空间吗? 所以,这个解法只能作为我们的“保底方案”和“思路起点”。
第二层:栈辅助法(或递归隐式栈) —— “后进先出”的天然逆序
既然数组可以随机访问,那如果我们不允许用数组,还有什么数据结构能帮我们“从后往前”获取数据?
答案是栈(Stack)。栈的“后进先出(LIFO)”特性天生适合处理逆序。
核心思想
- 第一遍遍历:把所有节点值压入栈。
- 第二遍遍历:从
head开始,依次弹出栈顶元素进行比较。栈顶元素正好对应链表的最后一个节点,依次弹出就是从尾到头。
代码实现
class Solution {
public boolean isPalindrome(ListNode head) {
Stack<Integer> stack = new Stack<>();
ListNode curr = head;
while (curr != null) {
stack.push(curr.val);
curr = curr.next;
}
curr = head;
while (curr != null) {
if (curr.val != stack.pop()) {
return false;
}
curr = curr.next;
}
return true;
}
}
复杂度分析:
- 时间复杂度:
O(n)。 - 空间复杂度:
O(n)。
拓展:递归法(隐式栈) 递归调用本身就是在利用系统调用栈。我们可以利用递归的回溯特性,实现“一个指针往前走,另一个指针在回溯时往回走”。
class Solution {
private ListNode frontPointer;
public boolean isPalindrome(ListNode head) {
frontPointer = head;
return recursivelyCheck(head);
}
private boolean recursivelyCheck(ListNode currentNode) {
if (currentNode != null) {
if (!recursivelyCheck(currentNode.next)) return false;
if (currentNode.val != frontPointer.val) return false;
frontPointer = frontPointer.next;
}
return true;
}
}
递归法虽然代码优雅,但空间复杂度依然是 O(n)(递归调用栈深度),且容易在长链表上爆栈(Stack Overflow)。面试中提一下作为思路拓展即可,不推荐作为最终答案。
第三层:快慢指针 + 反转后半部分 —— 终极O(1)空间解法
终于到了这道题最精彩的解法!它完美地结合了快慢指针找中点和原地反转链表(力扣206),是链表综合能力的最佳试金石。
核心思想
我们不复制数据,而是改变链表结构(当然,面试结束后最好能恢复,以示专业)。具体分三步走:
- 找中点:使用“快慢指针”(龟兔赛跑)。慢指针
slow每次走一步,快指针fast每次走两步。当fast到达尾部时,slow恰好到达链表中点。 - 反转后半部分:从
slow开始,反转后半部分链表(直接复用 206 题的迭代逻辑)。 - 比较两半:用两个指针
p1指向head,p2指向反转后的后半部分头节点,逐个比较值。 - (可选)恢复链表:为了不破坏原始数据结构,可以再次反转后半部分并拼接回去。LeetCode 不强制,但面试时可以提一句,这是加分项。
为什么这能解决问题?
- 前半部分(正序)和后半部分(反转后也是正序,即原链表的逆序)进行比较,本质上等价于“从两端向中间”比较。
- 整个过程只有几个指针变量,完全没有额外数组。
详细步骤图解
以 1 -> 2 -> 3 -> 2 -> 1 为例:
- 找中点:
slow停在3(偶数长度会停在中间偏右的位置,具体看代码实现)。 - 反转后半部分:
2 -> 1(反转后变成1 -> 2)。 - 比较:
p1: 1, 2, 3与p2: 1, 2比较,前两位相同,p2走到null,判定为回文。
代码实现(带恢复链表结构)
class Solution {
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) return true;
// 1. 快慢指针找中点
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 如果 fast != null,说明链表长度为奇数,slow 正好在中间节点
// 此时比较时不需要比较中间节点,所以让 slow 再往后移一位(跳过中间节点)
if (fast != null) {
slow = slow.next;
}
// 2. 反转后半部分链表 (从 slow 开始)
ListNode secondHalfHead = reverseList(slow);
// 3. 比较前半部分和反转后的后半部分
ListNode p1 = head;
ListNode p2 = secondHalfHead;
boolean isPalindrome = true;
while (p2 != null) { // 只需要比较到 p2 结束即可
if (p1.val != p2.val) {
isPalindrome = false;
break;
}
p1 = p1.next;
p2 = p2.next;
}
// 4. (可选) 恢复链表原状:再次反转后半部分并接回去
reverseList(secondHalfHead);
return isPalindrome;
}
// 复用 206 题的反转链表代码(迭代法)
private ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next;
curr.next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
}
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
if not head or not head.next:
return True
# 1. 找中点
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 如果 fast 不为空,说明奇数长度,跳过中间节点
if fast:
slow = slow.next
# 2. 反转后半部分
prev = None
curr = slow
while curr:
next_temp = curr.next
curr.next = prev
prev = curr
curr = next_temp
second_half = prev
# 3. 比较
p1, p2 = head, second_half
res = True
while p2:
if p1.val != p2.val:
res = False
break
p1, p2 = p1.next, p2.next
# 4. (可选) 恢复链表
# 再次反转 second_half(即 reverseList 逻辑)
return res
复杂度分析:
- 时间复杂度:
O(n)。找中点 O(n),反转 O(n),比较 O(n)。 - 空间复杂度:
O(1)。只用了若干个指针,完美符合进阶要求!
重点细节:
- 奇偶数的处理:
while (fast != null && fast.next != null)跳出循环时,如果fast != null,说明链表长度为奇数。此时slow正好指向正中央的节点(如1,2,3,2,1中的3),它不需要参与比较,所以slow = slow.next跳过它。 - 为什么只比较到 p2 结束:因为后半部分反转后长度可能比前半部分少一半(奇数长度情况),后半部分遍历完就代表比较结束。
第四层(进阶思考):只反转一半的“部分反转” vs “全部反转”
在第三层中,我们反转的是从中间到结尾的这一半。有些同学会问:“我能把整条链表反转了再比较吗?”
当然可以:你可以复制一个头指针,反转整个原链表,然后和原链表比较。但这样做要么需要 O(n) 空间存储原链表,要么破坏了原结构且无法恢复(因为反转整个链表后,原头节点就变成了尾节点,无法再变回原样)。所以,只反转后半部分是最优雅、最安全的原地做法。
深度总结:四种解法进化图谱
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 | 面试推荐度 |
|---|---|---|---|---|
| 辅助数组法 | O(n) | O(n) | 链表转数组,双指针判回文 | ⭐⭐⭐(快速实现) |
| 栈辅助法 | O(n) | O(n) | 栈的LIFO特性模拟逆序 | ⭐⭐⭐(思路拓展) |
| 递归隐式栈 | O(n) | O(n) | 利用系统调用栈回溯 | ⭐⭐(代码优雅,易爆栈) |
| 快慢指针 + 原地反转 | O(n) | O(1) | 分割 + 反转 + 比较 | ⭐⭐⭐⭐⭐(最优解) |
从这道题中我们学到了什么?
-
链表的“破坏性操作”是一把双刃剑。原地反转是 O(1) 空间的利器,但它会改变链表结构。在面试中,主动提出“我可以恢复原链表”会显得你非常专业和严谨。
-
快慢指针的“龟兔赛跑”模板:找中点、找环入口(142)、找倒数第K个节点(19),都是这个模板的变种。记住
fast走两步,slow走一步,这能解决 50% 的链表中间值问题。 -
问题的转化能力。链表的单向性导致不能“从右往左”,但我们通过“反转后半部分”把“从右往左”转化成了“从左往右(反转后的顺序)”。这种通过变换数据结构来简化问题的能力,是算法进阶的核心标志。
-
多道题的组合应用:这道题完美结合了力扣 876(找链表中点) 和力扣 206(反转链表)。如果你能把这三道题一起刷了,你的链表基本功将变得极其扎实。
最后的一些心里话
力扣234是一道非常“刁钻”的题。它用最简单的“回文”概念,包装了链表中最核心的“引用”难点。很多同学学到这里,可能会觉得“为了省这点空间,把代码搞得这么复杂值得吗?”
在真实的大数据场景下(比如流式处理、内存极度受限的嵌入式系统),O(1) 的空间往往意味着可行性,而 O(n) 可能直接导致内存溢出。正因为如此,大厂面试才如此看重这种极致优化的能力。
记住今天的口诀:
龟兔赛跑找中点,反转半截莫犯难。 双头齐步比大小,原地判回文不一般。
如果你能闭着眼睛把“快慢指针 + 反转半段链表”的代码手写出来,那么恭喜你,链表的“引用操作”你已经出师了!
如果你觉得这篇题解帮你彻底搞懂了回文链表,欢迎点赞、收藏、转发!我们链表专题的下一站——环形链表(141/142)见! 🚀