← 目录 第 26 / 60 节 全部 60 节

26 · BFS:一层一层地扩散

从“能不能走到”升级到“最少几步”:用队列把距离相同的点攒成一批处理,一次遍历拿到无权图上的所有最短路。掌握三条铁律(队列、入队即标记、dis 初值 −1),再把它推到多源与任意状态上。
普及/提高-预计 65 分钟BFS最短路多源 BFS
本节出现的新函数 / 新语法(先看懂再用)

第 24 节的 DFS 回答的是“能不能走到”。这一节回答“最少几步”。两个问法只差一个字,算法完全不同:DFS 一次沿一条路走到黑,要比较“最少”就得把所有路径长度都试一遍,棋盘上路径数是指数级的;BFS 换一种走法,把距离相同的点攒成一批一起处理,谁先被算出来,谁的答案就是最短的。

BFS 只做一件事:把距离为 k 的点全部处理完,再处理距离为 k+1 的点。队列的先进先出,就是这个顺序的物理实现。
DFSBFS
数据结构栈(递归调用)队列 queue
访问顺序一条路走到底,退回来再换按距离一圈一圈往外扩
第一次到某点不一定是最短路一定是最短路(边权全为 1 时)
擅长连通性、枚举所有方案、回溯无权最短路、最少步数、扩散类问题
复杂度O(V + E)O(V + E)

这一节要新增三样东西:一个容器、一个打包工具、一行批量初始化。先把结构看清楚。

新函数拆解 · queue(队列)
queue<类型> q; q.push(v); q.front(); q.pop(); q.empty();
组成含义 / 格式要求
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 与 make_pair(把两个值绑成一个)
pair<int,int> p = make_pair(x, y); p.first; p.second;
组成含义 / 格式要求
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 是列”,能省一半调试时间。

新函数拆解 · memset(把数组的每个字节设成同一个值)
memset(dis, -1, sizeof(dis)); // 每一项变成 -1
组成含义 / 格式要求
首地址数组名 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));   // ✗ 每一项会变成 16843009
注意

memset(dis, 0x3f, sizeof dis) 得到 1061109567,常当“无穷大”:它小于 INT_MAX,两个相加也不溢出。

多测时 memset 必须放在每组数据的开头,不能放在读入之后。

例题 · 马的遍历洛谷 P1443普及/提高-去洛谷做这题 ↗

有一个 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 月之后本题取消了场宽要求,用空格分隔即可。

输入: 3 3 1 1 输出: 0 3 2 3 -1 1 2 1 4
例题怎么想:把棋盘拆成“状态 + 转移”

先回答两个问题,代码就是照抄模板。状态是什么?格子 (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 后入队
    }
}

两个必须想清楚的地方:

完整代码
#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 就能算完,不需要对每个源点各跑一遍。

多源 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..Nu ± K[u],越界即无效
倒水问题(A 杯水量, B 杯水量)倒满 / 倒空 / 互相倒,共 6 种
单词接龙当前单词改一个字母,要求在词典里
BFS 的 7 种典型错法(含症状)

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,整张表跟着错位。

多源与状态抽象的 3 个坑

只 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 上限,所以常被当无穷大用。

举一反三 · 想清楚这些再往下走
  1. 把“马走日”换成“只能上下左右走”:没有障碍时,BFS 算出来的答案正好等于曼哈顿距离 |Δx| + |Δy|。那么这题还需要 BFS 吗?如果棋盘上加了若干不能通过的格子,直接套公式还成立吗?
  2. 如果棋盘上有障碍格不能通过,BFS 只需要改哪两行代码?如果障碍是“可以炸开一次”的墙,状态要怎么加一维,dis 数组的维度会变成什么?
  3. 要求“从起点到终点的不同最短路径条数”,应该在 BFS 的哪一步累加?为什么不能简单地把邻居的 dis 相加?
  4. 如果边权只有 1 和 2 两种,BFS 还成立吗?上面断言 1 的归纳坏在哪一步?如果只有 0 和 1 两种呢,能不能用一个双端队列救回来?
  5. 多源 BFS 与“从超级源点出发的 BFS”为什么等价?如果各个源点的初始距离不是 0 而是各不相同的值,还能一次 BFS 算完吗?
  6. 怎么在 BFS 里还原出一条具体的最短路径,而不只是它的长度?需要额外记录什么,从哪个点开始倒推?
自测:能全部回答再进下一节
挑战题 · 奇怪的电梯
洛谷 P1135普及/提高-去洛谷做这题 ↗

大楼每一层都能停电梯,第 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,两种情况都自动正确。
这题最容易错的地方
  • 下标只开到 K[200]、dis[200]:i = 200 时越界,改成 205。
  • 忘了判 1..N 边界,读到 dis[0] 或 dis[201],输出一个不存在的层数。
  • 把 dis 用 0 初始化:源点以外的层全被判成已访问,答案几乎恒为 −1。
  • Ki = 0 时删掉判重,程序原地无限入队,表现为 RE 或死循环。
延伸阅读 / 参考