跳跃游戏系列:维护可达区间的贪心艺术
摘要
跳跃游戏(LeetCode 55 和 45)是贪心算法中最经典、最值得深度剖析的一对题目。55 题(判断能否到达终点)和 45 题(最少跳跃次数)乍看是两道独立的题,实则共享同一个贪心核心:维护当前可达的最远位置。本文不只给代码,而是从为什么动态规划解法存在但不必要,到如何从 BFS 的视角理解贪心解法的本质,再到严格的数学归纳法证明,层层深入,帮你真正掌握”可达范围维护”这一贪心模式。
第 1 章 跳跃游戏的问题背景
1.1 LeetCode 55 题目
LeetCode 55 - Jump Game(中等)
给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true;否则,返回 false。
输入: nums = [2, 3, 1, 1, 4]
输出: true
解释: 可以先跳 1 步,从下标 0 到达下标 1,然后再从下标 1 跳 3 步到达最后一个下标
输入: nums = [3, 2, 1, 0, 4]
输出: false
解释: 无论怎样,总会到达下标 3,但该下标的最大跳跃长度是 0,所以永远不能到达最后一个下标
1.2 LeetCode 45 题目
LeetCode 45 - Jump Game II(中等)
给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。
每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换言之,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处。
返回到达 nums[n - 1] 的最少跳跃次数。测试用例保证可以到达 nums[n - 1]。
输入: nums = [2, 3, 1, 1, 4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2,从下标 0 跳到下标 1,然后跳 3 步到达数组的最后一个位置
输入: nums = [2, 3, 0, 1, 4]
输出: 2
1.3 两道题的关系
55 是”能不能到达”,45 是”最少跳几步”。45 是 55 的加强版:在 55 可以到达的前提下,求最少跳数。
从算法角度,两道题的贪心解法共享同一个核心变量 maxReach(当前能到达的最远位置),只是附加的统计逻辑不同。
第 2 章 LeetCode 55:能否到达终点
2.1 动态规划解法(先理解 DP,再理解贪心)
在理解贪心之前,先看 DP 解法,因为贪心是从 DP 简化来的。
设 canReach[i] = true 表示从 nums[0] 出发,能否到达位置 i。
转移方程:canReach[i] = true 当且仅当存在某个 j < i,使得 canReach[j] = true 且 j + nums[j] >= i。
// DP 解法,O(n²)
public boolean canJump(int[] nums) {
int n = nums.length;
boolean[] canReach = new boolean[n];
canReach[0] = true;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (canReach[j] && j + nums[j] >= i) {
canReach[i] = true;
break;
}
}
}
return canReach[n - 1];
}时间复杂度:,内层循环遍历所有可能的前驱位置。
DP 的冗余:注意到 canReach[i] = true 当且仅当 i <= max{j + nums[j] : canReach[j] = true, j < i}。所以我们只需要维护一个值:maxReach = max{j + nums[j] : canReach[j] = true, j <= 当前位置}。
2.2 贪心解法:维护最远可达位置
关键简化:
canReach[i] = true当且仅当i <= maxReach(其中maxReach是截至位置i-1可到达的最远位置)- 如果
i <= maxReach,则从i可以跳到i + nums[i],更新maxReach = max(maxReach, i + nums[i]) - 如果某时刻
i > maxReach,说明位置i无法到达,后续位置也无法到达
// 贪心解法,O(n)
public boolean canJump(int[] nums) {
int maxReach = 0; // 当前能到达的最远位置
for (int i = 0; i < nums.length; i++) {
// 如果当前位置 i 超过了最远可达位置,说明无法到达 i
if (i > maxReach) return false;
// 从位置 i 出发,最远能到 i + nums[i],更新 maxReach
maxReach = Math.max(maxReach, i + nums[i]);
}
// 走完整个数组都没有遇到"无法到达"的情形,能到达终点
return true;
}为什么这是 :只有一层线性扫描,每个位置处理 。
2.3 贪心正确性证明(数学归纳法)
命题:设 maxReach 是 canJump 贪心算法扫描到位置 i 时维护的最远可达位置。命题 P(i):从位置 0 出发,能到达的最远位置恰好是 maxReach。
基础情形(i = 0):maxReach = nums[0],从位置 0 能到达的最远位置是 nums[0]。P(0) 成立。
归纳步骤:假设 P(i-1) 成立,即扫描到 i-1 时的 maxReach 是从 0 出发能到达的真实最远位置。
现在到达位置 i:
- 若
i > maxReach(P(i-1) 下的maxReach):位置i不可达(由 P(i-1) 知,从 0 最远只能到maxReach < i),返回 false。正确。 - 若
i <= maxReach:位置i可达,从i可以到达i + nums[i],新的最远可达位置是max(maxReach, i + nums[i])。P(i) 成立。
结论:命题 P(i) 对所有 成立,maxReach 始终准确反映从 0 出发能到达的最远位置。因此,若扫描完整个数组都没有 i > maxReach,说明所有位置(包括最后一个)都可达。
第 3 章 LeetCode 45:最少跳跃次数
3.1 动态规划解法(先理解,再简化)
设 minJumps[i] = 从位置 0 到达位置 i 的最少跳跃次数。
// DP 解法,O(n²)
public int jump(int[] nums) {
int n = nums.length;
int[] minJumps = new int[n];
Arrays.fill(minJumps, Integer.MAX_VALUE);
minJumps[0] = 0;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (minJumps[j] != Integer.MAX_VALUE && j + nums[j] >= i) {
minJumps[i] = Math.min(minJumps[i], minJumps[j] + 1);
}
}
}
return minJumps[n - 1];
}这是 的,对 可能超时。贪心解法是 的。
3.2 BFS 视角:层次分明的跳跃
理解贪心解法的最直观方式是BFS 类比:
- 把每个位置想象成一个图节点,位置
j到位置k(只要k <= j + nums[j])有一条有向边 - 求从位置 0 到位置
n-1的最短路(BFS,边权为 1)
BFS 的特点是层次扩展:第 0 层是 {0}(出发点),第 1 层是第一跳能到的所有位置,第 2 层是第二跳能到的所有位置,……第 k 层是恰好需要 k 次跳跃才能到达的所有位置。答案是终点所在的层号。
贪心的等价性:在 BFS 中,每一”层”是从上一层的所有位置能到达的所有新位置。因为每个位置 j 能到达 [j+1, j+nums[j]] 的所有位置,这一层的最远边界是 max{j + nums[j] : j 在当前层}。
所以,BFS 的每一层可以用”最远右边界”来紧凑地表示:
| 层次 | 起点范围 | 该层最远右边界 |
|---|---|---|
| 第 0 层 | [0, 0] | nums[0] |
| 第 1 层 | [1, nums[0]] | max{j+nums[j] : 1<=j<=nums[0]} |
| 第 2 层 | [nums[0]+1, 第1层最远] | max{j+nums[j] : j 在第1层} |
| … | … | … |
3.3 贪心实现
public int jump(int[] nums) {
int n = nums.length;
int jumps = 0; // 跳跃次数
int currentEnd = 0; // 当前层的末尾(当前跳跃覆盖的最远位置)
int farthest = 0; // 下一层的末尾(下一跳能到达的最远位置)
// 注意:只扫描到 n-2,不包括最后一个元素
// 因为当 i == n-1 时,我们已经到达终点,不需要再跳
for (int i = 0; i < n - 1; i++) {
// 更新下一层的末尾(从当前位置 i 出发的最远可达位置)
farthest = Math.max(farthest, i + nums[i]);
// 当 i 到达当前层的末尾时,必须跳一次(进入下一层)
if (i == currentEnd) {
jumps++;
currentEnd = farthest;
// 已经能到达终点,提前退出
if (currentEnd >= n - 1) break;
}
}
return jumps;
}关键设计点:
-
currentEnd:当前跳跃的”覆盖范围”末尾。扫描到currentEnd时,必须再跳一次(进入下一跳的覆盖范围)。 -
farthest:在当前层扫描过程中,持续更新”下一跳能到达的最远位置”。当i == currentEnd时,farthest就是下一层的末尾。 -
为什么循环到
n-2而不是n-1:如果n-1已经在currentEnd以内,我们不需要再跳一次(已经到达终点)。如果循环到n-1,当i == n-1 == currentEnd时,会错误地多加一次jumps。
3.4 手工演示
以 nums = [2, 3, 1, 1, 4],n = 5 为例:
| i | nums[i] | farthest | i == currentEnd? | 操作 |
|---|---|---|---|---|
| 0 | 2 | max(0, 0+2)=2 | 0==0,是 | jumps=1, currentEnd=2 |
| 1 | 3 | max(2, 1+3)=4 | 1≠2 | 继续扫描 |
| 2 | 1 | max(4, 2+1)=4 | 2==2,是 | jumps=2, currentEnd=4 |
| (currentEnd=4 >= n-1=4,break) |
最终 jumps = 2,正确。
以 nums = [2, 3, 0, 1, 4] 为例:
| i | nums[i] | farthest | i == currentEnd? | 操作 |
|---|---|---|---|---|
| 0 | 2 | max(0, 0+2)=2 | 0==0,是 | jumps=1, currentEnd=2 |
| 1 | 3 | max(2, 1+3)=4 | 1≠2 | 继续扫描 |
| 2 | 0 | max(4, 2+0)=4 | 2==2,是 | jumps=2, currentEnd=4 |
| (currentEnd=4 >= n-1=4,break) |
最终 jumps = 2,正确。(注意 nums[2] = 0 没有问题,因为跳一次能直接到达终点)
3.5 贪心正确性证明(交换论证法)
命题:贪心解法给出的跳跃次数是最少的。
证明:设 OPT 是某个最优解,跳跃次数为 ,经过位置序列 。贪心解 GRD 的跳跃次数为 ,经过位置序列 。
关键引理:对所有 ,有 (贪心每一步能到达的位置不比 OPT 更差)。
引理证明(归纳):
- :,成立。
- 假设 ,证明 。
- 贪心第 跳的范围是 ,其中
farthest = max{j+nums[j] : q_{t-1-1}+1 <= j <= q_{t-1}}(即从上一层的所有位置能到达的最远位置) - OPT 第 跳是从 出发,到达
- 由于 ,贪心在 这一层扫描时,会扫描到 (因为 ),因此贪心的
farthest >= p_{t-1} + nums[p_{t-1}] >= p_t - 所以 ,引理成立
- 贪心第 跳的范围是 ,其中
由引理,,所以贪心在第 步或更早( 步)就能到达终点。因此 ,即贪心跳跃次数不多于最优解。又显然 (最优解就是最少的),所以 ,贪心解是最优的。
第 4 章 两道题的深度对比
4.1 关键变量的对应关系
| 变量 | LeetCode 55 | LeetCode 45 | 含义 |
|---|---|---|---|
maxReach | 核心变量,单一更新 | 对应 farthest(下一层末尾) | 当前能到达的最远位置 |
| 层分界 | 无(不需要计数跳次) | currentEnd(当前层末尾) | 触发跳跃计数的边界 |
| 答案 | maxReach >= n-1 | jumps | — |
4.2 为什么 45 需要”层”的概念而 55 不需要
55 只问”能不能到”,不关心跳几次,所以维护一个 maxReach 即可,只要 maxReach >= n-1 就能到达。
45 问”最少跳几次”,需要计数跳跃次数。BFS 中每次从一层扩展到下一层就是跳了一次,所以需要维护当前层的边界 currentEnd,每当扫描到边界时计数并更新到下一层的边界 farthest。
4.3 常见错误分析
错误 1(LeetCode 45):循环到 n-1 而非 n-2
// 错误写法
for (int i = 0; i < n; i++) { // 应该是 i < n - 1
farthest = Math.max(farthest, i + nums[i]);
if (i == currentEnd) {
jumps++;
currentEnd = farthest;
}
}当 i = n-1 且 n-1 == currentEnd 时,会多执行一次 jumps++,结果偏大 1。
错误 2(LeetCode 55):更新 maxReach 前就检查 i > maxReach
// 错误写法
for (int i = 0; i < nums.length; i++) {
maxReach = Math.max(maxReach, i + nums[i]); // 先更新
if (i > maxReach) return false; // 再检查(此时用的是已更新的 maxReach,逻辑错误)
}i > maxReach 应该在更新 maxReach 之前检查(检查的是”能否到达位置 i”,不是”能否到达 i+nums[i]”)。
正确写法:先检查 if (i > maxReach) return false,再更新 maxReach = Math.max(maxReach, i + nums[i])。
第 5 章 扩展思考:跳跃游戏的变体
5.1 Jump Game III(LeetCode 1306)
变体:从任意初始位置出发,每个位置 i 可以跳到 i + nums[i] 或 i - nums[i](双向跳),判断能否跳到值为 0 的位置。
这是图的可达性问题,用 BFS 或 DFS 解决,不是贪心。说明并非所有”跳跃游戏”都适合贪心——贪心仅在单向、单调(只能向右跳)的情形下有效。
5.2 Jump Game VII(LeetCode 1871)
变体:字符串上的跳跃,带有区间约束的跳跃范围。这类变体通常用差分数组或线段树优化,不再是简单贪心。
5.3 跳跃游戏的贪心适用条件
以上变体让我们反思:原版跳跃游戏为什么贪心有效?
根本原因:跳跃是单调向右的,且从任意可达位置 j 能到达的范围 [j+1, j+nums[j]] 具有”包含性”——maxReach 单调不减。这保证了”贪心维护最远位置”的正确性。
一旦跳跃方向不固定(可以左右),或者可达范围受到额外约束(非线性),单纯维护最远位置就不够了,需要更复杂的搜索或 DP。
第 6 章 本文核心结论
-
两道题共享”维护最远可达位置”的贪心核心:55 维护
maxReach,45 在此基础上增加currentEnd来计数层次(跳跃次数)。 -
BFS 类比是理解 45 的最直观视角:每一”层”对应需要相同跳跃次数的位置集合,贪心用最远右边界紧凑表示每一层,无需显式存储所有节点。
-
45 的细节陷阱:循环到
n-2:循环条件是i < n-1(不包括最后一个元素),否则会多计一次跳跃。 -
严格证明需要”引理:贪心每步不差于最优解”:这是交换论证的一种形式——证明贪心能始终”至少和最优解一样好”,从而贪心解就是最优解。
-
贪心的适用条件:单向跳跃 + 可达范围单调延伸。一旦引入双向跳跃或复杂约束,需改用 BFS/DP。