leetcode每日一题: 跳跃游戏 IV

跳跃游戏 IV:BFS 最优解详解

📋 题目:1345. 跳跃游戏 IV

原题描述

给你一个整数数组 arr,你一开始在数组的第一个元素处(下标为 0)。

每一步,你可以从下标 i 跳到下标:

  1. i + 1 (需满足:i + 1 < arr.length
  2. i - 1 (需满足:i - 1 >= 0
  3. j (需满足:arr[i] == arr[j]i != j

请你返回到达数组最后一个元素的下标处所需的 最少操作次数

注意:任何时候你都不能跳到数组外面。


示例

示例 1:

复制代码
输入:arr = [100,-23,-23,404,100,23,23,23,3,404]
输出:3
解释:需要跳跃 3 次,下标依次为 0 → 4 → 3 → 9。
     下标 9 为数组的最后一个元素的下标。

示例 2:

复制代码
输入:arr = [7]
输出:0
解释:从下标 0 到 0 就 1 步,不需要跳跃。

示例 3:

复制代码
输入:arr = [7,6,9,6,9,6,9,7]
输出:1
解释:你可以直接从 0 跳到 7 这个下标(因为 arr[0] == arr[7])。

提示

  • 1 <= arr.length <= 5 * 10^4
  • -10^8 <= arr[i] <= 10^8

🌍 真实世界动机

想象你在玩一个特殊的棋盘游戏:

  • 你可以向左或向右移动一格
  • 神奇的是 :如果你落在写着数字 "5" 的格子上,你可以瞬间传送到任何一个其他写着 "5" 的格子!

现在问你:从起点到终点,最少需要几步?

这就是这道题的本质------带传送门的最短路径问题


💡 问题建模:选择工具

让我们先把问题转化一下:

问题要素 对应模型
数组的每个位置 图中的节点
三种跳跃方式 节点之间的
求最少操作次数 求从起点到终点的最短路径

🤔 思考:如果问你"最少需要几步",你会选择什么策略?

提示 :求"最少步数" → BFS(广度优先搜索)

  • BFS 从起点开始,先访问所有 1 步能到的位置,再访问所有 2 步能到的位置...
  • 第一次到达终点时,走过的路径一定是最短的。

💡 类比:涟漪效应

想象你往平静的湖面扔一颗石子:

  • 涟漪 first 覆盖离石子 1 米的地方
  • 然后覆盖 2 米、3 米...
  • 第一次到达的距离,就是最短距离

BFS 就是这个涟漪!


🚀 BFS 最优解:每个细节都讲透

核心思路

  1. 预处理:用哈希表记录每个值对应的所有下标
  2. BFS 队列 :存储 {位置, 步数},层次遍历
  3. visited 集合:防止重复访问
  4. 核心优化 :展开相同值的位置后,立刻删除该值的记录

完整代码

cpp 复制代码
class Solution {
public:
    int minJumps(vector<int>& arr) {
        // 第一步:预处理,记录每个值对应的所有下标
        unordered_map<int, vector<int>> mem;
        for(int i = 0; i < arr.size(); i++){
            mem[arr[i]].push_back(i);
        } 
        
        // BFS 队列:{位置, 步数}
        queue<pair<int, int>> q;
        q.emplace(0, 0);  // 从位置 0 开始,步数为 0
        
        // visited 集合:防止重复访问
        unordered_set<int> vis;
        
        while(!q.empty()){
            auto [idx, deep] = q.front();
            q.pop();
            
            // 如果访问过,跳过(去重)
            if(vis.count(idx)){
                continue;
            }
            
            // 到达终点!返回步数
            if(idx == arr.size() - 1){
                return deep;
            }
            
            // 展开三个方向
            
            // 1. 向右跳
            if(idx + 1 < arr.size() && vis.count(idx + 1) == 0){
                q.emplace(idx + 1, deep + 1);
            }
            
            // 2. 向左跳
            if(idx - 1 >= 0 && vis.count(idx - 1) == 0){
                q.emplace(idx - 1, deep + 1);
            }
            
            // 3. 跳到所有相同值的位置(传送门!)
            if(mem.count(arr[idx])){
                for(auto nx: mem[arr[idx]]){
                    if(vis.count(nx) == 0){
                        q.emplace(nx, deep + 1);
                    }
                }
                // 关键优化:展开后删除,避免重复展开
                mem.erase(arr[idx]);
            }
            
            // 标记当前位置已访问
            vis.insert(idx);
        }
        
        return -1;  // 理论上不会到这里
    }
};

这段代码做了什么?

  • 用哈希表 mem 做预处理,快速找到所有相同值的位置
  • 用队列实现 BFS 层次遍历
  • 每次展开后删除 mem[arr[idx]],避免重复展开

细节逐一解释

细节 1:为什么用哈希表预处理?

问题 :如果我在位置 i,需要跳到所有值为 arr[i] 的位置,怎么快速找到它们?

笨方法 :遍历整个数组,找到所有值等于 arr[i] 的位置,每次查询 O(n),总复杂度 O(n²)

优化方法:预处理!用哈希表记录每个值对应的所有下标,查询 O(1)

💡 类比:图书馆目录

你在图书馆找所有写着 "算法" 的书:

  • 笨方法:一本本翻,O(n)
  • 聪明方法:查目录,"算法" 在哪几排 → 直接去那些排,O(1)

哈希表就是这个目录!


细节 2:为什么 mem.erase(arr[idx]) 这么重要?

🤔 思考:如果不删除,会发生什么?

让我们用一个例子说明:

复制代码
arr = [100, -23, -23, 404, 100, 23, 23, 23, 3, 404]
mem = {
  100: [0, 4],      // 位置 0 和 4 都是值 100
  ...
}

当我们从位置 0(值 100)展开时:

  • 会跳到位置 4(也是值 100)

后来,当我们从其他位置到达位置 4 时:

  • 如果不删除:又会展开所有值为 100 的位置(位置 0 和 4)→ 重复!
  • 如果已删除 :直接跳过,mem.count(arr[4]) 返回 false

💡 类比:导游带团

你是个导游,带团参观城市。

当你带团去过了所有 "红色建筑" 后,其他导游就不需要再带团去同样的地方了。

在地图上把这些标记删掉!

mem.erase(arr[idx]) 就是在做这件事------相同值的 "传送门" 只用一次!

性能对比

策略 时间复杂度
不删除,重复展开 O(n²) 甚至指数级
展开后删除 O(n)

细节 3:visited 集合的作用和标记时机

为什么需要 visited?

因为题目中可以 "往回跳"(i - 1),如果不标记已访问的位置,会陷入无限循环:

复制代码
0 → 1 → 0 → 1 → 0 → ... (死循环)

标记时机:出队时,不是入队时

🤔 思考:如果在入队时就标记 visited,会有什么问题?

cpp 复制代码
// 错误写法:入队时标记
if(vis.count(idx + 1) == 0){
    vis.insert(idx + 1);  // 入队时就标记
    q.emplace(idx + 1, deep + 1);
}

问题:假设位置 1 和位置 4 都能跳到位置 9(终点):

  • 位置 1 先处理,把位置 9 入队,并标记 visited
  • 等位置 4 处理时,发现位置 9 已 visited,不会入队
  • 但位置 4 到位置 9 的步数可能更少!我们错过了更优解!

正确做法:出队时标记

cpp 复制代码
// 正确写法:出队时标记
auto [idx, deep] = q.front();
q.pop();

if(vis.count(idx)){  // 出队时才检查是否访问过
    continue;
}

// ... 展开邻居 ...

vis.insert(idx);  // 处理完后才标记已访问

💡 类比:涟漪的波纹

涟漪在扩散过程中 ,波纹可能会重叠(多个方向同时到达同一个位置)。

但我们只在确定处理这个位置 时,才标记 "已处理"。

这样保证每个位置第一次被处理时,用的一定是最短路径!


细节 4:BFS 队列的层次遍历
cpp 复制代码
queue<pair<int, int>> q;
q.emplace(0, 0);  // {位置, 步数}

为什么队列里要存步数?

因为 BFS 是层次遍历,我们需要记录"当前位置是第几步到达的"。

层次遍历的过程

复制代码
初始:队列 = {(0, 0)}  →  位置 0,步数 0

第 1 层(步数 0 → 步数 1):
处理 (0, 0),展开邻居 → 队列 = {(1, 1), (4, 1)}

第 2 层(步数 1 → 步数 2):
处理 (1, 1),展开邻居 → 队列 = {(4, 1), (0, 2), (2, 2)}
处理 (4, 1),展开邻居 → 队列 = {(0, 2), (2, 2), (3, 2)}

...

当第一次遇到终点时,deep 就是最少步数!


📊 信息流动图

让我们用一个具体例子,看看 BFS 的信息流动:

复制代码
输入:arr = [100, -23, -23, 404, 100, 23, 23, 23, 3, 404]

预处理:
mem = {
  100: [0, 4],
  -23: [1, 2],
  404: [3, 9],
  23:  [5, 6, 7],
  3:   [8]
}

BFS 过程:
层次 0: {0}
层次 1: {1, 4}           (从 0 向右到 1,传送至 4)
层次 2: {2, 3, 5}        (从 1 到 2,从 4 传送至 3,从 2 传送至 5)
层次 3: {6, 9}           (从 3 传送至 9!到达终点)

输出:3

关键点

  • 每个位置只入队一次(visited 保证)
  • 每个值的 "传送门" 只用一次(mem.erase 保证)
  • 第一次到达终点时,步数一定最少(BFS 保证)

⚠️ 常见误区

误区 1:忘记 mem.erase(arr[idx])

"这个想法太自然了------你肯定会想'每次需要的时候查哈希表就行'。但问题是,相同值的传送门会被重复展开无数次!加上这行代码,性能从 O(n²) 降到 O(n)。"

后果 :对于数组 arr = [1, 1, 1, ..., 1](长度 5×10⁴),不删除会导致每个位置都展开 n 个邻居,总复杂度 O(n²) = 2.5×10⁹,直接超时!


误区 2:在入队时就标记 visited

"一种很自然的写法是入队时就标记,但这样会导致某些位置被错误标记(如果它在队列里还没处理)。正确做法是出队时标记。"

后果:可能错过更短的路径。


误区 3:没有检查数组边界

cpp 复制代码
// 错误:没有检查数组越界
if(idx + 1 < arr.size())  // ✅ 正确
if(idx + 1)               // ❌ 错误,可能越界

if(idx - 1 >= 0)          // ✅ 正确
if(idx - 1)               // ❌ 错误,可能越界

"这个错误太容易犯了------你肯定会想'idx + 1 就是下一个位置'。但数组是有边界的!一定要检查边界条件。"


🔍 触发信号清单

下次你看到这些特征,应该条件反射地想到 BFS + 哈希表预处理

信号 对应策略
"最少步数"、"最短距离" BFS 层次遍历
图中存在多种跳转方式 建图 + BFS
相同值/相同状态可互相跳转 哈希表预处理 + erase 优化
可能走回头路 visited 集合必备
值的范围很大(-10⁸ ~ 10⁸) 用哈希表而不是数组

🎯 下一步挑战

试试类似的 BFS 题目:

  • LeetCode 1091:二进制矩阵中的最短路径
  • LeetCode 752:打开转盘锁
  • LeetCode 126:单词接龙 II
相关推荐
_深海凉_6 小时前
LeetCode热题100-验证二叉搜索树
算法·leetcode·职场和发展
_深海凉_6 小时前
LeetCode热题100-二叉树的右视图
算法·leetcode·职场和发展
圣保罗的大教堂6 小时前
leetcode 1391. 检查网格中是否存在有效路径 中等
leetcode
木井巳8 小时前
【递归算法】不同路径Ⅲ
java·算法·leetcode·深度优先
sheeta19988 小时前
LeetCode 每日一题笔记 日期:2026.05.18 题目:1345. 跳跃游戏 IV
笔记·leetcode·游戏
Misnearch9 小时前
1345. 跳跃游戏 IV
java·leetcode·bfs
德迅云安全-小潘9 小时前
游戏行业面临的网络安全挑战
安全·web安全·游戏
是娇娇公主~10 小时前
力扣——105. 从前序与中序遍历序列构造二叉树详解
算法·leetcode·哈希算法
凌波粒10 小时前
LeetCode--100.相同的树(二叉树)
算法·leetcode·职场和发展