--- 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 之间(取决于数据规模),属于最优解。