diff --git a/贪心算法/77-买卖股票的最佳时机.md b/贪心算法/77-买卖股票的最佳时机.md new file mode 100644 index 0000000..7ce4115 --- /dev/null +++ b/贪心算法/77-买卖股票的最佳时机.md @@ -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) — 仅两个整型变量 | diff --git a/贪心算法/78-跳跃游戏.md b/贪心算法/78-跳跃游戏.md new file mode 100644 index 0000000..0fa81fb --- /dev/null +++ b/贪心算法/78-跳跃游戏.md @@ -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 之间(取决于数据规模)。 diff --git a/贪心算法/79-跳跃游戏 II.md b/贪心算法/79-跳跃游戏 II.md new file mode 100644 index 0000000..bda9de2 --- /dev/null +++ b/贪心算法/79-跳跃游戏 II.md @@ -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 之间(取决于数据规模),属于最优解。 diff --git a/链表/28-两数相加.md b/链表/28-两数相加.md index ea9d9a4..add7c70 100644 --- a/链表/28-两数相加.md +++ b/链表/28-两数相加.md @@ -268,6 +268,7 @@ type ListNode struct { > - [[2-两数相加 II]] — 进阶版:数字是**正序**存储的(需要从后往前加) > - [[27-合并两个有序链表]] — 同样是双链表遍历的经典范式 > - [[67-二进制求和]] — LeetCode 第 67 题:原理完全相同,只是逢二进一 +> - [[30-两两交换链表中的节点]] — 同级别中等难度,多指针协作的另一角度 --- diff --git a/链表/29-删除链表的倒数第 N 个结点.md b/链表/29-删除链表的倒数第 N 个结点.md index f0a6ecf..8b0af64 100644 --- a/链表/29-删除链表的倒数第 N 个结点.md +++ b/链表/29-删除链表的倒数第 N 个结点.md @@ -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) — 多指针协作的经典题目 diff --git a/链表/30-两两交换链表中的节点.md b/链表/30-两两交换链表中的节点.md index e69de29..a017da9 100644 --- a/链表/30-两两交换链表中的节点.md +++ b/链表/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 个节点」这道同级别的题目,"两两交换"的核心难度在于**多指针同时操作的顺序控制**——任何一个赋值的先后颠倒都可能导致断链或死循环。掌握这个四步重组模板后,面对任何涉及局部结构调整的链表问题都会更加从容。建议先用纸笔画出指针变化图,再动手编码。