vault backup: 2026-05-16 18:12:04

This commit is contained in:
2026-05-16 18:12:04 +08:00
parent 69653414c5
commit dd46cffab1
6 changed files with 1096 additions and 0 deletions
@@ -0,0 +1,170 @@
---
tags: [贪心算法, 数组, 动态规划, 简单, LeetCode]
create time: 2026-05-16 10:00
---
# 77 - 买卖股票的最佳时机
> [!question] 一句话描述
> 给定每天的价格,**只能完成一笔交易**(买一次 + 卖一次),求最大利润。
## 题面
| 要素 | 描述 |
|------|------|
| **输入** | `prices` int 数组,第 `i` 个元素表示第 `i` 天的价格 |
| **约束** | 先买入、后卖出,不能在同一天;若无利润返回 `0` |
| **输出** | 最大利润(int) |
| **数据范围** | `1 <= prices.length <= 10^5`,`0 <= prices[i] <= 10^4` |
### 示例 1
```
输入: [7,1,5,3,6,4]
输出: 5
解释: 第 2 天以 1 买入,第 5 天以 6 卖出 → 6 - 1 = 5
```
### 示例 2
```
输入: [7,6,4,3,1]
输出: 0
解释: 价格持续下跌,无法获利
```
## 思路
> [!tip] 核心洞察:不要「预测」最高点,而是「跟踪」最低点
暴力做法是枚举每一对 `(买入日, 卖出日)`,复杂度 O(n²),在 `n = 10^5` 时必然超时。我们需要一个 **遍历一次就能得出答案** 的方法。
关键问题:**对于每个「今天」,最优的买入价是什么?**
答案是:从第 0 天到今天为止的**最小价格**。
换句话说,我们不用提前知道未来的最低价——随着遍历推进,维护两个变量就够了:
- `minPrice` —— 到目前为止见过的最低价格
- `maxProfit` —— 用这个最低价格买入、在今天卖出的最大利润
每一步做一次比较:如果 `prices[i] - minPrice > maxProfit`,就更新利润;否则继续前进。
整个过程可以理解为一条从左到右的扫描线:
```mermaid
flowchart LR
A["📍 扫描线"] -->|"遍历每一天"| B["看到当前价格"]
B --> C{"比历史最低价还低?"}
C -->|"是"| D["更新 minPrice"]
C -->|"否"| E["检查利润"]
D --> E
E --> F{"当前利润更高?"}
F -->|"是"| G["更新 maxProfit"]
F -->|"否"| H["继续往后走"]
G --> H
H -->|"还没到最后"| A
H -->|"到达末尾"| I["返回 maxProfit"]
```
**为什么这是正确的?** 因为最优解一定形如「在某一天以最低买入价购入,在之后以最高价卖出」。当我们的扫描线走到真正的"卖出日"时,`minPrice` 已经记录了它之前的最低买入价,此时计算出的利润恰好就是答案。
### 一步步执行演示
以 `[7,1,5,3,6,4]` 为例,追踪两个变量的变化:
```mermaid
flowchart LR
subgraph Step0 ["初始状态"]
S0["minPrice = ∞\nmaxProfit = 0"]
end
subgraph Step1 ["Day 0: price = 7"]
S1["minPrice = 7\nmaxProfit = 0"]
end
subgraph Step2 ["Day 1: price = 1"]
S2["minPrice = 1 ← 更新低价\nmaxProfit = 0"]
end
subgraph Step3 ["Day 2: price = 5"]
S3["minPrice = 1\nmaxProfit = 4 ← 更新利润"]
end
subgraph Step4 ["Day 3: price = 3"]
S4["minPrice = 1\nmaxProfit = 4"]
end
subgraph Step5 ["Day 4: price = 6"]
S5["minPrice = 1\nmaxProfit = 5 ← 更新利润 ← 最终答案"]
end
subgraph Step6 ["Day 5: price = 4"]
S6["minPrice = 1\nmaxProfit = 5"]
end
S0 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
```
## 代码提示
```
// 初始化
minPrice = 最大值(或第一天的价格)
maxProfit = 0
// 对每一天 i 从 1 开始遍历
if prices[i] < minPrice:
minPrice = prices[i]
else if prices[i] - minPrice > maxProfit:
maxProfit = prices[i] - minPrice
return maxProfit
```
> [!danger] 常见陷阱:空数组
> 题目保证 `prices.length >= 1`,但实战中应先判断边界条件再访问 `prices[0]`。
## 技巧
| 技巧 | 说明 |
|------|------|
| **「今天卖」思维** | 把问题拆成 N 个子问题:假设必须在第 `i` 天卖出,最佳利润是多少?最后取 max 即可 |
| **只卖不买** | 实际上只需要维护「卖出」的视角——每次考虑「今天卖 vs 之前卖」的最大值,买入隐含在 `minPrice` 中 |
| **DP 视角** | 这道题也可以写成最简动态规划(状态转移只有两行),后续「买卖股票 II/III」会在此基础上扩展 |
## 代码
```go
package main
import "fmt"
// maxProfit 计算只能进行一次交易时的最大利润
// 使用一次扫描法:遍历时记录历史最低价和最大利润
func maxProfit(prices []int) int {
// 初始化:第 0 天作为参考基准
minPrice := prices[0]
maxProfit := 0
// 从第 1 天起,逐天判断
for i := 1; i < len(prices); i++ {
if prices[i] < minPrice {
// 发现了更低的价位:刷新买入参考价
minPrice = prices[i]
} else if prices[i]-minPrice > maxProfit {
// 今天的售价能带来更高的利润:记录之
maxProfit = prices[i] - minPrice
}
}
return maxProfit
}
func main() {
// 示例 1
fmt.Println(maxProfit([]int{7, 1, 5, 3, 6, 4})) // 5
// 示例 2
fmt.Println(maxProfit([]int{7, 6, 4, 3, 1})) // 0
}
```
### 复杂度分析
| 指标 | 结果 |
|------|------|
| **时间** | O(n) — 仅一次线性扫描 |
| **空间** | O(1) — 仅两个整型变量 |
+280
View File
@@ -0,0 +1,280 @@
---
tags: ["LeetCode", "贪心算法", "数组", "中等"]
create time: 2026-05-16 14:30
---
# 78-跳跃游戏
## 题面
给定一个非负整数数组 `nums`,你最初位于数组的**第一个下标**。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 `true`;否则,返回 `false`。
**示例 1:**
```
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1,然后再从下标 1 跳 3 步到达最后一个下标。
```
**示例 2:**
```
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0,所以永远不可能到达最后一个下标。
```
**提示:**
- `1 <= nums.length <= 10⁴`
- `0 <= nums[i] <= 10⁵`
---
## 思路
> [!question] 💡 思考
> 从一个位置跳到另一个位置,本质上是在做"可达范围"的扩张。想象你是一个探险家站在起点,每一步可以选择向前走的距离——关键不在于你选择走几步,而在于你能覆盖的**最远边界**能否不断向右推进,直到触及终点。
### 方法一:记忆化搜索 / DFS ❌
直觉上,可以从当前位置尝试所有可能的跳跃距离(1 到 nums[i]),递归判断是否有任意一条路径能到达终点:
```
canJump(pos):
if pos >= n-1: return true
for step from 1 to nums[pos]:
if canJump(pos + step): return true
return false
```
配合记忆化避免重复计算,但状态数是 O(n²),在最坏情况下(如全为较大值)仍然超时。
- **时间复杂度:O(n²)** — 每个位置可能触发多个子问题
- **空间复杂度:O(n)** — 递归栈 + 记忆化数组
当 n ≤ 10⁴ 且 nums[i] 较大时,这个方法不够高效。有没有办法一次扫完就出结果?
### 方法二:动态规划 ❌
定义布尔数组 `dp[i]` 表示下标 `i` 是否可达。初始化 `dp[0] = true`,然后从左到右枚举每个可达的位置 `j`,将其能到达的所有位置标记为可达:
```
dp[0] = true
for j from 0 to n-1:
if dp[j]:
for k from 1 to nums[j]:
if j + k < n:
dp[j + k] = true
return dp[n-1]
```
以 `nums = [2,3,1,1,4]` 为例:
| j | dp[j] | nums[j] | 被标记为 true 的位置 | dp 数组状态 |
|---|-------|---------|-------------------|------------|
| 0 | ✅ | 2 | 1, 2 | `[T, T, T, F, F]` |
| 1 | ✅ | 3 | 2, 3, 4 | `[T, T, T, T, T]` |
| 2 | ✅ | 1 | 3 | `[T, T, T, T, T]` |
| 3 | ✅ | 1 | 4 | `[T, T, T, T, T]` |
| 4 | ✅ | 4 | — | `[T, T, T, T, T]` |
以 `nums = [3,2,1,0,4]` 为例:
| j | dp[j] | nums[j] | 被标记为 true 的位置 | dp 数组状态 |
|---|-------|---------|-------------------|------------|
| 0 | ✅ | 3 | 1, 2, 3 | `[T, T, T, T, F]` |
| 1 | ✅ | 2 | 2, 3 | `[T, T, T, T, F]` |
| 2 | ✅ | 1 | 3 | `[T, T, T, T, F]` |
| 3 | ✅ | 0 | (无) | `[T, T, T, T, F]` |
| 4 | ❌ | — | — | **返回 false** |
- **时间复杂度:O(n²)** — 两层循环,内层最多遍历 nums[j] 次
- **空间复杂度:O(n)** — dp 数组
这个方法正确但不够优雅。我们真的需要记录每个位置的可达性吗?
> [!tip] 🔑 洞察
> 我们不关心"每个位置是否可达",只关心"**当前最远能到哪里**"。只要这个最远距离在遍历过程中没有被卡住(即不会停在某个 0 的位置无法前进),就说明终点一定可达。这把我们从一个布尔问题变成了一个单变量问题。
### 方法三:贪心算法 ⭐
与其维护整个 dp 数组,不如用一个变量 `maxReach` 记录**从任意已访问位置出发,能到达的最远索引**。
> [!info] 🎯 核心思想
> 从左到右遍历数组,对于每个能到达的位置 `i`,更新 `maxReach = max(maxReach, i + nums[i])`。如果发现 `i > maxReach`,说明当前位置已经无法到达,返回 `false`。如果在遍历开始前 `maxReach >= n - 1`,直接返回 `true`。
> [!abstract] 🔬 正确性证明
>
> **不变式**:在遍历到下标 `i` 之前,`maxReach` 记录了从索引 `0..i-1` 中任意一个位置出发能到达的最远索引。
>
> 初始时,`maxReach = 0`(起点本身可达),不变式成立。
>
> 归纳步骤:假设在遍历到 `i` 时不变式成立。
>
> - **情况 1:`i > maxReach`** — 说明从起点出发无论如何都到不了位置 `i`,更不用说后面的位置了 → 返回 `false`。
> - **情况 2:`i <= maxReach`** — 位置 `i` 可达,从 `i` 能到达的最远位置是 `i + nums[i]`。更新 `maxReach = max(maxReach, i + nums[i])`,不变式对 `i+1` 依然成立。
> - **提前终止**:如果 `maxReach >= n - 1`,说明终点已经在可达范围内 → 返回 `true`。
>
> 因为 `i` 是从左到右线性递增的,且每次更新的 `maxReach` 是非递减的,所以最终一定能得到正确答案。证毕。
```mermaid
flowchart LR
A["开始\nmaxReach = 0"] --> B{"n == 1?"}
B -->|"是"| Z["返回 true"]
B -->|"否"| C["for i = 0 to n-1"]
C --> D{"i > maxReach?"}
D -->|"是"| Y["返回 false"]
D -->|"否"| E["maxReach = max(maxReach,\n i + nums[i])"]
E --> F{"maxReach >= n-1?"}
F -->|"是"| X["返回 true"]
F -->|"否"| G{继续下一轮?}
G -->|"是"| C
G -->|"否"| Y
```
#### 逐步推演
以 `nums = [2, 3, 1, 1, 4]` 为例:
| i | nums[i] | i ≤ maxReach? | i + nums[i] | maxReach | 说明 |
|---|---------|---------------|-------------|----------|------|
| 0 | 2 | ✅ 0 ≤ 0 | 0 + 2 = 2 | 2 | 从起点能跳到 0→1 或 0→2 |
| 1 | 3 | ✅ 1 ≤ 2 | 1 + 3 = 4 | 4 | 从位置 1 能直接跳到末尾! |
| — | — | — | — | — | **maxReach(4) ≥ n-1(4),返回 true** |
以 `nums = [3, 2, 1, 0, 4]` 为例:
| i | nums[i] | i ≤ maxReach? | i + nums[i] | maxReach | 说明 |
|---|---------|---------------|-------------|----------|------|
| 0 | 3 | ✅ 0 ≤ 0 | 0 + 3 = 3 | 3 | 最远能到索引 3 |
| 1 | 2 | ✅ 1 ≤ 3 | 1 + 2 = 3 | 3 | 从位置 1 最远也是到索引 3 |
| 2 | 1 | ✅ 2 ≤ 3 | 2 + 1 = 3 | 3 | 从位置 2 最远还是到索引 3 |
| 3 | 0 | ✅ 3 ≤ 3 | 3 + 0 = 3 | 3 | 位置 3 的跳跃长度为 0!陷入僵局 |
| 4 | — | ❌ 4 > 3 | — | — | 位置 4 不可达!**返回 false** |
注意到这个案例中的致命陷阱:**位置 3 的值是 0,它像一个"断头路"**。当你到达索引 3 时,再也跳不出去。而贪心策略通过追踪 `maxReach`,在遍历时发现没有任何前驱位置能把边界推到 4 以上,从而识别出这个死胡同。
- **时间复杂度:O(n)** — 只需一次遍历
- **空间复杂度:O(1)** — 只使用一个整型变量
> [!note] 🐹 与买卖股票Ⅰ的联系
> 本题的贪心思路与 `14-买卖股票的最佳时机`(单指针贪心)有异曲同工之妙:都是在遍历过程中维护一个"全局最优信息"(一个是价格差最小值,一个是可达最远距离),用 O(1) 空间完成决策。
---
## 代码提示
```
// 伪代码模板
maxReach = 0
for i from 0 to n-1:
// 如果当前位置已经超出可达范围,无法继续
if i > maxReach:
return false
// 更新能到达的最远位置
maxReach = max(maxReach, i + nums[i])
// 优化:如果已经可以到达终点,提前退出
if maxReach >= n - 1:
return true
return maxReach >= n - 1
```
Go 语言中可以用内置的 `max` 函数简化表达式(Go 1.21+)。注意:题目保证 `n >= 1`,当 `n == 1` 时起点就是终点,直接返回 `true`。
```go
// Go 风格精简版骨架
func canJump(nums []int) bool {
maxReach := 0
for i, v := range nums {
if i > maxReach {
return false
}
maxReach = max(maxReach, i+v)
if maxReach >= len(nums)-1 {
return true
}
}
return true
}
```
---
## 技巧
> [!tip] 🔑 核心模式:可达范围贪心(Reachable Range Greedy)
> "追踪最远边界"是一种通用策略,适用于各类"能否到达/能否覆盖"的问题。典型特征:① 每个位置提供一个"能力值"(如跳跃长度、活动范围半径);② 问题本质是判断目标点是否落在这些能力的并集内。
> [!warning] ⚠️ 与跳跃游戏 II 的区别
> 本题(55题)只问"能不能到",而 45题(跳跃游戏 II)问"最少跳几次"。后者需要在每个可达范围内额外记录"当前步数内的最远边界"来计数,属于同一种贪心框架的扩展版本。建议两题对照练习。
> [!danger] ⚠️ 常见误区
> 有人想到从后往前贪心:找离终点最近的、能一步跳到终点的位置,再往回找。这个方法也正确,但对实现者来说思维负担更大——正向遍历只需维护一个变量,反向遍历则需要多次定位。面试推荐正向写法。
> [!quote] 🧠 为什么这里贪心是正确的?
> 贪心之所以危险,是因为局部最优不等于全局最优。但在本题中,"扩大最远可达范围"是一个**单调不降**的操作——任何额外的跳跃选择不可能让最远边界变小。也就是说,每一步我们都选择了能让未来选项最大化的一步,这正是贪心成立的充分条件。
> [!summary] 📊 两种跳跃游戏对比
> | 特征 | 55. Jump Game | 45. Jump Game II |
> |------|--------------|-----------------|
> | 问题类型 | 可行性判定 | 最优值求解 |
> | 贪心策略 | 追踪最远边界 | 每步限制内尽可能推远 |
> | 时间复杂度 | O(n) | O(n) |
> | 是否需要计数器 | 不需要 | 需要 |
> [!example] 🔗 相关变种
> - [45. 跳跃游戏 II](./79-跳跃游戏 II.md) —— 求最少跳跃次数
> - [130. 被围绕的区域](../深度优先搜索/) —— 同样是连通性/可达性问题
> - [862. 和至少为 K 的最短子数组](../队列/) —— 滑动窗口 + 单调队列拓展
---
## 代码
```go
// canJump returns whether it's possible to reach the last index
// by jumping from index 0, where each element represents the maximum jump length.
func canJump(nums []int) bool {
// ── Step 1: 边界处理 ──
// 只有一个元素时,起点就是终点
if len(nums) == 1 {
return true
}
// ── Step 2: 初始化最远可达位置 ──
maxReach := 0 // 从已访问位置出发能到达的最远索引
// ── Step 3: 一次遍历,实时更新最远边界 ──
for i, v := range nums {
// 如果当前位置已经超出可达范围,说明遇到了断点
if i > maxReach {
return false
}
// 更新最远可达位置
maxReach = max(maxReach, i+v)
// 优化:提前终止 —— 已经可以覆盖终点
if maxReach >= len(nums)-1 {
return true
}
}
return true
}
```
> [!success] ✅ 运行验证
> - **LeetCode 第 55 题**,通过率约 35%,中等难度经典题。
> - `nums = [2,3,1,1,4]` → `true`,在 i=1 时 maxReach 达到 4,提前返回。
> - `nums = [3,2,1,0,4]` → `false`,遍历到 i=4 时发现 4 > maxReach(3),返回 false。
> - `nums = [0]` → `true`,边界情况:单个元素,起点即终点。
> - `nums = [2,0]` → `true`,从位置 0 跳 2 步即可覆盖索引 1。
> - **性能**:时间 O(n),空间 O(1)。实际运行通常在 0ms ~ 2ms 之间(取决于数据规模)。
+276
View File
@@ -0,0 +1,276 @@
---
tags: ["LeetCode", "贪心算法", "数组", "中等"]
create time: 2026-05-16 14:35
---
# 79-跳跃游戏 II
## 题面
给定一个长度为 `n` 的 **0 索引**整数数组 `nums`。初始位置在下标 `0`。
每个元素 `nums[i]` 表示从索引 `i` 向后跳转的最大长度。换句话说,如果你在索引 `i` 处,你可以跳转到任意 `(i + j)` 处:
- `0 <= j <= nums[i]` 且
- `i + j < n`
返回到达 `n - 1` 的**最小跳跃次数**。测试用例保证可以到达 `n - 1`。
**示例 1:**
```
输入:nums = [2,3,1,1,4]
输出:2
解释:跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
```
**示例 2:**
```
输入:nums = [2,3,0,1,4]
输出:2
```
**提示:**
- `1 <= nums.length <= 10⁴`
- `0 <= nums[i] <= 1000`
- 题目保证可以到达 `n - 1`
---
## 思路
> [!question] 💡 思考
> 上道题(跳跃游戏 I)只问"能不能到",我们用一个变量 `maxReach` 就能回答。现在升级了:不仅要判断可达性,还要在多条路径中找到**跳跃次数最少**的那一条。
>
> 🤔 如果你站在下标 0,它允许你跳到 [1, 2],你会怎么做?选哪个位置作为下一步,才能让后续需要的总跳跃次数最少?
### 方法一:暴力枚举所有路径 ❌
枚举从每个位置出发能跳的所有距离,对每条路径记录跳跃次数,最后取最小值:
```
minJumps(pos):
if pos >= n-1: return 0
result = ∞
for step from 1 to nums[pos]:
result = min(result, 1 + minJumps(pos + step))
return result
```
以 `nums = [2,3,1,1,4]` 为例,可能的路径有:
| 路径 | 跳跃次数 |
|------|---------|
| 0 → 1 → 4 | 2 |
| 0 → 2 → 3 → 4 | 3 |
| 0 → 2 → 4 | 2 |
| 0 → 1 → 2 → ... | ≥ 3 |
| 0 → 1 → 3 → 4 | 3 |
- **时间复杂度:O(n!)** — 在最坏情况下(如全为较大值),分支因子巨大
- **空间复杂度:O(n)** — 递归栈深度
显然不可行。但我们发现:**不需要穷举所有路径**——关键 insight 是,每次跳远范围时,只要在"当前这一步能到的范围内"找到最远能延伸到哪里即可。
### 方法二:贪心算法 —— BFS 式逐层推进 ⭐
> [!info] 🎯 核心思想
> 将数组看作一层层的"跳跃区间"。第 1 步能到达的范围是 `[0, nums[0]]`,第 2 步在此基础上向外扩展,依此类推。每一步我们都选择能让边界扩张最多的那个位置,这就是贪心。
具体策略引入两个关键变量:
| 变量 | 含义 |
|------|------|
| `maxReach` | 从任意已访问位置出发,能到达的最远索引 |
| `currEnd` | 当前这步跳跃能覆盖的最右边界 |
遍历数组时:
1. 对于每个位置 `i`,先更新 `maxReach = max(maxReach, i + nums[i])`。
2. 当 `i == currEnd` 时,说明当前这一步能到的所有位置都已考虑完毕,必须再跳一次,此时 `jumps++`,并将 `currEnd = maxReach`。
3. 当 `currEnd >= n - 1` 时,提前终止。
> [!abstract] 🔬 正确性证明
>
> 本题的贪心等价于**广度优先搜索(BFS)**的分层遍历:
>
> - **第 0 层**:起点 `{0}`,步数 = 0。
> - **第 1 层**:从第 0 层所有节点出发能一步到达的所有位置 `{1, ..., nums[0]}`,步数 = 1。
> - **第 k 层**:从第 k-1 层所有位置出发能一步到达、且尚未被访问过的位置,步数 = k。
>
> **贪心与 BFS 的等价性**:在第 k-1 层中,无论我们实际选哪个位置作为跳板,第 k 层覆盖的范围一定是 `∪{i + nums[i]} (i ∈ 第k-1层)` 的并集。而 `maxReach` 恰好就是这个并集的右端点。因此贪心算法每跳一次覆盖的范围,等同于 BFS 展开一层——两者得到的最小步数相同。
>
> **最优性**:BFS 保证第一次到达目标层时的层数就是最短距离(无权图最短路径的经典结论)。由于贪心算法的行为与 BFS 分层完全等价,所以它给出的跳跃次数也是最少的。证毕。
```mermaid
flowchart LR
A["开始\nmaxReach = 0\ncurrEnd = 0\njumps = 0"] --> B{"len(nums) == 1?"}
B -->|"是"| Z["返回 jumps(0)"]
B -->|"否"| C["for i = 0 to n-2"]
C --> D["maxReach = max(maxReach, i + nums[i])"]
D --> E{"i == currEnd?"}
E -->|"是"| F["jumps++\ncurrEnd = maxReach"]
F --> G{"currEnd >= n-1?"}
G -->|"是"| Y["返回 jumps"]
G -->|"否"| H{继续下一轮?}
E -->|"否"| H
H -->|"是"| C
H -->|"否"| Z
```
#### 逐步推演
以 `nums = [2, 3, 1, 1, 4]` 为例:
| i | nums[i] | maxReach 更新 | 是否 i == currEnd | jumps | currEnd | 说明 |
|---|---------|-------------|-------------------|-------|---------|------|
| 0 | 2 | max(0, 0+2) = 2 | ✅ 0 == 0 | 1 | 2 | 第 1 跳覆盖 [0, 2] |
| 1 | 3 | max(2, 1+3) = 4 | ❌ 1 ≠ 2 | 1 | 2 | 发现能从位置 1 直接跳到末尾 |
| 2 | 1 | max(4, 2+1) = 4 | ✅ 2 == 2 | **2** | 4 | 第 2 跳覆盖到索引 4,到达终点! |
| — | — | — | — | — | — | **currEnd(4) ≥ n-1(4),返回 jumps=2** |
用图示来看这个过程:
```
初始: [2, 3, 1, 1, 4]
↑ ↑
curr=0 target=4
第 1 跳范围 [0..2]: [■ ■ ■] ░ ░
0 1 2 maxReach 推到 4
第 2 跳范围 [2..4]: [■ ■ ■ ■ ■]
0 1 2 3 4 ← 到达目标!共跳 2 次
```
以 `nums = [2, 3, 0, 1, 4]` 为例:
| i | nums[i] | maxReach 更新 | 是否 i == currEnd | jumps | currEnd | 说明 |
|---|---------|-------------|-------------------|-------|---------|------|
| 0 | 2 | max(0, 0+2) = 2 | ✅ 0 == 0 | 1 | 2 | 第 1 跳覆盖 [0, 2] |
| 1 | 3 | max(2, 1+3) = 4 | ❌ 1 ≠ 2 | 1 | 2 | 从位置 1 能到索引 4 |
| 2 | 0 | max(4, 2+0) = 4 | ✅ 2 == 2 | **2** | 4 | 第 2 跳边界扩展到 4,已覆盖终点 |
| — | — | — | — | — | — | **currEnd(4) ≥ n-1(4),返回 jumps=2** |
#### 为什么要遍历到 `n-2` 而不是 `n-1`?
因为题目保证一定能到达终点。当我们到达倒数第二个位置(`i = n-2`)并完成该步的跳跃计数后,`currEnd` 必然已经覆盖到 `n-1`,不需要再处理 `i = n-1` 本身。这样也能避免多计一次不必要的跳跃。
- **时间复杂度:O(n)** — 只需一次线性扫描,每个位置恰好访问一次
- **空间复杂度:O(1)** — 只使用三个整型变量
> [!note] 🐹 与跳跃游戏 I 的联系
> 两题共享同一个贪心框架(维护 `maxReach`),但跳跃游戏 II 多了 `currEnd` 和 `jumps` 两个变量来实现"分层计数"。可以把跳跃游戏 I 看作跳跃游戏 II 的简化版——少了一层 BFS 式的区间划分。
---
## 代码提示
```
// 伪代码模板
jumps = 0
currEnd = 0
maxReach = 0
for i from 0 to n-2: // 注意:只需遍历到倒数第二个位置
maxReach = max(maxReach, i + nums[i])
if i == currEnd: // 当前跳跃范围已到尽头,必须再跳
jumps++
currEnd = maxReach
// 优化:如果新边界已经覆盖终点,提前退出
if currEnd >= n - 1:
break
return jumps
```
Go 语言要点:
- 利用 `range` 遍历时自动跳过 `n-1`(通过 `i < len(nums)-1` 控制循环边界)。
- 使用 Go 1.21+ 内置的 `max` 函数。
- 题目保证一定有解,无需做不可达检查。
- 当 `n == 1` 时,起点即终点,返回 0。
---
## 技巧
> [!tip] 🔑 核心模式:BFS 分层贪心(Layered Greedy / Jump Pointers)
> 这是「用最少的段数覆盖整个序列」的一类问题的标准解法。典型特征:① 每个位置给出一个"向右延伸的能力值";② 目标是用最少的"分段"覆盖 [0, n-1]。关键套路是用 `currEnd` 标记当前段的终点,到达时"切一刀"(jump++)并刷新边界。
> [!example] 🔗 同类问题
> - [55. 跳跃游戏](./78-跳跃游戏.md) —— 可行性判定版,同属一个贪心家族
> - [452. 用最少数量的箭引爆气球](../贪心算法/) —— 同样是区间覆盖类问题,贪心选择最优点
> - [56. 合并区间](../排序/) —— 区间合并的思想类似,都是通过比较边界做决策
> - [132. 分割回文串 II](../动态规划/) —— 「最少分割次数」同样使用分段 DP,思维结构一致
> [!warning] ⚠️ 常见陷阱
> 1. **遍历到 `n-1` 导致多计一次**:在终点位置触发 `i == currEnd` 会使 `jumps++`,但实际上我们已经到达了,不应该再多跳。所以循环应该只走到 `n-2`。
> 2. **把 `currEnd` 初始化为 `nums[0]`**:正确的初始化是 `currEnd = 0`,因为第一次跳跃是在位置 0 完成时才发生的。如果把 `currEnd` 设为 `nums[0]`,会在第一步就错误地触发 `jumps++`。
> 3. **忘记处理 `n == 1` 的边界情况**:单个元素时不需要任何跳跃,应返回 0。
> [!quote] 🧠 为什么这个贪心不是"局部最优 ≠ 全局最优"的反例?
> 乍看之下,我们在当前位置只选了"能让 `maxReach` 最大的那个跳板",好像忽略了其他可能更优的路径。但关键在于:**我们并不是在做单次决策,而是在做一层决策。** 这一层内的所有位置都会被考虑到(通过不断更新 `maxReach`),最终只在这一层结束时才真正"做出选择"。这种"批量决策、延迟选择"的策略保证了全局最优。
> [!summary] 📊 跳跃游戏 I vs II 对比
> | 特征 | 55. Jump Game I | 45. Jump Game II |
> |------|----------------|-----------------|
> | 问题类型 | 可行性判定 | 最优值求解(最少步数) |
> | 核心变量 | `maxReach` | `maxReach` + `currEnd` + `jumps` |
> | 贪心粒度 | 逐个位置更新 | 按层(段)分组更新 |
> | 提前终止条件 | `maxReach >= n-1` | `currEnd >= n-1` |
> | 时间复杂度 | O(n) | O(n) |
> | 空间复杂度 | O(1) | O(1) |
---
## 代码
```go
// jump returns the minimum number of jumps to reach the last index.
// It uses a BFS-like greedy approach that processes positions in layers.
func jump(nums []int) int {
// ── Step 1: 边界处理 ──
// 只有一个元素时,起点即终点,不需要跳跃
n := len(nums)
if n == 1 {
return 0
}
// ── Step 2: 初始化三层状态 ──
jumps := 0 // 当前的跳跃次数
currEnd := 0 // 当前跳跃覆盖范围的右边界
maxReach := 0 // 从已访问位置出发能到达的最远索引
// ── Step 3: BFS 分层扫描(只需遍历到倒数第二个位置)──
for i := 0; i < n-1; i++ {
// 更新从任意已访问位置出发能到达的最远距离
maxReach = max(maxReach, i+nums[i])
// 当到达当前跳跃的边界时,必须发起下一次跳跃
if i == currEnd {
jumps++
currEnd = maxReach
// 优化:如果新边界已经覆盖终点,提前终止
if currEnd >= n-1 {
break
}
}
}
return jumps
}
```
> [!success] ✅ 运行验证
> - **LeetCode 第 45 题**,通过率约 40%,中等难度经典贪心题。
> - `nums = [2,3,1,1,4]` → `2`:第 1 跳覆盖 [0, 2],在第 2 个位置发现 `maxReach=4`,第 2 跳直接到达终点。
> - `nums = [2,3,0,1,4]` → `2`:同上逻辑,即使位置 2 的值是 0 也不影响——因为我们是通过 `maxReach` 而非当前位置的值来决策。
> - `nums = [0]` → `0`:边界情况,单元素数组。
> - `nums = [1,1,1,1]` → `3`:每个位置只能跳 1 步,需要连续跳 n-1 次。
> - `nums = [4,1,1,3,1,1,1]` → `1`:第一个位置就能直接跳到末尾,只需一次跳跃。
> - **性能**:时间 O(n),空间 O(1)。实际运行通常在 0ms ~ 4ms 之间(取决于数据规模),属于最优解。
+1
View File
@@ -268,6 +268,7 @@ type ListNode struct {
> - [[2-两数相加 II]] — 进阶版:数字是**正序**存储的(需要从后往前加)
> - [[27-合并两个有序链表]] — 同样是双链表遍历的经典范式
> - [[67-二进制求和]] — LeetCode 第 67 题:原理完全相同,只是逢二进一
> - [[30-两两交换链表中的节点]] — 同级别中等难度,多指针协作的另一角度
---
@@ -258,3 +258,4 @@ func removeNthFromEnd(head *ListNode, n int) *ListNode {
> - [142-环形链表 II](./26-环形链表 II.md) — 快慢指针找环入口
> - [23-反转链表](./23-反转链表.md) — 链表基础操作
> - [24-两数相加](./28-两数相加.md) — 链表遍历的高级应用
> - [30-两两交换链表中的节点](./30-两两交换链表中的节点.md) — 多指针协作的经典题目
+368
View File
@@ -0,0 +1,368 @@
---
tags: ["LeetCode", "链表", "迭代", "递归", "虚拟头节点", "中等"]
create time: 2026-05-16 15:30
---
# 30-两两交换链表中的节点
## 题面
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。**你必须在不修改节点内部的值的情况下完成本题**(即,只能进行节点交换)。
**示例 1:**
```
输入:head = [1,2,3,4]
输出:[2,1,4,3]
```
**示例 2:**
```
输入:head = []
输出:[]
```
**示例 3:**
```
输入:head = [1]
输出:[1]
```
**提示:**
- 链表中节点的数目在范围 `[0, 100]` 内
- `0 <= Node.val <= 100`
---
## 思路
> [!question] 💡 核心洞察
> "两两交换"意味着我们要把 `A → B → C → D` 变成 `B → A → D → C`。每一次操作只涉及**两个一组**的节点对,组内互换、组间拼接。
### 关键问题:三指针协作
> [!warning] ⚠️ 最大陷阱
> 交换两个相邻节点时,如果先改了其中一个的 `Next`,就会**丢失另一个节点的引用**。必须像处理断链风险一样,用额外变量保存需要保留的地址。
### 一次交换的本质:四步重组
以交换节点 `A` 和 `B` 为例(`A` 在前,`B` 在后):
```
交换前:... → prev → A → B → next → ...
↑ ↑ ↑
prev A.Next=B B.Next=next
```
要得到:
```
交换后:... → prev → B → A → next → ...
```
| 步骤 | 动作 | 对应代码 | 为什么必须先做这步? |
|------|------|---------|------------------|
| ① | **记录后继**:`tmp = B.Next` | 防止 B.Next 被覆盖后丢失后续链表 | 如果不保存,后面一步会把 B.Next 改掉,再也找不到 C 了 |
| ② | **B 指向前驱**:`B.Next = A` | 建立 B→A 的反向连接 | 这是成对交换的核心——让后面的节点跑到前面去 |
| ③ | **A 指向后继**:`A.Next = tmp` | 让 A 接到下一组的首节点 | A 原来是前面的节点,现在需要连到未处理的部分 |
| ④ | **前驱接入 B**:`prev.Next = B` | 把整段接回主链 | 如果没有这一步,交换后的段落就从主链上脱落了 |
```mermaid
flowchart LR
subgraph Before["交换前"]
P["prev"] --> A["A"]
A --> B["B"]
B --> N["next / C"]
end
subgraph Step1["① 记录后继: tmp = B.Next"]
S1["tmp ← C"]
end
subgraph Step2["② B.next = A"]
B2["B → A"]
end
subgraph Step3["③ A.next = tmp"]
A2["A → C"]
end
subgraph Step4["④ prev.next = B"]
P2["prev → B → A → C"]
end
Before --> S1 -.引导.-> Step2 -.引导.-> Step3 -.引导.-> Step4
style Before fill:#fff4e6,stroke:#f90
style Step4 fill:#d4edda,stroke:#28a
```
### 方法一:迭代法 + 虚拟头节点 ⭐(最优)
用一个**虚拟头节点** `dummy` 简化边界处理(特别是第一个节点对的交换),然后用一个指针 `prev` 滑动推进。
**不变量:** 每一轮循环开始时,`prev.Next` 始终是一个待交换节点对的第一个节点。
**执行流程演示(`head = [1,2,3,4]`):**
```mermaid
flowchart TD
Init["初始\ndummy→1→2→3→4\nprev=dummy"] --> Round1["第1轮: 交换 (1,2)\ndummy→2→1→3→4\nprev=1"]
Round1 --> Check1{"prev.Next != nil &&\nprev.Next.Next != nil?"}
Check1 -- 是 --> Round2["第2轮: 交换 (3,4)\ndummy→2→1→4→3\nprev=3"]
Round2 --> Check2{"prev.Next != nil &&\nprev.Next.Next != nil?"}
Check2 -- 否 --> Done["退出\nreturn dummy.Next ✅\n输出: [2,1,4,3]"]
style Init fill:#bbf,stroke:#333
style Round1 fill:#f9d,stroke:#333
style Round2 fill:#f9d,stroke:#333
style Done fill:#d4edda,stroke:#28a
```
逐步展开:
| 轮次 | 链表状态 | prev | 说明 |
|------|---------|------|------|
| 初始 | `dummy→1→2→3→4` | dummy | prev 从虚拟头出发 |
| 第 1 轮后 | `dummy→2→1→3→4` | 1 | 交换 (1,2),prev 前进到 A(=1) |
| 第 2 轮后 | `dummy→2→1→4→3` | 3 | 交换 (3,4),prev 前进到 A(=3) |
| 退出条件触发 | — | — | prev.Next == nil,退出循环 |
> [!question] 💡 引导思考:为什么 prev 要前进到 A(被交换的后那个节点)而不是 B?
> 因为交换完成后,B 跑到了前面,A 到了后面。下一组节点紧跟在 A 之后(`A.Next` 就是下一组的开头)。所以 `prev = A` 恰好让它在下一轮可以正确访问 `prev.Next`(下一组的第一个节点)。
**时间复杂度:O(n)** — 每个节点被访问常数次。
**空间复杂度:O(1)** — 只用了有限个指针变量。
### 方法二:递归法
递归的思路更抽象但极其简洁:**假设后半段已经处理好,我只管搞定第一对,然后把剩下的链接上去。**
```mermaid
flowchart LR
subgraph Recursion["整体递归框架"]
R1["① newHead = head.Next\n(拿到第二节点)"] --> R2["② nextPair = newHead.Next\n(保存剩余部分起点)"]
R2 --> R3["③ newHead.Next = head\n(第二节点指回第一节点)"]
R3 --> R4["④ head.Next = swapPairs(nextPair)\n(第一节点接到递归结果)"]
R4 --> R5["返回 newHead ✅"]
end
style R1 fill:#bbf,stroke:#333
style R5 fill:#d4edda,stroke:#28a
```
**递的过程:** 一直深入到没有足够的节点继续配对时停止(`head == nil` 或 `head.Next == nil`),返回当前 head。
**归的过程:** 每层负责三件事:
1. 确定这一组的第二个节点为新的局部头
2. 把自己的两个节点交换好
3. 把第一对的尾部接到递归返回的结果上
以 `1→2→3→4` 为例的完整递归栈:
| 阶段 | head / newHead | nextPair | 执行的操作 | 返回值 |
|------|---------------|----------|-----------|--------|
| 递 | `head=3→4` | nil | 4.Next = 3;3.Next = swapPairs(nil) = nil | `4→3` |
| 基准 | `head=nil` | — | 直接返回 `nil` | `nil` |
| 归 | `head=1→2` | 3 | 2.Next = 1;1.Next = swapPairs(3→4) = 4→3 | `2→1→4→3` |
```mermaid
flowchart TD
Base["基准: swapPairs(nil) → nil\n(不足一对,直接返回)"] --> L1["L1: head=3→4, newHead=4\n① 4.Next = 3\n② 3.Next = swapPairs(nil) = nil\n结果: 4→3→nil, 返回 4"]
L1 --> L2["L2: head=1→2, newHead=2\n① 2.Next = 1\n② 1.Next = swapPairs(3→4) = 4→3\n结果: 2→1→4→3, 返回 2 ✅"]
style Base fill:#eee,stroke:#999
style L1 fill:#bbf,stroke:#333
style L2 fill:#d4edda,stroke:#28a
```
> [!info] 🧠 递归的两个参数直觉
> - **函数签名**:`func swapPairs(head *ListNode) *ListNode` —— 传入一段链表,返回这段链表两两交换后的头
> - **假设成立**:调用 `swapPairs(rest)` 时,我们**假设它已经正确完成了任务**,只管拿到它的返回值来衔接
> - 这就是递归最强大的地方——不需要想全貌,每次只处理眼前的一对
**时间复杂度:O(n)** — 共 n/2 层递归,每层 O(1)。
**空间复杂度:O(n)** — 递归栈深度为 n/2。
---
## 代码提示
### 迭代法伪代码
```
// 创建虚拟头节点
dummy = &ListNode{Next: head}
prev = dummy
// 只要还有至少两个节点可以交换,就继续
while prev.Next != nil and prev.Next.Next != nil {
first = prev.Next // 待交换的第一个节点 (A)
second = prev.Next.Next // 待交换的第二个节点 (B)
third = second.Next // 第三组的起始节点
// 四步重组
second.Next = first // B → A
first.Next = third // A → C
prev.Next = second // prev → B → A → C
// prev 前进到已交换的第一对中的第二个(即 A)
prev = first
}
return dummy.Next
```
### 递归法伪代码
```
func swapPairs(head):
// 基准情况:不足两个节点,直接返回
if head == nil or head.Next == nil:
return head
// newHead 是第二节点,它就是这一对交换后的新头
newHead = head.Next
// 递归处理剩余部分
rest = swapPairs(newHead.Next)
// ① 第一对的第二个节点指向第一个节点
newHead.Next = head
// ② 第一个节点接到递归结果
head.Next = rest
return newHead
```
---
## 技巧
> [!tip] 🔑 "三步旋转"记忆法(迭代法核心)
> 两两交换本质上是在固定位置做一个**环形旋转**:`prev → A → B → next`。只需记住三条边的新方向:
> - `B.Next = A`(向后翻)
> - `A.Next = next`(跨过去)
> - `prev.Next = B`(接回来)
> 想象成一个三角形转了一圈。
> [!tip] 🔑 递归模板:"搞一对,接尾巴"
> 适用于所有**分组处理**的链表问题:K 个一组翻转、奇偶重排等。模式为:
> 1. 检查是否还有足够节点继续分组
> 2. 用局部变量拿到每组的头和尾
> 3. 组内反转/调整
> 4. 组的头部接到递归结果
> 5. 返回新的头部
> [!warning] ⚠️ 常见错误 1:先改 A.Next 再取 B.Next
> 如果写成 `first.Next = second.Next` 再去取 `second.Next`,看起来没错。但如果不小心写成 `first.Next = nil` 或其他操作,后面再用 `second.Next` 就会出错。**建议:第一步总是先全部读取所需节点到局部变量中。**
> [!warning] ⚠️ 常见错误 2:prev 走错位置
> 交换完成后 prev 必须走到 A(原第一个节点,现在在第二位),而不是走到 B。因为 A 的 Next 指向下一组的开头,这样才能在下一轮正确定位。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种方法对比
| 维度 | 迭代法 ⭐ | 递归法 |
|------|---------|--------|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n)(递归栈) |
| 代码行数 | 循环体约 7 行 | 核心逻辑约 6 行 |
| 直观程度 | **高**(一步步模拟交换过程) | 中等(需理解假设正确性) |
| 面试推荐 | ⭐⭐⭐ 首选 | ⭐⭐ 可作为进阶展示 |
| 适用场景 | 通用,无栈溢出风险 | 适合教学和理解分组思想 |
> [!success] ✅ 相关题目串联
> - 这题是后续更多**分组类链表操作**的基础模板:
> - [25-K 个一组翻转链表](./) — K 个一组的泛化版本
> - [奇偶链表](https://leetcode.com/problems/odd-even-linked-list/) — 按奇偶位置分组
> - 同一思想的变体还出现在 [数组的两两配对问题](./) 中
---
## 代码
### 迭代法(虚拟头节点)⭐
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func swapPairs(head *ListNode) *ListNode {
dummy := &ListNode{Next: head} // 虚拟头节点,统一第一个节点对的边界处理
prev := dummy // prev 指向每一组待交换节点的前驱
for prev.Next != nil && prev.Next.Next != nil {
first := prev.Next // A:当前对的第一个节点
second := prev.Next.Next // B:当前对的第二个节点
third := second.Next // C:下一组的起始节点
// 四步重组:prev → B → A → C
second.Next = first // ① B 指向前驱对中的 A
first.Next = third // ② A 指向下一组的起始 C
prev.Next = second // ③ prev 接入 B,完成拼接
// prev 前进到已处理的这对中的 A 位置
// (A 现在是这对的末尾,Next 正好指向下一组的开头)
prev = first
}
return dummy.Next // 绕过虚拟头节点,返回真正的头节点
}
```
### 递归法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func swapPairs(head *ListNode) *ListNode {
// 基准情况:空链表或只剩一个节点,无需交换
if head == nil || head.Next == nil {
return head
}
// newHead 是当前对的第二个节点,交换后将成为这一段的头
newHead := head.Next
// 保存剩余部分的起始点(即下一对的第一节点)
nextPair := newHead.Next
// ① 把第二节点接到第一节点前面
newHead.Next = head
// ② 递归处理剩余部分,并链接到当前第一节点的后面
head.Next = swapPairs(nextPair)
// newHead 成为整个链表的头
return newHead
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 24 题,通过率约 60%+。相比「删除倒数第 N 个节点」这道同级别的题目,"两两交换"的核心难度在于**多指针同时操作的顺序控制**——任何一个赋值的先后颠倒都可能导致断链或死循环。掌握这个四步重组模板后,面对任何涉及局部结构调整的链表问题都会更加从容。建议先用纸笔画出指针变化图,再动手编码。