vault backup: 2026-05-16 18:12:04
This commit is contained in:
@@ -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
@@ -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 之间(取决于数据规模)。
|
||||
@@ -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 之间(取决于数据规模),属于最优解。
|
||||
@@ -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) — 多指针协作的经典题目
|
||||
|
||||
@@ -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 个节点」这道同级别的题目,"两两交换"的核心难度在于**多指针同时操作的顺序控制**——任何一个赋值的先后颠倒都可能导致断链或死循环。掌握这个四步重组模板后,面对任何涉及局部结构调整的链表问题都会更加从容。建议先用纸笔画出指针变化图,再动手编码。
|
||||
|
||||
Reference in New Issue
Block a user