11 KiB
tags, create time
| tags | create time | |
|---|---|---|
|
2026-05-16 14:40 |
2026-05-16
今日学习
思考笔记:反转后链表的头是 prev 而不是 curr
在迭代法反转链表中,循环结束后返回的是 prev,而非 curr。这个细节很容易让人困惑——毕竟两个指针都在"走",凭什么 prev 就是对的?
核心原因:循环的终止时机决定了两个指针的最终位置
三指针法的循环条件是 for curr != nil,也就是说:只有当 curr 指向一个有效节点时,循环体才会执行。循环体内,prev 和 curr 各向前走一步。
关键来了——最后一步发生了什么?
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] 💡 先问自己:如果让你写快慢指针找中点,你会怎么初始化两个指针?
大多数人会这样写(看似合理的直觉):
slow, fast := head, head // ← 两个都从头开始
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
}
halfHead := slow.Next
但正确的写法其实是:
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:
- 初始:slow=1, fast=1
- 条件检查:fast!=nil && fast.Next!=nil → true(fast.Next=2)
- 第一轮:slow→2, fast→nil
- 条件检查:fast==nil → false,退出
- slow 直接跳到节点2,halfHead=slow.Next=nil
- 对比阶段 p2=nil → 直接 return true
- ❌ 明明应该 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 上的表现
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:
// 环形链表:检测环存在性
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 是稳妥之选