在这篇文章中,我们将深入探讨一个经典且极具魅力的算法问题:如何判断一个链表是否为回文结构。这不仅是数据结构基础中的核心考点,也是许多大型科技公司面试中的高频手写题。我们将一起从最直观的解法出发,逐步深入到最优的空间复杂度优化,帮助你彻底掌握这一技术细节。
通过阅读本文,你将学会:
- 理解回文在链表中的定义:它与数组回文检测有何不同?
- 掌握栈解法:利用“后进先出”特性快速实现逻辑,理解空间换时间的权衡。
- 精通原地反转法:这是最优解法,我们将通过快慢指针和链表反转技术,将空间复杂度降至 O(1)。
- 避坑指南:了解在编写边界条件和恢复链表状态时的常见错误。
让我们先明确一下什么是回文链表。简单来说,就是链表从前往后读和从后往前读是一样的。
示例 1:是回文
输入: R->A->D->A->R->NULL
输出: Yes
解释: 正读是 RADAR,倒过来读也是 RADAR。
示例 2:不是回文
输入: C->O->D->E->NULL
输出: No
解释: CODE 和 EDOC 显然不一样。
方法一:借助栈的“暴力”美学
当我们第一次遇到这个问题时,可能会感到有些棘手。因为链表不像数组,我们可以通过索引直接访问任意元素,链表只能单向遍历。如果无法从后往前访问,我们如何比较第一个节点和最后一个节点呢?
这时候,栈 就成了我们手中最直观的武器。栈具有“后进先出”的特性,完美契合我们需要“倒序访问”的需求。
核心思路
想象一下,我们将链表的所有节点像叠盘子一样压入栈中。当我们从栈顶弹出元素时,拿到的就是链表的最后一个节点。紧接着,我们再从头遍历链表,将当前节点与栈顶弹出的节点一一对比。
代码实现
为了让你更好地理解,这里提供一个完整的 Python 代码示例:
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
class Solution:
def isPalindromeUsingStack(self, head: ListNode) -> bool:
# 如果链表为空或只有一个节点,必然是回文
if not head or not head.next:
return True
stack = []
current = head
# 第一步:将链表节点全部压入栈中
while current:
stack.append(current.val)
current = current.next
# 第二步:再次遍历链表,并与栈顶元素比较
current = head
while current:
# 弹出栈顶元素(即链表末尾的值)
top_val = stack.pop()
if current.val != top_val:
# 只要有一个不匹配,就失败
return False
current = current.next
return True
我们也可以看看 Java 的实现方式,逻辑是完全一致的:
import java.util.Stack;
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class PalindromeStack {
public boolean isPalindrome(ListNode head) {
Stack stack = new Stack();
ListNode temp = head;
// 入栈
while (temp != null) {
stack.push(temp.val);
temp = temp.next;
}
// 出栈并比较
temp = head;
while (temp != null) {
if (temp.val != stack.pop()) {
return false;
}
temp = temp.next;
}
return true;
}
}
复杂度分析
- 时间复杂度:O(N)。我们需要遍历链表两次,一次用于压栈,一次用于比较。由于常数系数不影响量级,所以是线性的。
- 空间复杂度:O(N)。这是该方法的痛点。我们需要一个额外的栈来存储链表中所有的 N 个节点值。如果链表非常大,这会消耗相当可观的内存。
小结
这种方法非常直观,适合快速通过初步的逻辑验证。但在面试中,当面试官问出“能不能优化一下空间复杂度?”时,我们就需要祭出更高级的武器了。
方法二:O(1) 空间复杂度的优雅解法
要消除 O(N) 的额外空间开销,我们就不能使用栈或数组来存储数据。既然我们无法直接“倒序”访问单链表,那我们可以改变链表的结构(在比较完成后可以恢复),将后半部分“反转”过来,这样就可以像普通的双指针比较一样处理了。
这种方法分为三个主要步骤:
- 找到中间节点:使用快慢指针(Floyd 判圈算法的变种)。
- 反转后半部分链表:原地修改指针指向。
- 比较与恢复:依次比较值,可选地恢复链表结构。
步骤 1:寻找链表中点
如何一次遍历就找到中间节点?我们维护两个指针:INLINECODEe36dcfeb 和 INLINECODE2229eb16。
-
slow指针每次走一步。 -
fast指针每次走两步。
当 INLINECODE57993990 到达终点时,INLINECODEfc74cde6 刚好就在中间(对于偶数个节点,INLINECODE10dae40e 会停在前半部分的末尾;对于奇数个节点,INLINECODEa0e599b4 刚好在正中间,我们需要处理一下分割点)。
步骤 2:反转链表
这是链表操作中的基本功。假设我们要反转 INLINECODE6acb3b7f,结果是 INLINECODE2c49fb6c。我们需要三个指针:INLINECODEb0beb9f7(前一个),INLINECODE08ec7aa8(当前),next(下一个),在遍历过程中改变指针指向。
完整代码实现
让我们用 C++ 来实现这个逻辑,这是面试中最常用的语言之一,需要非常注意指针的操作细节。
#include
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
class Solution {
public:
bool isPalindrome(ListNode* head) {
// 边界条件:空链表或单节点链表直接返回 true
if (head == nullptr || head->next == nullptr) {
return true;
}
// --- 第一阶段:寻找中间节点 ---
ListNode* slow = head;
ListNode* fast = head;
// 注意这里的循环条件:
// 对于偶数长度 (1->2->2->1),结束时 slow 指向第一个 2
// 对于奇数长度 (1->2->3->2->1),结束时 slow 指向 3
// 为了简化反转操作,我们要让 slow 停在前半部分的末尾
// 所以 fast 检查 next 和 next->next
while (fast->next != nullptr && fast->next->next != nullptr) {
slow = slow->next; // 慢指针走一步
fast = fast->next->next; // 快指针走两步
}
// --- 第二阶段:反转后半部分 ---
// 从 slow->next 开始反转
ListNode* prev = nullptr; // 新链表的尾部(初始为空)
ListNode* curr = slow->next; // 当前要反转的节点
ListNode* nextTemp = nullptr; // 暂存下一个节点
while (curr != nullptr) {
nextTemp = curr->next; // 1. 保存下一个节点
curr->next = prev; // 2. 当前节点指向前一个节点(反转关键)
prev = curr; // 3. prev 前移
curr = nextTemp; // 4. curr 前移
}
// 此时 prev 指向了反转后链表的头节点
// --- 第三阶段:依次比较 ---
ListNode* p1 = head; // 前半部分的头
ListNode* p2 = prev; // 反转后半部分的头
bool result = true;
while (p2 != nullptr) {
if (p1->val != p2->val) {
result = false;
break;
}
p1 = p1->next;
p2 = p2->next;
}
// --- 第四阶段(关键):恢复链表结构 ---
// 虽然在很多算法题中不强制要求恢复链表,
// 但在工程实践中,破坏性操作必须复原!
// 我们需要再次反转 prev 指向的链表,并接回 slow 后面
curr = prev; // 反转部分的头
prev = nullptr; // 新的尾部
while (curr != nullptr) {
nextTemp = curr->next;
curr->next = prev;
prev = curr;
curr = nextTemp;
}
// 将前半部分接回反转回来的后半部分
slow->next = prev;
return result;
}
};
为了方便 Python 开发者,我们也给出 Python 的实现版本,注意 Python 中引用的处理方式:
class Solution:
def isPalindromeOptimized(self, head):
if not head or not head.next:
return True
# 1. 找中点
slow, fast = head, head
while fast.next and fast.next.next:
slow = slow.next
fast = fast.next.next
# 2. 反转后半部分 (从 slow.next 开始)
prev, curr = None, slow.next
while curr:
next_temp = curr.next
curr.next = prev
prev = curr
curr = next_temp
# 3. 比较
p1, p2 = head, prev # prev 是反转后的头
result = True
while p2:
if p1.val != p2.val:
result = False
break
p1 = p1.next
p2 = p2.next
# 4. 恢复链表 (可选,视面试官要求而定)
# ... (反转操作与上方相同,接回 slow.next) ...
return result
关键逻辑详解
你可能会有疑问,为什么寻找中间节点的循环条件是 fast->next != nullptr && fast->next->next != nullptr?
- 让我们想象一下奇数个节点的情况:
1 -> 2 -> 3 -> 2 -> 1。
* 初始:INLINECODE7693c800 在 1,INLINECODE3b4dbc3e 在 1。
* 第一次循环后:INLINECODE2c814130 在 3,INLINECODEba77e174 在 2。
* 检查条件:INLINECODEaaaa6c6d 是 2,INLINECODE0751ab0b 是 1。条件成立,继续。
* 第二次循环后:INLINECODEef24210c 在末尾的 1,INLINECODE87222237 在中间的 3。
* 检查条件:fast->next 是 None,循环终止。
* 此时 INLINECODE8a55bab5 指向中间节点 3。我们要反转的是 INLINECODE734c0431 之后的节点,即 INLINECODE60db76d7。这样前半部分是 INLINECODE87e398c3,后半部分反转后是 1->2。这是正确的,因为中间节点不需要和谁比较。
- 再看偶数个节点:
1 -> 2 -> 2 -> 1。
* 初始:INLINECODE81ed404c 在 1,INLINECODE7dca4b8d 在 1。
* 第一次循环后:INLINECODEeae1bb68 在第一个 2,INLINECODE5a3aa601 在第一个 2。
* 检查条件:INLINECODE5d0d727c 是第二个 2,INLINECODE5bab5c87 是 1。条件成立,继续。
* 第二次循环后:INLINECODE49b867e1 在末尾的 1,INLINECODE4cdd597f 在第二个 2(即前半部分的最后一个)。
* 检查条件:fast->next 是 None,循环终止。
* 此时 INLINECODEa097e090 指向第一个 INLINECODEfa2fee0c。我们要反转 INLINECODE282f4885,即第二个 INLINECODE0e4cb569。前半部分 INLINECODEe50c1976,后半部分反转后 INLINECODE9a8c9473。完美匹配。
复杂度分析
- 时间复杂度:O(N)。
* 寻找中点:遍历了 N/2。
* 反转链表:遍历了 N/2。
* 比较链表:遍历了 N/2。
* 总运算次数大约是 1.5N,这依然是线性的 O(N)。
- 空间复杂度:O(1)。
* 我们只使用了 INLINECODEa0b335ff, INLINECODE3380fcbf, INLINECODE60d64b7f, INLINECODE3b1c1c11 等几个指针变量。无论链表多长,占用的内存都是固定的。这就是我们要追求的最优解。
常见陷阱与最佳实践
在解决这个问题时,新手开发者(甚至有经验的开发者)经常会遇到一些陷阱。让我们来总结一下。
1. 忘记处理空链表或单节点
这是最愚蠢但最容易被忽略的错误。如果 INLINECODEe1aa4223 本身是 INLINECODE4ebe1158,你的代码会崩溃吗?如果 head 只有一个节点,它天然就是回文,直接返回 True 即可。在编写任何链表代码前,先写好这两行防御性代码。
2. 快慢指针的边界死循环
如果写成 INLINECODEc02b3fcf,slow 停下来的位置就会不一样。对于奇数个节点,INLINECODEbb657977 会多走一步,导致我们切分链表的位置不对。一定要仔细模拟指针的最后几步移动。
3. 破坏性操作未恢复
在工程代码中,如果你修改了一个传入的数据结构,必须在函数返回前将其恢复原状,除非函数的明确目的就是为了修改它。如果在判断回文的过程中把链表切断了,后续调用者拿到这个链表头节点遍历时,只能得到一半的数据,这会导致严重的 Bug。上面的 C++ 代码中包含了恢复步骤,这是一个优秀工程师的素养体现。
4. 数据类型的选择
在这个问题中,我们假设存储的是字符或整数。如果链表中存储的是很大的字符串或者对象,比较操作的开销可能会变大。此外,如果链表非常长,递归解法(虽然优雅)可能会导致栈溢出。因此,这里不推荐使用递归解法,始终推荐上述的迭代反转法。
结语
在这篇文章中,我们从一个简单的栈解法出发,理解了回文检测的基本逻辑,随后深入探讨了如何通过反转链表来实现 O(1) 空间复杂度的最优解。
你看,算法的魅力就在于权衡。
- 栈解法代码好写,不容易出错,适合原型开发。
- 反转链表法空间效率极高,适合对内存敏感的底层系统或嵌入式环境。
当你下一次在面试或工作中遇到这个问题时,我希望你能自信地说出:“我们可以先用栈来解决,但我更推荐用反转链表的方式,因为它能将空间复杂度优化到 O(1)。”这不仅仅是写出代码,更是对底层原理的深刻理解。
希望这篇详细的解析对你有所帮助。继续加油,在算法之路上不断前行!