引言
在刷题圈中,有这样两道“孪生题”——LeetCode 55 和 45 题《跳跃游戏》,看似背景相同,实则内藏玄机。它们共享一个模型:你在数组起点,每个位置能跳一定步数,问你能否到终点?或最少跳几次?
但正是这微妙的目标差异,让两道题从可行性判断走向了最优化求解,也让我们得以窥见贪心算法的两种高级应用范式。
今天,我们就来深挖这两道经典题背后的贪心逻辑,看看如何用“局部最优”的选择,一步步逼近全局最优答案。
🌟问题对比:表面相似,本质不同
先来看两道题的核心描述:
| 题号 | 名称 | 目标 | 关键点 |
|---|---|---|---|
| 55. 跳跃游戏 | Jump Game | 判断是否能到达最后一个下标 | 可行性问题 |
| 45. 跳跃游戏 II | Jump Game II | 求最少跳跃次数到达终点 | 最优化问题(且保证可达) |
虽然都叫“跳跃游戏”,但一个是“能不能到”,另一个是“怎么最快到”。这种目标上的差异,直接决定了我们该使用哪种贪心策略。
✅ 第一重境界:跳跃游戏 I —— “我能不能跳过去?”
💡核心思想:能跳多远就多远,看能不能覆盖终点
面对“能否到达终点”这个问题,我们不需要关心具体路径,也不需要记录每一步怎么走。我们要回答的是:
“以当前所有可能的操作,我的影响力最大能覆盖到哪里?”
于是,诞生了一个非常优雅的贪心策略:
👉 维护一个变量 maxReach,表示当前能够到达的最远下标。遍历过程中不断更新它,只要它能覆盖终点,就成功了。
🧠 贪心逻辑拆解
- 初始时你能跳
nums[0]步,所以maxReach = 0 + nums[0] - 对于每一个位置
i:- 如果
i > maxReach,说明这个位置根本到不了,后面的更别提 → 返回false - 否则,你可以站在
i上尝试起跳:更新maxReach = max(maxReach, i + nums[i])
- 如果
- 一旦
maxReach >= n - 1,立刻返回true
✅ JavaScript 实现
var canJump = function(nums) {
if (nums.length === 1) return true;
let maxReach = nums[0]; // 当前最远可达位置
for (let i = 1; i < nums.length; i++) {
if (i > maxReach) break; // 当前位置不可达
if (maxReach >= nums.length - 1) return true; // 已覆盖终点
maxReach = Math.max(maxReach, i + nums[i]);
}
return false;
};
📊 示例演示:[3,2,1,0,4]
| i | nums[i] | 是否可达 | maxReach 更新 |
|---|---|---|---|
| 0 | 3 | 是 | → 3 |
| 1 | 2 | 是 | → max(3, 3)=3 |
| 2 | 1 | 是 | → 3 |
| 3 | 0 | 是 | → 3 |
| 4 | 4 | 否 (4 > 3) | 终止 → false |
结果:卡在索引 4 前,无法抵达。
💡 关键洞察:
我们并不需要真的模拟跳跃过程,而是通过“我能影响的范围”来推理连通性——这是一种典型的覆盖型贪心。
⚡ 第二重境界:跳跃游戏 II —— “我最少要跳几步?”
现在问题升级了:不仅要能到,还要用最少的跳跃次数到达终点。
此时不能再只关注“最远能跳多远”,而必须思考:
“我在哪一步必须跳?什么时候跳才是性价比最高的?”
这就引出了一个更高阶的贪心技巧:
💡 核心思想:分层推进,按“跳跃边界”划分阶段
🎯 关键观察:
- 我们不是每走到一个格子就跳一次。
- 真正决定步数的是:每次跳跃所能扩展的新边界。
- 所以我们可以把整个数组划分为若干个“跳跃层级”:
- 第 1 步能覆盖
[0, curEnd] - 在这个范围内探索,找出下一步最远能到哪(
nextEnd) - 到达
curEnd时,必须跳一步,进入下一层
- 第 1 步能覆盖
🧩 三变量协同作战
| 变量 | 含义 |
|---|---|
curEnd | 当前这一步跳跃所能到达的最远位置 |
nextEnd | 下一步跳跃能到达的最远位置(提前预判) |
steps | 已经跳跃的次数 |
🚀 精髓在于:不到万不得已不跳!只有触碰到当前边界的最后一刻才增加步数。
✅ JavaScript 实现
var jump = function(nums) {
let curEnd = 0; // 当前步的边界
let nextEnd = 0; // 下一步的最远可达
let steps = 0; // 跳跃次数
// 注意:只需遍历到倒数第二个元素
for (let i = 0; i < nums.length - 1; i++) {
nextEnd = Math.max(nextEnd, i + nums[i]); // 更新下一步最远
if (i === curEnd) { // 到达当前步的边界,必须跳!
curEnd = nextEnd;
steps++;
}
}
return steps;
};
📊 示例演示:[2,3,1,1,4]
| i | nums[i] | nextEnd | i == curEnd? | 动作 |
|---|---|---|---|---|
| 0 | 2 | 2 | 是 (0==0) | 跳第1步 → steps=1, curEnd=2 |
| 1 | 3 | max(2,4)=4 | 否 | 继续收集信息 |
| 2 | 1 | 4 | 是 (2==2) | 跳第2步 → steps=2, curEnd=4 |
| 3 | 1 | 4 | 否 | 不跳 |
循环结束(i < 4)→ 返回 2 |
🎯 结果:仅需 2 步即可到达终点。
🤔 为什么不在 i=1 就跳?因为还没到边界,还有希望靠前面的点跳得更远,不必急于一时。
这就是贪心的智慧:延迟决策,最大化收益窗口。
🔁 对比总结:同一模型,两种贪心思维
| 维度 | 跳跃游戏 I(55) | 跳跃游戏 II(45) |
|---|---|---|
| 问题类型 | 可行性判断 | 最优化求解 |
| 贪心目标 | 是否可达终点 | 最少跳跃次数 |
| 状态变量 | maxReach(单变量) | curEnd, nextEnd, steps(三变量协作) |
| 更新时机 | 每个可达位置都尝试扩展范围 | 仅在触及当前跳跃边界时才增加步数 |
| 终止条件 | maxReach >= n-1 或 i > maxReach | 遍历完前 n-1 个元素 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(1) |
🧠 一句话概括区别:
- 55题是“广度优先式贪心”:不断扩大势力范围,直到吞并终点。
- 45题是“阶段推进式贪心”:像打怪升级一样,每一关打完才进下一关。
🛠️ 贪心设计四原则:学会举一反三
通过这两道题,我们可以提炼出一套通用的贪心算法设计方法论:
1️⃣ 明确核心目标
- 是判断存在性?还是求最小/最大值?
- 目标不同,维护的状态也不同。
2️⃣ 定义局部最优规则
- 55题:“只要能跳,就尽量扩大覆盖”
- 45题:“不到边界不跳,跳就跳到最远”
✅ 局部最优必须可累积成全局最优!
3️⃣ 设计高效状态变量
- 尽量减少冗余信息,只保留关键指标
- 如 45 题用
curEnd和nextEnd实现“预加载”机制
4️⃣ 处理边界情况
- 数组长度为 1 时无需跳跃
- 循环范围控制(如 45 题只需到
n-2)
🤔 为什么不用动态规划?
你可能会想:这类问题不是可以用 DP 解吗?
确实可以,比如定义 dp[i] 表示到达位置 i 的最少步数,然后状态转移:
for (let j = 0; j < i; j++) {
if (j + nums[j] >= i) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
但这会带来 O(n²) 时间复杂度,在 n=1e5 的情况下直接超时。
而贪心凭借对“覆盖范围”的宏观把控,将复杂度压到 O(n),效率碾压 DP。
✅ 适用贪心的前提:
具有贪心选择性质 + 最优子结构
在跳跃游戏中,“走得越远越好”永远不会吃亏,因此贪心成立。
🧭 拓展延伸:哪些题适合类似思路?
以下题目均可采用“覆盖范围 + 边界推进”类贪心:
| 题目 | 思路关联 |
|---|---|
| 452. 用最少数量的箭引爆气球 | 区间调度,按右端点排序后贪心 |
| 134. 加油站 | 累积差值,找最小负值点后一位 |
| 1024. 视频拼接 | 类似跳跃游戏II,按区间覆盖推进 |
| 1326. 灌溉花园的最少水龙头数目 | 转化为跳跃问题,贪心覆盖 |
🔗 这些题的本质都是:用最少的操作完成连续区间的覆盖。
🎯 写在最后:贪心的艺术在于“取舍”
跳跃游戏系列告诉我们:
真正的高手,不是跳得最多的人,而是知道何时该跳、何时该等的人。
- 在 55 题中,我们追求的是“可能性的极限”;
- 在 45 题中,我们追求的是“效率的极致”。
两者虽同源,却展现了贪心算法的双重魅力:
🔸 简单直接地解决问题(55题)
🔸 精巧设计达成最优(45题)
当你下次遇到类似的“路径可达”、“最少操作”类问题时,不妨问问自己:
“我能不能不模拟全过程,而是通过维护某个‘覆盖范围’来推导结果?”
也许,答案就在一步之外。