vault backup: 2026-05-16 14:10:20

This commit is contained in:
2026-05-16 14:10:21 +08:00
parent 2768d4eeb4
commit 9067e29acb
6 changed files with 1869 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
---
tags: ["LeetCode", "链表", "双指针", "简单"]
create time: 2026-05-16 14:30
---
# 22-相交链表
## 题面
给你两个单链表的头节点 `headA` 和 `headB`,请你找出并返回两个单链表**相交的起始节点**。如果两个链表不存在相交节点,返回 `nil`。
注意:
- 函数返回结果后,链表必须**保持其原始结构**。
- 整个链式结构中**不存在环**。
- 如果相交,相交节点的地址(引用)相同——仅值相等不算相交。
**示例 1:**
```
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:链表 A 为 [4,1,8,4,5],链表 B 为 [5,6,1,8,4,5]。在 A 中相交节点前有 2 个节点,在 B 中有 3 个节点。
```
**示例 2:**
```
输入:intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出:Intersected at '2'
```
**示例 3(不相交):**
```
输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:No intersection
解释:两个链表不相交,因此返回 nil 。
```
**提示:**
- `listA` 中节点数目为 `m`,`listB` 中节点数目为 `n`
- `1 <= m, n <= 3 * 10^4`
- `1 <= Node.val <= 10^5`
- **进阶:** 你能否设计一个时间复杂度 `O(m + n)`、仅用 `O(1)` 内存的解决方案?
---
## 思路
> [!question] 💡 关键区分
> 相交 ≠ 值相等。题目判断的是**内存地址是否相同**——即同一个节点对象被两个链表同时引用。值相同的不同节点不算相交。
> [!info] 🧩 重要性质
> 由于每个节点最多只有一个 `next` 指针,且不存在环,两个链表一旦相交就**永远不会分开**——它们从相交点开始形成 Y 字形共享尾部。这意味着:要么完全不相交,要么共享一段相同的后缀。
### 方法一:暴力枚举(O(m×n))
对链表 A 的每个节点,遍历链表 B 的所有节点,检查是否存在地址相同的节点。
**缺点:** 时间复杂度太高,不满足进阶要求。直接跳过。
### 方法二:双指针交换法 ⭐(最优 O(m+n),O(1) 空间)
> [!question] 💡 引导思考
> 假设 A 长 m 个节点,B 长 n 个节点,两个指针 pA、pB 分别从 headA、headB 出发。如何让它们**同时**到达相交点?
核心洞察:**让两个指针走等长的路程。**
如果指针 **pA** 遍历完 A 后转到 B 头部继续走,指针 **pB** 遍历完 B 后转到 A 头部继续走——各自恰好走了 `m + n` 步。那么它们会在哪里相遇?
> [!info] 🧮 关键等式:m + len(B→交点) = n + len(A→交点)
定义变量如下:
| 符号 | 含义 |
|------|------|
| **pA, pB** | 两个移动中的指针 |
| **m, n** | 链表 A、B 的总长度 |
| **lenA** | 从 headA 到交点的距离(即 skipA) |
| **lenB** | 从 headB 到交点的距离(即 skipB) |
| **L** | 共享后缀长度 = m − lenA = n − lenB |
指针 pA 的路径 = 「A 全长」+「B 中从开头走到交点」= **m + lenB**
指针 pB 的路径 = 「B 全长」+「A 中从开头走到交点」= **n + lenA**
两者相等吗?验证:
$$m + \text{lenB} = (\text{lenA} + L) + \text{lenB} = \text{lenA} + \text{lenB} + L$$
$$n + \text{lenA} = (\text{lenB} + L) + \text{lenA} = \text{lenA} + \text{lenB} + L$$
两边相等 ✅ — 都等于 `lenA + lenB + L`。
以示例 1(m=5, n=6, lenA=2, lenB=3, L=3)验证:
| 指针 | 路径拆解 | 总步数 |
|------|---------|--------|
| pA | 5(A 全长)+ 3(B 的前缀到交点) | **8** |
| pB | 6(B 全长)+ 2(A 的前缀到交点) | **8** |
两指针同步前进,总路程相等,必然在同一个位置首次相遇——那就是交点。
```mermaid
flowchart LR
subgraph A ["链表 A(4→1→[8→4→5])"]
A1["4"] --> A2["1"]
A2 --> JC["8 ★ 相交点"]
JC --> JN1["4"]
JN1 --> JN2["5"]
JN2 --> NIL1["nil"]
end
subgraph B ["链表 B(5→6→1→[8→4→5])"]
B1["5"] --> B2["6"]
B2 --> B3["1"]
B3 --> JC2["8 ★ 同一节点"]
JC2 -.-> JN1
JC2 -.-> JN2
JN2 --> NIL2["nil"]
end
style JC fill:#f9d,stroke:#333
style JC2 fill:#f9d,stroke:#333
```
**图解两个指针的路径:**
```
pA 的路径:headA → … → nil(A) → headB → … → JC ← 相遇!
pB 的路径:headB → … → nil(B) → headA → … → JC ← 同时到达!
```
| 时刻 | pA 所在位置 | pB 所在位置 | 说明 |
|------|-----------|-----------|------|
| 初始 | headA | headB | 各自主张的起点 |
| 第 2 步 | A 中第 2 个 | B 中第 3 个 | 各自在自己的链表中 |
| 第 5 步 | nil(A 末尾)| nil(B 末尾)| 各自走完自己的链表 |
| 切换 | headB[1] | headA[1] | null 时切换到对方头部 |
| 第 8 步 | **JC** | **JC** | **同时到达相交点!** |
> [!warning] ⚠️ 如果不相交呢?
> 如果没有相交点,两个指针会继续走完 `m + n` 步后都停在 `nil`。此时 pA == nil && pB == nil,退出循环,返回 nil。逻辑仍然正确。
**时间复杂度:O(m + n)** — 每个指针最多遍历两条链表的总长度。
**空间复杂度:O(1)** — 只使用两个指针变量。
> [!note] 🤔 为什么一定在相交点相遇而不是更早或更晚?
> 因为两指针同步前进(每步都移动),且走的总路程完全相等。第一个相遇的位置必然是第一次"踩到"同一个节点的时刻,也就是相交点。不可能更早相遇——否则那才是相交点;也不可能更晚——因为在相交点处路程已经对齐了。
---
## 代码提示
```
// 伪代码模板
pA := headA
pB := headB
for pA != pB {
if pA == nil {
pA = headB // pA 走完 A,转去走 B
} else {
pA = pA.next
}
if pB == nil {
pB = headA // pB 走完 B,转去走 A
} else {
pB = pB.next
}
}
return pA // pA == pB,要么是相交节点,要么是 nil
```
**Go 语言技巧:**
- Go 不支持多赋值同时推进两个指针到不同目标,需要用临时变量或逐行判断
- 利用 Go 的 `nil` 语义:`if node == nil` 安全且直观
- 也可以用 `for` 循环内联两种切换逻辑,但拆分会更清晰易读
> [!tip] 🔑 一行 Go 写法(竞赛向)
> 利用 Go 的短变量声明,可以在循环体内依次推进 a 和 b,利用延迟求值的特性完成切换:
```go
```go
for pA != pB {
if pA == nil {
pA = headB
} else {
pA = pA.Next
}
if pB == nil {
pB = headA
} else {
pB = pB.Next
}
}
```
---
## 技巧
> [!tip] 🔑 核心模式:交叉遍历(Cross Walk / Two-pointer Switching)
> 当两个序列长度不同但需要"对齐"时,让它们互相接管对方的剩余路程。本质是构造等长路径来消除长度差异。类似的变体包括「环形链表入口」(Floyd 判圈算法也用了对称思想)。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 其他解法对比
| 方法 | 时间复杂度 | 空间复杂度 | 备注 |
|------|-----------|-----------|------|
| 暴力枚举 | O(m × n) | O(1) | 双重嵌套遍历,太慢 |
| 哈希集合 | O(m + n) | O(m) | 把 A 的所有节点存入 set,再遍历 B 查找 |
| 计算长度差 | O(m + n) | O(1) | 先求各自长度,长的先走差值步数 |
| **交叉遍历** ⭐ | **O(m + n)** | **O(1)** | **最优,无需预先遍历** |
> [!success] ✅ 关于"计算长度差"法的补充
> 这也是 O(m + n) 和 O(1) 空间的合法方案,且更符合直觉:
> 1. 分别遍历得到长度 lenA、lenB
> 2. 让较长的链表先走 |lenA - lenB| 步
> 3. 然后两个指针同步前进,第一个相同的节点就是相交点
>
> 缺点是**需要两次遍历**(一次算长度 + 一次找交点),而交叉遍历法虽然也遍历 m + n 步,但在实际运行中往往更快收敛到答案。
---
## 代码
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func getIntersectionNode(headA, headB *ListNode) *ListNode {
pA, pB := headA, headB
for pA != pB {
// pA 走完 A 后转到 B 头部
if pA == nil {
pA = headB
} else {
pA = pA.Next
}
// pB 走完 B 后转到 A 头部
if pB == nil {
pB = headA
} else {
pB = pB.Next
}
}
return pA // pA == pB,要么是非 nil 的相交节点,要么是 nil
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 160 题,通过率约 50%。看似简单但双指针交换法非常优雅——它用"以空间换对称"的思想,将长度差异的问题转化为"一起绕一圈就能对齐"的自然过程。建议动手实现并理解其正确性证明(数学归纳:每一步 a 和 b 距离各自起点的步数之差恒等于 |m - n|)。
+413
View File
@@ -0,0 +1,413 @@
---
tags: ["LeetCode", "链表", "双指针", "递归", "简单"]
create time: 2026-05-16 14:35
---
# 23-反转链表
## 题面
给你单链表的头节点 `head`,请你反转链表,并返回反转后的链表。
**示例 1:**
```
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
```
**示例 2:**
```
输入:head = [1,2]
输出:[2,1]
```
**示例 3(空链表):**
```
输入:head = []
输出:[]
```
**提示:**
- 链表中节点的数目范围是 `[0, 5000]`
- `-5000 <= Node.val <= 5000`
- **进阶:** 链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?
---
## 思路
> [!question] 💡 核心洞察
> 反转链表就是让每条边的方向"掉头"——原来的 `A → B` 变成 `A ← B`。直观来看,我们需要把每个节点的 `next` 指针指向上一个节点。
### 关键问题:断链风险
> [!warning] ⚠️ 最大陷阱
> 当你把 `node.next` 改成指向前驱时,你就**丢失了原来 `node.next` 指向的后继节点**。一旦丢失,整条链的后续部分再也找不到了。
所以反转的本质操作是三步:
| 步骤 | 动作 | 目的 |
|------|------|------|
| ① | **保存后继**:记住 `node.next` | 防止断链 |
| ② | **反转指针**:把 `node.next` 指向前驱 | 完成方向翻转 |
| ③ | **前移窗口**:把前驱和当前指针各走一步 | 继续处理下一个节点 |
### 方法一:迭代法 — 三指针滑动窗口 ⭐(最优)
维护三个变量:`prev`(已反转部分的尾部 / 新方向的前驱)、`curr`(正在处理的节点)、`nextTemp`(临时保存后继)。
**初始化:**
- `prev = nil` — 反转后原头节点的 `next` 将指向 `nil`
- `curr = head` — 从原头节点开始逐个处理
**每一步的操作(以节点 1→2→3→4→5 为例):**
```mermaid
flowchart LR
subgraph 原始状态
P["prev: nil"]
C["curr: 1"]
N["nextTemp ← 2"]
end
subgraph 反转操作
R1["1.next = prev → nil"]
end
subgraph 窗口前移
PM["prev = 1"]
CM["curr = 2"]
end
P --> C
C --> N
N -.引导.-> R1
R1 -.-> PM
PM --> CM
style C fill:#f9d,stroke:#333
style R1 fill:#bfb,stroke:#333
style CM fill:#bbf,stroke:#333
```
逐步展开完整过程:
| 步骤 | prev | curr | nextTemp (操作前) | 执行:curr.next = prev |
|------|------|------|--------------------|----------------------|
| 初始 | nil | 1 | — | — |
| 第 1 轮 | 1 | 2 | 2 | 1.next → nil |
| 第 2 轮 | 2 | 3 | 3 | 2.next → 1 |
| 第 3 轮 | 3 | 4 | 4 | 3.next → 2 |
| 第 4 轮 | 4 | 5 | 5 | 4.next → 3 |
| 第 5 轮 | 5 | nil | — | 5.next → 4 |
当 `curr == nil` 时遍历结束,返回 `prev`(即新的头节点 5)。
> [!note] 🤔 为什么返回 `prev` 而不是 `curr`?
> 循环结束时 `curr` 已经走到了 `nil`(原链表末尾之后),而 `prev` 恰好停在最后一个有效节点上——它就是反转后的新头节点。可以用 `curr != nil` 代替终止条件,但代码会稍显冗余(需要最后再走一步)。
**时间复杂度:O(n)** — 每个节点只遍历一次。
**空间复杂度:O(1)** — 只用了三个指针变量。
### 方法二(精简版):虚拟头节点 + 头插法 ⭐(更简洁的迭代法)
> [!question] 💡 引导思考
> 刚才的三指针法需要 `prev` / `curr` / `nextTemp` 三个变量。但如果我们用一个虚拟头节点 `dummy`,让 `dummy.Next` **自动维护已反转部分的头部**,是不是就可以少维护一个变量?
这正是经典的**头插法**——每从原链表取出一个节点,就把它插入到 `dummy` 之后。
```mermaid
flowchart LR
subgraph 初始化
D["dummy → nil"]
H["head → 1 → 2 → 3 → nil"]
end
subgraph 第1轮
D2["dummy → 1"]
H2["head → 2 → 3 → nil"]
D2 -.head插入后.-> H2
end
subgraph 第2轮
D3["dummy → 2 → 1"]
H3["head → 3 → nil"]
D3 -.head插入后.-> H3
end
subgraph 第3轮
D4["dummy → 3 → 2 → 1"]
H4["head = nil"]
D4 -.head插入后.-> H4
end
D --> D2 --> D3 --> D4
style D4 fill:#4c4,stroke:#333
```
**核心洞察:** `dummy.Next` 始终等于上一轮的 `prev`!它天然维护着反转部分的头部引用,所以不需要单独声明 `prev`。
每轮只需要三个动作(四行代码中的前三行为一组):
| 步骤 | 代码 | 说明 |
|------|------|------|
| ① | `temp := head.Next` | 保存后继 |
| ② | `head.Next = dummy.Next` | 断开原链表,指向已反转部分 |
| ③ | `dummy.Next = head` | **头插**:把 head 插到 dummy 之后 |
| ④ | `head = temp` | 继续处理下一个 |
以 `1→2→3→nil` 为例:
| 轮次 | dummy 之后的链表 | head | temp | 执行的动作 |
|------|-------------------|------|------|-----------|
| 初始 | nil | 1 | — | — |
| 第 1 轮 | **1** → nil | 2 | 3 | 1 插入 dummy 后 |
| 第 2 轮 | **2** → 1 → nil | 3 | nil | 2 插到 1 前面 |
| 第 3 轮 | **3** → 2 → 1 → nil | nil | — | 3 插到 2 前面 |
循环结束时返回 `dummy.Next`,即反转后的新头节点。
> [!tip] 🔑 对比三指针法
> | 维度 | 三指针法 | 头插法 |
> |------|---------|--------|
> | 额外变量 | prev, curr, nextTemp(3 个) | dummy, head, temp(3 个,但 head 是输入参数可复用) |
> | 核心思路 | 逐个翻转指针方向 | **逐个摘除并头插到新链表** |
> | 代码行数 | 5 行(循环体内) | 4 行(循环体内) |
> | 直观程度 | 较抽象(指向前驱) | **最直观**(就是"拔出来插回去") |
**时间复杂度:O(n)** — 每个节点恰好被处理一次。
**空间复杂度:O(1)** — 只用了两个局部指针变量(head 可复用)。
> [!note] 🤔 为什么头插法和三指针法结果一样但中间过程不同?
> 三指针法是原地修改指针方向(像翻多米诺骨牌),头插法则是把节点逐个摘下来重新挂到新位置。虽然路径不同,但最终效果等价——都让每条边的方向掉转了。头插法之所以不会导致断链,是因为每步操作前都用 `temp` 保存了后继,且 `head.Next = dummy.Next` 这步先于 `dummy.Next = head`,保证了已反转部分不会被切断。
### 方法三:递归法(自底向上)
递归的核心思想:**把「反转整个链表」分解为「反转剩余部分 + 调整当前节点」**。
考虑链表 `1 → 2 → 3 → 4 → 5 → nil`:
> [!question] 💡 递归的两个阶段
> 1. **递(深入)**:一直往深处走,直到遇到基准情况
> 2. **归(回溯)**:在返回的过程中逐层反转指针
**基准情况:** 当 `head == nil` 或 `head.Next == nil` 时,直接返回 `head`(空链表或单节点无需反转)。
**递的过程(不断深入到最后):**
```
reverseList(1) → reverseList(2) → reverseList(3) → reverseList(4) → reverseList(5)
↑
遇到基准情况,返回 5(新头节点)
```
**归的过程(逐层反转,注意箭头方向表示 node.next 的赋值):**
```mermaid
flowchart LR
L5["5"] -->|返回新头| L4["4"]
L4 -->|"4.next.Next = 4"| L4B["5 → 4"]
L4B -->|"4.next = nil"| L4C["5 → 4 → nil"]
L4C -->|"下一层: 3.next.Next = 3"| L3B["5 → 4 → 3"]
L3B -->|"3.next = nil"| L3C["5 → 4 → 3 → nil"]
L3C -->|"下一层: 2.next.Next = 2"| L2B["5 → 4 → 3 → 2"]
L2B -->|"2.next = nil"| L2C["5 → 4 → 3 → 2 → nil"]
L2C -->|"下一层: 1.next.Next = 1"| L1B["5 → 4 → 3 → 2 → 1"]
L1B -->|"1.next = nil"| L1C["5 → 4 → 3 → 2 → 1 → nil"]
style L4 fill:#f9d,stroke:#333
style L1C fill:#4c4,stroke:#333
```
用 `1 → 2 → 3` 简化演示关键步骤:
| 阶段 | 递归栈状态 | 链表结构 | 执行的操作 |
|------|-----------|---------|-----------|
| 递到最深 | `reverse(3)` 返回 3 | `1 → 2 → 3` | 基准情况,返回 head=3 |
| 回溯第 1 层 | `reverse(2)` 中 `last=3` | `1 → 2 → 3` | `2.Next.Next = 2` → `3 → 2`;`2.Next = nil` |
| 回溯第 2 层 | `reverse(1)` 中 `last=3` | `3 → 2 → nil, 1 → 2` | `1.Next.Next = 1` → `2 → 1`;`1.Next = nil` |
最终得到 `3 → 2 → 1 → nil`,返回新头节点 3。
> [!info] 🧠 递归的关键理解点
> 每一层递归返回的都是**同一个值**——最开始那个基准情况返回的新头节点(原链表的尾节点)。所有层共享这个返回值,不需要重新拼接。真正发生变化的只是中间各层的 `node.Next` 指针方向。
**时间复杂度:O(n)** — 每层 O(1),共 n 层。
**空间复杂度:O(n)** — 递归调用栈深度为 n。
---
## 代码提示
### 迭代法伪代码
```
prev = nil
curr = head
while curr != nil {
nextTemp = curr.Next // ① 保存后继
curr.Next = prev // ② 反转指针
prev = curr // ③ 前移:prev 往前走
curr = nextTemp // ③ 前移:curr 也往前走
}
return prev // prev 是新头节点
```
### 头插法伪代码
```
dummy = &ListNode{} // 虚拟头节点
while head != nil {
temp := head.Next // ① 保存后继
head.Next = dummy.Next // ② 断开原链表,指向已反转部分
dummy.Next = head // ③ 头插:插入到 dummy 之后
head = temp // ④ 继续处理下一个
}
return dummy.Next // dummy.Next 是新头节点
```
### 递归法伪代码
```
func reverse(head):
if head == nil or head.Next == nil:
return head // 基准情况
last = reverse(head.Next) // 递:反转剩余部分
// 归:反转当前节点与后继之间的边
head.Next.Next = head // 后继指向当前
head.Next = nil // 当前指向 nil
return last // 始终返回新头节点
```
---
## 技巧
> [!tip] 🔑 迭代法口诀:三步走
> 记不住顺序?想 **"save → flip → advance"**(三指针法)或 **"摘 → 插 → 走"**(头插法)。Go 语言中的三变量交换非常自然,没有额外的临时声明开销。
> [!tip] 🔑 递归法记忆法:"别人帮我搞定后半段,我只管调头自己这条边"
> 递归模板适用于大量链表/树问题——`last = recur(rest)` → `调整当前关系` → `return last`。常见变体包括:反转链表 II(区间反转)、两两交换节点、K 个一组翻转等。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 三种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|------|-----------|-----------|------|------|
| 三指针迭代 ⭐ | O(n) | O(1) | 原地操作、最经典 | 需要维护 prev / curr / nextTemp |
| **头插法 ⭐** | **O(n)** | **O(1)** | **代码最短(循环体 4 行)、最直观** | 需理解 dummy.Next 的维护逻辑 |
| 递归法 | O(n) | O(n) | 代码简洁、逻辑清晰 | 深度大时可能栈溢出 |
> [!success] ✅ 相关题目串联
> - [剑指 Offer 24-反转链表](../剑指Offer/) — 完全相同的题目
> - [92-反转链表 II](./92-反转链表-II.md) — 进阶版,只需反转 [m, n] 区间
> - [25-K 个一组翻转链表](./25-K-grouper-reverse.md) — 综合应用:分组 + 反转 + 拼接
---
## 代码
### 迭代法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func reverseList(head *ListNode) *ListNode {
var prev *ListNode // 初始为 nil,反转后原头节点的 Next 指向 nil
curr := head
for curr != nil {
nextTemp := curr.Next // ① 保存后继,防止断链
curr.Next = prev // ② 反转指针:当前节点指向前驱
prev = curr // ③ prev 前进到当前位置
curr = nextTemp // ③ curr 前进到保存的后继位置
}
return prev // prev 现在是原链表的最后一个节点,即新头节点
}
```
### 头插法(虚拟头节点)
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func reverseList(head *ListNode) *ListNode {
dummy := &ListNode{} // 虚拟头节点,dummy.Next 自动维护已反转部分的头部
for head != nil {
temp := head.Next // ① 保存后继,防止断链
head.Next = dummy.Next // ② 断开原链表,指向已反转部分
dummy.Next = head // ③ 头插:把 head 插入到 dummy 之后
head = temp // ④ 继续处理下一个
}
return dummy.Next // dummy.Next 是新链表的头节点
}
```
### 递归法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func reverseList(head *ListNode) *ListNode {
// 基准情况:空链表或只有一个节点
if head == nil || head.Next == nil {
return head
}
// 递归反转剩余部分,last 始终是新的头节点(原链表的尾节点)
last := reverseList(head.Next)
// 反转当前节点 head 和其后继 head.Next 之间的边
head.Next.Next = head // 后继节点的 Next 指回当前节点
head.Next = nil // 断开原方向的边
return last
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 206 题,通过率约 75%+。作为链表入门必做题,它的价值不在于难度而在于**思维模式的建立**——"保存-翻转-推进" 的迭代模式是链表操作的基础范式;而递归版本则展示了如何用函数的调用栈隐式地管理状态。两道实现都值得手写一遍。
+465
View File
@@ -0,0 +1,465 @@
---
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 通过函数的调用栈隐式地保存了每一层的位置信息。这种技巧在其他需要同时正序/逆序遍历链表的场景中也有应用。