Files
leetcode-go/note/2026-05-16.md
T

239 lines
11 KiB
Markdown
Raw 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: [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 是稳妥之选