leetcode 面试经典 150 题(10/150) 45.跳跃游戏 II

115 阅读9分钟

题目描述

给定一个长度为 n0 索引 整数数组 nums。初始位置为 nums[0]

每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

  • 0 <= j <= nums[i]
  • i + j < n

返回到达 nums[n - 1]最小跳跃次数生成的测试用例可以到达 nums[n - 1]

示例 1:

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

示例 2:

输入: nums = [2,3,0,1,4]
输出: 2

提示:

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 1000
  • 题目保证可以到达 nums[n-1]

算法思路

核心思想:贪心算法

这道题要求找到到达数组末尾的 最小跳跃次数。我们可以使用贪心算法来解决。 核心思想是在每一步都选择跳跃到 下一步最远可达的位置,而不是只跳到当前最远可达的位置。 这样做可以保证我们以最少的跳跃次数到达终点。

从 0 到 1 的思考过程:

  1. 最少跳跃次数: 题目明确要求最小跳跃次数,这意味着我们需要在每一步都做出最优的选择,以减少总的跳跃次数。

  2. 每一步的最优选择: 在当前位置 i,我们可以跳跃的范围是 [i+1, i + nums[i]]。 为了最小化跳跃次数,我们应该选择下一步能够跳得 最远 的位置作为我们的下一个跳跃点。 也就是说,在所有可以从当前位置 i 到达的下一步位置中,我们要选择那个能够到达更远距离的位置。

  3. 维护当前可达范围和下一步最远范围: 我们需要维护两个关键变量:

    • end: 表示当前跳跃次数下,能够到达的最远边界。 初始时 end = 0,表示从起始位置跳 0 步能到达的最远距离就是起始位置本身。
    • maxReach: 表示从当前可达范围内,下一步能够到达的最远距离。 在遍历当前可达范围内的每个位置时,都更新 maxReach
  4. 遍历数组并更新跳跃次数: 我们遍历数组,当遍历到当前可达范围的边界 end 时,意味着我们需要进行下一次跳跃了。 此时,我们将跳跃次数 steps 加 1,并将新的 end 更新为 maxReach。 新的 end 就是在上次跳跃的基础上,通过选择下一步最优跳跃点,能够到达的最远距离。

  5. 提前到达终点: 在更新 end 的过程中,我们需要判断新的 end 是否已经到达或超过数组的末尾。 如果是,则说明我们已经到达终点,可以直接返回当前的跳跃次数 steps

复杂度分析

  • 时间复杂度: O(n),其中 n 是 nums 数组的长度。虽然有一个内层循环隐含在 maxReach = max(maxReach, i + nums[i]) 中,但实际上我们只遍历了数组一次。
  • 空间复杂度: O(1),我们只使用了常数个变量 (steps, maxReach, end, i)。

代码实现

func jump(nums []int) int {
    n := len(nums)
    if n == 1 { // 如果数组长度为 1,无需跳跃,直接返回 0
        return 0
    }
    steps := 0    // 跳跃次数
    maxReach := 0 // 当前能跳到的最远位置
    end := 0      // 当前跳跃次数下能到达的最远边界
    for i := 0; i < n; i++ {
        maxReach = max(maxReach, i+nums[i]) // 更新当前能跳到的最远位置

        if i == end { // 到达当前可达边界
            steps++    // 增加跳跃次数
            end = maxReach // 更新新的可达边界
            if end >= n-1 { // 如果新的可达边界已经到达或超过终点,则提前结束
                break
            }
        }
    }
    return steps
}

过程梳理:

  • 初始化:
    • steps = 0 (跳跃次数初始化为 0)
    • maxReach = 0 (最远可达距离初始化为 0,即起始位置)
    • end = 0 (当前跳跃次数下,能到达的最远边界,初始为 0)
  • 遍历 nums 数组: 对于数组中的每个位置 i (从 0 到数组长度 - 1):
    • 更新当前位置能到达的最远距离: maxReach = max(maxReach, i + nums[i])。 始终记录从当前可达范围内,下一步能跳到的最远距离。
    • 判断是否到达当前可达范围边界: if i == end。 如果当前位置 i 等于当前可达范围的边界 end,则说明需要进行下一次跳跃了。
      • 进行跳跃: steps++ (跳跃次数加 1)。
      • 更新新的可达范围边界: end = maxReach (将新的可达范围边界更新为 maxReach)。 新的 end 是在上次跳跃的基础上,通过选择下一步最优跳跃点,能够到达的最远距离。
      • 检查是否到达终点: if end >= n-1。 如果新的可达范围边界 end 已经到达或超过数组的最后一个索引 n-1,则说明已经到达终点,直接 break 循环。
  • 返回 steps

示例解析

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

  1. 初始状态: steps = 0, maxReach = 0, end = 0
  2. i = 0: nums[0] = 2maxReach = max(0, 0 + 2) = 2i == end (0 == 0) 成立:
    • steps = 0 + 1 = 1
    • end = maxReach = 2
    • end < n - 1 (2 < 4) 不成立,继续循环
  3. i = 1: nums[1] = 3maxReach = max(2, 1 + 3) = 4i == end (1 == 2) 不成立
  4. i = 2: nums[2] = 1maxReach = max(4, 2 + 1) = 4i == end (2 == 2) 成立:
    • steps = 1 + 1 = 2
    • end = maxReach = 4
    • end >= n - 1 (4 >= 4) 成立,break 循环

最终结果: steps = 2

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

  1. 初始状态: steps = 0, maxReach = 0, end = 0
  2. i = 0: nums[0] = 2maxReach = max(0, 0 + 2) = 2i == end (0 == 0) 成立:
    • steps = 0 + 1 = 1
    • end = maxReach = 2
    • end < n - 1 (2 < 4) 不成立,继续循环
  3. i = 1: nums[1] = 3maxReach = max(2, 1 + 3) = 4i == end (1 == 2) 不成立
  4. i = 2: nums[2] = 0maxReach = max(4, 2 + 0) = 4i == end (2 == 2) 成立:
    • steps = 1 + 1 = 2
    • end = maxReach = 4
    • end >= n - 1 (4 >= 4) 成立,break 循环

最终结果: steps = 2

关键点

  • 贪心策略: 每一步都选择下一步能跳得最远的位置,保证跳跃次数最少。
  • 可达范围边界 end end 变量是核心,它定义了在当前跳跃次数下,我们能够到达的最远范围。 每次跳跃后,end 都会被更新,扩大可达范围。
  • 步数计数 steps 在到达当前可达范围边界 end 时,才增加跳跃次数 steps,保证了跳跃次数的最小化。
  • 提前到达终点判断: 在更新 end 后,立即判断是否到达终点,避免不必要的遍历,提高效率。

附:在考虑使用贪心前可以进行思考

初步分析

  • 问题类型识别: 本题要求计算从起始位置跳到最后一个位置的最小跳跃次数,属于最优化问题。

  • 是否可能使用贪心? 是的,贪心算法适用于此问题。每一步选择跳跃到最远的位置,能够尽量减少跳跃次数,符合最优解的要求。

贪心策略探索与验证

贪心策略的定义: 每次选择跳跃到当前位置可到达的最远位置 i + nums[i],从而减少跳跃次数。

贪心策略的合理性分析:

  • 反例构建: 对于数组 [1, 2, 3, 4, 5],从 i = 0 跳到 i = 1,再跳到 i = 3,最终到 i = 4,是最优解。如果直接跳到 i = 4,也符合贪心策略,依旧是最优解。
  • 直觉上的解释: 选择跳得最远的位置,可以减少未来的跳跃次数,使得跳跃过程更加高效。
  • 最优子结构性质: 问题具有最优子结构性质,每次跳跃后,选择最远位置能够构成全局最优解。
  • 贪心选择性质: 每次跳跃选择最远位置,能够确保跳跃次数最少,因此局部最优选择能够导致全局最优。
  • 无后效性: 新的跳跃决策将根据新的位置和新的跳跃范围做出,因此,之前的选择已经不再影响当前的决策。

正确性证明

  • 数学归纳法: 假设贪心策略在第 k 步最优,若存在更优解,则必定与贪心选择相矛盾。因贪心策略选择的跳跃点总能覆盖更远的位置,因此从当前位置开始,贪心策略不会导致次优解。由此,可以通过归纳法证明贪心策略的正确性。

  • 反证法: 假设存在一个更优解,该解的跳跃次数比贪心策略少。但因为贪心策略每次选择最远位置,这意味着任何更优解必定违背了贪心选择,矛盾。因此,贪心策略是最优的。

完整证明

基础假设:

  • 假设贪心策略在第 k 步是最优的,即当前贪心策略选择跳到的最远位置能保证跳跃次数是最少的。

归纳目标:

  • 证明如果贪心策略在前 k 步是最优的,则在第 k+1 步的选择也是最优的。

证明步骤

  1. 基础情况:

当 k = 1 时,表示我们从起始位置 0 开始,选择跳跃的第一个位置。贪心策略选择从当前位置跳到可以到达的最远位置(即选择能跳到的最大位置)。如果这个选择是最优的,那么跳跃次数为 1。

  1. 归纳假设:

假设在第 k 步,贪心策略选择的跳跃点是最优的,即从当前位置跳跃到的最远位置能保证最少的跳跃次数。

  1. 归纳步骤:

我们需要证明在第 k+1 步,贪心策略的选择仍然是最优的。假设存在一个不同的跳跃选择能够导致更少的跳跃次数。

• 贪心策略在当前位置选择的跳跃点 i + nums[i] 是当前跳跃范围内能够跳到的最远位置。

• 如果假设存在一个更优的解(即在第 k+1 步选择了不同的位置 j,使得跳跃次数更少),那么说明从当前位置 i 跳到位置 j 会导致更少的跳跃次数。

但根据贪心策略的选择,它已经选择了最远的跳跃点,从而让后续的跳跃范围最大化。如果我们选择 j 而不是贪心选择的 i + nums[i],则意味着我们选择的跳跃点没有最大化后续的可达范围,跳跃次数就会增加,或者我们可能会需要多次中途停顿。

由于我们选择的跳跃点是贪心策略下能跳到的最远位置,这就保证了我们总是跳到最优的地方,不可能有更优的跳跃点。

  1. 归纳结论:

通过以上推导可知,如果第 k 步的选择是最优的,那么第 k+1 步的选择也是最优的。因此,根据数学归纳法,贪心策略在每一步选择跳跃到最远的点,总能得到最优解。