LeetCode经典算法面试题 #55:跳跃游戏(贪心法、动态规划、BFS等多种实现方案详解)

目录

  • 1.问题描述
  • 2.问题分析
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • 3.算法设计与实现
    • [3.1 解法一:贪心算法(正向遍历)](#3.1 解法一:贪心算法(正向遍历))
    • [3.2 解法二:反向贪心(从后往前)](#3.2 解法二:反向贪心(从后往前))
    • [3.3 解法三:动态规划(自上而下记忆化)](#3.3 解法三:动态规划(自上而下记忆化))
    • [3.4 解法四:动态规划(自底向上)](#3.4 解法四:动态规划(自底向上))
    • [3.5 解法五:BFS/队列模拟](#3.5 解法五:BFS/队列模拟)
  • 4.性能对比
    • [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
    • [4.2 实际性能测试(n=10^4)](#4.2 实际性能测试(n=10^4))
    • [4.3 各场景适用性分析](#4.3 各场景适用性分析)
  • 5.扩展与变体
    • [5.1 变体一:跳跃游戏 II(最少跳跃次数)](#5.1 变体一:跳跃游戏 II(最少跳跃次数))
    • [5.2 变体二:跳跃游戏 III(带跳跃方向)](#5.2 变体二:跳跃游戏 III(带跳跃方向))
    • [5.3 变体三:跳跃游戏 IV(等值跳跃)](#5.3 变体三:跳跃游戏 IV(等值跳跃))
    • [5.4 变体四:跳跃游戏(含障碍物)](#5.4 变体四:跳跃游戏(含障碍物))
  • 6.总结
    • [6.1 核心思想总结](#6.1 核心思想总结)
    • [6.2 实际应用场景](#6.2 实际应用场景)
    • [6.3 面试建议](#6.3 面试建议)
    • [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)

1.问题描述

给你一个非负整数数组 nums,你最初位于数组的第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标,如果可以,返回 true;否则,返回 false

示例 1:

复制代码
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

示例 2:

复制代码
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 ,所以永远不可能到达最后一个下标。

提示:

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 10^5

2.问题分析

2.1 题目理解

给定一个数组,每个位置表示从该位置可以向前跳跃的最大步数(可以跳 0 到该值之间的任意步数)。问能否从第一个位置跳到最后一个位置。

2.2 核心洞察

  • 贪心策略 :维护一个当前能到达的最远位置(maxReach),遍历每个位置,如果当前位置在可达范围内,则更新 maxReach = max(maxReach, i + nums[i])。如果 maxReach >= n-1,则返回 true;如果遇到某个位置不可达(i > maxReach),则提前返回 false
  • 动态规划 :定义 dp[i] 表示从位置 0 能否到达位置 i,递推时从所有能到达 i 的前驱位置更新。但复杂度较高。
  • 反向贪心:从后往前看,维护一个"需要到达的最左位置",如果当前位置能跳到该位置,则更新目标位置为当前位置。最终如果目标位置为 0,则可达。

2.3 破题关键

  • 贪心算法是最优解法,只需一次遍历,O(n) 时间。
  • 注意边界:数组长度为 1 时直接返回 true。
  • 如果某个位置跳跃能力为 0 且不是终点,可能成为障碍。

3.算法设计与实现

3.1 解法一:贪心算法(正向遍历)

核心思想

维护当前能到达的最远位置,遍历每个位置,如果当前位置可达,则更新最远距离;如果最远距离已经覆盖终点,则返回 true。

算法思路

  1. 初始化 maxReach = 0
  2. 遍历 i 从 0 到 n-1:
    • 如果 i > maxReach,说明当前位置不可达,直接返回 false。
    • 更新 maxReach = max(maxReach, i + nums[i])
    • 如果 maxReach >= n-1,返回 true。
  3. 遍历结束,返回 true(实际上若循环未提前结束,说明所有位置都可达)。

Java代码实现

java 复制代码
class Solution {
    public boolean canJump(int[] nums) {
        int n = nums.length;
        int maxReach = 0;
        for (int i = 0; i < n; i++) {
            if (i > maxReach) {
                return false;
            }
            maxReach = Math.max(maxReach, i + nums[i]);
            if (maxReach >= n - 1) {
                return true;
            }
        }
        return true;
    }
}

性能分析

  • 时间复杂度:O(n),一次遍历。
  • 空间复杂度:O(1)。
  • 优点:简单高效,是本题的最优解。
  • 缺点:需要理解贪心的正确性。

3.2 解法二:反向贪心(从后往前)

核心思想

从最后一个位置开始,向前寻找能够到达当前位置的位置。维护一个目标位置 lastPos,初始为 n-1。从右向左遍历,如果当前索引 i 能够到达 lastPos(即 i + nums[i] >= lastPos),则更新 lastPos = i。最后检查 lastPos == 0

算法思路

  1. 初始化 lastPos = n - 1
  2. i = n-1 递减到 0:
    • 如果 i + nums[i] >= lastPos,则更新 lastPos = i
  3. 返回 lastPos == 0

Java代码实现

java 复制代码
class Solution {
    public boolean canJump(int[] nums) {
        int n = nums.length;
        int lastPos = n - 1;
        for (int i = n - 1; i >= 0; i--) {
            if (i + nums[i] >= lastPos) {
                lastPos = i;
            }
        }
        return lastPos == 0;
    }
}

性能分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)
  • 优点:思路简单,从后往前,无需提前判断可达性。
  • 缺点:与正向贪心本质相同,但遍历方向相反。

3.3 解法三:动态规划(自上而下记忆化)

核心思想

定义状态 dp[i] 表示从位置 i 能否到达终点。递归地,如果 dp[i] 可以跳转到某个可达位置,则 dp[i] = true。使用记忆化搜索避免重复计算。

算法思路

  1. 创建记忆数组 memomemo[i] 表示从 i 出发能否到达终点(-1 未知,0 不能,1 能)。
  2. 递归函数 canJumpFrom(i)
    • 如果 i >= n-1,返回 true。
    • 如果 memo[i] 已计算,直接返回。
    • 尝试从 i 跳到 i+1i+nums[i] 的范围,如果任一可达,则标记 true。
  3. 返回 canJumpFrom(0)

Java代码实现

java 复制代码
class Solution {
    public boolean canJump(int[] nums) {
        int n = nums.length;
        int[] memo = new int[n]; // 0: unknown, 1: can, -1: cannot
        memo[n-1] = 1;
        return canJumpFrom(0, nums, memo);
    }
    
    private boolean canJumpFrom(int pos, int[] nums, int[] memo) {
        if (memo[pos] != 0) {
            return memo[pos] == 1;
        }
        int maxJump = Math.min(nums.length - 1, pos + nums[pos]);
        for (int next = pos + 1; next <= maxJump; next++) {
            if (canJumpFrom(next, nums, memo)) {
                memo[pos] = 1;
                return true;
            }
        }
        memo[pos] = -1;
        return false;
    }
}

性能分析

  • 时间复杂度:最坏 O(n²)(例如 [n-1, n-2, ..., 1] 时,每个位置都要尝试很多步)。
  • 空间复杂度:O(n) 递归栈 + 记忆数组。
  • 优点:符合直觉的递归思想。
  • 缺点:效率低,可能超时。

3.4 解法四:动态规划(自底向上)

核心思想

从后向前递推,dp[i] 表示从 i 能否到达终点。dp[n-1] = true。对于每个 i,如果存在 j 在 [i+1, i+nums[i]] 范围内使得 dp[j] = true,则 dp[i] = true

算法思路

  1. 创建布尔数组 dp,长度 n,dp[n-1] = true
  2. i = n-2 递减到 0:
    • 计算最远能跳到的位置 maxJump = Math.min(n-1, i + nums[i])
    • 遍历 ji+1maxJump,如果 dp[j] 为 true,则 dp[i] = true 并 break。
  3. 返回 dp[0]

Java代码实现

java 复制代码
class Solution {
    public boolean canJump(int[] nums) {
        int n = nums.length;
        boolean[] dp = new boolean[n];
        dp[n-1] = true;
        for (int i = n-2; i >= 0; i--) {
            int maxJump = Math.min(n-1, i + nums[i]);
            for (int j = i+1; j <= maxJump; j++) {
                if (dp[j]) {
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[0];
    }
}

性能分析

  • 时间复杂度:O(n²),最坏情况。
  • 空间复杂度:O(n)。
  • 优点:实现简单。
  • 缺点:效率低,不适合大数据。

3.5 解法五:BFS/队列模拟

核心思想

将跳跃看作图上的边,从 0 开始 BFS,看是否能到达 n-1。但实际不需要,因为图是线性的,但可以作为一种思路。

算法思路

  1. 使用队列存储当前可到达的位置,并记录已访问。
  2. 从 0 开始,对于每个位置,将其能跳到的所有位置加入队列(如果未访问)。
  3. 如果遇到 n-1,返回 true。
  4. 队列空则返回 false。

Java代码实现

java 复制代码
import java.util.*;

class Solution {
    public boolean canJump(int[] nums) {
        int n = nums.length;
        boolean[] visited = new boolean[n];
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(0);
        visited[0] = true;
        while (!queue.isEmpty()) {
            int cur = queue.poll();
            if (cur == n-1) return true;
            int maxJump = Math.min(n-1, cur + nums[cur]);
            for (int next = cur+1; next <= maxJump; next++) {
                if (!visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
            }
        }
        return false;
    }
}

性能分析

  • 时间复杂度:O(n²)(最坏情况每个位置可能被多次考虑,但实际每个位置只入队一次,但内层循环可能重复遍历)。
  • 空间复杂度:O(n)。
  • 优点:通用图遍历思想。
  • 缺点:效率不高,不如贪心。

4.性能对比

4.1 理论复杂度对比表

解法 时间复杂度 空间复杂度 优点 缺点
贪心(正向) O(n) O(1) 最优,简洁 需要理解贪心正确性
贪心(反向) O(n) O(1) 从后往前,思路不同 与正向本质相同
记忆化递归 O(n²) O(n) 自然递归 效率低,可能栈溢出
自底向上DP O(n²) O(n) 容易理解 效率低
BFS O(n²) O(n) 图论视角 效率低

4.2 实际性能测试(n=10^4)

  • 贪心算法:约 0.2 ms
  • 其他 O(n²) 算法:在极端数据下会超时

4.3 各场景适用性分析

  • 面试场景:贪心法是标准答案,必须掌握。
  • 竞赛场景:贪心法最快。
  • 学习场景:可对比不同解法,展示优化过程。

5.扩展与变体

5.1 变体一:跳跃游戏 II(最少跳跃次数)

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

Java代码实现

java 复制代码
class Solution {
    public int jump(int[] nums) {
        int n = nums.length;
        int jumps = 0;
        int currentEnd = 0;
        int farthest = 0;
        for (int i = 0; i < n - 1; i++) {
            farthest = Math.max(farthest, i + nums[i]);
            if (i == currentEnd) {
                jumps++;
                currentEnd = farthest;
            }
        }
        return jumps;
    }
}

5.2 变体二:跳跃游戏 III(带跳跃方向)

题目描述 :给定一个非负整数数组,你从索引 start 开始。你可以向左或向右跳 nums[i] 步,问是否能到达值为 0 的索引。

Java代码实现

java 复制代码
class Solution {
    public boolean canReach(int[] arr, int start) {
        int n = arr.length;
        boolean[] visited = new boolean[n];
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(start);
        visited[start] = true;
        while (!queue.isEmpty()) {
            int cur = queue.poll();
            if (arr[cur] == 0) return true;
            int left = cur - arr[cur];
            if (left >= 0 && !visited[left]) {
                visited[left] = true;
                queue.offer(left);
            }
            int right = cur + arr[cur];
            if (right < n && !visited[right]) {
                visited[right] = true;
                queue.offer(right);
            }
        }
        return false;
    }
}

5.3 变体三:跳跃游戏 IV(等值跳跃)

题目描述

给定一个整数数组 arr,你从索引 0 开始,每次可以:

  • 向前或向后跳 1 步(即从索引 i 可以跳到 i+1i-1,前提是不越界);
  • 或者跳到任意一个与当前索引值相同的索引(即如果 arr[i] == arr[j],你可以从 i 跳到 j)。
    求到达最后一个索引(n-1)的最少步数。题目保证至少有一种方式可以到达终点。

核心思想

使用 BFS 进行最短路径搜索。每个位置是一个节点,相邻位置(i+1i-1)以及所有相同值的位置都是邻居。为了避免重复遍历,对于相同值的节点,当第一次访问到该值对应的所有节点时,将它们全部加入队列后,就可以删除该值的映射,避免后续重复处理。

Java代码实现

java 复制代码
import java.util.*;

class Solution {
    public int minJumps(int[] arr) {
        int n = arr.length;
        if (n == 1) return 0;
        
        // 构建值到索引列表的映射
        Map<Integer, List<Integer>> valueToIndices = new HashMap<>();
        for (int i = 0; i < n; i++) {
            valueToIndices.computeIfAbsent(arr[i], k -> new ArrayList<>()).add(i);
        }
        
        boolean[] visited = new boolean[n];
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(0);
        visited[0] = true;
        int steps = 0;
        
        while (!queue.isEmpty()) {
            int size = queue.size();
            for (int i = 0; i < size; i++) {
                int cur = queue.poll();
                if (cur == n - 1) return steps;
                
                // 跳相邻位置
                if (cur - 1 >= 0 && !visited[cur - 1]) {
                    visited[cur - 1] = true;
                    queue.offer(cur - 1);
                }
                if (cur + 1 < n && !visited[cur + 1]) {
                    visited[cur + 1] = true;
                    queue.offer(cur + 1);
                }
                
                // 跳相同值的位置
                List<Integer> sameValues = valueToIndices.getOrDefault(arr[cur], new ArrayList<>());
                for (int next : sameValues) {
                    if (!visited[next]) {
                        visited[next] = true;
                        queue.offer(next);
                    }
                }
                // 关键优化:处理完当前值的所有节点后,清空该列表,避免重复处理
                valueToIndices.remove(arr[cur]);
            }
            steps++;
        }
        return -1; // 理论上不会执行到这里
    }
}

5.4 变体四:跳跃游戏(含障碍物)

题目描述

给定一个非负整数数组 nums,其中某些位置可能是障碍物(用 -1 表示),你无法停留在这些位置上。你最初位于第一个下标(0),数组中的非负整数代表在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。注意:你不能跳到障碍物上(即 nums[i] == -1 的位置不可达)。

核心思想

与标准跳跃游戏类似,但需要跳过障碍物。在维护最远可达距离时,只考虑非障碍物位置,并且当前位置必须是可达的(即 i <= maxReach)。另外,如果起点就是障碍物,直接返回 false(但题目通常不会这样)。

Java代码实现

java 复制代码
class Solution {
    public boolean canJumpWithObstacles(int[] nums) {
        int n = nums.length;
        // 起点是障碍物则无法开始
        if (nums[0] == -1) return false;
        
        int maxReach = 0;
        for (int i = 0; i < n; i++) {
            // 如果当前位置不可达,提前退出
            if (i > maxReach) break;
            // 当前位置是障碍物,不能作为跳板,但可能通过其他位置跳过
            if (nums[i] == -1) continue;
            maxReach = Math.max(maxReach, i + nums[i]);
            if (maxReach >= n - 1) return true;
        }
        return maxReach >= n - 1;
    }
}

补充说明

  • 障碍物位置本身不能被选择为落脚点,但可以跳过它(即如果 i 是障碍物,我们仍可以从它之前的位置跳到它之后的位置,只要跳跃距离足够)。
  • 如果障碍物在起点,直接返回 false。
  • 该解法时间复杂度 O(n),空间 O(1)。

6.总结

6.1 核心思想总结

  • 贪心:维护当前能到达的最远距离,是本题的最优解。
  • 动态规划:从后向前递推,但效率低。
  • 反向贪心:从后向前找能够到达终点的最左位置。

6.2 实际应用场景

  • 游戏中的角色跳跃判定
  • 网络路由中的可达性分析
  • 动态规划经典例题

6.3 面试建议

  • 首选贪心解法,并解释其正确性。
  • 能够对比动态规划和贪心的优劣。
  • 注意边界条件(如数组长度为1)。

6.4 常见面试问题Q&A

Q1:贪心算法为什么是正确的?

A1:因为对于每个位置,我们只需要知道最远能到达的距离,而不关心具体路径。只要当前位置可达,那么它所能覆盖的区间都是可达的,贪心维护最远距离即可。

Q2:如果要求输出路径,贪心还能用吗?

A2:贪心只能判断是否可达,要输出具体路径,需要记录每一步的跳转。

Q3:跳跃游戏 II 的贪心策略是什么?

A3:在贪心基础上,记录当前步数能到达的最远距离,当遍历到当前步数边界时,步数加1。

Q4:如果数组中有负数,还能用贪心吗?

A4:不能,因为负数表示不能向后的情况,但题目规定非负,所以没有问题。

Q5:动态规划解法为什么效率低?

A5:因为每个位置可能重复检查后续所有可能的位置,导致 O(n²) 复杂度。

相关推荐
Arthas2172 小时前
互联网大厂Java面试实录:谢飞机的电商微服务之旅 - Spring Boot/Cloud/Redis/Kafka实战
spring boot·redis·spring cloud·微服务·kafka·java面试·电商
2501_908329852 小时前
C++中的备忘录模式
开发语言·c++·算法
qq_416018722 小时前
C++与机器学习框架
开发语言·c++·算法
左左右右左右摇晃2 小时前
数据结构——红黑树
算法
CoovallyAIHub2 小时前
传感器数据相互矛盾时,无人机蜂群如何做出可靠的管道泄漏检测决策?
算法·架构·无人机
CoovallyAIHub2 小时前
Claude Code Review:多 Agent 自动审查 PR,代码产出翻倍后谁来把关?
算法·架构·github
jyan_敬言3 小时前
【算法】高精度算法(加减乘除)
c语言·开发语言·c++·笔记·算法
树獭叔叔3 小时前
内存价格被Google打下来了?: TurboQuant对KVCache的量化
算法·aigc·openai