从“能不能跳”到“最少跳几步”:贪心算法在跳跃游戏中的双面哲学

176 阅读7分钟

引言

在刷题圈中,有这样两道“孪生题”——LeetCode 55 和 45 题《跳跃游戏》,看似背景相同,实则内藏玄机。它们共享一个模型:你在数组起点,每个位置能跳一定步数,问你能否到终点?或最少跳几次?

但正是这微妙的目标差异,让两道题从可行性判断走向了最优化求解,也让我们得以窥见贪心算法的两种高级应用范式

今天,我们就来深挖这两道经典题背后的贪心逻辑,看看如何用“局部最优”的选择,一步步逼近全局最优答案。


🌟问题对比:表面相似,本质不同

先来看两道题的核心描述:

题号名称目标关键点
55. 跳跃游戏Jump Game判断是否能到达最后一个下标可行性问题
45. 跳跃游戏 IIJump 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]

inums[i]是否可达maxReach 更新
03→ 3
12→ max(3, 3)=3
21→ 3
30→ 3
44否 (4 > 3)终止 → false

结果:卡在索引 4 前,无法抵达。

💡 关键洞察
我们并不需要真的模拟跳跃过程,而是通过“我能影响的范围”来推理连通性——这是一种典型的覆盖型贪心


⚡ 第二重境界:跳跃游戏 II —— “我最少要跳几步?”

现在问题升级了:不仅要能到,还要用最少的跳跃次数到达终点。

此时不能再只关注“最远能跳多远”,而必须思考:

“我在哪一步必须跳?什么时候跳才是性价比最高的?”

这就引出了一个更高阶的贪心技巧:


💡 核心思想:分层推进,按“跳跃边界”划分阶段

🎯 关键观察:
  • 我们不是每走到一个格子就跳一次。
  • 真正决定步数的是:每次跳跃所能扩展的新边界
  • 所以我们可以把整个数组划分为若干个“跳跃层级”:
    • 第 1 步能覆盖 [0, curEnd]
    • 在这个范围内探索,找出下一步最远能到哪(nextEnd
    • 到达 curEnd 时,必须跳一步,进入下一层

🧩 三变量协同作战

变量含义
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]

inums[i]nextEndi == curEnd?动作
022是 (0==0)跳第1步 → steps=1, curEnd=2
13max(2,4)=4继续收集信息
214是 (2==2)跳第2步 → steps=2, curEnd=4
314不跳
循环结束(i < 4)→ 返回 2

🎯 结果:仅需 2 步即可到达终点。

🤔 为什么不在 i=1 就跳?因为还没到边界,还有希望靠前面的点跳得更远,不必急于一时。

这就是贪心的智慧:延迟决策,最大化收益窗口


🔁 对比总结:同一模型,两种贪心思维

维度跳跃游戏 I(55)跳跃游戏 II(45)
问题类型可行性判断最优化求解
贪心目标是否可达终点最少跳跃次数
状态变量maxReach(单变量)curEnd, nextEnd, steps(三变量协作)
更新时机每个可达位置都尝试扩展范围仅在触及当前跳跃边界时才增加步数
终止条件maxReach >= n-1i > maxReach遍历完前 n-1 个元素
时间复杂度O(n)O(n)
空间复杂度O(1)O(1)

🧠 一句话概括区别

  • 55题是“广度优先式贪心”:不断扩大势力范围,直到吞并终点。
  • 45题是“阶段推进式贪心”:像打怪升级一样,每一关打完才进下一关。

🛠️ 贪心设计四原则:学会举一反三

通过这两道题,我们可以提炼出一套通用的贪心算法设计方法论:

1️⃣ 明确核心目标

  • 是判断存在性?还是求最小/最大值?
  • 目标不同,维护的状态也不同。

2️⃣ 定义局部最优规则

  • 55题:“只要能跳,就尽量扩大覆盖”
  • 45题:“不到边界不跳,跳就跳到最远”

✅ 局部最优必须可累积成全局最优!

3️⃣ 设计高效状态变量

  • 尽量减少冗余信息,只保留关键指标
  • 如 45 题用 curEndnextEnd 实现“预加载”机制

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题)

当你下次遇到类似的“路径可达”、“最少操作”类问题时,不妨问问自己:

“我能不能不模拟全过程,而是通过维护某个‘覆盖范围’来推导结果?”

也许,答案就在一步之外。