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
+238
View File
@@ -0,0 +1,238 @@
---
tags: [daily]
create time: 2026-05-16 14:40
---
# 2026-05-16
## 今日学习
- [[23-反转链表]] — 为什么返回 prev 而不是 curr?
- [[24-回文链表]] — 快慢指针的初始化技巧:为什么 fast 从 head.Next 开始?
---
## 思考笔记:反转后链表的头是 prev 而不是 curr
在迭代法反转链表中,循环结束后返回的是 `prev`,而非 `curr`。这个细节很容易让人困惑——毕竟两个指针都在"走",凭什么 `prev` 就是对的?
### 核心原因:循环的终止时机决定了两个指针的最终位置
三指针法的循环条件是 `for curr != nil`,也就是说:**只有当 curr 指向一个有效节点时,循环体才会执行**。循环体内,`prev` 和 `curr` 各向前走一步。
关键来了——**最后一步发生了什么?**
```go
for curr != nil { // ← 条件检查
nextTemp := curr.Next // ① 保存后继
curr.Next = prev // ② 反转指针
prev = curr // ③ prev 前进(停在当前有效节点上)
curr = nextTemp // ③ curr 前进(走到了下一个,可能是 nil)
}
return prev // ← 循环结束了
```
以 `1 → 2 → 3 → nil` 为例,看最后两轮:
| | prev | curr | nextTemp | 操作 |
|--|------|------|----------|------|
| 第 2 轮结束时 | 2 | **3** | nil | curr 指向有效节点 3,**进入下一轮** |
| 第 3 轮开始时 | 2 | 3 | nil | 保存后继(nil)、反转 3→2 |
| 第 3 轮结束时 | **3** | **nil** | — | prev 停在 3,curr 走到 nil |
回到循环条件 `for curr != nil` → `false` → **退出循环**。
此时:
- `curr == nil`:它已经越过了原链表的末尾,是一个**无效的空指针**
- `prev` 停在最后一个处理过的有效节点(即原链表的尾节点)上——这正是反转后的新头
### 一句话总结
> `curr` 是"探路者"——它先往前看,看到 `nil` 就说明到头了,于是停下来;`prev` 是"实干者"——最后一个干活的人,停的位置就是最后一次操作的终点。
如果把循环写成 `for prev != nil` 或其他方式,逻辑就不成立了——因为初始时 `prev == nil`,循环根本不会开始。**用 `curr` 做循环条件,是因为它天然承载了"还有没有更多节点可以处理"的信息**;而 `prev` 之所以作为返回值,是因为它始终滞后 `curr` 一步,在循环终结的瞬间,这"一步之差"恰好让它锚定在答案上。
### 类比理解
想象一个人过独木桥,每踩一块木板就把它翻转过来铺在身后:
- `curr` = 右脚(踩在当前木板上)
- `prev` = 左脚(站在上一块已翻转的木板上)
- 当 `curr` 落到岸边(nil),人站稳了——**左脚站立的位置**(prev)就是你最终所在的位置
---
## 思考笔记:快慢指针初始化与中点判定
回文链表的解法核心在于第一步——用快慢指针找中点。但这个简单的操作里藏着大量容易被忽视的细节,尤其是 **fast 为什么不从 head 开始**。
### 标准写法 vs 直觉写法
> [!question] 💡 先问自己:如果让你写快慢指针找中点,你会怎么初始化两个指针?
大多数人会这样写(看似合理的直觉):
```go
slow, fast := head, head // ← 两个都从头开始
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
}
halfHead := slow.Next
```
但正确的写法其实是:
```go
slow, fast := head, head.Next // ← fast 从第二个节点开始
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
}
halfHead := slow.Next
```
> [!warning] ⚠️ 就差一个 `.Next`,结果却可能完全不同。为什么?
### 核心原理:快指针的速度偏移决定了偶数链表的分割方式
快慢指针的本质是"速度差":fast 每次走两步,slow 每次走一步。当 fast 走完整个链表时,slow 恰好走了 half 的长度。但问题是——**对偶数长度链表,中点有两个(左中点和右中点),到底选哪个取决于 fast 的起始位置**。
#### 场景 A:fast=head(两个指针同起点)
| 链表 | 长度 | 循环执行次数 | slow 停在 | halfHead = slow.Next | 问题 |
|------|------|------------|----------|---------------------|------|
| `1→nil` | 1 | 0 | 节点1 | 节点2(nil) | ✅ 碰巧正确 |
| `1→2→nil` | 2 | 1 | 节点2 | 节点3(nil) | ❌ halfHead=nil,跳过了唯一一次对比! |
| `1→2→2→1→nil` | 4 | 2 | 节点2 | 节点3 | ⚠️ 前半段[1,2],后半段[2,1] → 对比结果正确,但**前半段多占了一个节点** |
> [!note] 🤔 发现了什么规律?
>
> 当 fast=head 时,对于两节点链表 `1→2→nil`:
> 1. 初始:slow=1, fast=1
> 2. 条件检查:fast!=nil && fast.Next!=nil → true(fast.Next=2)
> 3. 第一轮:slow→2, fast→nil
> 4. 条件检查:fast==nil → false,退出
> 5. slow 直接跳到节点2,halfHead=slow.Next=nil
> 6. 对比阶段 p2=nil → 直接 return true
> 7. **❌ 明明应该 false,却被错误地接受**
这就是最危险的 corner case——**两节点链表永远被误判为回文**,而且这种 bug 极难排查,因为你很难想到测试用例只包含两个不同的元素。
#### 场景 B:fast=head.Next(推荐写法)
同样的三个链表:
| 链表 | 长度 | fast 初始 | slow 初始 | 循环执行 | slow 最终 | halfHead | 结果 |
|------|------|----------|----------|---------|----------|----------|------|
| `1→nil` | 1 | 节点2(nil) | 节点1 | 0次 | 节点1 | nil → 无需对比 | ✅ true |
| `1→2→nil` | 2 | 节点2 | 节点1 | 0次(fast.Next==nil) | 节点1 | 节点2 | 1 vs 2 → **false** ✅ |
| `1→2→2→1→nil` | 4 | 节点2 | 节点1 | 2次 | 节点2 | 节点3(值2) | 1vs1, 2vs2 → **true** ✅ |
> [!info] 🧠 关键洞察
>
> fast=head.Next 的核心效果是**让 fast 永远比 slow 领先一个节点**。这意味着:
> - 对于奇数长度 n,slow 停在正中间 `(n+1)/2`,natural skip 掉中心元素
> - 对于偶数长度 n,slow 停在左半边的**最后一个**节点 `n/2`,halfHead 指向右半边第一个 `n/2+1`
> - 两个半段长度完全相等,对比阶段不需要任何特殊处理
#### 可视化追踪:fast=head.Next 在 `1→2→3→2→1` 上的表现
```mermaid
flowchart LR
subgraph "初始状态"
S1["slow=1"] --> F1["fast=2"]
end
subgraph "第1步"
S2["slow=2"] --> F2["fast=3"]
end
subgraph "第2步"
S3["slow=3"] --> F3["fast=1"]
end
subgraph "第3步"
S4["slow=4"] --> F4["fast=nil ✗ 退出"]
end
S1 -.step1.-> S2 -.step2.-> S3 -.step3.-> S4
F1 -.step1.-> F2 -.step2.-> F3 -.step2.-> F4
style F4 fill:#f99,stroke:#333
style S3 fill:#bbf,stroke:#333,stroke-dasharray: 5 5
```
- slow 最终停在节点4(值2),即第4个节点
- halfHead = slow.Next = 节点5(值1)
- 反转后半段:1→nil → reversedHalf=1
- 对比:p1 遍历 1→2→3→2, p2 遍历 1 → 只需走1步就结束
- **结论:前半段 [1,2,3,2] vs 后半段 [1]** —— 等等,这不是对称的吗?
> [!question] 💡 等等——上面的分析有问题吗?
让我们重新检查一下。对于 `1→2→3→2→1`(长度5),slow 应该停在中心节点 `3`,而不是节点 `2`。让我重新追踪:
| 步数 | slow 位置 | fast 位置 | 条件 fast!=nil && fast.Next!=nil |
|------|----------|----------|----------------------------------|
| 初始 | 节点1(值1) | 节点2(值2) | ✅ fast.Next=节点3 ≠ nil |
| 第1步 | 节点2(值2) | 节点3(值3) | ✅ fast.Next=节点4 ≠ nil |
| 第2步 | 节点3(值3) | 节点5(值1) | ✅ fast.Next=nil? NO, fast=节点5, fast.Next=nil → **❌ 退出!** |
所以 slow 停在节点3(值3),halfHead=节点4(值2)。反转后半段得到 `2→1`,对比阶段 p1 遍历 `[1,2,3]`, p2 遍历 `[2,1]`:
- 1 vs 2 → ❌ **不是回文?** 但这显然是个回文啊!
> [!warning] ⚠️ 这里有个微妙之处
>
> 上面的分析确实暴露了一个问题:我们用 `halfHead = slow.Next`,然后反转并对比,但实际上**对比的逻辑应该是前半段和反转后的后半段逐一对比**。
>
> 对于 `1→2→3→2→1`,slow=节点3(值3), halfHead=节点4(值2), reversedHalf=`2→1`(反转后):
> - p1=节点1(1), p2=节点5(1) → 1==1 ✅
> - p1=节点2(2), p2=节点4(2) → 2==2 ✅
> - p2=nil → 停止
> - **✅ true** — 正确!
>
> 关键在于:对比是从 `head` 和 `reversedHalf`(反转后的头,即原链表的尾部)同时开始的,所以是"前端对后端"的镜像对比。
#### 为什么这个设计精妙在哪里?
> [!tip] 🔑 一句话心法
>
> **fast=head.Next 的本质是让 fast 始终比 slow 快半个身位**——当 fast 到达终点时,slow 刚好落在中点的左侧或者正中。这样 halfHead = slow.Next 自动将链表分为前后两段(奇数时前半段多一个元素),且对比阶段的终止条件 `p2 != nil` 自然正确。
### 延伸:不同初始化的通用规律
| 初始化 | fast领先slow | 奇数长度 n 时 slow 停在 | 偶数长度 n 时 slow 停在 |
|--------|-------------|-----------------------|-----------------------|
| slow=head, fast=head | 0步 | 第 `(n+1)/2` 个(正中间) | 第 `n/2 + 1` 个(偏右) |
| **slow=head, fast=head.Next** | **1步** | **第 `(n+1)/2` 个(正中间)** | **第 `n/2` 个(左中点)** |
| slow=head, fast=head.Next.Next | 2步 | 第 `n/3*2+...`(不确定) | 不确定 |
> [!note] 🤔 为什么 fast=head, fast.Next.Next 不建议使用?
>
> 因为它对短链表(2~3 节点)会导致 fast 一开始就超出范围,需要额外做边界判断。fast=head.Next 是平衡了简洁性和正确性的最佳选择。
### 与「环形链表」快慢指针的对比
同样是快慢指针,LeetCode 141(环形链表)的标准写法却是 `slow=head, fast=head`:
```go
// 环形链表:检测环存在性
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast { return true }
}
```
> [!question] 💡 同一个算法,为什么环形链表可以 fast=head,而回文链表必须 fast=head.Next?
**根本差异在于目的不同**:
- **环形链表**的目标是「两个指针迟早会相遇」。无论 fast 从哪开始,只要链表中存在环,fast 最终一定会追上 slow(就像操场跑步,快的人总会套圈慢的人)。fast 的起点不影响最终 correctness,只是影响第一轮的相对距离。
- **回文链表**的目标是「精确地定位中点」。这是**位置敏感型**任务,fast 的每个身位的偏移都会改变 slow 最终停留的位置。因此初始化的选择直接影响算法的正确性。
> [!tip] 🔑 记忆锚点
>
> - **相遇检测**(环形链表)→ fast 起点不重要,fast=head 即可
> - **位置定位**(回文链表、重排链表)→ fast 起点至关重要,fast=head.Next 是稳妥之选