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

11 KiB
Raw Blame History

tags, create time
tags create time
daily
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 各向前走一步。

关键来了——最后一步发生了什么?

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:

  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 上的表现

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 是稳妥之选