[USACO11OPEN] Corn Maze S
BFS 展开邻居时若走入传送门端点,立即免费瞬移到配对端点,整次移动仍只计一步。
OJ: luogu
题目 ID: P1825
难度:普及
标签:BFS网格最短路usaco
日期: 2026-07-16 18:01
形式化题目
有一个
思路
先看一个可以直接验证想法的朴素解:
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-08-13 13:33
* update_at: 2026-08-13 13:33
*/
// brute.cpp:小数据暴力解。把每个格子看成一个点,把每个合法移动建成一条
// 代价为 1 的有向边(走入传送门端点时直接连到配对端点),在显式图上跑普通 BFS。
// 与 main.cpp"展开邻居时内联瞬移"的写法相互独立,用来辅助对拍。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 305;
const int MAXV = 305 * 305 + 5;
int n, m;
char maze[MAXN][MAXN]; // 迷宫
int sx, sy, ex, ey; // 起点 @ 与终点 =
int cnt[26]; // 每个大写字母出现的次数
int px[26][2], py[26][2]; // 每个大写字母两个端点的坐标
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};
vector<int> g[MAXV]; // 显式图:g[id] 存放从该点走一步能到达的点
int dist[MAXV]; // dist[id] 起点到该点的最少步数,-1 表示未访问
// 把坐标 (x,y) 映射成图上的点编号
int id(int x, int y) {
return x * m + y;
}
// 读入迷宫,记录起点、终点和传送门端点
void read_input() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> (maze[i] + 1);
for (int j = 1; j <= m; j++) {
if (maze[i][j] == '@') {
sx = i;
sy = j;
} else if (maze[i][j] == '=') {
ex = i;
ey = j;
} else if (maze[i][j] >= 'A' && maze[i][j] <= 'Z') {
int c = maze[i][j] - 'A';
px[c][cnt[c]] = i;
py[c][cnt[c]] = j;
cnt[c]++;
}
}
}
}
// 显式建图:从每个格子向"走一步能到达的格子"连一条 1 权边
void build_graph() {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (maze[i][j] == '#') continue;
for (int k = 0; k < 4; k++) {
int nx = i + dx[k];
int ny = j + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (maze[nx][ny] == '#') continue;
if (maze[nx][ny] >= 'A' && maze[nx][ny] <= 'Z') {
// 走入传送门端点:必须瞬移到配对端点,瞬移免费,所以仍是 1 权边
int c = maze[nx][ny] - 'A';
int ox = px[c][0], oy = py[c][0];
if (ox == nx && oy == ny) {
ox = px[c][1];
oy = py[c][1];
}
g[id(i, j)].push_back(id(ox, oy));
} else {
g[id(i, j)].push_back(id(nx, ny));
}
}
}
}
}
// 普通 BFS 求最短路
int bfs() {
queue<int> q;
memset(dist, -1, sizeof(dist));
int s = id(sx, sy);
int t = id(ex, ey);
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
if (u == t) {
return dist[u];
}
for (size_t i = 0; i < g[u].size(); i++) {
int v = g[u][i];
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
return -1; // 终点不可达
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
build_graph();
cout << bfs() << '\n';
return 0;
}brute.cpp 把题意直接翻译成图:每个格子一个点,"走入传送门端点"被建成一条直达配对端点的 1 权有向边,然后跑普通 BFS。它正确、容易逐条对照题意,但对本题而言把整张邻接表建出来是多余的。
关键观察有三点:
- 走入端点必须瞬移:移动目标是端点时,落点直接变成配对端点,不存在"停在端点上"的状态;
- 瞬移恰好一次即可:配对端点同字母,若瞬移后再判定传送门会跳回原端点,在两个端点间无限往返;
- 瞬移免费:整个"走一步 + 瞬移"仍然只花 1 时间,所有边的有效代价都是 1,普通 BFS 的层数就是步数。
于是正式解是内联瞬移的 BFS(main.cpp):预处理扫描迷宫,收集每个大写字母的两个端点,建立"端点 → 配对端点"的映射表;BFS 展开邻居时,若目标是端点就先替换成配对端点,再判重入队,步数照常加 1。
样例走法
这张图把样例的最优路径画成状态序列,重点看第 1 步的瞬移:
@(4,3) 步数0 --第1步 走1步--> W(4,4) ==瞬移0步(第1步内)==> W(2,3) --第2步 走1步--> .(2,4) --第3步 走1步--> =(1,4)观察要点:第 1 步从 @ 向右走入 W 端点,落点不是 (4,4) 而是配对端点 (2,3),但这一步仍然只算 1 步(瞬移免费);到达 (2,3) 后端点本身可以停留,第 2、3 步是普通移动。全程 3 步,与样例输出一致。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-08-13 13:33
* update_at: 2026-08-13 13:35
*/
// main.cpp:P1825 Corn Maze S 正式解。BFS 求最短路,
// 走入传送门端点时在展开邻居的同一层内立即瞬移到配对端点。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 305;
const int MAXM = 305;
int n, m;
char maze[MAXN][MAXM]; // 迷宫,maze[i][j] 是 (i,j) 上的字符
int dist[MAXN][MAXM]; // dist[i][j] 起点到 (i,j) 的最少步数,-1 表示未访问
int sx, sy; // 起点 @ 的坐标
int ex, ey; // 终点 = 的坐标
int to_x[MAXN][MAXM]; // (i,j) 是传送门端点时,配对端点的横坐标,否则为 -1
int to_y[MAXN][MAXM]; // 配对端点的纵坐标
int cnt[26]; // cnt[c] 大写字母 c 出现的次数(每个字母恰好 2 次)
int px[26][2], py[26][2]; // 每个大写字母两个端点的坐标
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};
// 判断 (x,y) 是否在迷宫范围内
bool in_maze(int x, int y) {
return x >= 1 && x <= n && y >= 1 && y <= m;
}
void read_input() {
cin >> n >> m;
memset(to_x, -1, sizeof(to_x));
memset(to_y, -1, sizeof(to_y));
for (int i = 1; i <= n; i++) {
cin >> (maze[i] + 1);
for (int j = 1; j <= m; j++) {
if (maze[i][j] == '@') {
sx = i;
sy = j;
} else if (maze[i][j] == '=') {
ex = i;
ey = j;
} else if (maze[i][j] >= 'A' && maze[i][j] <= 'Z') {
int c = maze[i][j] - 'A';
px[c][cnt[c]] = i;
py[c][cnt[c]] = j;
cnt[c]++;
}
}
}
// 每个字母恰好两个端点,把它们互相记录为对方的配对端点
for (int c = 0; c < 26; c++) {
if (cnt[c] == 2) {
int x1 = px[c][0], y1 = py[c][0];
int x2 = px[c][1], y2 = py[c][1];
to_x[x1][y1] = x2;
to_y[x1][y1] = y2;
to_x[x2][y2] = x1;
to_y[x2][y2] = y1;
}
}
}
// BFS 求最短步数:移动一格计 1 步;走入传送门端点必须瞬移,瞬移免费,
// 所以"走一步 + 瞬移"整体仍只计 1 步。
int bfs() {
queue<pair<int, int>> q;
memset(dist, -1, sizeof(dist));
dist[sx][sy] = 0;
q.push(make_pair(sx, sy));
while (!q.empty()) {
int x = q.front().first;
int y = q.front().second;
q.pop();
if (x == ex && y == ey) {
return dist[x][y];
}
for (int k = 0; k < 4; k++) {
int nx = x + dx[k];
int ny = y + dy[k];
if (!in_maze(nx, ny) || maze[nx][ny] == '#') continue;
// 走入的是传送门端点:必须瞬移到配对端点。
// 配对端点与当前端点同字母,瞬移恰好一次即完成;
// 若瞬移后再次检查传送门,就会在两个端点之间来回跳,形成死循环。
if (to_x[nx][ny] != -1) {
// 先保存配对端点坐标再赋值,避免修改 nx 后 to_y 下标错位
int tx = to_x[nx][ny];
int ty = to_y[nx][ny];
nx = tx;
ny = ty;
}
if (dist[nx][ny] == -1) {
dist[nx][ny] = dist[x][y] + 1;
q.push(make_pair(nx, ny));
}
}
}
return -1; // 终点不可达
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
cout << bfs() << '\n';
return 0;
}复杂度
- 时间:预处理与 BFS 都只扫常数次网格,
。 - 空间:迷宫、配对表、步数表各
,总空间 。
总结
传送门没有引入新的代价:把"走一步 + 瞬移"整体看成一条代价 1 的边,问题就和普通网格最短路一样,普通 BFS 即可。实现上只需注意两点:走入端点必须替换成配对端点(只替换一次,防止端点间死循环);替换前先保存配对坐标再赋值。BFS 基础可参考 rbook《图的遍历》。
图示解析
这张 ASCII 图串起本题从建模到得到答案的主线:
网格迷宫 @ 到 =(每对同字母传送门 A-Z,双向免费)
`- 关键观察 1:走入传送门端点必须立即瞬移到配对端点,不能停留在端点
`- 关键观察 2:配对端点同字母,瞬移恰好一次即可,二次瞬移会来回死循环
`- 关键观察 3:瞬移免费,所以"走一步 + 瞬移"整体代价仍是 1
`- 选择算法:普通 BFS(所有边代价 1,层数即步数)
|- 预处理:扫描迷宫,记录每字母两个端点,建立 to_x/to_y 配对表
|- 展开邻居:若邻居是端点,先替换成配对端点再判重入队
`- 首次到达终点时的 dist 就是答案顺着箭头看,三条关键观察依次排除了"额外建图"“多次瞬移”"分层入队"三种绕路做法,最终落点是"与普通网格最短路几乎相同"的 BFS,预处理与遍历都只扫常数次网格。