From 9067e29acb27140e528a3fa2f94767a6a698b171 Mon Sep 17 00:00:00 2001 From: wonder Date: Sat, 16 May 2026 14:10:21 +0800 Subject: [PATCH] vault backup: 2026-05-16 14:10:20 --- note/2026-05-16.md | 238 ++++++++++++++++++ 技巧/96-只出现一次的数字.md | 167 +++++++++++++ 技巧/97-多数元素.md | 310 ++++++++++++++++++++++++ 链表/22-相交链表.md | 276 +++++++++++++++++++++ 链表/23-反转链表.md | 413 ++++++++++++++++++++++++++++++++ 链表/24-回文链表.md | 465 ++++++++++++++++++++++++++++++++++++ 6 files changed, 1869 insertions(+) create mode 100644 note/2026-05-16.md create mode 100644 技巧/96-只出现一次的数字.md create mode 100644 技巧/97-多数元素.md create mode 100644 链表/22-相交链表.md create mode 100644 链表/23-反转链表.md create mode 100644 链表/24-回文链表.md diff --git a/note/2026-05-16.md b/note/2026-05-16.md new file mode 100644 index 0000000..8d87734 --- /dev/null +++ b/note/2026-05-16.md @@ -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 是稳妥之选 diff --git a/技巧/96-只出现一次的数字.md b/技巧/96-只出现一次的数字.md new file mode 100644 index 0000000..c01ade9 --- /dev/null +++ b/技巧/96-只出现一次的数字.md @@ -0,0 +1,167 @@ +--- +tags: [技巧, 位运算, 异或, 数组] +create time: 2026-05-16 14:30 +--- + +# 96-只出现一次的数字 + +## 题面 + +给你一个**非空**整数数组 `nums` ,除了某个元素**只出现一次**以外,其余每个元素均出现**两次**。找出那个只出现了一次的元素。 + +**要求:** +- 线性时间复杂度 $O(n)$ +- 常量额外空间 $O(1)$ + +**示例 1:** + +> 输入:`nums = [2, 2, 1]` → 输出:`1` + +**示例 2:** + +> 输入:`nums = [4, 1, 2, 1, 2]` → 输出:`4` + +**示例 3:** + +> 输入:`nums = [1]` → 输出:`1` + +--- + +## 思路 + +### 先思考一个问题 🤔 + +如果不用任何算法,你会怎么找到那个唯一的数字? + +> [!tip] 直觉方案 +> 遍历数组,对每个数字统计出现次数,然后找出出现一次的——但这需要哈希表或排序,不满足 $O(1)$ 空间的要求。 + +### 核心洞察:异或运算的性质 💡 + +回忆一下**异或(XOR)**的三条基本性质: + +| 性质 | 表达式 | 含义 | +|------|--------|------| +| **归零律** | `a ^ a = 0` | 相同数字异或为 0 | +| **恒等律** | `a ^ 0 = a` | 任何数与 0 异或等于自身 | +| **交换律 & 结合律** | `a ^ b ^ c = a ^ (b ^ c) = (a ^ c) ^ b` | 异或的顺序不影响结果 | + +> [!question] 启发式提问 +> 如果一个数组中除了一个数字外,其余都恰好出现两次,把这些数字全部异或起来会发生什么? + +根据归零律,每对相同的数字异或后变为 `0`;再根据恒等律,所有的 `0` 异或起来还是 `0`;最后剩下的就是那个只出现一次的数字! + +### 流程演示 + +以 `nums = [4, 1, 2, 1, 2]` 为例: + +```mermaid +flowchart LR + A["开始: result = 0"] --> B["result ^= 4 → 4"] + B --> C["result ^= 1 → 5"] + C --> D["result ^= 2 → 7"] + D --> E["result ^= 1 → 6"] + E --> F["result ^= 2 → 4"] + F --> G["result = 4 ✅"] + + style A fill:#e1f5fe + style G fill:#c8e6c9 +``` + +**更直观的理解**(利用交换律和结合律重新排列): + +$$4 \oplus 1 \oplus 2 \oplus 1 \oplus 2 = 4 \oplus (1 \oplus 1) \oplus (2 \oplus 2) = 4 \oplus 0 \oplus 0 = 4$$ + +> [!info] 关键结论 +> 不需要关心顺序——异或的**交换律**保证了无论成对的数字在数组中的位置如何,它们最终一定会被配对抵消。 + +--- + +## 代码提示 + +### 伪代码 + +``` +初始化 result = 0 +遍历数组中的每个数字 num: + result = result XOR num +返回 result +``` + +### 复杂度分析 + +| 指标 | 结果 | 说明 | +|------|------|------| +| **时间复杂度** | $O(n)$ | 只需遍历数组一次 | +| **空间复杂度** | $O(1)$ | 只使用了一个变量 `result` | + +完美满足题目要求! + +--- + +## 技巧 + +### 1. 异或找唯一元素的通用模式 + +> [!summary] 模板记忆法 +> ```go +> ans := 0 +> for _, v := range nums { +> ans ^= v +> } +> return ans +> ``` +> 看到「除一个元素出现一次/奇数次外,其余出现偶数次」→ 优先考虑异或。 + +### 2. 异或的小应用清单 + +这些面试高频技巧都源于异或的相同两条性质: + +| 应用场景 | 方法 | 核心原理 | +|----------|------|---------| +| 找唯一元素 | 全数组异或 | `a ^ a = 0`, `a ^ 0 = a` | +| 判断两个数是否相等 | `a ^ b == 0` | 相等则结果为 0 | +| 交换两个变量(经典面试题) | `a ^= b; b ^= a; a ^= b` | 三步异或完成交换,无需临时变量 | +| 翻转指定位 | `num ^= mask` | mask 中为 1 的位被翻转 | + +### 3. 变体题延伸 🚀 + +| 题目 | 变化点 | 思路升级 | +|------|--------|---------| +| **136. Single Number**(本题) | 其余出现两次 | 直接全异或 ✅ | +| **137. Single Number II** | 其余出现三次 | 按位统计每个位上 1 的出现次数,%3 剩余的拼接 | +| **260. Single Number III** | 有两个只出现一次的数 | 先全异或得到 `x ^ y`,找最低位的 1 分组,再分别异或 | + +> [!note] 思考方向 +> 当「其余元素出现 k 次」时,本质上是把逐位计数的模运算从 %2 推广到 %k。异或是最自然的 %2 计数器。 + +--- + +## 代码 + +```go +// SingleNumber finds the element that appears exactly once +// while all other elements appear exactly twice. +func singleNumber(nums []int) int { + // result 初始化为 0(异或的单位元) + // 根据恒等律:a ^ 0 = a + result := 0 + + // 遍历所有数字,依次异或 + // 根据归零律:a ^ a = 0,成对的数字会相互抵消 + // 根据交换律:无论顺序如何,结果不变 + for _, num := range nums { + result ^= num + } + + // 此时 result 就是那个只出现一次的数字 + return result +} +``` + +### Go 运行细节 + +> [!example] Go 中异或运算符 +> Go 语言使用 `^` 作为按位异或运算符: +> - `5 ^ 3 = 0b0101 ^ 0b0011 = 0b0110 = 6` +> - 注意区分逻辑异或 `!=` —— 这里用的是**按位**异或 diff --git a/技巧/97-多数元素.md b/技巧/97-多数元素.md new file mode 100644 index 0000000..d173d36 --- /dev/null +++ b/技巧/97-多数元素.md @@ -0,0 +1,310 @@ +--- +tags: [技巧, 摩尔投票, Boyer-Moore, 数组] +create time: 2026-05-16 18:00 +--- + +# 97-多数元素 + +## 题面 + +> **LeetCode 169. Majority Element** + +给定一个大小为 `n` 的整数数组,返回其中的**多数元素**。多数元素是指在数组中出现次数 **大于 ⌊ n/2 ⌋** 的元素。 + +**要求:** +- 线性时间复杂度 $O(n)$ +- 常量额外空间 $O(1)$ + +**假设:** +- 数组非空 +- 给定的数组**总是存在**多数元素 + +**示例 1:** + +``` +输入:nums = [3, 2, 3] +输出:3 +``` + +**示例 2:** + +``` +输入:nums = [2, 2, 1, 1, 1, 2, 2] +输出:2 +``` + +**提示:** +- `n == nums.length` +- `1 <= n <= 5 * 10^4` +- `-10^9 <= nums[i] <= 10^9` + +--- + +## 思路 + +### 先思考一个问题 🤔 + +> 如果一个候选者在选举中获得了超过半数选票,那么在"一票赞成、一票反对"互相抵消的过程中,他还能活到最后吗? + +> [!tip] 💡 直觉方案 +> 暴力做法是用哈希表统计每个数字的出现次数——但这需要 $O(n)$ 空间。排序后取中间位置的元素可以做到 $O(1)$ 空间但代价是 $O(n \log n)$ 时间。**有没有办法在扫一遍的同时用常数空间找到答案?** + +### 核心洞察:投票消除法 ⭐ + +多数元素的定义是出现次数 **大于 n/2**。这是一个非常强的条件——意味着它的数量比其他所有元素加起来还多。 + +> [!question] 启发式提问 +> 如果把多数元素想象成"支持票",其他所有元素加起来是"反对票"。每次拿一张支持票和一张反对票互相抵消,最后剩下来的会是谁? + +因为多数元素的数量 **> n/2**,即使每一次抵消都恰好消耗一张多数元素的票和一张其他元素的票,多数元素的票也一定会有剩余! + +这就是 **Boyer-Moore 投票算法** 的核心思想:**维护一个候选者和它的支持计数,遇到相同的就加票,遇到不同的就抵消。** + +### 算法流程 + +``` +维持两个变量: +- candidate:当前的"候选人" +- count:当前候选人的"净票数" + +遍历数组: + - 如果 count == 0 → 更换候选人 + - 如果当前元素 == candidate → 加一票(count++) + - 如果当前元素 != candidate → 抵消一票(count--) + +最终 candidate 就是多数元素 +``` + +### 逐步跟踪演示 + +以 `nums = [2, 2, 1, 1, 1, 2, 2]` 为例(n=7,多数元素需出现 > 3 次即至少 4 次): + +| 步骤 | 读取元素 | 操作 | candidate | count | 解读 | +|------|---------|------|-----------|-------|------| +| 初始 | - | 初始化 | `2` | `1` | 第一个元素自动成为候选人 | +| 1 | `2` | 相同,加票 | `2` | `2` | 二号支持者到来 | +| 2 | `1` | 不同,抵消 | `2` | `1` | 一敌一消 | +| 3 | `1` | 不同,抵消 | `1` | `0` | 票归零,候选人更换 | +| 4 | `1` | count=0,重选 | `1` | `1` | 新候选人上位 | +| 5 | `2` | 不同,抵消 | `1` | `0` | 又归零了 | +| 6 | `2` | count=0,重选 | `2` | `1` | 再次更换 | + +等等——最后的 candidate 是 `2`,正好是正确答案! + +> [!info] 为什么算法保证正确? + +设多数元素出现了 $m$ 次,非多数元素共出现 $n-m$ 次,且 $m > n/2$。 + +整个过程中,每次抵消都会同时消耗一张多数元素的票和一张非多数元素的票。最多被抵消的次数是非多数元素的数量 $(n-m)$。因此多数元素剩余的票数至少为: + +$$m - (n - m) = 2m - n > 0$$ + +所以无论抵消的顺序如何,多数元素永远不可能被完全清除。当 `count` 归零时,更换的候选人不一定是多数元素,但后续的过程会在新的起始点重新累积——**只要后面还有足够的多数元素票就能翻盘**,而根据上面的推导,最终一定能把多数元素推到胜出位置。 + +> [!summary] 🎯 关键结论 +> +> 多数元素"数量超半"的特性保证了它在任何一对一抵消规则下都是"打不死的小强"。 + +### 流程图 + +```mermaid +flowchart TD + Start(["开始
candidate = _, count = 0"]) --> Loop{"有下一个元素?"} + Loop -- 否 --> Result["返回 candidate ✅"] + Loop -- 是 --> ZeroCheck{count == 0?} + ZeroCheck -- 是 --> SetCand["candidate = 当前元素"] + ZeroCheck -- 否 --> Match{当前元素 == candidate?} + Match -- 是 --> Inc["count++"] + Match -- 否 --> Dec["count--"] + SetCand --> Next + Inc --> Next + Dec --> Next + Next --> Loop + Result --> End(["结束"]) +``` + +### 备选方案对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 说明 | +|------|----------|----------|------| +| **Boyce-Moore 投票** ⭐ | $O(n)$ | $O(1)$ | 最优解,本题标准答案 | +| 哈希表计数 | $O(n)$ | $O(n)$ | 直观但空间不达标 | +| 排序取中位 | $O(n \log n)$ | $O(1)$ 或 $O(\log n)$ | 多数元素一定在 `nums[n/2]` | +| 随机抽样 | $O(n)$ 期望 | $O(1)$ | 随机选一个数验证,期望很快命中 | + +> [!note] 📊 为什么排序也能做? + +既然多数元素出现超过一半,把它排序后放在任意位置,索引 `n/2` 处一定是多数元素。就像超过半数的人穿同一种颜色的衣服站成一排,从中间看去必然是那种颜色。但这不是最优解,仅作面试展示思路广度之用。 + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架 + +``` +candidate := 不存在 +count := 0 + +遍历 nums 中的每个 num: + 如果 count == 0: + candidate = num + 否则如果 num == candidate: + count++ + 否则: + count-- + +返回 candidate +``` + +> [!step] 实现要点 + +1. **count 为零时的处理是最关键的细节**——此时"选举重新开始",当前元素自动成为新候选人 +2. **不需要最后验证**——题目保证了多数元素一定存在,省去了反证步骤 +3. **一次遍历即可**——不需要像某些变体那样分两阶段 + +--- + +## 技巧 + +> [!summary] 🔑 核心模式记忆 + +```go +count, candidate := 0, 0 +for _, v := range nums { + if count == 0 { + candidate = v + } + if v == candidate { + count++ + } else { + count-- + } +} +return candidate +``` + +看到 **"出现次数超过 n/k"** + **"O(1) 空间"** → 优先考虑摩尔投票或其推广。 + +### 1. 摩尔投票的思想本质 + +> [!quote] 💬 一句话总结 +> +> **"少数互抵消,少数扛 majority"** ——让不同的互相消灭,剩下的就是强者。 + +这是一种"去冲突"的思维:不需要知道每个元素的具体频次,只需要关注"相同还是不同"这个相对关系。 + +### 2. 扩展:出现次数 > n/3 的元素 + +当条件放宽到"出现次数超过 n/3"时,最多可能有 **两个** 这样的元素。摩尔投票可以自然地推广到维护**两个候选者**: + +```go +func majorityElement(nums []int) []int { + cand1, cand2, c1, c2 := 0, 0, 0, 0 + + // 第一阶段:选出两个候选人 + for _, v := range nums { + switch { + case v == cand1: + c1++ + case v == cand2: + c2++ + case c1 == 0: + cand1, c1 = v, 1 + case c2 == 0: + cand2, c2 = 1 + default: + c1-- + c2-- + } + } + + // 第二阶段:验证(题目若保证存在可省略) + var result []int + threshold := len(nums) / 3 + for _, v := range nums { + if v == cand1 && c1 > threshold { result = append(result, cand1); break } + if v == cand2 && c2 > threshold { result = append(result, cand2); break } + } + return result +} +``` + +> [!warning] ⚠️ n/3 版本的注意事项 +> +> 与 n/2 版本不同,n/3 版本在第一阶段选出候选者后**必须再进行第二阶段验证**,因为可能存在两个候选者都不是真正的多数元素的情况(数组中没有任何元素真正超过 n/3)。 + +### 3. 关联变体题 + +| 题目 | 变化点 | 核心思路 | +|------|--------|---------| +| **169. Majority Element** | 超过 n/2 | 摩尔投票 ✅ | +| **229. Majority Element II** | 超过 n/3 | 双候选人摩尔投票 | +| **GCD Sort 类问题** | 按最大出现频率选择 | 摩尔投票的变种应用 | + +### 4. Go 运行细节 + +> [!example] 🐹 Go 特有注意事项 + +- `count` 和 `candidate` 的初始化值不重要,因为在第一次遍历时 `count == 0` 的条件一定会触发 `candidate = nums[0]` +- 由于题目保证多数元素存在,无需第二阶段的计数验证——这比严格实现节省了 $O(n)$ 时间和 $O(n)$ 空间 +- 如果想写得更紧凑,可以用 Go 的三元替代(if 表达式)来减少行数,但**可读性优先** + +--- + +## 代码 + +```go +// majorityElement returns the majority element using the Boyer-Moore Voting Algorithm. +// A majority element appears more than ⌊ n/2 ⌋ times. +// The problem guarantees that a majority element always exists. +func majorityElement(nums []int) int { + // ── Step 1: 初始化 ── + // count 为 0 时,下一次赋值会自动选定第一个候选人 + var candidate, count int + + // ── Step 2: 投票阶段(一次遍历) ── + for _, num := range nums { + if count == 0 { + // 当前没有候选人(或票数归零),新候选人上任 + candidate = num + } + // 投给当前候选人,或者投给别人(抵消) + if num == candidate { + count++ // 支持 + } else { + count-- // 反对 + } + } + + // ── Step 3: 返回结果 ── + // 题目保证多数元素一定存在,无需二次验证 + return candidate +} +``` + +### 代码走读 + +> [!success] ✅ 复杂度总结 + +| 指标 | 结果 | 说明 | +|------|------|------| +| **时间复杂度** | $O(n)$ | 仅需一次线性扫描 | +| **空间复杂度** | $O(1)$ | 仅使用了两个整型变量 `candidate` 和 `count` | + +完美满足题目的进阶要求! + +> [!quote] 💬 面试建议 + +这道题是摩尔投票算法最经典的入门题。面试中被问到后: +1. **先阐述暴力解法**(哈希表 / 排序),展示你能想到多种方案 +2. **引出"有没有 O(1) 空间的解法"**,自然过渡到摩尔投票 +3. **手绘流程图**解释"抵消"过程,展现你的思路清晰程度 +4. **证明正确性**——简要说明多数元素因数量过半而不可能被完全消除 +5. **提及扩展到 n/3**——展示你对这个算法的理解不止于表面 + +> [!warning] ⚠️ 常见陷阱 + +> 1. **忘记处理 `count == 0` 的情况**——这是换候选人时机,漏掉会导致错误 +> 2. **把 `==` 写成 `!=` 导致逻辑反转**——仔细检查条件分支 +> 3. **不加验证地用于不保证存在的场景**——实际工程中应该增加第二阶段验证 diff --git a/链表/22-相交链表.md b/链表/22-相交链表.md new file mode 100644 index 0000000..41fafad --- /dev/null +++ b/链表/22-相交链表.md @@ -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|)。 diff --git a/链表/23-反转链表.md b/链表/23-反转链表.md new file mode 100644 index 0000000..52d5a27 --- /dev/null +++ b/链表/23-反转链表.md @@ -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%+。作为链表入门必做题,它的价值不在于难度而在于**思维模式的建立**——"保存-翻转-推进" 的迭代模式是链表操作的基础范式;而递归版本则展示了如何用函数的调用栈隐式地管理状态。两道实现都值得手写一遍。 diff --git a/链表/24-回文链表.md b/链表/24-回文链表.md new file mode 100644 index 0000000..69a3f54 --- /dev/null +++ b/链表/24-回文链表.md @@ -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 通过函数的调用栈隐式地保存了每一层的位置信息。这种技巧在其他需要同时正序/逆序遍历链表的场景中也有应用。