第 24 节的 DFS 回答的是“能不能走到”。这一节回答“最少几步”。两个问法只差一个字,算法完全不同:DFS 一次沿一条路走到黑,要比较“最少”就得把所有路径长度都试一遍,棋盘上路径数是指数级的;BFS 换一种走法,把距离相同的点攒成一批一起处理,谁先被算出来,谁的答案就是最短的。
| DFS | BFS | |
|---|---|---|
| 数据结构 | 栈(递归调用) | 队列 queue |
| 访问顺序 | 一条路走到底,退回来再换 | 按距离一圈一圈往外扩 |
| 第一次到某点 | 不一定是最短路 | 一定是最短路(边权全为 1 时) |
| 擅长 | 连通性、枚举所有方案、回溯 | 无权最短路、最少步数、扩散类问题 |
| 复杂度 | O(V + E) | O(V + E) |
q.push(v) 的下一句(甚至同一句)就写 dis[v] = dis[u] + 1。等出队才标记,同一个点会被好几个前驱各塞一次。这一节要新增三样东西:一个容器、一个打包工具、一行批量初始化。先把结构看清楚。
| 组成 | 含义 / 格式要求 |
|---|---|
queue<int> q; | 声明一个放 int 的队列。尖括号里可以是任何类型:pair、结构体、string。 |
q.push(v) | 把 v 放到队尾。返回 void。 |
q.front() | 只看队首,不删除。要用的值必须先取出来存进变量。 |
q.pop() | 删除队首。它不返回被删的元素,所以 front 和 pop 必须成对写,顺序不能反。 |
q.empty() | 队列空返回 true。取 front 之前必须确认它不空。 |
q.size() | 当前元素个数。 |
没有 clear、不能下标 | queue 不支持 q.clear(),也不支持 q[i]。多测要清空就重新声明一个,或者 while (!q.empty()) q.pop();。 |
queue<int> q;
q.push(1); q.push(2); q.push(3); // 从队尾进
while (!q.empty()) {
int u = q.front(); // ① 先取队首
q.pop(); // ② 再删掉它
cout << u << " "; // 输出 1 2 3:先进先出
}int u = q.pop(); 编译不过:pop() 的返回类型是 void。
空队列上取 front / pop 是未定义行为,本地可能返回垃圾,线上直接 RE。
| 组成 | 含义 / 格式要求 |
|---|---|
pair<A,B> | 把两个值(类型可以不同)打包成一个整体。BFS 里用它同时存行和列。 |
make_pair(x, y) | 按 x、y 的类型自动造一个 pair,不用手写尖括号里的类型。 |
p.first / p.second | 取第一个、第二个分量。它们是成员变量,不是函数,不加括号。 |
{x, y} | C++11 起可以直接用花括号构造,q.push({nx, ny}) 与 make_pair 等价。 |
自带比较 | pair 按字典序比较:先比 first,相等再比 second。所以能直接放进 set / map / sort。 |
嵌套写法 | queue<pair<int,int>> 在 C++11 之前要写成 > > 中间留空格,现在直接写即可。 |
pair<int,int> s = make_pair(3, 5);
cout << s.first << " " << s.second << "\n"; // 3 5
queue<pair<int,int>> q; // 存坐标的队列
q.push(make_pair(1, 2));
q.push({3, 4}); // C++11 写法,等价
pair<int,int> cur = q.front();
int x = cur.first, y = cur.second; // 拆开用,比每次写 cur.first 清楚pair 没有下标,p[0] 编译不过。
pair 的两个 int 各存什么全靠你自己的约定。在注释里写死“first 是行、second 是列”,能省一半调试时间。
| 组成 | 含义 / 格式要求 |
|---|---|
首地址 | 数组名 dis 会自动退化成指针。只对真正的数组有效;如果传的是函数参数(指针),sizeof 会变成 8 字节,只填了开头一点。 |
第二个参数 | 要填的字节,不是 int 值。它逐字节覆盖,所以只有 0、−1 这类“四个字节全一样”的数值才填得出想要的 int。 |
sizeof(dis) | 总字节数。数组必须在这一层作用域可见,否则算错。 |
为什么 −1 可以 | −1 的补码是 32 个 1,拆成 4 个字节是 FF FF FF FF,逐字节填 FF 拼回来仍然是 −1。 |
为什么 1 不行 | 填 1 得到 0x01010101 = 16843009,不是 1。这是 memset 最经典的坑。 |
替代写法 | int dis[N][M] = {}; 只能全 0。要全 −1 又不想 memset,就另开一个 vis 数组记“到过没有”。 |
int dis[405][405];
memset(dis, -1, sizeof(dis)); // 全变成 -1:没到过
memset(dis, 0, sizeof(dis)); // 全变成 0
// memset(dis, 1, sizeof(dis)); // ✗ 每一项会变成 16843009memset(dis, 0x3f, sizeof dis) 得到 1061109567,常当“无穷大”:它小于 INT_MAX,两个相加也不溢出。
多测时 memset 必须放在每组数据的开头,不能放在读入之后。
有一个 n × m 的棋盘,某个点 (x, y) 上有一个马。马走“日”字,共有 8 个方向。求马到达棋盘上每个点最少要走几步,不能到达输出 −1。
输入:一行四个整数 n、m、x、y(1 ≤ x ≤ n ≤ 400,1 ≤ y ≤ m ≤ 400)。
输出:n 行,每行 m 个整数,第 i 行第 j 个数表示马到 (i, j) 的最少步数。2022 年 8 月之后本题取消了场宽要求,用空格分隔即可。
先回答两个问题,代码就是照抄模板。状态是什么?格子 (i, j),一共 n × m ≤ 160000 个。从状态能到哪些状态?8 个日字方向,每跳一步代价都是 1。
每步代价都是 1 → 无权最短路 → BFS。源点 (x, y) 距离 0,答案就是每个状态的距离。
DFS 为什么不行:DFS 没有“第一次访问就是最短”这个性质,要拿到最少步数必须把所有路径都走完再取最小,而棋盘上的路径数量是指数级的。这不是剪枝能救的。
memset(dis, -1, sizeof(dis)); // ① 所有格子标记为“没到过”
q.push(make_pair(sx, sy));
dis[sx][sy] = 0; // ② 源点入队,同一时刻标记
while (!q.empty()) { // ③ 一层一层往外扩
pair<int,int> cur = q.front(); q.pop();
for (int k = 0; k < 8; ++k) {
int nx = cur.first + dx[k], ny = cur.second + dy[k];
// 出界 → 跳过;到过 → 跳过;否则 dis + 1 后入队
}
}两个必须想清楚的地方:
dis[nx][ny] = dis[x][y] + 1 就够,不用取 min?因为第一次访问 (nx, ny) 时,扩展它的 (x, y) 一定属于队列里距离最小的那一批,而任何更短的路径都得经过一个距离更小的邻点,那个邻点会先出队、先把 (nx, ny) 写掉。dis != -1 立刻失效。#include <bits/stdc++.h>
using namespace std;
const int MAXN = 405; // n, m ≤ 400,多留几个位置给边界
int dis[MAXN][MAXN]; // dis[i][j]:最少步数,-1 表示没到过
const int dx[8] = {1, -1, 2, -2, 1, -1, 2, -2}; // 8 个日字方向,dx[k] 与 dy[k] 配对使用
const int dy[8] = {2, 2, -1, -1, -2, -2, 1, 1};
int main() {
ios::sync_with_stdio(false); // 要输出 16 万个数,关同步更稳
cin.tie(nullptr);
int n, m, sx, sy;
cin >> n >> m >> sx >> sy;
memset(dis, -1, sizeof(dis)); // 全局数组默认是 0,必须显式置 -1
queue<pair<int, int>> q;
q.push(make_pair(sx, sy));
dis[sx][sy] = 0; // ★ 入队的同一时刻标记,不等出队
while (!q.empty()) {
pair<int, int> cur = q.front();
q.pop(); // front 取值、pop 删除,两句不能合并
int x = cur.first, y = cur.second;
for (int k = 0; k < 8; ++k) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue; // 跳出棋盘
if (dis[nx][ny] != -1) continue; // 到过,且步数不会更少
dis[nx][ny] = dis[x][y] + 1; // 第一次到 = 最短
q.push(make_pair(nx, ny));
}
}
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cout << dis[i][j] << (j == m ? '\n' : ' ');
}
}
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
const int MAXN = 405; | 下标用到 1..400,开 400 会在 n = 400 时越界一格。开 405 是习惯性的安全余量。 |
const int dx[8] = {1, -1, 2, -2, 1, -1, 2, -2}; | 方向数组。马有 8 个方向,不是 4 个:除了 (1,2)、(2,1)、(-1,-2)、(-2,-1),还有 (1,-2)、(2,-1)、(-1,2)、(-2,1)。 |
memset(dis, -1, sizeof(dis)); | −1 的 32 位全是 1,逐字节填 FF 拼回来还是 −1,所以这句能一次刷完整张表。 |
queue<pair<int, int>> q; | 队列元素是 pair,一次带两个整数,省掉两个并行的队列。 |
q.push(make_pair(sx, sy)); dis[sx][sy] = 0; | 入队和标记紧挨着写,中间不插别的语句。判重完全靠这个标记;一旦漏写,源点会被反复入队,整张表跟着错位(每个格子少 1)。 |
pair<int, int> cur = q.front(); q.pop(); | 先取值再删除。写成 q.pop(); cur = q.front(); 就丢了一个元素。 |
if (nx < 1 || nx > n || ny < 1 || ny > m) continue; | 棋盘下标从 1 开始,所以下界是 1,不是 0。写成 < 0 会读到 dis[0][*]。 |
if (dis[nx][ny] != -1) continue; | 判重。BFS 里“已经到过”等价于“最短距离已经确定”,后面任何一次到达都不会更优。 |
dis[nx][ny] = dis[x][y] + 1; | 转移。因为队列按距离非降出队,这一句写下的就是最小值。 |
cout << dis[i][j] << (j == m ? '\n' : ' '); | 行末只换行、不加空格。本题对场宽不敏感,但养成按格式输出的习惯。 |
把“一个源点”改成“很多个源点”,代码只改一处:初始时把所有源点都 push 进队列、dis 都设 0,之后的过程一个字不动。
为什么这样是对的?想象棋盘外有一个虚拟的超级源点 S,它到每个真源点都有一条权为 1 的边。从 S 出发做 BFS,第一层恰好是全部真源点,第二层是“离某个源点 1 步”的格子……把 S 那一层去掉,剩下的就是多源 BFS。所以“每个格子到最近源点的距离”一次 BFS 就能算完,不需要对每个源点各跑一遍。
// k 个源点,一次 BFS 求出每个格子到最近源点的距离
memset(dis, -1, sizeof(dis));
while (k--) {
int x, y;
cin >> x >> y;
dis[x][y] = 0;
q.push(make_pair(x, y)); // ★ 所有源点一起入队,不是只 push 第一个
}
// 下面的 while (!q.empty()) 与单源 BFS 完全一样题目问的是“到最近的某个东西的距离”,而不是“到指定的那个东西的距离”。
典型场景:每个格子离最近的火源要烧多久、离最近的出口多远、多个起点的队伍同时出发什么时候会合。
如果对每个源点各跑一次 BFS,复杂度是 k × nm,k 一多就 TLE——这正是多源 BFS 存在的理由。
BFS 的三条铁律里,没有一条要求状态必须是格子。只要满足两件事就能用 BFS:状态数量有限、任意两个状态之间的转移代价都是 1。
比如电梯问题:状态是楼层号,转移只有“上 K[u] 层”和“下 K[u] 层”两条。把坐标 + 8 个方向换成楼层 + 2 个方向,模板一个字都不用改。
queue<int> q; // 状态只是一个整数,队列就存 int
q.push(A);
dis[A] = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
int nxt[2] = { u + K[u], u - K[u] }; // 两条出边
for (int t = 0; t < 2; ++t) {
int v = nxt[t];
if (v < 1 || v > N) continue; // 出界:这个按钮失灵
if (dis[v] != -1) continue; // 已经用更少的步数到过
dis[v] = dis[u] + 1;
q.push(v);
}
}这就是 BFS 模板的价值:写熟一遍,之后每道题只填两件事——状态是什么、从状态能到哪些状态。其余部分(队列、标记、距离 +1)永远相同。
| 题目 | 状态 | 转移 |
|---|---|---|
| 马的遍历 | (行, 列) | 8 个日字方向 |
| 迷宫最短路 | (行, 列) | 上下左右 4 个方向 |
| 奇怪的电梯 | 楼层 1..N | u ± K[u],越界即无效 |
| 倒水问题 | (A 杯水量, B 杯水量) | 倒满 / 倒空 / 互相倒,共 6 种 |
| 单词接龙 | 当前单词 | 改一个字母,要求在词典里 |
WA:除源点外全是 −1。—— dis 没置 −1(全局数组默认 0),判重条件 dis != -1 对所有格子都成立,第一次扩展就全被跳过。
WA:答案偏大。—— 在出队时才标记。同一个格子被多个前驱各 push 一次,队列膨胀;如果 dis 又在 push 时被无条件覆盖,后写入的较大距离会盖掉先写入的最小值。标记要和 push 同时发生。
RE(内存吃光)或 TLE 死循环。—— 删掉判重那一行。棋盘有环,A→B→A 会无限入队,几秒内队列吃掉几十 MB 然后 RE。
RE:数组越界。—— 棋盘是 1-based 却写成 nx < 0,或者根本不判越界。数组开 405 时 n = 400 读到 dis[401][*] 不会立刻崩,症状是随机 WA。
WA:大片格子变成 −1。—— 方向数组只写了 4 个。检查 dx/dy 是否成对出现 8 组,并且每组都用了正确的正负号(dx[k] 和 dy[k] 必须同属一个“日”)。
TLE:多源题写成了单源循环。—— 对每个源点各跑一次 BFS,复杂度 k × nm。多源 BFS 只跑一次。
WA:每个格子都比正确答案少 1,源点变成 −1。—— push 了源点却忘了写 dis[sx][sy] = 0,于是第一层算出的是 −1 + 1 = 0,整张表跟着错位。
只 push 了第一个源点,后面的都当成普通点:答案只对第一个源点正确。
多源时忘了先 memset(dis, -1, ...):dis 全是 0,判重把所有格子都当成“已到过”,BFS 一步都扩不出去,输出全 0。
状态维度估少了。若状态是 (行, 列, 朝向),dis 必须开三维;只开二维会让不同朝向互相覆盖,答案偏小。
关掉页面,从空文件把 P1443 敲一遍并提交。
改一改 ①:把 if (dis[nx][ny] != -1) continue; 整行删掉,用 n = m = 400 的数据跑。程序再也不会停:队列一直涨,内存很快耗光(评测机上表现为 RE 或 TLE)——亲眼看一下没有标记的 BFS 怎么在环里绕圈。
改一改 ②:把 memset(dis, -1, sizeof(dis)) 改成 memset(dis, 0, sizeof(dis)),输入 3 3 1 1,输出会变成 9 个 0。想清楚:为什么除了源点之外也全是 0?
改一改 ③:把方向数组砍成 4 个(dx[] = {1,1,2,2} 配 dy[] = {2,-2,1,-1}),再跑样例,看哪些格子从数字变回了 −1,想清楚为什么它们到不了。
改一改 ④:输出前统计 dis == -1 的格子个数,顺便输出最大步数。
BFS 的正确性可以压缩成两条断言,每条两三行就能证完。
断言 1:任意时刻,队列中元素的 dis 值单调不降,且最多只有两种取值:t 和 t+1。
证明:初始时队列里只有源点,dis = 0,成立。设当前队首 dis = t。出队后,我们把所有新点按 dis = t+1 压到队尾。原来队里的元素 dis ∈ {t, t+1},新压入的是 t+1,所以队里仍是非降的,取值仍是至多两种。∎
断言 2:点 v 第一次被写入的 dis 值,就是 v 到源点的最短距离 d(v)。
证明(对 d 归纳):d = 0 时只有源点,显然。设所有 d < k 的点都写对了,考察 d = k 的点 v。取最短路上紧挨着 v 的前驱 u,有 d(u) = k−1,由归纳假设 dis(u) 已正确写成 k−1。u 出队时会尝试扩展 v:若 v 还没被写过,就写入 k。若 v 在此之前已被某个点 w 写过,则 w 出队早于 u,由断言 1(队列非降)得 dis(w) ≤ k−1,所以写入值 ≤ k;另一方面,任何能到达 v 的点 x 都有 d(x) ≥ k−1(否则 v 的最短距离还能更小),所以写入值 ≥ k。两边夹住,dis(v) = k。∎
推论(复杂度):每个状态最多入队一次、出队一次,每条转移边只被检查一次,所以是 O(V + E)。本题 V = n·m ≤ 160000,E = 8V,约 1.28×10⁶ 次检查。
边界在哪:断言 1 的归纳只用到“每次新增的距离恰好是 t+1”。若某条边权是 2,队列里就会出现 t+2,取值超过两种,“最近的一批先出队”不再成立,第一次到达也不再是最短。边权不全为 1 时要用 Dijkstra(第 42 节),只有 0 和 1 两种权时用 0-1 BFS(第 28 节)。
顺带一个字节运算:memset(dis, -1, ...) 之所以成立,是因为 −1 的补码是 32 个 1,4 个字节完全相同;0x3f3f3f3f = 1061109567 也满足“四字节相同”,而且它小于 2³⁰,两个相加不超过 int 上限,所以常被当无穷大用。
大楼每一层都能停电梯,第 i 层上写着一个数字 Ki。电梯只有“上”和“下”两个有效按钮,按一次就上升或下降 Ki 层;如果目标楼层不在 1..N 范围内,这个按钮失灵。
现在从 A 层出发,求到 B 层最少要按几次按钮。
输入:第一行三个正整数 N、A、B(1 ≤ N ≤ 200,1 ≤ A, B ≤ N);第二行 N 个非负整数 Ki(0 ≤ Ki ≤ N)。
输出:一行一个整数,表示最少按键次数;若无法到达则输出 −1。
把“楼层”当成点:从第 i 层能到 i + Ki 和 i − Ki 两层(只要还在 1..N 内),所以每个点最多两条出边。
每按一次按钮代价都是 1,问的是“最少按几次”,这就是一张无权图上的最短路。
从 A 出发 BFS,BFS 结束后 dis[B] 就是答案;它仍然是 −1 就说明到不了。别忘了一种情况:A 等于 B。
状态:楼层编号 1..N,共 N ≤ 200 个。源点是 A,dis[A] = 0。
转移:u → u + K[u]、u → u − K[u],落在 [1, N] 之外的一律丢弃(按钮失灵)。
边权全为 1 → BFS。BFS 的分层性质保证 dis 就是最少按键次数。
为什么 A == B 不用特判:初始就写了 dis[A] = 0,循环结束输出 dis[B] 自然是 0。
为什么 Ki = 0 不会死循环:此时两个方向都是 v = u,而 dis[u] 已经被标记,两句话都被 continue 挡掉。但如果把判重删掉,就会原地无限入队。
复杂度:每个楼层最多入队一次,2N 次转移,O(N)。
#include <bits/stdc++.h>
using namespace std;
int N, A, B;
int K[205], dis[205]; // 全局数组默认全 0
int main() {
cin >> N >> A >> B;
for (int i = 1; i <= N; ++i) cin >> K[i];
memset(dis, -1, sizeof(dis)); // -1:这一层还没到过
queue<int> q;
q.push(A);
dis[A] = 0; // 起点:0 次按键
while (!q.empty()) {
int u = q.front();
q.pop();
int nxt[2] = { u + K[u], u - K[u] }; // 上 K[u] 层 / 下 K[u] 层
for (int t = 0; t < 2; ++t) {
int v = nxt[t];
if (v < 1 || v > N) continue; // 出了大楼:按钮失灵
if (dis[v] != -1) continue; // 已经有更少的按键次数了
dis[v] = dis[u] + 1;
q.push(v);
}
}
cout << dis[B] << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
memset(dis, -1, sizeof(dis)); | 先把所有楼层当“没到过”。全局数组默认是 0,不置 −1 的话第 1 层以外都会被判成已访问。 |
q.push(A); dis[A] = 0; | 源点入队并立即标记,与 P1443 一模一样。 |
int nxt[2] = { u + K[u], u - K[u] }; | 把两条出边先算出来,避免在循环里写两遍同样的判断逻辑。 |
if (v < 1 || v > N) continue; | 边界。楼层是 1..N,越界说明按钮失灵,不是走到楼外。 |
if (dis[v] != -1) continue; | 判重。注意 K[u] = 0 时 v = u,这一句正好挡住自己到自己。 |
dis[v] = dis[u] + 1; | 一次按键,距离 +1。 |
cout << dis[B] << "\n"; | A == B 时输出 0,到不了时 dis[B] 还是 −1,两种情况都自动正确。 |