在 LeetCode 的贪心算法专题中,“跳跃游戏 II” 是极具代表性的题目。它不仅要求我们判断能否到达终点,更需要找到最少的跳跃次数,对贪心策略的精准应用提出了更高要求。本文将详细拆解两种贪心思路的实现代码,分析其核心逻辑、存在的问题,并给出优化方案,帮助大家彻底掌握这道经典题目。
一、题目回顾:明确核心需求
给定一个长度为 n 的 0 索引整数数组 nums,初始位置在下标 0。每个元素 nums[i] 表示从索引 i 向后跳转的最大长度(即可以跳转到 i+j 处,其中 0 ≤ j ≤ nums[i] 且 i+j < n)。题目保证可以到达终点 n-1,要求返回到达终点的最小跳跃次数。
核心难点:如何通过“局部最优选择”实现“全局最优结果”——每一步都选择能让后续跳跃更高效的落点,最终实现最少跳跃次数。
二、解法一:逐步选最优落点(暴力贪心优化版)
1. 核心思路
从当前位置出发,遍历所有可到达的落点,选择“能让下一步跳得最远”的落点作为本次跳跃的目标。通过这种“每一步都选最优”的贪心策略,最终实现全局最少跳跃次数。
具体逻辑:
-
记录当前位置 cur,以及跳跃次数 count;
-
遍历 cur 可到达的所有范围(cur+1 到 cur+nums[cur]),计算每个落点 j 的“潜在最远可达位置”;
-
选择潜在最远可达位置对应的落点作为新的 cur,跳跃次数 count+1;
-
重复上述过程,直到到达终点。
2. 代码详解(jump_1)
function jump_1(nums: number[]): number {
const numsLen = nums.length;
if (numsLen === 1) return 0; // 特殊情况:数组长度为1,无需跳跃
let count = 0; // 记录跳跃次数
let cur = 0; // 当前所在位置
while (cur < numsLen - 1) { // 未到达终点时循环
let maxNextReach = 0; // 下一步能到达的最远位置
let aft = cur; // 本次跳跃的目标落点
// 遍历当前位置的所有可跳落点
for (let j = cur + 1; j <= cur + nums[cur]; j++) {
if (j === numsLen - 1) {
return count + 1; // 提前终止:当前落点已是终点,直接返回次数+1
}
// 计算落点j的潜在最远可达位置,更新最优落点
if (j - cur + nums[j] >= maxNextReach) {
maxNextReach = j - cur + nums[j];
aft = j;
}
}
if (aft === cur) return -1; // 防御性判断(题目保证可达,实际不触发)
cur = aft; // 跳转到最优落点
count++; // 跳跃次数递增
}
return count;
};
3. 关键细节说明
-
提前终止逻辑:当遍历到的落点 j 已是终点(j === numsLen - 1)时,直接返回 count+1。因为从当前位置跳一步就能到达终点,无需继续后续遍历,提升效率;
-
最优落点判断:核心是比较 j 的潜在最远可达位置(代码中 j - cur + nums[j] 等价于 j + nums[j],j - cur 是本次跳跃的步数,不影响最终结果),确保每次跳跃都能为后续铺垫最远的可达范围;
-
特殊情况处理:数组长度为 1 时,初始位置就是终点,直接返回 0。
4. 优缺点分析
优点:逻辑直观,符合“找最优落点”的直觉,容易理解和调试;
缺点:存在嵌套循环(while 外层循环 + for 内层循环),时间复杂度为 O(n²),在数组长度较大时(如 n=10⁴)会出现超时问题,效率较低。
三、解法二:边界贪心(优化方向,需修正)
1. 核心思路
通过“边界”划分每一次跳跃的范围:用 border 表示当前跳跃能到达的最远边界,用 maxReach 表示遍历过程中能到达的全局最远位置。当遍历到 border 时,说明当前跳跃的范围已遍历完毕,需要进行一次跳跃(count+1),并将 border 更新为 maxReach。通过这种方式,用单层循环实现贪心策略,提升效率。
2. 原代码问题分析(jump_2)
function jump_2(nums: number[]): number {
const numsLen = nums.length;
if (numsLen === 1) return 0;
let count = 0;
let maxIndex = 0; // 表示当前步数可走的区域(冗余变量,未实际使用)
let border = 0;
let maxReach = 0;
for (let i = 0; i <= maxReach; i++) { // 循环条件依赖动态变量,存在逻辑漏洞
if (i > border) { // 遍历超边界时更新
border = maxReach;
count++;
}
if (i === numsLen - 1) return count; // 提前返回,未统计最后一次跳跃
if (i + nums[i] >= maxReach) {
maxIndex = i;
maxReach = i + nums[i];
}
}
return count;
};
3. 核心问题拆解
-
循环条件不合理:for 循环的终止条件是 i ≤ maxReach,而 maxReach 是动态更新的。当遇到类似 [1,2] 的用例时,会出现“到达终点但未统计跳跃次数”的问题;
-
提前返回逻辑错误:当 i === numsLen - 1 时直接返回 count,但此时可能尚未触发“边界更新”,导致跳跃次数少算一次。例如用例 [1,2],i=1 时到达终点,直接返回 count=0,正确结果应为 1;
-
冗余变量:maxIndex 被定义为“当前步数可走的区域”,但代码中未实际使用,增加了逻辑复杂度。
4. 修正后的最优代码
function jump_2_optimized(nums: number[]): number {
const numsLen = nums.length;
if (numsLen === 1) return 0;
let count = 0; // 跳跃次数
let border = 0; // 当前跳跃的边界
let maxReach = 0; // 全局最远可达位置
// 遍历到倒数第二个元素:最后一个元素无需跳跃
for (let i = 0; i < numsLen - 1; i++) {
maxReach = Math.max(maxReach, i + nums[i]); // 更新全局最远可达位置
if (i === border) { // 遍历到当前边界,触发跳跃
count++;
border = maxReach; // 更新边界为全局最远可达位置
if (border >= numsLen - 1) break; // 提前终止:边界已覆盖终点
}
}
return count;
};
5. 修正点说明
-
循环范围调整:将 for 循环条件改为 i < numsLen - 1,避免遍历到终点(最后一个元素无需跳跃);
-
边界触发逻辑优化:当 i === border 时,说明当前跳跃范围已遍历完毕,此时更新 count 和 border,确保每一次跳跃都被正确统计;
-
提前终止优化:若更新后的 border 已覆盖终点(border >= numsLen - 1),直接跳出循环,减少无效遍历;
-
删除冗余变量:移除未使用的 maxIndex,简化逻辑。
四、两种解法对比
| 解法类型 | 时间复杂度 | 空间复杂度 | 核心优势 | 适用场景 |
|---|---|---|---|---|
| 逐步选最优落点(jump_1) | O(n²) | O(1) | 逻辑直观,易理解调试 | 小规模数组,学习理解贪心思路 |
| 边界贪心(修正后 jump_2_optimized) | O(n) | O(1) | 效率最优,空间占用少 | 大规模数组,算法面试最优解 |
五、核心总结与拓展思考
1. 贪心算法核心精髓
本题的两种思路均围绕“局部最优→全局最优”:要么选择“当前落点中后续能跳最远的”,要么选择“当前跳跃范围的最远边界”,本质都是通过每一步的最优选择,最终实现最少跳跃次数。
2. 必掌握的关键细节
-
特殊情况处理:数组长度为 1 时直接返回 0;
-
遍历范围控制:避免遍历到终点(最后一个元素无需跳跃);
-
提前终止:当边界覆盖终点时直接跳出循环,提升效率。
3. 拓展思考
-
若题目不保证“一定能到达终点”,如何修改代码?需增加判断:若 maxReach ≤ i 且未到达终点,说明无法继续前进,返回 -1;
-
动态规划 vs 贪心:本题也可通过动态规划(dp[i] 表示到达 i 的最少跳跃次数)实现,但时间复杂度为 O(n²),空间复杂度为 O(n),远不如贪心算法高效。
通过本文的解析,相信大家已经掌握了“跳跃游戏 II”的两种核心思路及优化技巧。贪心算法的关键在于找到“局部最优”的判断标准,多练习、多拆解类似题目,就能逐步提升对贪心策略的敏感度~