跳跃游戏 IV:BFS 最优解详解
📋 题目:1345. 跳跃游戏 IV
原题描述
给你一个整数数组 arr,你一开始在数组的第一个元素处(下标为 0)。
每一步,你可以从下标 i 跳到下标:
i + 1(需满足:i + 1 < arr.length)i - 1(需满足:i - 1 >= 0)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 最优解:每个细节都讲透
核心思路
- 预处理:用哈希表记录每个值对应的所有下标
- BFS 队列 :存储
{位置, 步数},层次遍历 - visited 集合:防止重复访问
- 核心优化 :展开相同值的位置后,立刻删除该值的记录
完整代码
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