一、问题理解
问题描述
给你一个单链表的头节点 head,请你判断该链表是否为回文链表。如果是,返回 true;否则,返回 false。
回文 的定义:正着读和反着读都一样。
示例
示例 1:
text
输入: head = [1,2,2,1]
输出: true
示例 2:
text
输入: head = [1,2]
输出: false
示例 3:
text
输入: head = [1,2,3,2,1]
输出: true
示例 4:
text
输入: head = []
输出: true
要求
-
时间复杂度: O(n)
-
空间复杂度: O(1) (进阶要求,不包含递归栈空间)
-
能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决? 这是本题的核心挑战。
二、核心思路:从中间一分为二,反转后半部分
基本思想
判断回文的核心是比较对称位置的节点值。对于数组,我们可以直接用双指针从两端向中间比较。但对于单向链表,我们无法直接从后往前遍历。
因此,一种经典的 O(1) 空间解法是:
-
找到中点: 使用快慢指针找到链表的中间节点。
-
反转后半部分: 将链表从中点之后的部分进行反转。
-
比较两半: 将前半部分与反转后的后半部分逐一比较节点值。
-
(可选)恢复链表: 为了不破坏原链表结构,可以在比较完成后将后半部分再反转回来。
双指针与反转的结合
这道题巧妙地结合了 快慢指针 和 链表反转 这两个核心技巧。我们主要依赖三个指针或操作:
-
slow和fast指针: 用于寻找链表的中间节点。 -
反转操作: 用于反转后半部分链表,以便顺序比较。
三、代码逐行解析
方法一:快慢指针 + 反转后半部分(最优解,O(1) 空间)
Python 解法
python
# Definition for singly-linked list.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
# 1. 使用快慢指针找到链表的中间节点
# 慢指针 slow 每次走一步,快指针 fast 每次走两步
# 当 fast 走到末尾时,slow 正好指向中间节点
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 2. 反转从 slow 开始的后半部分链表
# 如果链表长度是奇数,slow 正好指向中间节点,反转中间节点之后的部分即可
prev = None
curr = slow
while curr:
# 保存下一个节点
next_node = curr.next
# 反转指针
curr.next = prev
# 移动指针
prev = curr
curr = next_node
# 循环结束后,prev 指向反转后的后半部分链表的头节点
# 3. 比较前半部分和反转后的后半部分
left, right = head, prev
# 只需要比较到 right 遍历完即可
# 因为如果链表长度是奇数,中间节点会被包含在 right 中,但 left 会比 right 少一个节点?需要确认
# 实际上,对于奇数长度 [1,2,3,2,1]:
# - 找到的 slow 是 3,反转后半部分 [3,2,1] 得到 [1,2,3]
# - left 是 [1,2,3], right 是 [1,2,3]?这里需要精确处理
# 为了统一处理奇偶,我们可以在第二步反转时,从 slow 开始反转,这样对于奇数长度,中间节点会被反转后成为后半部分的最后一个节点。
# 比较时,循环条件设为 while right: 即可,因为当 right 为 None 时,说明后半部分已遍历完,比较结束。
# 但这样 left 可能会遍历到中间节点?仔细分析:反转后半部分后,right 是反转后的头。对于奇数长度,原链表 slow 指向中间节点,反转后半部分后,中间节点成为后半部分的尾。
# 此时 left 从头开始,right 从反转后的头开始,它们遍历的长度恰好是 floor(n/2)。中间节点不会被比较,因为它与自身相等,不影响结果。
while right:
if left.val != right.val:
return False
left = left.next
right = right.next
# 4. (可选)如果需要恢复链表,可以在这里再次反转后半部分
# restore_head = self.reverseList(prev) # 需要定义反转函数
return True
Java 解法
java
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public boolean isPalindrome(ListNode head) {
// 1. 快慢指针找中点
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. 反转后半部分链表
ListNode prev = null;
ListNode curr = slow;
while (curr != null) {
ListNode nextNode = curr.next;
curr.next = prev;
prev = curr;
curr = nextNode;
}
// 此时 prev 是反转后的后半部分的头节点
// 3. 比较前半部分和后半部分
ListNode left = head;
ListNode right = prev;
while (right != null) {
if (left.val != right.val) {
return false;
}
left = left.next;
right = right.next;
}
// 4. (可选)恢复链表
// reverseList(prev);
return true;
}
}
方法二:将值复制到数组后用双指针(简单解法,O(n) 空间)
Python 解法
python
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
# 1. 将链表节点值复制到数组
vals = []
current_node = head
while current_node:
vals.append(current_node.val)
current_node = current_node.next
# 2. 使用双指针判断数组是否为回文
left, right = 0, len(vals) - 1
while left < right:
if vals[left] != vals[right]:
return False
left += 1
right -= 1
return True
Java 解法
java
class Solution {
public boolean isPalindrome(ListNode head) {
// 1. 将值复制到 ArrayList
List<Integer> vals = new ArrayList<>();
ListNode curr = head;
while (curr != null) {
vals.add(curr.val);
curr = curr.next;
}
// 2. 双指针判断
int left = 0;
int right = vals.size() - 1;
while (left < right) {
if (!vals.get(left).equals(vals.get(right))) {
return false;
}
left++;
right--;
}
return true;
}
}
四、Java 与 Python 语法对比
(此部分内容与反转链表文章中的语法对比高度相似,在此保留以保持结构完整,但内容上关联回文链表的代码)
| 操作 | Java | Python |
|---|---|---|
| 链表节点类 | 需要预定义 public class ListNode { ... } |
需要预定义 class ListNode: ... |
| 创建节点 | new ListNode(val) |
ListNode(val) |
| 检查 null | node == null |
node is None |
| 访问属性 | node.next |
node.next |
| 修改属性 | node.next = otherNode |
node.next = otherNode |
| List/数组操作 | List<Integer> list = new ArrayList<>(); list.add(val); list.get(index); |
vals = [] vals.append(val) vals[left] |
五、实例演示
示例: head = [1, 2, 3, 2, 1] (奇数长度)
-
初始状态:
- 链表:
1 → 2 → 3 → 2 → 1 → null
- 链表:
-
找中点(快慢指针):
-
slow = 1,fast = 1 -
第1步后:
slow = 2,fast = 3 -
第2步后:
slow = 3,fast = 1(fast走两步,到达最后一个节点1) -
fast.next为null,循环结束。slow指向中间节点3。
-
-
反转后半部分(从
slow开始):-
待反转部分:
3 → 2 → 1 → null -
反转后:
1 → 2 → 3 → null,prev指向1。
-
-
比较两半:
-
left指向原链表头1 -
right指向反转后的后半部分头1 -
比较
1和1:相等。移动:left=2,right=2 -
比较
2和2:相等。移动:left=3,right=3 -
此时
right指向3,left也指向原链表的3,但继续比较: -
比较
3和3:相等。移动:left=2(原链表的后半部分),right=null -
while right:条件不满足,循环结束。 -
注意: 实际上我们比较了
n/2 + 1次?这是因为我们的反转包括了中间节点。对于奇数长度,中间节点与自己比较,不影响结果,但为了确保left和right的边界处理正确,循环条件设为while right:是安全的,因为它保证只比较后半部分存在的节点。当right为null时,所有不对称的可能性都已检查完毕。由于前半部分多出的中间节点(在left中)不需要与任何人比较,因此结果是正确的。
-
六、关键细节解析
1. 为什么用快慢指针找中点?
因为链表不能随机访问,无法直接通过索引找到中间位置。快慢指针可以在一次遍历中找到中间节点,满足 O(n) 时间复杂度的要求。当 fast 走到末尾时,slow 正好指向中间(偶数长度时指向后半部分的第一个节点)。
2. 如何统一处理链表长度的奇偶性?
-
偶数长度
[1,2,2,1]:slow会指向第二个2。反转后半部分[2,1]得到[1,2]。前半部分[1,2]与[1,2]完美一一对应。 -
奇数长度
[1,2,3,2,1]:slow指向中间的3。反转后半部分[3,2,1]得到[1,2,3]。前半部分[1,2,3]与[1,2,3]比较。中间的3与自己比较了一次,这不会导致错误,因为回文要求它等于自身。
3. 为什么反转后半部分而不是前半部分?
反转后半部分更方便。因为我们有指向原链表头的指针 head,可以顺序遍历前半部分。如果反转前半部分,我们将丢失前半部分的起始点,需要额外处理。反转后半部分后,我们只需将反转后的新头与原来的头进行比较即可。
4. 如何处理空链表或单节点链表?
这两种情况都是回文。在找中点前的循环 while fast and fast.next: 中,空链表 fast 为 None,不进入循环,slow 为 None。后续反转和比较循环 while right: 也不会执行,函数直接返回 True。单节点链表 fast.next 为 None,同样不进入循环,slow 指向唯一的节点。反转部分只有一个节点,prev 指向该节点。比较循环 while right: 执行一次,比较 left.val 与 right.val(同一个节点),相等,返回 True。
5. 空间复杂度 O(1) 的解法为什么是最优的?
虽然将值复制到数组的方法简单易懂,但它需要 O(n) 的额外空间。题目要求能否用 O(1) 空间解决,这迫使我们必须在不使用额外线性空间的情况下操作链表,考察了对链表指针的熟练运用。
七、复杂度分析
方法一:快慢指针 + 反转后半部分
-
时间复杂度: O(n),其中 n 是链表的长度。需要遍历链表找中点(O(n/2))、反转后半部分(O(n/2))、比较两半(O(n/2)),总体仍是线性时间。
-
空间复杂度: O(1)。我们只使用了
slow、fast、prev、curr、next_node、left、right等常数个指针变量。
方法二:复制到数组
-
时间复杂度: O(n),遍历链表复制到数组 O(n),双指针比较 O(n/2)。
-
空间复杂度: O(n),需要额外一个大小为 n 的数组或列表来存储节点值。
八、其他解法
解法三:递归法(巧妙但空间复杂度高)
利用递归的特性,在递归返回时与链表头节点进行比较。这实际上利用了系统栈,空间复杂度为 O(n)。
python
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
self.front_pointer = head
def recursively_check(current_node):
if current_node is not None:
if not recursively_check(current_node.next):
return False
if self.front_pointer.val != current_node.val:
return False
self.front_pointer = self.front_pointer.next
return True
return recursively_check(head)
分析: 代码非常简洁,但递归深度等于链表长度,对于长链表有栈溢出的风险,且空间复杂度 O(n),不符合进阶要求。
九、常见问题与解答
Q1: 为什么不能直接反转整个链表然后比较?
A1: 如果直接反转整个链表得到一个新链表 newHead,然后比较原链表 head 和新链表,那么比较过程中原链表已经被反转破坏了。除非我们先复制一份原链表再反转,但这又会引入 O(n) 的空间。因此,比较两半的方案更优。
Q2: 比较两半时的循环条件为什么是 while right: 而不是 while left and right:?
A2: 因为经过我们的分割和反转,right 链表(反转后的后半部分)的长度总是小于等于 left 链表(前半部分)的长度(奇数时相等,偶数时 right 短一个)。所以当 right 遍历完时,所有需要比较的节点都已比较完毕,剩余的 left 节点(如果有的话,即奇数长度的中间节点)不需要再比较。这样可以简化循环条件。
Q3: 如何在比较后恢复链表原状?
A3: 可以在比较之前记录下反转部分的起始节点 slow 和反转后的头节点 prev。比较完成后,再次调用反转函数 reverseList(prev),将后半部分反转回原来的顺序,然后再将 slow 节点与反转后的头节点连接(实际上因为反转函数会返回新的头,即原来的 slow,只需将 slow.next 指向 None 即可断开,但为了完全恢复,需要重新连接)。不过本题没有要求恢复,因此通常省略这一步。
Q4: 如果链表很大,快慢指针找中点会有什么风险?
A4: 快慢指针是一种非常安全的 O(1) 空间方法。它只遍历链表一次,没有使用额外内存,因此可以处理任意大小的链表(只要内存能存下链表本身)。相比之下,递归或复制数组的方法在处理超大链表时可能会因为内存不足而失败。
十、相关题目
1. LeetCode 206. 反转链表
核心操作: 本题是回文链表的基础。掌握反转链表是实现 O(1) 空间解法的前提。
python
def reverseList(head):
prev = None
curr = head
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
2. LeetCode 876. 链表的中间结点
核心操作: 本题用于寻找链表的中间节点,是回文链表的第一步。
python
def middleNode(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
3. LeetCode 143. 重排链表
思路: 这道题结合了找中点、反转链表和合并链表三个操作。先将链表从中间分开,反转后半部分,然后将两个链表交错合并。
python
def reorderList(head):
if not head or not head.next: return
# 1. 找中点
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 2. 反转后半部分
prev, curr = None, slow
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
# 3. 合并前半部分和反转后的后半部分
first, second = head, prev
while second.next:
first.next, first = second, first.next
second.next, second = first, second.next
4. LeetCode 面试题 02.06. 回文链表
相同题目: 与本题完全一致。
十一、总结
核心要点
-
问题本质: 判断链表是否中心对称。
-
最优解法: 快慢指针找中点 + 反转后半部分 + 双指针比较。该方法时间复杂度 O(n),空间复杂度 O(1)。
-
核心技巧: 将回文比较问题转化为两个顺序链表的比较问题,利用链表反转改变遍历方向。
算法步骤(O(1) 空间)
-
找中点: 使用快慢指针找到链表的中间节点
slow。 -
反转后半部分: 反转从
slow开始的链表,得到新的头节点prev。 -
比较: 设置指针
left = head,right = prev,依次比较left.val和right.val,直到right为null。若所有值相等,则为回文,否则不是。 -
(可选)恢复链表: 如果需要,可再次反转
prev部分。
复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 复制到数组 | O(n) | O(n) | 思路简单,易于实现 | 占用额外内存,不符合进阶要求 |
| 递归 | O(n) | O(n) | 代码简洁 | 有栈溢出风险,空间复杂度高 |
| 快慢指针+反转 | O(n) | O(1) | 最优解,满足进阶要求 | 代码稍复杂,需小心指针操作 |
扩展思考
掌握回文链表的解法,不仅解决了这一道题,更重要的是学会了如何将 "反转" 和 "双指针" 这两个操作结合起来解决链表问题。这种组合思路在 重排链表 、旋转链表 等问题中也会用到。可以说,反转链表是操作"形",而回文链表是应用"意"。通过这道题,你应该能更深刻地理解如何通过修改指针来改变链表的遍历逻辑,从而解决复杂问题。