在 LeetCode 的数组类题目中,「跳跃游戏」是一道经典的贪心算法应用题型。它不仅考察我们对数组遍历的掌控,更核心的是考验对「局部最优推导全局最优」思想的理解。今天,我们就来详细拆解这道题,并深入分析两种不同的解法思路。
一、题目解读
题目描述很简洁:给定一个非负整数数组 nums,我们最初位于数组的第一个下标。数组中的每个元素代表在该位置可以跳跃的最大长度,判断是否能够到达最后一个下标。如果可以返回 true,否则返回 false。
举两个简单例子帮助理解:
-
示例 1:输入
[2,3,1,1,4],输出true。解释:从下标 0 跳 1 步到 1,再从 1 跳 3 步直达最后一个下标。 -
示例 2:输入
[3,2,1,0,4],输出false。解释:无论从下标 0 怎么跳,最终都会停在下标 3(因为下标 3 的元素是 0,无法继续跳跃),无法到达最后一个下标。
核心问题:如何通过合理的跳跃策略,判断是否能覆盖到数组的最后一个位置。
二、解法一:贪心策略(局部最优选最远)
1. 思路分析
这是一种比较直观的贪心思路:每一步都选择「能让后续跳跃范围最远」的位置。具体来说,在当前位置cur 可跳跃的范围内(即 [cur+1, cur+nums[cur]]),找到一个位置 aft,使得从 aft 出发能到达的最远距离(aft + nums[aft])最大。然后将当前位置更新为 aft,重复这个过程,直到到达最后一个下标或无法继续跳跃。
2. 代码解析
function canJump_1(nums: number[]): boolean {
const numsLen = nums.length;
let cur = 0; // 当前所在位置
while (cur < numsLen - 1) { // 只要没到最后一个位置,就继续尝试跳跃
let count = 0; // 记录当前范围内能到达的最远距离增量
let aft = cur; // 记录当前范围内最优的下一个跳跃位置
// 遍历当前位置可跳跃的所有范围
for (let j = cur + 1; j <= cur + nums[cur]; j++) {
// 计算从j出发能到达的最远距离(相对于cur的增量)
// 这里j - cur是从cur到j的步数,加上nums[j]就是j能再跳的距离
if (j - cur + nums[j] > count) {
count = j - cur + nums[j];
aft = j; // 更新最优跳跃位置
}
}
// 如果aft没有变化,说明当前位置无法跳跃到任何新位置,直接返回false
if (aft === cur) return false;
cur = aft; // 跳转到最优位置
}
return true; // 循环结束说明到达了最后一个位置
};
3. 关键细节说明
-
count的含义:这里的count计算的是「从当前位置cur到j的步数(j - cur)加上j能跳跃的最大长度(nums[j])」,本质上等价于j + nums[j](因为j - cur + nums[j] + cur = j + nums[j]),只是计算时以cur为基准,逻辑更清晰。 -
终止条件:当
aft === cur时,说明在当前位置的跳跃范围内,没有任何一个位置能让我们跳得更远,即陷入了「无法前进」的困境,直接返回false;当cur到达或超过最后一个下标时,返回true。
4. 时间复杂度与空间复杂度
-
时间复杂度:O(n)。虽然看起来有两层循环,但每个位置最多被遍历一次(因为每次跳跃都会跳到新的范围,不会重复遍历之前的位置)。
-
空间复杂度:O(1)。只使用了
cur、count、aft等几个常量级变量,没有额外占用空间。
三、解法二:贪心策略(全局最远覆盖)
1. 思路分析
这是一种更简洁的贪心思路:我们不关心每一步具体跳哪里,只关心「当前能到达的最远距离」。通过遍历数组,不断更新这个最远距离(maxReach)。如果在遍历过程中,maxReach 已经覆盖到最后一个下标,直接返回 true;如果遍历到了 maxReach 所在的位置,却还没覆盖到最后一个下标,说明无法继续前进,返回 false。
核心逻辑:maxReach = Math.max(maxReach, i + nums[i]),即每遍历一个位置,就更新当前能到达的最远距离。
2. 代码解析
function canJump_2(nums: number[]): boolean {
const numsLen = nums.length;
if (numsLen === 1) return true; // 特殊情况:数组只有一个元素,本身就在终点
let maxReach = 0; // 当前能到达的最远距离
// 遍历范围:0 到 maxReach(超过maxReach的位置无法到达,无需遍历)
for (let i = 0; i <= maxReach; i++) {
// 更新能到达的最远距离
maxReach = Math.max(maxReach, i + nums[i]);
// 如果已经覆盖到最后一个下标,直接返回true
if (maxReach >= numsLen - 1) return true;
// 如果遍历到maxReach位置,却还没覆盖终点,说明无法前进
if (i === maxReach) return false;
}
return false; // 理论上不会走到这里,因为循环内已覆盖所有情况
};
3. 关键细节说明
-
特殊情况处理:当数组长度为 1 时,我们已经在最后一个下标,直接返回
true,避免不必要的遍历。 -
遍历范围:
i <= maxReach是核心。因为超过maxReach的位置,我们当前无法到达,遍历这些位置没有意义。 -
终止条件:
maxReach >= numsLen - 1表示已覆盖终点;i === maxReach表示当前遍历到了能到达的最远距离,却还没到终点,此时无法继续前进,返回false。
4. 时间复杂度与空间复杂度
-
时间复杂度:O(n)。最多遍历数组一次(遍历到
maxReach覆盖终点或无法前进时就终止)。 -
空间复杂度:O(1)。同样只使用了常量级变量,空间效率极高。
四、两种解法对比与总结
1. 解法对比
| 维度 | 解法一(局部最优选最远) | 解法二(全局最远覆盖) |
|---|---|---|
| 核心思路 | 每步选能让后续跳得最远的位置 | 只跟踪全局能到达的最远距离 |
| 代码复杂度 | 稍高(两层循环,逻辑稍繁琐) | 极低(单层循环,逻辑简洁) |
| 效率 | O(n),但实际遍历次数可能更少(跳跃跨度大) | O(n),遍历次数更稳定 |
| 适用场景 | 不仅要判断能否到达,还想知道具体跳跃路径(可扩展) | 仅需判断能否到达,追求代码简洁高效 |
2. 核心总结
两道解法的本质都是「贪心算法」,核心都是通过「局部最优选择」推导「全局最优结果」:
-
解法一的局部最优是「当前步选能让后续跳得最远的位置」,全局最优是「最终能到达终点」。
-
解法二的局部最优是「每一步都更新能到达的最远距离」,全局最优是「这个最远距离能覆盖终点」。
在实际面试或解题中,解法二 更推荐使用,因为它代码更简洁、逻辑更清晰,且空间和时间效率都处于最优水平。而解法一则适合在需要获取具体跳跃路径的场景下进行扩展(比如记录每一步的跳跃位置)。
五、常见误区提醒
-
误区 1:暴力枚举所有跳跃路径。这种方法时间复杂度是 O(2^n),对于大数据量的数组(比如 n=10^4)会直接超时,绝对不推荐。
-
误区 2:忽略「0」的影响。当数组中存在 0 时,需要判断这个 0 是否在「能到达的范围」内,且是否是最后一个元素。如果是中间的 0 且无法跳过,就会导致无法到达终点。
-
误区 3:遍历范围过大。比如解法二中,如果遍历整个数组而不是
i <= maxReach,会导致遍历一些无法到达的位置,浪费时间。
希望通过这篇解析,大家能彻底理解跳跃游戏的贪心解法思路,并且能根据实际需求选择合适的解法。如果有疑问,欢迎在评论区交流~