LEETCODE 055Medium
跳跃游戏
不必记录怎么跳,只要一路维护“最远能到哪”——当前位置一旦超出这个边界,就永远补不回来了。
问题拆解
站在数组开头,nums[i] 是在下标 i 处最多能往前跳的步数,问能否到达最后一个下标。
按“路径”去想会走弯路:从每个位置枚举所有跳法做 DFS 是指数级的,加记忆化的 DP 也要 O(n²)。但这题只问“能不能到”,不问怎么到——这类可达性判断往往不需要保留路径。
关键观察是可达范围是连续的:如果能到下标 j,那 0 到 j 之间的每个下标都能到(跳远了可以少跳几步)。所以全部状态可以压缩成一个数:当前的最远可达下标 furthest。从左到右扫,每个还够得着的位置都用 i + nums[i] 去扩展它。
可达集合是一段连续前缀,于是“能到哪些点”这个集合问题坍缩成“右边界在哪”这个单值问题——贪心成立的根源就在这里。
一路维护最远可达边界
public boolean canJump(int[] nums) {
int furthest = 0; // 目前能到达的最远下标
for (int i = 0; i < nums.length; i++) {
if (i > furthest) {
return false; // 当前位置本身就够不着,后面更不可能
}
furthest = Math.max(furthest, i + nums[i]);
if (furthest >= nums.length - 1) {
return true;
}
}
return true;
}
def canJump(nums: List[int]) -> bool:
furthest = 0 # 目前能到达的最远下标
for i, step in enumerate(nums):
if i > furthest:
return False # 当前位置本身就够不着,后面更不可能
furthest = max(furthest, i + step)
if furthest >= len(nums) - 1:
return True
return True
func canJump(nums []int) bool {
furthest := 0 // 目前能到达的最远下标
for i, step := range nums {
if i > furthest {
return false // 当前位置本身就够不着,后面更不可能
}
furthest = max(furthest, i+step)
if furthest >= len(nums)-1 {
return true
}
}
return true
}
pub fn can_jump(nums: Vec<i32>) -> bool {
let mut furthest = 0usize; // 目前能到达的最远下标
for (i, &step) in nums.iter().enumerate() {
if i > furthest {
return false; // 当前位置本身就够不着,后面更不可能
}
furthest = furthest.max(i + step as usize);
if furthest >= nums.len() - 1 {
return true;
}
}
true
}
i > furthest 这个判断必须放在更新 furthest 之前:一个自己都到不了的位置,它的 nums[i] 是无效信息,不能拿来扩展边界。漏掉这个顺序,[3, 2, 1, 0, 4] 这种被 0 卡死的用例就会误判为 true——下标 4 够不着,却用它的值把边界撑了过去。
再解释一下为什么贪心不会漏解。担心的场景是:某条“绕路”的跳法能到终点,而 furthest 没覆盖到。但 furthest 取的是所有可达位置扩展量的最大值,任何合法跳法经过的每个落点都在 furthest 之内,它下一跳的终点自然也被 max 收进去了——furthest 是所有跳法可达范围的上确界,不存在漏网的路径。
复杂度
| 指标 | 复杂度 | 原因 |
|---|---|---|
| 时间 | O(n) |
单次遍历,每个下标处理一次 |
| 空间 | O(1) |
状态只有一个边界值 |
可以迁移的模式
- 只问“可行与否”不问方案时,先试着把状态压成一两个聚合量,而不是老实做 DP;
- “可达范围连续”是把集合问题压成边界问题的通行证,区间合并、加油站类题目里反复出现;
- 贪心的正确性论证:证明维护的量是所有方案的上界(或下界),任何解都逃不出它。
姊妹题 45(跳跃游戏 II)问最少跳几次,同一个边界思想再分层推进即可,值得接着做。