P12684 【MX-J15-T4】叉叉学习魔法 解题报告
P12684 【MX-J15-T4】叉叉学习魔法 解题报告
一、 题目解读:我们要解决什么问题?
首先,我们来弄清楚题目的要求。在一个迷宫里,我们要从起点 X
走到终点 W
。有两种移动方式:
- 普通移动:向上、下、左、右走一步。这会消耗 1 步数,但不消耗魔法。
- 魔法移动:向左上、左下、右上、右下走一步。这不消耗步数,但会消耗 1 次魔法。
我们的目标是找到一条从 X
到 W
的路径,这条路径需要满足两个条件,并且有严格的优先级:
- 首要目标:步数要最少。
- 次要目标:在满足“步数最少”的前提下,使用的魔法次数要最少。
如果 X
根本走不到 W
,就输出 -1 -1
。
简单来说,这就是一个带有两种不同“代价”的最短路问题。步数是我们的主要代价,魔法次数是次要代价。
二、 思路演进:从常规到巧妙
1. 初步想法:标准的最短路算法 (Dijkstra)
一看到最短路,我们很自然会想到经典的 Dijkstra 算法。Dijkstra 算法通常用于解决单源最短路问题,它通过一个优先队列来保证每次都取出当前距离起点最近的节点进行扩展。
我们可以把这个问题也套用到 Dijkstra 的框架里:
- 状态:一个点的状态不能仅仅是它的坐标
(x, y)
,还必须包含到达它所花费的代价。所以,一个完整的状态是(坐标x, 坐标y, 已用步数, 已用魔法次数)
。 - 代价:我们的代价是双重的,
(步数, 魔法次数)
。优先队列在比较两个状态的“远近”时,需要先比较步数,步数少的更优;如果步数相同,再比较魔法次数,魔法次数少的更优。 - 过程:
- 把起点
(X的坐标, 0步, 0魔法)
放入优先队列。 - 每次从队列中取出代价最小的状态
(x, y, s, m)
。 - 从这个状态出发,探索所有可能的下一步:
- 普通移动到
(nx, ny)
,新状态的代价是(s+1, m)
。 - 魔法移动到
(nx, ny)
,新状态的代价是(s, m+1)
。
- 普通移动到
- 将这些新的状态放入优先队列。
- 重复以上过程,直到第一次到达终点
W
。此时的(步数, 魔法次数)
就是最优解。
- 把起点
这个方法可行吗? 可行!逻辑上完全正确。但它的时间复杂度是 O(nm * log(nm))
,对于 n, m
高达 5000 的数据规模,这个算法会超时,只能拿到部分分数。我们需要更快的算法。
2. 优化思路:0/1 BFS (双端队列 BFS)
Dijkstra 算法之所以需要用复杂的优先队列,是因为边的权重可以是任意正数。但观察我们的问题,每次移动,代价的变化非常有规律:
- 普通移动:步数
+1
,魔法+0
。 - 魔法移动:步数
+0
,魔法+1
。
因为步数是第一关键字,所以魔法移动(代价 (0, 1)
)总是比普通移动(代价 (1, 0)
)“更优”或“更便宜”。这启发我们,可以把这个问题看作一个边权只为 0 或 1 的特殊最短路问题,从而使用一种更高效的算法——0/1 BFS。
0/1 BFS 使用双端队列 (deque) 来代替优先队列:
- 当我们通过代价为 0 的边(魔法移动)到达一个新节点时,将它插入到队头。
- 当我们通过代价为 1 的边(普通移动)到达一个新节点时,将它插入到队尾。
这样做可以保证队列中的节点,其代价(以步数为主要关键字)大致上是单调递增的,从而保证了算法的正确性,并且时间复杂度可以降到 O(nm)
。
3. 发现陷阱:朴素的 0/1 BFS 为什么错了?
按照上面的思路写出代码,提交后发现——答案错误(WA)!问题出在哪里?
让我们模拟一下朴素 0/1 BFS 的过程,看看陷阱在哪:
- 队列初始状态:
[ 状态A(步数s, 魔法m) ]
- 取出队头的
状态A
。 - 假设
状态A
可以通过魔法移动到达状态C
。新状态C
的代价是(s, m+1)
。我们把它插入队头。 - 此时队列变为:
[ 状态C(s, m+1), ... 队列里剩下的状态 ... ]
- 问题来了! 假设队列里原本紧跟在
状态A
后面的状态B
,它的代价也是(s, m)
。现在,队头变成了代价为(s, m+1)
的状态C
,而下一个要处理的却是代价为(s, m)
的状态B
。队列的代价顺序不再是严格的“从小到大”了,(s, m+1)
比(s, m)
要“差”,却排在了前面。
这破坏了 BFS/Dijkstra 算法的核心——总是从当前所有可达节点中选择代价最小的进行扩展。
4. 最终方案:分层处理的 0/1 BFS
为了解决上述问题,我们需要对 0/1 BFS 做一个修正。根本原因在于,我们不应该在处理一个代价为 (s, m)
的节点时,立刻把它的子节点插回队列,因为可能还有其他代价同为 (s, m)
的节点没有被处理。
正确的做法是:一次性处理完所有代价相同的节点。
这就像是 BFS 的“分层”思想。我们把所有代价为 (s, m)
的节点看作是同一“层”。
- Step 1: 在主循环中,我们首先确定当前要处理的“层”的代价,比如是
(s, m)
。 - Step 2: 我们用一个内部循环,不断从队头取出节点,只要它的代价还是
(s, m)
,就一直处理。 - Step 3: 在处理这些节点时,我们不直接把它们的新邻居放回队列。而是先用两个临时列表(比如
vector
)存起来:- 一个列表
v_magic
存放所有通过魔法移动到达的新状态(代价都是(s, m+1)
)。 - 一个列表
v_normal
存放所有通过普通移动到达的新状态(代价都是(s+1, m)
)。
- 一个列表
- Step 4: 当内部循环结束(即所有代价为
(s, m)
的节点都处理完了),我们再把这两个列表里的新状态放回双端队列:- 先把
v_magic
里的所有状态全部插入到队头。 - 再把
v_normal
里的所有状态全部插入到队尾。
- 先把
这样一来,队列的顺序就完美地维持了:队头总是一批代价为 (s, m+1)
的节点,队尾则是一批代价为 (s+1, m)
的节点,始终保持着代价的 lexicographical(字典序)顺序。
这个经过修正的算法,时间复杂度依然是 O(nm)
,因为每个格子最多被访问一次。它既高效又正确,能够通过本题的所有数据。
三、 代码解读
让我们对照着最终方案来看一下题解提供的代码。
#include <iostream>
#include <queue>
#include <deque>
#include <vector>
// ... 省略了一些宏定义和常量 ...
struct Node {
int x, y, t1, t2; // t1: 步数, t2: 魔法次数
};
deque<Node> q; // 核心数据结构:双端队列
vector<Node> v1, v2; // v1: 存普通移动的邻居, v2: 存魔法移动的邻居
inline void calc() {
// 1. 初始化:将起点放入队列
q.push_front({x1, y1, 0, 0});
// 2. 主循环,只要队列不空就继续搜索
while (!q.empty()) {
// 2.1. 确定当前层的代价
Node cur = q.front();
// 2.2. 内循环:处理当前层所有代价相同的节点
while (!q.empty() && cur.t1 == q.front().t1 && cur.t2 == q.front().t2) {
Node current_node = q.front();
q.pop_front();
// 如果到达终点,直接输出结果并返回
if (current_node.x == x2 && current_node.y == y2) {
cout << current_node.t1 << ' ' << current_node.t2 << '\n';
return;
}
// 如果这个点之前以更优或相同的代价访问过,就跳过
if (vis[current_node.x][current_node.y]) continue;
vis[current_node.x][current_node.y] = 1; // 标记为已访问
// 2.3. 扩展邻居,放入临时列表
// 普通移动(代价+1),放入 v1
for (int i = 0; i < 4; ++i) {
// ... 计算新坐标 x, y ...
// ... 检查边界和墙 ...
v1.push_back({x, y, current_node.t1 + 1, current_node.t2});
}
// 魔法移动(代价+0),放入 v2
for (int i = 0; i < 4; ++i) {
// ... 计算新坐标 x, y ...
// ... 检查边界和墙 ...
v2.push_back({x, y, current_node.t1, current_node.t2 + 1});
}
}
// 2.4. 将临时列表中的节点放回双端队列
// 魔法移动的节点(更优)放队头
for (Node st : v2) q.push_front(st);
// 普通移动的节点(次优)放队尾
for (Node st : v1) q.push_back(st);
// 清空临时列表,为下一层做准备
v1.clear(), v2.clear();
}
// 3. 如果队列为空还没找到终点,说明无解
cout << "-1 -1\n";
}
四、 总结
本题是一个在网格图上求“字典序最短路”的经典问题。
- 问题的核心是最小化一个二元组代价
(步数, 魔法次数)
。 - 标准 Dijkstra 算法可以解决,但因数据量大而超时。
- 通过分析代价变化的特点(
+1
或+0
),我们想到使用更高效的 0/1 BFS。 - 关键的陷阱在于,朴素的 0/1 BFS 会破坏队列的代价单调性。
- 最终的正确解法是分层处理的 0/1 BFS:一次性处理完队列中所有代价完全相同的节点,将它们产生的新邻居暂存,然后统一按代价放入双端队列的头和尾。这保证了算法的正确性,并将时间复杂度优化到
O(nm)
。