Files

466 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: ["LeetCode", "链表", "双指针", "反转链表", "递归", "简单"]
create time: 2026-05-16 14:40
---
# 24-回文链表
## 题面
给你一个单链表的头节点 `head`,请你判断该链表是否为回文链表。如果是,返回 `true`;否则,返回 `false`。
**示例 1:**
```
输入:head = [1,2,2,1]
输出:true
```
**示例 2:**
```
输入:head = [1,2]
输出:false
```
**提示:**
- 链表中节点数目在范围 `[1, 10^5]` 内
- `0 <= Node.val <= 9`
- **进阶:** 你能否用 `O(n)` 时间复杂度和 `O(1)` 空间复杂度解决此题?
---
## 思路
> [!question] 💡 核心洞察
> 回文的本质是"前后对称"——前半段和后半段镜像相同。对数组来说我们天然可以随机访问,可以直接对比 `arr[i] == arr[n-1-i]`。但**链表只能顺序遍历,无法从后往前看**,这是解题的最大障碍。
> [!warning] ⚠️ 关键差异:数组 vs 链表
> | 特性 | 数组 | 链表 |
> |------|------|------|
> | 正序遍历 | O(1)(直接索引) | O(1)(next 指针) |
> | 逆序遍历 | O(1)(反向索引) | ❌ 不支持,必须走一遍 |
> | 中间点定位 | O(1)(`(n-1)/2`) | 需要遍历 |
>
> 所以挑战在于:**如何在只走一遍或常数次遍历时拿到"后半段的逆序"?**
### 方法一:栈 / 切片法(直觉方案)
把每个节点的值依次压入栈(或存进切片),然后再次从头遍历链表,将节点值与栈顶(或切片末尾)逐个比较。
```mermaid
flowchart LR
subgraph "第一轮:收集数据"
H["head → 1→2→2→1"] --> S["stack: [1,2,2,1]"]
end
subgraph "第二轮:对比"
H2["head → 1→2→2→1"] --> C["对比:top==val? yes → yes → yes → yes"]
end
C --> RESULT["✅ true"]
style RESULT fill:#4c4,stroke:#333
```
**步骤拆解:**
| 轮次 | 动作 | 代价 |
|------|------|------|
| 第 1 轮 | 遍历全链表,将所有 val 存入栈/切片 | O(n) |
| 第 2 轮 | 重新遍历链表,每次取栈顶对比当前 val | O(n) |
**时间复杂度:O(n)** — 两次线性遍历。
**空间复杂度:O(n)** — 需要额外的栈存储所有元素。
> [!note] 🤔 这个方法的问题是什么?
> 完全满足时间要求,但不满足进阶的 O(1) 空间。而且它做了**两次完整遍历**——如果能把后半段原地翻转,就能避免额外空间和重复遍历。
---
### 方法二:快慢指针 + 反转后半段 ⭐(最优,O(1) 空间)⭐
这是进阶问题的标准解法,核心思路分三步:**找到中点 → 反转后半段 → 逐一对比**。
#### 第一步:用快慢指针找中点
> [!question] 💡 为什么快慢指针能找中点?
> 让快指针每次走两步、慢指针每次走一步。当快指针到达终点时,慢指针恰好走到中间——因为快指针的速度是慢指针的两倍,路程也是两倍。
> [!tip] 🔑 关键细节:初始化 fast 为 `head.Next`
> 如果两个指针都从 `head` 开始,偶数长度链表的比较会出现问题(见下方分析)。标准写法是将 fast 初始化在第二个节点上:
```go
slow, fast := head, head.Next
```
以 `1→2→2→1`(偶数长度)为例:
```mermaid
flowchart LR
subgraph "初始"
A["快=2, 慢=1"]
end
subgraph "第1步"
B["快=1, 慢=2"]
end
subgraph "退出"
C["快=nil, 慢=2 ★中点"]
end
A -.两步.-> B -.两步.-> C
style C fill:#f9d,stroke:#333
```
详细过程:
| 步数 | fast 位置 | slow 位置 | fast!=nil && fast.Next!=nil |
|------|----------|----------|-----------------------------|
| 开始前 | 2 | 1 | ✅ 继续 |
| 第 1 轮后 | 1 | 2 | ✅ 继续 |
| 第 2 轮后 | nil | 2 | ❌ fast == nil,退出 |
对于奇数长度的链表 `1→2→3→2→1`:
| 步数 | fast 位置 | slow 位置 | fast!=nil && fast.Next!=nil |
|------|----------|----------|-----------------------------|
| 开始前 | 2 | 1 | ✅ 继续 |
| 第 1 轮后 | 3 | 2 | ✅ 继续 |
| 第 2 轮后 | 1(尾部)| 3 | ✅ 继续 |
| 第 3 轮后 | nil | 4 | ❌ fast == nil,退出 |
**边界规则总结:**
| 链表长度 | fast 最终位置 | slow 最终位置 | halfHead(slow.Next)含义 |
|---------|-------------|-------------|-------------------------|
| 偶数(如 4) | nil | 第 n/2 个节点 | 后半段起点(第 n/2+1 个) |
| 奇数(如 5) | nil | 第 (n+1)/2 个 | 跳过中间元素,后半段从第 (n+1)/2+1 个开始 |
> [!info] 🧠 为什么 fast=head.Next 比 fast=head 更正确?
>
> **问题演示(fast=head 的错误情况):**
>
> 对于单节点链表 `1→nil`:fast=head, slow=head → 不进入循环 → slow=1, halfHead=nil → 对比阶段 p2=nil → 直接返回 true。**正确结果碰巧相同,但逻辑上有隐患。**
>
> 对于两节点链表 `1→2→nil`:fast=head, slow=head → 执行一轮 → fast=nil, slow=2 → halfHead=nil → 对比阶段 p2=nil → 直接返回 true。**❌ 错误!应该返回 false。**
>
> **原因**:fast=head 时,两节点链表只执行了一轮迭代,slow 直接跳到第二个节点,halfHead 变成 nil,跳过了唯一一次有意义的对比。
>
> **修正后(fast=head.Next):**
>
> | 链表 | fast 初始 | slow 初始 | 循环是否执行 | slow 最终 | halfHead | 对比结果 |
> |------|----------|----------|------------|----------|----------|---------|
> | `1→nil` | nil | 1 | ❌ | 1 | nil → 直接 return true ✅ | 无需对比 |
> | `1→2→nil` | 2 | 1 | ❌ fast.Next==nil | 1 | 2 → 对比 1 vs 2 → false ✅ | 正确 |
> | `1→2→2→1→nil` | 2 | 1 | ✅ × 2 | 2 | 2 → 对比 1==1, 2==2 → true ✅ | 正确 |
#### 第二步:反转后半段
利用「23-反转链表」中的迭代反转方法,将 `halfHead` 之后的链表原地反转。
以 `1→2→2→1` 为例:
```mermaid
flowchart LR
subgraph "前半段"
F["1"] --> S["2"]
end
subgraph "后半段未反转"
SH["2"] --> L["1"]
end
subgraph "后半段已反转"
SH2["1"] --> L2["2"]
end
subgraph "对比阶段"
COMP["1==1 ✅, 2==2 ✅"]
end
F --> S --> SH -.反转.-> SH2 --> L2 --> COMP
style COMP fill:#4c4,stroke:#333
```
#### 第三步:逐一对比
从 `head` 和 `reversedHalf` 同时出发,逐个节点对比 val。
> [!question] 💡 什么时候停止对比?
> 因为 `reversedHalf` 是反转后的后半段,长度最多等于前半段。当 `reversedHalf == nil` 时说明已全部比对完毕。不需要用到原始的后半段尾节点。
#### 完整流程图
```mermaid
flowchart TD
START(["head = 1→2→2→1"]) --> FIND["① 快慢指针找中点"]
FIND --> MIDDLE["slow → 节点2, halfHead → slow.Next → 节点2"]
MIDDLE --> REVERSE["② 反转后半段: 2→1 变成 1→2"]
REVERSE --> COMPARE["③ 逐一对比\np1: 1→2, p2: 1→2"]
COMPARE --> EQUAL{"全部相等?"}
EQUAL -->|是| TRUE["返回 true ✓"]
EQUAL -->|否| FALSE["返回 false ✗"]
style TRUE fill:#4c4,stroke:#333
style FALSE fill:#f99,stroke:#333
```
**时间复杂度:O(n)** — 找中点遍历 n/2 步 + 反转 n/2 步 + 对比 n/2 步 = 总共约 1.5n 步,仍为 O(n)。
**空间复杂度:O(1)** — 只用了四个指针变量(slow, fast, prev, curr),无额外空间。
> [!warning] ⚠️ 细节陷阱
> 1. **奇数长度链表**:中间元素不属于任何一半,`slow.Next` 作为后半段起点自然跳过了它,无需特殊处理。
> 2. **单节点链表 `1→nil`**:fast=head.Next=nil → 不进入找中点循环 → slow=1, halfHead=nil → 对比阶段 p2=nil → 直接返回 true ✅。
> 3. **两个节点的链表 `1→2→nil`**:fast=head.Next=2 → fast.Next=nil → 不进入循环 → slow=1, halfHead=2 → 反转后 reversedHalf=2 → 对比 1 vs 2 → false ✅。**如果错误地用 fast=head,这里会跳过唯一一次有意义的对比,错误返回 true。**
---
## 代码提示
### 伪代码模板
```
// ① 找中点 —— 注意 fast 从 head.Next 开始
slow := head
fast := head.Next
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针走一步
fast = fast.Next.Next // 快指针走两步
}
// 此时 slow 在中点位置,halfHead 是后半段起点
halfHead := slow.Next
// ② 反转后半段(复用反转链表模板)
prev := nil
curr := halfHead
for curr != nil {
nextTemp := curr.Next
curr.Next = prev
prev = curr
curr = nextTemp
}
reversedHalf := prev
// ③ 逐一对比
p1 := head
p2 := reversedHalf
for p2 != nil { // 只需遍历较短的后半段即可
if p1.Val != p2.Val {
return false
}
p1 = p1.Next
p2 = p2.Next
}
return true
```
---
## 技巧
> [!tip] 🔑 三步口诀:"找、翻、比"
> 回文链表问题记住 **"找中点 → 反转后半段 → 逐一对比"** 这个固定套路。这类模式还会出现在以下场景中:
> - 判断回文链表 → 本道题
> - 重排链表(L₀→Lₙ→L₁→Lₙ₋₁...)→ 同样用这三步,对比后拼接回去
> - 链表分割(左半部分 / 右半部分)→ 找中点后断开即可
> [!tip] 🔑 循环终止条件的选择
> 对比阶段的循环可以用 `p2 != nil`(遍历短的那段)也可以用 `p1 != nil`(遍历长的)。选短的更高效,因为后半段长度 ≤ 前半段。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 三种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|------|-----------|-----------|------|------|
| 栈/切片法 | O(n) | O(n) | 最直观,代码最少 | 不满足进阶 O(1) 空间 |
| **快慢+反转 ⭐** | **O(n)** | **O(1)** | **最优解,面试标配** | 需要注意奇偶长度边界 |
| 递归法 | O(n) | O(n) | 代码优雅,天然"逆向" | 空间复杂度高 |
> [!success] ✅ 相关题目串联
> - [23-反转链表](./23-反转链表.md) — 本题的核心子程序,必须先掌握
> - [141-环形链表](./141-环形链表.md) — 同一组快慢指针技术,换了一个应用场景
> - [143-重排链表](./143-重排链表.md) — 同样的三步套路,只是最后改为"拼接"而非"对比"
---
## 代码
### 方法一:切片法(O(n) 空间)
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func isPalindrome(head *ListNode) bool {
// 单节点一定是回文
if head == nil || head.Next == nil {
return true
}
// 第一轮:把所有节点的值放入切片
var vals []int
for node := head; node != nil; node = node.Next {
vals = append(vals, node.Val)
}
// 第二轮:双指针对比首尾
n := len(vals)
for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
if vals[i] != vals[j] {
return false
}
}
return true
}
```
---
### 方法二:快慢指针 + 反转后半段 ⭐(最优 O(1) 空间)⭐
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func isPalindrome(head *ListNode) bool {
// 边界情况:空链表、单节点
if head == nil || head.Next == nil {
return true
}
// ① 快慢指针找中点 —— fast 从 head.Next 开始,正确处理两节点等边界
slow, fast := head, head.Next
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针走一步
fast = fast.Next.Next // 快指针走两步
}
// ② 反转 slow 之后的后半段链表
halfHead := slow.Next
var prev *ListNode
curr := halfHead
for curr != nil {
nextTemp := curr.Next
curr.Next = prev
prev = curr
curr = nextTemp
}
reversedHalf := prev
// ③ 从头节点和反转后的后半段同步对比
p1, p2 := head, reversedHalf
for p2 != nil { // 只需遍历较短的后半段
if p1.Val != p2.Val {
return false
}
p1 = p1.Next
p2 = p2.Next
}
return true
}
```
> [!tip] 🔧 可选优化:恢复链表
> 如果题目要求"不修改原链表",可以在对比完成后把后半段再反转回去恢复原状。只需再调用一次反转函数(以 `reversedHalf` 为入口),并将 `slow.Next` 重新指向新头部即可。不过 LeetCode 原题不要求恢复,所以这步可以省略。
---
### 方法三:递归法(进阶理解)
> [!question] 💡 一个奇妙的角度
> 如果用递归来遍历链表,"递"的时候往前走,"归"的时候往回来——这不就是天然的"从后往前遍历"吗?我们可以让左右两个指针分别在"递"和"归"的过程中相遇对比。
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
var left *ListNode // 包级变量:从左向右移动(递推过程中保持不变量)
func isPalindromeRecursive(head *ListNode) bool {
left = head // 初始化左指针
return recurseAndCheck(head)
}
func recurseAndCheck(right *ListNode) bool {
// 基准情况:遇到 nil,回溯到头节点
if right == nil {
return true
}
// 递:一直走到链表末尾
if !recurseAndCheck(right.Next) {
return false
}
// 归:在回溯路上逐层对比
if right.Val != left.Val {
return false
}
left = left.Next // 左指针向前移动一位
return true
}
```
**执行轨迹示意(`1→2→2→1`):**
```mermaid
flowchart LR
subgraph "递"
D1["recurse(1)"] --> D2["recurse(2)"]
D2 --> D3["recurse(2)"]
D3 --> D4["recurse(1)"]
D4 --> D5["recurse(nil) ← 基准"]
end
subgraph "归"
G5["return true"] --> G4["right=1, left=1 → ✅ → left→2"]
G4 --> G3["right=2, left=2 → ✅ → left→2"]
G3 --> G2["right=2, left=2 → ✅ → left→1"]
G2 --> G1["right=1, left=1 → ✅ → done"]
end
D1 --> D2 --> D3 --> D4 --> D5
style D5 fill:#bbf,stroke:#333
style G1 fill:#4c4,stroke:#333
```
**时间复杂度:O(n)** — 递深度为 n。
**空间复杂度:O(n)** — 递归栈占用 O(n) 空间。
> [!note] 🤔 这种方法的空间复杂度是 O(n),不如方法二优秀。但它展示了另一种思考路径——递归可以隐式地"倒序"遍历链表,这在其他场景下也很实用。比如判断链表是否回文、反转链表的递归版本等。
> [!tip] 🔑 为什么可以用包级变量 left 而不需要参数传递?
> 因为递归的 "归" 阶段天然就是后进先出(LIFO)的顺序,恰好模拟了从链表尾部向头部的逆序遍历。左指针 left 只需要在每次返回时前进一步,而右指针 right 通过函数的调用栈隐式地保存了每一层的位置信息。这种技巧在其他需要同时正序/逆序遍历链表的场景中也有应用。