📖 第67课:跳跃游戏II

2 阅读20分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第67课:跳跃游戏II

模块:贪心算法 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/ju… 前置知识:第66课《跳跃游戏》 预计学习时间:25分钟


🎯 题目描述

给定一个非负整数数组nums,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。你的目标是使用最少的跳跃次数到达数组的最后一个位置。注意:假设你总是可以到达数组的最后一个位置。

示例:

输入:nums = [2,3,1,1,4]
输出:2
解释:跳到最后位置的最小跳跃次数是2。从下标0跳1步到下标1,然后跳3步到最后位置。

约束条件:

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 1000
  • 题目保证总能到达最后位置(与第66题的关键区别)

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入nums=[0]0已在终点无需跳
单步到达nums=[5,1,1,1,1,1]1第一步直接跳到终点
每次跳1nums=[1,1,1,1,1]4最坏情况,需n-1次
大跨度nums=[10,9,8,7,6,5,4,3,2,1,0]1第一步就能到
混合策略nums=[2,3,1,1,4]2核心测试用例

💡 思路引导

生活化比喻

想象你在玩一个跳格子游戏,每个格子上写着你能向前跳的最大步数。你的目标是用最少的跳跃次数到达终点。

🐌 笨办法:每次跳一小步,谨慎前进。这样虽然稳妥,但会浪费很多次跳跃机会。比如站在能跳5步的格子上却只跳1步,太保守了!

🚀 聪明办法:站在当前位置时,先"环顾四周",看看在当前这次跳跃范围内,哪个落脚点能让你在下一次跳得最远。这就像登山时选择能看到更远风景的中转站——贪心地选择最佳跳板

关键洞察

每次跳跃都选择"下一跳能到达最远位置"的落脚点,局部最优即全局最优,这就是贪心策略的精髓。

与第66题的本质区别

  • 第66题(跳跃游戏):判断能否到达终点 → 只需维护一个max_reach最远可达位置
  • 第67题(跳跃游戏II):求最少跳跃次数 → 需要分层跳跃,记录每次跳跃的边界

🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:nums = [2,3,1,1,4],每个元素表示最大跳跃步数
  • 输出:最少跳跃次数,如示例中是2
  • 限制:题目保证能到达终点(简化了问题,无需判断可达性)

Step 2:先想笨办法(暴力法)

可以用BFS层序遍历:把每个位置看作节点,能跳到的位置是邻接节点,求从起点到终点的最短路径。

# BFS思路伪代码
queue = [0]  # 起点
steps = 0
while queue:
    # 处理当前层所有节点
    for pos in current_level:
        if pos == n-1:  # 到达终点
            return steps
        # 把所有能跳到的位置加入下一层
        for next_pos in range(pos+1, pos+nums[pos]+1):
            queue.append(next_pos)
    steps += 1
  • 时间复杂度:O(n²) — 最坏情况每个位置都要展开所有可达位置
  • 瓶颈在哪:重复处理很多中间位置,有大量冗余计算

Step 3:瓶颈分析 → 优化方向

观察示例[2,3,1,1,4]:

第0步:站在index=0(值为2),能跳到index=12
第1步:从1或2起跳,哪个更好?
  - 从index=1(值为3)能跳到index=2,3,4 → 最远到4(终点)✅
  - 从index=2(值为1)只能跳到index=3 → 不如前者

核心问题:不需要逐个尝试所有可达位置,只需知道当前跳跃范围内的最远可达位置

优化思路:

  1. 维护当前跳跃的边界current_end
  2. 在到达边界前,贪心地更新"下一跳能到达的最远位置"farthest
  3. 到达边界时,跳跃次数+1,更新边界为farthest

Step 4:选择武器

  • 选用:贪心算法 + 双边界
  • 理由:通过贪心策略(每次选择最远可达)避免了BFS的冗余,O(n)一次遍历解决

🔑 模式识别提示:当题目出现"最少次数到达终点"+"每个位置有多种选择",优先考虑贪心分层跳跃模式


🔑 解法一:BFS层序遍历(直觉法)

思路

把数组看作图,用BFS求最短路径:每次跳跃相当于走一层,第一次到达终点时就是最少步数。

图解过程

示例:nums = [2,3,1,1,4]

初始:queue=[0], steps=0
  位置: 0  1  2  3  4
  值:   2  3  1  1  4
        ↑ 当前位置

第1次跳跃(处理位置0):
  从0跳到1,2 → queue=[1,2], steps=1
  位置: 0  1  2  3  4
           ↑  ↑ 可选落脚点

第2次跳跃(处理位置1,2):
  从1能跳到2,3,4 → 发现4是终点!
  从2能跳到3
  → 返回steps=2

Python代码

from typing import List
from collections import deque


def jump_bfs(nums: List[int]) -> int:
    """
    解法一:BFS层序遍历
    思路:把跳跃问题转化为图的最短路径问题
    """
    n = len(nums)
    if n == 1:  # 已在终点
        return 0

    queue = deque([0])  # 起点
    visited = {0}
    steps = 0

    while queue:
        size = len(queue)
        steps += 1

        # 处理当前层所有位置
        for _ in range(size):
            pos = queue.popleft()

            # 尝试从当前位置跳到所有可达位置
            for next_pos in range(pos + 1, min(pos + nums[pos] + 1, n)):
                if next_pos == n - 1:  # 到达终点
                    return steps
                if next_pos not in visited:
                    visited.add(next_pos)
                    queue.append(next_pos)

    return steps


# ✅ 测试
print(jump_bfs([2, 3, 1, 1, 4]))  # 期望输出:2
print(jump_bfs([2, 3, 0, 1, 4]))  # 期望输出:2
print(jump_bfs([0]))  # 期望输出:0
print(jump_bfs([1, 1, 1, 1]))  # 期望输出:3

复杂度分析

  • 时间复杂度:O(n²) — 最坏情况下每个位置要展开其所有可达位置,如[1,1,1,..1]需要O(1+2+3+...+n) = O(n²)
    • 具体地说:如果n=10000,可能需要约5000万次操作
  • 空间复杂度:O(n) — 队列和visited集合最多存储n个位置

优缺点

  • ✅ 思路直观,符合"最短路径"的直觉
  • ✅ 代码结构清晰,易于理解
  • ❌ 时间复杂度高,大规模数据会超时
  • ❌ 有大量重复计算,访问了很多不必要的中间位置

🏆 解法二:贪心分层跳跃(最优解)

优化思路

BFS的问题在于逐个尝试所有可达位置。其实我们不需要知道具体跳到哪个位置,只需知道:

  • 当前跳跃能覆盖的范围(current_end)
  • 在这个范围内,下一跳最远能到哪(farthest)

当到达current_end时,必须跳一次,更新边界为farthest

💡 关键想法:贪心地维护"分层边界",每层内找到能跳得最远的位置作为下一层的边界。

图解过程

示例:nums = [2,3,1,1,4]

初始化:
  current_end = 0  (第1跳的边界)
  farthest = 0     (目前能到达的最远位置)
  jumps = 0

遍历过程:

i=0: nums[0]=2
  farthest = max(0, 0+2) = 2  (在index=0能跳到2)
  到达current_end → jumps++ → current_end=2

  [2, 3, 1, 1, 4]
   ^-边界-^  (第1跳能覆盖0~2)

i=1: nums[1]=3
  farthest = max(2, 1+3) = 4  (在index=1能跳到4,更远!)
  未到边界,继续

i=2: nums[2]=1
  farthest = max(4, 2+1) = 4  (还是4)
  到达current_end=2 → jumps++ → current_end=4

  [2, 3, 1, 1, 4]
         ^-----^  (第2跳能覆盖3~4,已包含终点!)

停止遍历(i不到n-1就结束了)
返回jumps=2 ✅

可视化分层:
层0(起点):    [0]
             /  \
层1(第1跳):  [1] [2]  → 覆盖范围0~2
              |\ /|
层2(第2跳):   [3][4]  → 从1能跳到4(终点)

再看一个例子:nums = [5,9,3,2,1,0,2,3,3,1,0,0]

初始:current_end=0, farthest=0, jumps=0

i=0: farthest=0+5=5, 到达边界 → jumps=1, current_end=5
层1覆盖:0~5
在这个范围内:
  i=1: farthest=max(5,1+9)=10
  i=2: farthest=max(10,2+3)=10
  ...
  i=5: farthest=10, 到达边界 → jumps=2, current_end=10

层2覆盖:6~10
在这个范围内farthest能到11 → 已包含终点
→ 返回jumps=2

Python代码

def jump(nums: List[int]) -> int:
    """
    解法二:贪心分层跳跃(最优解)
    思路:维护当前跳跃边界和最远可达位置,贪心地选择最佳跳板
    """
    n = len(nums)
    if n == 1:  # 特殊情况:已在终点
        return 0

    jumps = 0           # 跳跃次数
    current_end = 0     # 当前跳跃能到达的最远位置(边界)
    farthest = 0        # 在当前边界内探索到的下一跳最远位置

    # 注意:只需遍历到n-2,因为到达n-1就是终点了
    for i in range(n - 1):
        # 贪心:在当前范围内找到能跳得最远的位置
        farthest = max(farthest, i + nums[i])

        # 到达当前跳跃的边界,必须跳一次
        if i == current_end:
            jumps += 1
            current_end = farthest  # 更新边界为下一跳能到达的最远位置

            # 剪枝:如果已经能到达终点,提前结束
            if current_end >= n - 1:
                break

    return jumps


# ✅ 测试
print(jump([2, 3, 1, 1, 4]))  # 期望输出:2
print(jump([2, 3, 0, 1, 4]))  # 期望输出:2
print(jump([0]))  # 期望输出:0
print(jump([1, 1, 1, 1]))  # 期望输出:3
print(jump([5, 9, 3, 2, 1, 0, 2, 3, 3, 1, 0, 0]))  # 期望输出:3
print(jump([10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]))  # 期望输出:1

复杂度分析

  • 时间复杂度:O(n) — 只需一次线性遍历,每个位置访问一次
    • 具体地说:如果n=10000,只需10000次操作,比BFS的5000万次快了5000倍!
  • 空间复杂度:O(1) — 只使用常数个变量

为什么这是最优解?

  1. 时间已达理论下限:必须至少遍历一次数组来获取信息,O(n)已是最优
  2. 空间也达到最优:O(1)常数空间,无需额外数据结构
  3. 贪心策略正确性:每次选择局部最优(最远可达)保证了全局最优(最少跳跃)
  4. 代码简洁高效:核心逻辑不到10行,易于理解和实现

⚡ 解法三:动态规划(完整性补充)

思路

定义dp[i]为到达位置i的最少跳跃次数,从前往后推导。

Python代码

def jump_dp(nums: List[int]) -> int:
    """
    解法三:动态规划
    思路:dp[i] = 到达位置i的最少跳跃次数
    """
    n = len(nums)
    dp = [float('inf')] * n
    dp[0] = 0  # 起点不需跳跃

    for i in range(n):
        # 从位置i能跳到的所有位置
        for j in range(i + 1, min(i + nums[i] + 1, n)):
            dp[j] = min(dp[j], dp[i] + 1)

    return dp[n - 1]


# ✅ 测试
print(jump_dp([2, 3, 1, 1, 4]))  # 期望输出:2
print(jump_dp([2, 3, 0, 1, 4]))  # 期望输出:2

复杂度分析

  • 时间复杂度:O(n²) — 双层循环
  • 空间复杂度:O(n) — dp数组

优缺点

  • ✅ 思路清晰,DP思想经典
  • ❌ 时间和空间都不如贪心解法
  • ❌ 面试中如果先说DP会被追问"能否优化"

🐍 Pythonic 写法

利用enumerate和条件表达式的简化版:

def jump_pythonic(nums: List[int]) -> int:
    """Pythonic写法:更紧凑的贪心实现"""
    jumps, current_end, farthest = 0, 0, 0

    for i, jump_range in enumerate(nums[:-1]):  # 排除最后一个元素
        farthest = max(farthest, i + jump_range)
        if i == current_end:
            jumps += 1
            current_end = farthest
            if current_end >= len(nums) - 1:  # 提前终止
                break

    return jumps

特点:

  • enumerate同时获取索引和值
  • 用切片nums[:-1]避免越界判断
  • 代码更紧凑,保持了可读性

⚠️ 面试建议:先写解法二的标准贪心版本展示清晰思路,再提Pythonic写法展示语言功底。面试官更看重你的算法思维,而非代码行数。


📊 解法对比

维度解法一:BFS🏆 解法二:贪心分层(最优)解法三:DP
时间复杂度O(n²)O(n) ← 时间最优O(n²)
空间复杂度O(n)O(1) ← 空间最优O(n)
代码难度中等(需要BFS模板)简单(10行核心代码)中等(DP思想)
面试推荐⭐⭐⭐ ← 首选⭐⭐
适用场景教学理解用面试首选,工程实用备选方案

为什么贪心是最优解:

  • 时间O(n)已达理论最优:必须至少遍历一次数组,不可能更快
  • 空间O(1)也是最优:只用几个变量,不可能更省
  • 贪心策略简洁正确:局部最优(选最远跳板)=全局最优(最少跳跃)
  • 代码简洁易懂:核心逻辑不到10行,面试中5分钟内可以写出并讲清楚

面试建议:

  1. 先用30秒简述BFS思路(O(n²)),表明你理解问题本质是"最短路径"
  2. 立即优化到🏆贪心解法(O(n)),展示优化能力:"我们不需要逐个尝试,只需贪心地维护最远可达边界"
  3. 重点画图讲解分层跳跃过程,用[2,3,1,1,4]演示如何分层
  4. 强调为什么这是最优:时间空间都达到理论极限,贪心策略正确性
  5. 对比第66题:66题只需一个变量记录最远位置,67题需要两个变量记录当前边界和下一跳最远位置

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下跳跃游戏II,求最少跳跃次数。

:(审题30秒)好的,这道题要求用最少的跳跃次数到达数组末尾,每个位置的值表示最大跳跃步数。这和刚才做的"能否到达"不同,这里要求次数最少

我的第一个想法是用BFS层序遍历,把问题转化为图的最短路径,时间复杂度O(n²)。但这样会有很多重复计算。

更优的方法是贪心分层跳跃:我维护两个变量——current_end表示当前跳跃的边界,farthest表示在这个边界内能探索到的下一跳最远位置。当我遍历到边界时,就必须跳一次,然后更新边界为farthest。这样只需O(n)时间,O(1)空间。

面试官:很好,请写一下代码并解释。

:(边写边说)

def jump(nums):
    n = len(nums)
    if n == 1:
        return 0  # 已在终点

    jumps = 0       # 跳跃次数
    current_end = 0 # 当前跳跃边界
    farthest = 0    # 下一跳最远位置

    for i in range(n - 1):  # 只需遍历到倒数第二个
        # 贪心:在当前范围内找最远可达位置
        farthest = max(farthest, i + nums[i])

        # 到达边界,必须跳一次
        if i == current_end:
            jumps += 1
            current_end = farthest  # 更新为下一跳边界

    return jumps

核心思想是分层跳跃:每次跳跃覆盖一个范围,在这个范围内贪心地找到下一跳能到达的最远位置,作为新的边界。

面试官:测试一下?

:用[2,3,1,1,4]走一遍:

  • i=0:farthest=2,到达边界→jumps=1,current_end=2(第1跳覆盖0~2)
  • i=1:farthest=4,未到边界
  • i=2:到达边界→jumps=2,current_end=4(第2跳覆盖3~4,包含终点)
  • 结果:2次跳跃 ✅

再测边界情况[0]:直接返回0,正确 ✅

面试官:为什么循环是range(n-1)而不是range(n)?

:因为我们的目标是到达最后位置,而不是"从"最后位置跳出去。当current_end到达或超过n-1时,就已经能到达终点了,不需要再处理最后一个元素。这也是一个小优化。

高频追问

追问应答策略
"还有更优解吗?"时间O(n)已经是最优(必须遍历),空间O(1)也是最优,无法再优化。如果数据有特殊性质(如有序、稀疏)可以讨论特殊优化。
"如果数组非常大,无法一次加载到内存?"可以流式处理:逐段读取,维护全局的current_end和farthest,核心算法不变。
"能否用DP解决?"可以,定义dp[i]为到达i的最少步数,但时间O(n²)、空间O(n),不如贪心。面试中会被追问优化。
"这道题和第66题(能否到达)有什么区别?"66题只需一个变量记录max_reach,判断是否>=n-1即可;67题需要分层,用两个变量(边界+最远位置)记录每次跳跃的覆盖范围,这是本质区别。
"如果某个位置值为0怎么办?"题目保证能到达终点,所以0不会成为死路。我们的算法通过farthest会跨越0的位置。

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:enumerate同时获取索引和值
for i, val in enumerate(nums):
    farthest = max(farthest, i + val)

# 技巧2:切片排除最后元素
for i in range(len(nums) - 1):  # 等价于
for i, _ in enumerate(nums[:-1]):

# 技巧3:多变量同时赋值
jumps, current_end, farthest = 0, 0, 0

# 技巧4:提前终止循环
if current_end >= n - 1:
    break  # 已能到达终点,无需继续

💡 底层原理(选读)

为什么贪心策略是正确的?

这道题的贪心正确性可以用反证法证明:

  1. 假设存在某个最优解,在第k跳时没有选择"最远可达"位置,而是选了一个较近的位置
  2. 那么在第k+1跳时,从"较近位置"能到达的范围必然 ⊆ 从"最远位置"能到达的范围
  3. 这意味着选"最远位置"不会让后续变差,甚至可能让后续更优
  4. 因此"不选最远"不可能是最优解,矛盾!

贪心 vs DP 的选择:

  • DP适用场景:子问题间有复杂依赖,需要保存中间状态
  • 贪心适用场景:局部最优能导出全局最优,有"无后效性"
  • 本题中,每次选择最远可达位置不会影响之前的决策,符合贪心特征

与第66题的技术区别:

维度第66题(能否到达)第67题(最少次数)
核心变量1个:max_reach2个:current_end, farthest
关注点全局最远可达分层边界
复杂度O(n), O(1)O(n), O(1)
策略单一贪心贪心分层

算法模式卡片 📐

  • 模式名称:贪心分层跳跃(Greedy Jump with Layering)
  • 适用条件:
    • 求"最少步数"到达目标
    • 每步有多种选择,选择范围由当前状态决定
    • 局部最优(选最远)能推导全局最优
  • 识别关键词:
    • "最少跳跃次数"
    • "最多能跳k步"
    • "到达最后位置"
  • 模板代码:
def min_jumps(nums):
    n = len(nums)
    if n == 1:
        return 0

    jumps = 0           # 跳跃次数
    current_end = 0     # 当前层边界
    farthest = 0        # 下一层最远位置

    for i in range(n - 1):
        farthest = max(farthest, i + nums[i])  # 贪心更新
        if i == current_end:  # 到达边界,开启新一层
            jumps += 1
            current_end = farthest

    return jumps

易错点 ⚠️

  1. 循环范围错误:

    • ❌ 错误:for i in range(n) → 会多处理最后一个元素
    • ✅ 正确:for i in range(n-1) → 只需到倒数第二个
    • 原因:目标是到达最后位置,不是从最后位置起跳
  2. 边界更新时机错误:

    • ❌ 错误:在farthest更新后立即更新current_end
    • ✅ 正确:只在i == current_end时更新
    • 原因:必须等到遍历完当前层所有位置,才知道下一跳最远能到哪
  3. 跳跃次数初值错误:

    • ❌ 错误:jumps = 1(认为起点算一跳)
    • ✅ 正确:jumps = 0
    • 原因:起点不算跳跃,只有"从起点跳到其他位置"才算
  4. 忘记特判n==1:

    • ❌ 错误:直接进入循环
    • ✅ 正确:if n == 1: return 0
    • 原因:已在终点,无需跳跃,否则算法会返回错误结果
  5. 与第66题混淆:

    • ❌ 错误:只用一个max_reach变量
    • ✅ 正确:需要current_endfarthest两个变量
    • 原因:67题需要分层计数,66题只需判断可达性

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:网络路由优化 在计算机网络中,数据包从源主机到目标主机需要经过多个路由器。每个路由器有不同的"覆盖范围"(能直接到达的下一跳节点)。这道题的思想可以用于计算"最少跳数路由":贪心地选择每一跳能覆盖范围最大的路由器,减少总跳数,降低延迟。

  • 场景2:游戏关卡设计 在跑酷或平台跳跃游戏中,关卡设计师需要确保玩家能通过关卡,并计算"理论最少跳跃次数"作为金牌达成标准。这道题的算法可以自动计算最优路径,辅助难度平衡。

  • 场景3:资源分配优化 在云计算资源调度中,任务需要在不同数据中心间迁移。每个数据中心有"辐射范围"(能直接连接的其他中心)。使用贪心分层思想可以最小化任务迁移次数,降低网络开销。


🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 55. 跳跃游戏Medium贪心,单变量最远可达第66课前置题,只需判断能否到达,比本题简单
LeetCode 1306. 跳跃游戏IIIMediumBFS/DFS,可前可后跳不再是"只能向右",可以双向跳,用BFS求最短路径
LeetCode 1345. 跳跃游戏IVHardBFS+哈希表,相同值跳跃加入了"值相同的位置可互跳",需要哈希表优化
LeetCode 1871. 跳跃游戏VIIMedium贪心+前缀和,范围跳跃跳跃范围是区间[minJump, maxJump],需要前缀和优化
LeetCode 1696. 跳跃游戏VIMedium单调队列+DP,求最大得分不是求次数,而是求路径最大分数,用单调队列优化DP

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:跳跃游戏III - 给定数组arr和起始位置start,每次可以向左或向右跳arr[i]步,判断能否跳到值为0的位置。

例如:arr = [4,2,3,0,3,1,2], start = 5,能否跳到0?

💡 提示(实在想不出来再点开)

这题不再是"只能向右跳",而是"可以双向跳",需要用BFS或DFS遍历所有可达位置,判断能否到达值为0的位置。注意要用visited集合防止重复访问形成死循环。

✅ 参考答案
from collections import deque

def canReach(arr: List[int], start: int) -> bool:
    """
    跳跃游戏III:双向跳跃,判断能否到达0
    思路:BFS遍历所有可达位置
    """
    n = len(arr)
    queue = deque([start])
    visited = {start}

    while queue:
        pos = queue.popleft()

        # 到达值为0的位置
        if arr[pos] == 0:
            return True

        # 向左跳和向右跳
        for next_pos in [pos - arr[pos], pos + arr[pos]]:
            if 0 <= next_pos < n and next_pos not in visited:
                visited.add(next_pos)
                queue.append(next_pos)

    return False


# 测试
print(canReach([4, 2, 3, 0, 3, 1, 2], 5))  # True
print(canReach([3, 0, 2, 1, 2], 2))  # False

核心思路:

  1. 用BFS层序遍历,从start开始扩展
  2. 每个位置可以跳到pos-arr[pos]pos+arr[pos]两个方向
  3. visited集合记录访问过的位置,防止死循环
  4. 一旦遇到arr[pos]==0就返回True

时间复杂度:O(n),每个位置最多访问一次 空间复杂度:O(n),队列和visited集合


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。