BFS 不只是「在网格上走」——今天把它升级成三个通用套路:多源起点、带时间的环境、以及「搜一次、答很多次」的预处理
| 0 - 25 min | 搞清「状态」是什么,学会估算搜索复杂度(第 1 节) |
| 25 - 55 min | 多源 BFS + 时间维度预处理(第 2、3 节) |
| 55 - 85 min | 连通块染色——今天的重头戏(第 4 节) |
| 85 - 175 min | 做 4 道题,每题限时 30 分钟(其中 P1228 可以放宽到 40 分钟) |
| 175 - 205 min | 对照本文题解,把没做出来的补掉 |
| 205 - 240 min | ★ 第三周复盘:默写模板 + 重做卡住的题(第 6 节) |
Day 20 你已经会写「网格上的 BFS 模板」了:起点入队、四方向扩展、dist 数组记步数、队列空了就结束。
今天的四道题,模板一行都不用改,改的全是「起点有几个」「什么时候能进这个格子」「搜完之后答案怎么复用」。也就是说:今天的难点不在 BFS 本身,而在怎么给 BFS 用对地方。
BFS 的本质是「在一张图上按层扩散」。图上的每个结点叫一个状态,每条边叫一次转移。
Day 20 的迷宫题里,状态就是 (x, y) 这一个坐标对。但状态不一定是坐标,它可以是任何「能唯一描述当前局面」的东西:
| 题目 | 状态是什么 | 状态总数 | 转移是什么 |
|---|---|---|---|
| P1443 马的遍历 | 棋盘上的格子 (x, y) | n × m | 马的 8 种走法 |
| P1135 奇怪的电梯 | 当前楼层 f | n | 上楼 ki 层 / 下楼 ki 层 |
| P1141 01迷宫 | 格子 (x, y) | n × n = 10⁶ | 走向四邻中数字不同的格 |
| P1332 血色先锋队 | 格子 (x, y) | n × m | 四方向走一格 |
| P2895 陨石 | 格子 (x, y) + 时刻 | 格子数 × 时刻数 | 四方向走一格(耗时 1) |
总操作数 ≈ 状态总数 × 每个状态的转移数
先把状态数估出来,你就知道会不会 TLE,也就知道该往哪个方向优化。
P1141 里,迷宫是 n × n(n ≤ 1000),所以状态总数是 10⁶,每个状态的转移是 4。
| 做法 | 一次询问的代价 | m 次询问的总代价 | n=1000, m=10⁵ |
|---|---|---|---|
| 每次询问都单独 BFS 一遍 | O(n²) = 10⁶ | O(m · n²) | 10¹¹ ❌ 必 TLE |
| 先染色预处理,再 O(1) 查表 | O(1) | O(n² + m) | 约 1.1×10⁶ ✅ |
同样一道题,同样的 BFS 模板,一个 10¹¹,一个 10⁶,差了十万倍。
差别只在于:你是在「每次询问都重新搜一遍」,还是「搜一次,把答案存下来给所有询问用」。
军团是 n 行 m 列的一片区域,每格一个人。有 a 个感染源,瘟疫每小时向四周扩散一圈。问 b 个领主各自在第几小时被感染。
如果只有一个感染源,那就是 Day 20 的模板:源点入队,dist[源] = 0,正常 BFS。
现在有 a 个源。最笨的想法是:对每个领主,从每个源各搜一次取最小值 —— 那是 a × b 次 BFS,直接爆炸。
把所有起点在 BFS 开始前一次性全部入队,且它们的距离都是 0,然后照常 BFS。
这样搜完之后,dist[x][y] 就等于「到最近的那个起点的距离」。
想象一个不存在的「超级起点 S」,它到每一个感染源都有一条长度为 0 的边。
那么「从超级起点 S 出发的普通 BFS」给出的距离,正是「到最近感染源的距离」。而把 a 个源同时以距离 0 入队,效果和这个超级起点完全一样——它们都是「第 0 层」的结点。
BFS 是按层扩散的,第 0 层同时站着 a 个人,第 1 层就是离这些源最近的格子……谁先扩散到某一格,那个格子的距离就是「到最近源」的距离。因为 BFS 保证第一次访问到某个点时就是最短距离,而抢先到达它的那个源,就是离它最近的那个源。
只改读入那一段,其它一行不动:
// 普通 BFS:一个起点 dist[sx][sy] = 0; q.push({sx, sy}); // 多源 BFS:所有起点一起入队,距离都是 0 for(int k = 0; k < a; k++){ int x, y; cin >> x >> y; if(dist[x][y] == -1){ // 防止同一个源被塞进队列两次 dist[x][y] = 0; q.push({x, y}); } }
数据范围里 a ≤ 10⁵,但格子只有 n × m ≤ 250000 个,所以同一个格子被反复列为感染源是很正常的。
加一句 if(dist[x][y] == -1) 就不会重复入队。
就算不加,BFS 里也有 dist[nx][ny] != -1 的判重保护,答案依然正确,只是队列里会多出一堆无用的元素——能加就加上,白省一点。
入队 O(a),BFS O(n·m),回答 O(b) → O(n·m + a + b)
本题 n, m ≤ 500(25 万格),a, b ≤ 10⁵,总操作数约 4.5×10⁵,随便过。
贝茜站在原点 (0,0)。有 M 颗流星会砸下来,第 i 颗在时刻 Tᵢ 砸在 (Xᵢ, Yᵢ),并把落点以及上下左右四格全部烧成焦土。贝茜每单位时间能走一格,问最少多久能走到一个「永远不会被烧」的格子上,不可能则输出 −1。
Day 20 的迷宫,障碍是固定的:0 永远是 0,1 永远是 1。
这里障碍是随时间出现的:t = 1 时那格还能站,t = 2 时那格已经烧了。
所以「能不能进某个格子」不再只看坐标,还要看你几点到。
很自然会想:状态写成 (x, y, t) 不就行了?
但这样状态总数变成「格子数 × 时刻数」。坐标最多 300×300,时刻最多 1000,那就是 9×10⁷ 个状态,内存和时间都吃紧,而且——根本没必要。
危险区只会越来越大,不会缩小。所以如果你在时刻 t 能站在格子 (x,y),那么在 t′ < t 的时刻你当然也能站在这里;而且早到意味着你之后的选择只多不少。
既然「早到」永远不吃亏,那我们只需要知道到达每一格的最早时刻——这正是 BFS 求出来的 dist!
所以时间根本不用进状态,只要在「能不能踏进去」这一步加一个时刻判断即可。
分两步走:
burn[x][y]:这个格子最早在第几时刻变成焦土。没被烧过就记成一个很大的数(表示永远安全)。// 第一步:造危险时刻表 if(t < burn[x][y]) burn[x][y] = t; // 落点本身 for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue; if(t < burn[nx][ny]) burn[nx][ny] = t; // ★ 取最早的那次 } // 第二步:BFS,加一个时刻判断 if(dist[nx][ny] != -1) continue; // 访问过就跳过 if(dist[x][y] + 1 < burn[nx][ny]){ // ★ 必须「严格早于」被烧的时刻 dist[nx][ny] = dist[x][y] + 1; q.push(nx * N + ny); }
坑 1:把 < 写成 <=。
题目原话是「如果一个格子在时刻 t 被流星撞击或烧焦,那么贝茜只能在 t 之前的时刻在这个格子里出现」。
所以在时刻 t 踏进一个「时刻 t 被烧」的格子是不允许的。条件必须是 到达时刻 < burn,不能取等号。
坑 2:数组开太小。
流星坐标范围是 0 ~ 300,但「烧焦」还会波及上下左右,所以最远会烧到 301。
数组要开到 305 左右(这样下标 0 ~ 304 都合法),BFS 也允许走到 304。开到 301 会漏掉边界情况,直接 RE 或者 WA。
BFS 按时间从小到大出队。每弹出一个格子,先看它的 burn 是不是「永远安全」那个大数:
dist[x][y] 并结束程序。队列空了还没找到安全格 → 她被困死在焦土里了 → 输出 −1。
另外还有个特判:如果起点 (0,0) 在时刻 0 就被流星砸中,那她一开始就没地方站,答案也是 −1。
这一节是今天的重头戏,请一定看懂再往下写代码。
有一个 n × n 的迷宫,每格是 0 或 1。规则很特别:
站在 0 上,只能走到相邻四格中的 1;站在 1 上,只能走到相邻四格中的 0。
换句话说:只能从一格走到「数字和它不同」的相邻格。
然后有 m 次询问,每次问「从第 i 行第 j 列出发,能移动到多少个格子(含自身)」。
1 ≤ n ≤ 1000,1 ≤ m ≤ 100000。
格子总数 10⁶,询问次数 10⁵。两者都很大,这才是本题的考点。
最直接的想法:读一次询问,就从那个格子 BFS 一遍,数能走到多少格。BFS 一次是 O(n²) = 10⁶。
m = 10⁵ 次询问 → 10⁵ × 10⁶ = 10¹¹ 次操作。
| 每秒能跑多少次操作 | 10¹¹ 次操作要跑多久 |
|---|---|
| 约 10⁸ 次 | 约 1000 秒 |
| 本题时限 | 1 秒 |
差了一千倍,无论你怎么优化常数都救不回来。必须换思路。
先问一个问题:如果从 A 能走到 B,那从 B 能走到多少格?
规则是「数字不同才能走」,这是双向的:A 能走到 B,说明 A 和 B 相邻且数字不同,那么 B 当然也能走回 A。所以「能到达」是一个相互的关系。
这意味着:所有能互相到达的格子,能到达的格子集合完全相同,因此答案也完全相同!
把迷宫按「能互相到达」分成若干个连通块。同一个连通块里的所有格子,答案都等于这个块的大小。
下面这个 4 × 4 的迷宫:
这是一张「棋盘式」的交错图:每个格子的上下左右都是和自己不同的数字,所以整张图 16 个格子全部互通,构成 1 个连通块,块大小 16。
于是不管问哪个格子,答案都是 16。10⁵ 次询问全部 O(1) 回答。
再看一个被「隔断」的例子(第 2 行的两个 1 挡住了):
左上角 3 个格(0,1,1 竖着)互相可达;右下角那一片同理自成一块。块与块之间过不去,答案就不同。
id[i][j] 数组,记录「第 i 行第 j 列属于几号连通块」,初值 0 表示还没访问过(所以连通块编号从 1 开始,0 空出来当哨兵)。cnt[块编号]。cnt[ id[i][j] ]。// 核心:一次扫描,把整张图染色 for(int i = 0; i < n; i++) for(int j = 0; j < n; j++){ if(id[i][j]) continue; // 已经染过色,跳过 blocks++; // 发现一个新连通块 // …… 从 (i,j) 开始 BFS,把所有能到的格子都染成 blocks 号 …… cnt[blocks] = total; // 记下这块的大小 } // 询问:O(1) 查表,一次 BFS 都不用做 cin >> i >> j; cout << cnt[ id[i-1][j-1] ] << endl; // 题目坐标从 1 开始,减 1 转成数组下标
预处理:每个格子最多被 BFS 访问一次 → O(n²)
每次询问:一次数组读取 → O(1)
总计 O(n² + m),n = 1000、m = 10⁵ 时约 1.1×10⁶ 次操作,几十毫秒就完事了。
把「会被反复问到的同一个问题」的答案提前算好存起来,之后直接查表。
你会发现这个套路到处都在用:
看到「询问次数很多」,第一反应就应该是:能不能提前预处理?
1. 转移条件不是「能走」,而是「数字不同」。
if(g[nx][ny] == g[x][y]) continue; —— 数字相同就过不去。别写成「必须是 0」或者「必须是 1」。
2. 队列里存什么?
存 x * n + y 这个编码(解码用 x = cur / n, y = cur % n),比存 pair 省事也省内存。
3. 内存要算一下。
char g[1005][1005] ≈ 1MB,int id[1005][1005] ≈ 4MB,int cnt[10⁶+5] ≈ 4MB,加起来约 9MB,很安全。
P1228 不是搜索题,但计划把它放在今天,因为它用一个非常漂亮的「分而治之」思路,而这个思路下周开始会反复出现。
迷宫是 2ᵏ × 2ᵏ 的正方形。公主站在其中一格,这一格不能盖。剩下的格子要用 L 形毯子(3 个格子组成的「拐角」形状,有 4 种朝向)铺满,每格只能铺一层。要求输出一种方案。
0 < k ≤ 10,也就是棋盘最大 1024 × 1024。
需要铺的毯子数量是 (2ᵏ × 2ᵏ − 1) ÷ 3,k = 10 时是 349525 块。方案行数就是这么多,输出量不小,所以要用 printf 而不是 cout << endl。
把 2ᵏ × 2ᵏ 的棋盘从正中间切成四块,每块是 2ᵏ⁻¹ × 2ᵏ⁻¹ 的小正方形。
公主必然落在其中一块里。剩下三块呢?它们各自都还没有「不能盖的格子」。
棋盘正中间有一个 2×2 的小方块,它由四块小正方形各贡献 1 格。我们在正中间放一块 L 形毯子,正好盖住「不属于公主所在那块」的那 3 个格子。
这样一来:
四块小正方形,全部变成了「有一个洞、需要铺满其余部分」的同类问题,只是规模减半。递归下去就行。
* = 公主(洞,不能被盖) ■ = 正中间那块毯子盖住的 3 格
公主在左上象限,所以中间这块毯子盖住 TR、BL、BR 三块各 1 格。
每行输出 x y c。其中:
(x, y) 是这块毯子的拐角格:毯子由 3 格组成,其中有一格和另外两格都相邻,那一格就是拐角。c 是 1 ~ 4,表示「空格在拐角的哪个方向」,而拐角和空格总是同一个小 2×2 方格里的对角关系:| c | 空格相对拐角的位置 | 毯子盖住的另外 3 格(相对拐角 x, y) |
|---|---|---|
| 1 | 左上 | (x−1,y)、(x,y−1)、(x,y) |
| 2 | 右上 | (x−1,y)、(x,y)、(x,y+1) |
| 3 | 左下 | (x,y)、(x,y−1)、(x+1,y) |
| 4 | 右下 | (x+1,y)、(x,y+1)、(x,y) |
官方样例里第二行是 2 2 4。棋盘 8×8,公主在 (3,3)(左上象限),所以第一行 5 5 1 盖的是 (4,5)、(5,4)、(5,5) 三格,拐角是 (5,5)。
接着处理左上象限(1~4 行、1~4 列),公主 (3,3) 在它的右下小象限,所以毯子盖住中心的另外三格:(2,2)、(2,3)、(3,2),拐角 (2,2) → 输出 2 2 4,也就是「空格在拐角的右下」,c = 4。
拿样例对着表格验一遍,比背十遍都管用。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行过,「实测结果」里写的是我真实跑出来的输出。
题意:n × n 的 0/1 迷宫,只能走到相邻的、数字与自己不同的格子。给 m 次询问,每次问从某格出发能到达多少格(含自身)。
1 ≤ n ≤ 1000,1 ≤ m ≤ 100000。
m 可以到 10⁵,而每次询问单独 BFS 是 O(n²) = 10⁶,乘起来 10¹¹——必然超时。
必须用第 4 节讲的「连通块染色 + 预处理答案」。
「能互相到达」把格子分成若干连通块,同块内所有格子的答案都相等,等于块的大小。所以:
id[i][j]),同时统计每块大小(cnt[块号])。cnt[id[i][j]],O(1)。总复杂度 O(n² + m),实测 n = 1000、m = 10⁵ 时约 0.2 秒(详见下面的实测结果)。
2 2 01 10 1 1 2 2
4 4
2×2 的棋盘上 0 和 1 交错,四格两两可达,所以从哪一格出发都能到 4 格。
#include <cstdio> #include <queue> using namespace std; int n, m; char g[1005][1005]; // 迷宫(0/1 字符) int id[1005][1005]; // 每个格子属于哪个连通块,0 表示还没染色 int cnt[1000005]; // 每个连通块的大小(能走到的格子数) int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int main(){ scanf("%d %d", &n, &m); for(int i = 0; i < n; i++) scanf("%s", g[i]); int blocks = 0; // 连通块编号,从 1 开始(0 留给"未访问") queue<int> q; // 队列里存 i*n+j,省内存 for(int i = 0; i < n; i++){ for(int j = 0; j < n; j++){ if(id[i][j]) continue; // 已经染过色,跳过 blocks++; id[i][j] = blocks; q.push(i * n + j); int total = 0; while(!q.empty()){ int cur = q.front(); q.pop(); int x = cur / n, y = cur % n; total++; for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(nx < 0 || nx >= n || ny < 0 || ny >= n) continue; if(id[nx][ny]) continue; // 已经属于某个连通块 if(g[nx][ny] == g[x][y]) continue; // ★ 数字相同不能走 id[nx][ny] = blocks; // 染成同一块 q.push(nx * n + ny); } } cnt[blocks] = total; // 这块的答案就是 total } } // 询问直接查表,O(1) 一个 for(int k = 0; k < m; k++){ int i, j; scanf("%d %d", &i, &j); printf("%d\n", cnt[id[i - 1][j - 1]]); } return 0; }
官方样例 2 2 / 01 / 10 / 1 1 / 2 2 → 输出 4、4 完全一致
大数据:n = 1000、m = 100000(全 0 迷宫 + 随机询问)→ 输出 100000 行,用时 197 ms(时限 1 s)
全 0 迷宫里相邻格数字相同,谁也走不动,所以每格自成一队,答案都是 1——与实际输出一致。
最坏情况:n = 1000 的棋盘交错图案(整张图 10⁶ 格全在一个连通块里,BFS 要一次走遍全图)→ 100000 次询问全部输出 1000000,用时 213 ms
以上计时都是在本机(Windows)上同一个程序连跑 4 次取最小值,已经包含了进程启动和读写输入输出的全部开销;洛谷是 Linux 环境,通常还会更快一些。
对拍:10 组随机小迷宫(n = 3~11,每组 25 次询问),与「每次询问单独 BFS」的暴力程序逐行比对,结果完全一致
id 初值 0 用来表示「没访问过」,所以块编号必须从 1 开始。id[i-1][j-1]。cin/cout 加同步:n = 10⁶ 级别输入 + 10⁵ 输出,建议直接用 scanf/printf,或者开头加 ios::sync_with_stdio(false);。题意:n 行 m 列的军团方阵,有 a 个感染源,瘟疫每小时向上下左右扩散一格。给 b 个领主的位置,按输入顺序输出每个人被感染的时刻(在感染源上的人时刻为 0)。
1 ≤ n, m ≤ 500(最多 250000 格),1 ≤ a, b ≤ 10⁵。
a 最大 10⁵,但格子只有 25 万个——感染源重复出现是必然的。入队时判一下 dist == -1 就不会重复塞。
另外 b 也能到 10⁵,输出量大,用 printf。
多源 BFS:把 a 个感染源全部以距离 0 入队,然后照常 BFS。搜完后 dist[x][y] 就是「到最近感染源的时间」,也就是领主被感染的时刻。
为什么对?因为它们等价于从一个「超级源点」出发的普通 BFS(见第 2 节)。
5 4 2 3 1 1 5 4 3 3 5 3 2 4
3 1 3
感染源在 (1,1) 和 (5,4)。
(3,3):从 (5,4) 走 |3−5|+|3−4| = 3 步,从 (1,1) 要 4 步 → 取 3。
(5,3):紧挨着 (5,4) → 1 步。
(2,4):从 (5,4) 走 3 步,从 (1,1) 走 4 步 → 3。
#include <cstdio> #include <queue> #include <cstring> using namespace std; int n, m, a, b; int dist[505][505]; // -1 表示还没被感染(没访问) int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int main(){ scanf("%d %d %d %d", &n, &m, &a, &b); memset(dist, -1, sizeof(dist)); queue<int> q; // ★ 多源 BFS:所有感染源一起入队,距离都是 0 for(int k = 0; k < a; k++){ int x, y; scanf("%d %d", &x, &y); if(dist[x][y] == -1){ // 同一格重复出现也只用入队一次 dist[x][y] = 0; q.push(x * (m + 1) + y); } } while(!q.empty()){ int cur = q.front(); q.pop(); int x = cur / (m + 1), y = cur % (m + 1); for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(nx < 1 || nx > n || ny < 1 || ny > m) continue; if(dist[nx][ny] != -1) continue; dist[nx][ny] = dist[x][y] + 1; q.push(nx * (m + 1) + ny); } } // 输出顺序和输入顺序一致,所以读完一个就输出一个 for(int k = 0; k < b; k++){ int x, y; scanf("%d %d", &x, &y); printf("%d\n", dist[x][y]); } return 0; }
官方样例 → 输出 3、1、3 完全一致
大数据:n = m = 500、a = b = 100000 → 输出 100000 行,用时 207 ms
输入把感染源和领主都循环撒在 500×500 的网格上,首行答案是 0(第一个领主正好踩在第一个感染源上),与程序输出一致。
对拍:30 组随机小数据(n, m ≤ 8,源和领主随机),与「每个领主都对每个感染源各 BFS 一次、再取最小值」的暴力程序逐行比对,结果完全一致
if(dist[x][y] == -1) 去重。memset(dist, -1, ...) 把整个数组设为 −1 正好当哨兵。x * (m + 1) + y,别写成 x * m + y(列下标是 1..m,用 m 做基数会撞车)。题意:贝茜在 (0,0)。M 颗流星分别在时刻 Tᵢ 砸在 (Xᵢ, Yᵢ),把落点和上下左右四格烧成焦土。她每秒走一格,只能待在 X, Y ≥ 0 的区域,问最少多久能走到一个永远安全的格子(安全 = 永远不会被烧);到不了输出 −1。
1 ≤ M ≤ 50000,0 ≤ Tᵢ ≤ 1000,0 ≤ Xᵢ, Yᵢ ≤ 300。
流星坐标最大 300,但烧焦会波及四邻,最远烧到 301 → 数组开到 305、BFS 允许走到下标 304。
到达时刻必须严格小于被烧时刻(< 而不是 <=)。
burn[x][y] = 该格最早被烧的时刻(没被烧记成 INF)。对每颗流星更新落点 + 四邻,取 min。dist[x][y] + 1 < burn[nx][ny]。burn 是 INF,说明站在安全格上,而 BFS 保证这是最早时刻 → 输出 dist 并结束。因为危险区只会越来越大,早到永远不吃亏:早到意味着之后的选择更多。
所以「到达某格的最早时刻」就足够描述一切,而这个最早时刻正是 BFS 的 dist。
把状态从 (x, y, t) 砍回 (x, y),靠的就是这种「单调性」。
4 0 0 2 2 1 2 1 1 2 0 3 5
5
时刻 2 被烧:(0,0)、(1,0)、(0,1)、(2,0)、(1,1)、(2,1)、(3,1)、(2,2)、(1,2)。
时刻 5 被烧:(0,3)、(1,3)、(0,2)、(0,4)。
贝茜的路线:(0,0) → (0,1) → (0,2) → (0,3) → (0,4) → (1,4),第 5 秒站上 (1,4),它永远不会被烧。
#include <cstdio> #include <queue> #include <cstring> using namespace std; const int N = 305; // 流星坐标 0..300,烧焦还会波及 301,多开一点 const int INF = 0x3f3f3f3f; int burn[N][N]; // burn[x][y] = 最早被烧的时刻,INF = 永远安全 int dist[N][N]; // 贝茜到达的时刻,-1 表示没到过 int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; int main(){ for(int i = 0; i < N; i++) for(int j = 0; j < N; j++) burn[i][j] = INF; memset(dist, -1, sizeof(dist)); int M; scanf("%d", &M); for(int k = 0; k < M; k++){ int x, y, t; scanf("%d %d %d", &x, &y, &t); // ★ 流星把落点和上下左右四格都变成焦土,取「最早被烧」的时刻 if(t < burn[x][y]) burn[x][y] = t; for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue; if(t < burn[nx][ny]) burn[nx][ny] = t; } } if(burn[0][0] == 0){ // 时刻 0 起点就被砸,活不下来 printf("-1\n"); return 0; } queue<int> q; dist[0][0] = 0; q.push(0); while(!q.empty()){ int cur = q.front(); q.pop(); int x = cur / N, y = cur % N; if(burn[x][y] == INF){ // 站在永远不会被烧的格子上,安全! printf("%d\n", dist[x][y]); return 0; } for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue; if(dist[nx][ny] != -1) continue; // ★ 必须「严格早于」被烧的时刻才能踏进去 if(dist[x][y] + 1 < burn[nx][ny]){ dist[nx][ny] = dist[x][y] + 1; q.push(nx * N + ny); } } } printf("-1\n"); return 0; }
官方样例 → 输出 5 完全一致
边界测试 1:1 / 0 0 0(起点在时刻 0 就被砸)→ -1 正确
边界测试 2:5 / 0 0 1 / 1 0 1 / 0 1 1 / 1 1 1 / 2 2 1(起点四周在时刻 1 全被烧)→ -1 正确
出发后第 1 秒能到的格子全都「时刻 1 被烧」,需要 1 < 1 不成立,全部进不去 → 被困死。
边界测试 3:1 / 300 300 1000(唯一一颗流星在很远处)→ 0 正确
(0,0) 永远不会被烧,原地不动就是安全格,耗时 0。
大数据:M = 50000(流星撒满 0~300 的网格,T 从 0 到 1000)→ 用时 41 ms,输出 -1(构造的数据里有 T = 0 且落在 (0,0) 的流星,起点当场被烧,符合预期)
数组上界的验证:我另外写了一份把数组上界放大到 405 的版本,和上面这份 305 的版本对拍了 200 组随机数据(流星特意撒在 0~300 的边界附近以及起点附近),200 组输出全部一致 —— 说明数组开到 305 是够用的,不会因为「要走到坐标 301 ~ 304」而漏解。
< 写成 <=:题目规定「时刻 t 被烧的格子,只能在 t 之前出现」,所以取等号是错的。burn[0][0] == 0 时要直接输出 −1,否则会输出 0(错误答案)。burn 要取 min:同一格可能被多颗流星波及,写 burn[x][y] = t 会被后面的流星覆盖成较晚的时刻,导致答案错误。题意:2ᵏ × 2ᵏ 的棋盘,公主占一格不能铺。用 L 形毯子(3 格,4 种朝向)把其余格子铺满,每格只铺一层。输出任意一种方案。
0 < k ≤ 10,棋盘最大 1024 × 1024。
每行输出 x y c:(x, y) 是毯子的拐角格,c 是 1~4 表示空格相对拐角的方向(1 左上 / 2 右上 / 3 左下 / 4 右下)。
本题有 Special Judge:任何合法的铺法都算对,不要求和样例一模一样(但样例本身当然也是合法方案)。
把棋盘切成四个象限。公主在其中一块里,我们在正中放一块毯子盖住另外三块各 1 格,于是四块都变成「有一个洞」的同类子问题,规模减半,递归求解。
递归到边长 1 就结束。核心是那句「洞在哪个象限」的分支判断。
3 3 3
5 5 1 2 2 4 1 1 4 1 4 3 4 1 2 4 4 1 2 7 3 1 5 4 1 8 3 3 6 3 4 8 1 7 2 2 5 1 4 6 3 2 8 1 2 8 4 1 7 7 1 6 6 1 5 8 3 8 5 2 8 8 1
8 × 8 的棋盘去掉公主那一格,还剩 63 格,63 ÷ 3 = 21 —— 正好 21 块毯子,也正好是 21 行输出。
#include <cstdio> int k, px, py; // 用一个 L 形毯子盖住「洞」以外的三个中心格 // (x,y) 当前方块左上角;len 边长;(hx,hy) 洞的位置 void solve(int x, int y, int len, int hx, int hy){ if(len == 1) return; // 1×1 不用盖 int half = len >> 1; int cx = x + half, cy = y + half; // 中心 2×2 块的右下角 if(hx < cx && hy < cy){ // 洞在左上象限 printf("%d %d 1\n", cx, cy); // 盖住右上、左下、右下三个中心格 solve(x, y, half, hx, hy); // 左上:洞不变 solve(x, cy, half, cx - 1, cy); // 右上:洞 = 自己的中心格 solve(cx, y, half, cx, cy - 1); // 左下 solve(cx, cy, half, cx, cy); // 右下 } else if(hx < cx && hy >= cy){ // 洞在右上象限 printf("%d %d 2\n", cx, cy - 1); solve(x, y, half, cx - 1, cy - 1); solve(x, cy, half, hx, hy); // 右上:洞不变 solve(cx, y, half, cx, cy - 1); solve(cx, cy, half, cx, cy); } else if(hx >= cx && hy < cy){ // 洞在左下象限 printf("%d %d 3\n", cx - 1, cy); solve(x, y, half, cx - 1, cy - 1); solve(x, cy, half, cx - 1, cy); solve(cx, y, half, hx, hy); // 左下:洞不变 solve(cx, cy, half, cx, cy); } else { // 洞在右下象限 printf("%d %d 4\n", cx - 1, cy - 1); solve(x, y, half, cx - 1, cy - 1); solve(x, cy, half, cx - 1, cy); solve(cx, y, half, cx, cy - 1); solve(cx, cy, half, hx, hy); // 右下:洞不变 } } int main(){ scanf("%d %d %d", &k, &px, &py); int n = 1 << k; // 2^k solve(1, 1, n, px, py); return 0; }
官方样例 3 / 3 3 → 输出 21 行,与官方样例输出逐字节完全一致(包括 6 3 2 这一行)
独立校验:我另外写了一个校验程序,按题目规则把每行的毯子翻译成 3 个格子,检查「是否越界 / 是否重复覆盖 / 最终是否只剩公主格为空 / 行数是否等于 (4ᵏ−1)/3」。
对 k = 1, 2, 3, 4, 5, 6, 10 且公主分别取「左上角、正中心、右下角、右上角」共 28 组输入进行校验,28 组全部通过(覆盖合法、无重叠、无越界、只剩公主格)。
最大规模:k = 10 → 输出 349525 行(等于 (4¹⁰−1)/3),用时 546 ms
solve 的参数是最容易写错的地方,写完拿样例对一遍。cout << endl:35 万行输出,endl 每次都强制刷缓冲,会明显变慢。用 printf("\n") 或 '\n'。len == 0:应该是 len == 1(只剩洞那一格,没得盖)。solve(1, 1, 2^k, px, py),减半时用 len >> 1,别写成除以 2 后又少算一格。Day 21 是第二个复盘日。先把这两周学过的东西串成一条线。
| Day | 主题 | 一句话核心 |
|---|---|---|
| 15 | 前缀和与差分 | 把「反复求区间和」变成「提前累加 + 两次相减」 |
| 16 | 递归 | 函数自己调用自己,一定要有终止条件 |
| 17 | 递推与记忆化 | 把算过的结果存下来,别重复算(今天 P1141 用的就是它) |
| 18 | DFS 入门 | 一条路走到黑,走不通再回头 |
| 19 | DFS 回溯 | 「恢复现场」是回溯的灵魂 |
| 20 | BFS 入门 | 一层一层扩散,天然求出最短步数 |
| 21 | BFS 进阶 | 多源起点 / 预处理环境 / 把答案复用给很多次询问 |
| Day | 主题 | 一句话核心 |
|---|---|---|
| 8 | 字符与字符串基础 | 字符本质是数字,c - 'a' 就能当下标 |
| 9 | 字符串进阶 | 进制转换、前导零、格式细节 |
| 10 | 函数 | 把重复的代码包起来,起个名字 |
| 11 | 结构体 | 把一组相关数据打包成一个整体 |
| 12 | 排序 | sort 加自定义比较器,先排序再处理是万能开局 |
| 13 - 14 | 贪心 | 每步都取当前最优,但必须先论证它是对的 |
复盘不是「把笔记再看一遍」,而是合上笔记,凭记忆把东西重新写出来。看笔记时觉得「我会了」,写的时候才发现位置全对不上——这才是需要补的地方。
| 错误写法 | 后果 | 正确写法 |
|---|---|---|
| P1141 每个询问单独 BFS | 10⁵ × 10⁶ = 10¹¹,必 TLE | 先染色预处理,询问 O(1) 查表 |
if(g[nx][ny] == g[x][y]) continue; 漏写 | 0 和 1 之间随便走,连通块全错 | 相邻格数字必须不同才能走 |
| P1332 只把一个感染源入队 | 答案偏大,WA | 所有源一起入队,距离都是 0 |
P2895 用 t <= burn[x][y] | 在被烧的那一刻踏进去,WA | 必须 t < burn[x][y](严格小于) |
| P2895 数组只开到 301 | 烧焦波及 301,越界 RE | 开到 305,允许走到下标 304 |
P2895 burn[x][y] = t 直接赋值 | 后面的流星覆盖掉更早的时刻 → WA | if(t < burn[x][y]) burn[x][y] = t; 取 min |
P1228 输出用 endl | 35 万行反复刷缓冲,慢 | 用 printf("...\n") |
| 连通块编号从 0 开始 | 和「未访问」的哨兵 0 撞车 | 编号从 1 开始,0 留给未访问 |
| 询问时忘了坐标减 1 | 整体偏移一行一列 → WA/RE | id[i-1][j-1] |
| 用 BFS 求「最多能走多少步」 | 概念混淆:BFS 求的是最短距离 | 求最长路要用 DP / 拓扑(后面会学) |
答:只差在初始化那一步。普通 BFS 是「一个起点入队,dist = 0」;多源 BFS 是「把 a 个起点全部入队,每个的 dist 都是 0」,之后的主循环一行都不用改。
原理上它等价于:凭空加一个「超级源点」,它到每个真实源点的距离都是 0,然后跑普通 BFS。
答:n ≤ 1000,一次 BFS 要访问最多 10⁶ 个格子;m ≤ 10⁵ 次询问 → 10⁵ × 10⁶ = 10¹¹ 次操作。按每秒 10⁸ 次算要 1000 秒,而时限是 1 秒。
正确做法:把所有连通块一次染色,块大小存在 cnt[] 里,询问时直接读 cnt[id[i][j]]。总复杂度 O(n² + m) ≈ 1.1×10⁶,实测约 0.2 秒。
答:因为危险区只增不减,早到永远不比晚到差——早到只会让之后的选择更多。既然「越早越好」,那每个格子只需要记「最早能到达的时刻」,而这个值正好就是 BFS 的 dist。
所以只要预先算出 burn[x][y],BFS 时用 dist + 1 < burn[ nx ][ ny ] 判断能不能进就行,状态仍然是 (x, y)。
答:不能。题目写的是「如果一个格子在时刻 t 被流星撞击或烧焦,那么贝茜只能在 t 之前的时刻在这个格子里出现」。
所以判断条件是 到达时刻 < burn,写 <= 就会错。
答:棋盘有 4ᵏ 个格子,公主占 1 格不能盖,需要盖 4ᵏ − 1 格;每块毯子盖 3 格,所以需要 (4ᵏ − 1) ÷ 3 块。
而 4ᵏ − 1 一定能被 3 整除(因为 4ᵏ − 1 = (4 − 1)(4ᵏ⁻¹ + …… + 1) = 3 × 整数),所以这个数量总是整数。k = 10 时是 349525 块。
for(int k = 0; k < a; k++){
int x, y; scanf("%d %d", &x, &y);
if(dist[x][y] == -1){ dist[x][y] = 0; q.push(x * (m+1) + y); }
}
★ 关键是「所有源距离都是 0」,以及「重复的源只入队一次」。
【网格 BFS 模板(Day 20 的底子,今天全靠它)】
queue<int> q; dist[sx][sy] = 0; q.push(sx*M + sy);
while(!q.empty()){
int cur = q.front(); q.pop();
int x = cur / M, y = cur % M;
for(int d = 0; d < 4; d++){
int nx = x + dx[d], ny = y + dy[d];
if(越界) continue;
if(dist[nx][ny] != -1) continue;
dist[nx][ny] = dist[x][y] + 1;
q.push(nx * M + ny);
}
}
【多源 BFS(P1332)】
把普通 BFS 的初始化换成:
for(k = 0; k < a; k++){ 读入源;
if(dist[x][y] == -1){ dist[x][y] = 0; q.push(编码); } }
// 主循环一行都不用改
【带时间的 BFS(P2895)】
// ① 预处理危险时刻表,取 min
burn[x][y] = min(burn[x][y], t); // 落点
burn[nx][ny] = min(burn[nx][ny], t); // 四邻
// ② BFS 时多一个条件(必须严格小于!)
if(dist[x][y] + 1 < burn[nx][ny]) { …… }
// ③ 出队时 burn == INF 就是安全格,直接输出 dist
【连通块染色 + 记忆化(P1141)】
int id[N][N]; // 0 = 未访问,块号从 1 开始
int cnt[N*N]; // cnt[块号] = 块大小
for(i, j) if(!id[i][j]){ blocks++; BFS 整块染色; cnt[blocks] = total; }
询问:cout << cnt[ id[i-1][j-1] ]; // O(1)!
【复杂度估算】
操作数 ≈ 状态总数 × 每个状态的转移数
状态数先估出来,再决定要不要预处理
二分查找:在有序里快速定位
今天的 P1141 你已经体会过「预处理换时间」的威力了。明天是另一种「用聪明换取时间」的经典手段:每次砍掉一半的搜索范围。
会学到下面的东西:
lower_bound / upper_bound 的语义和手写实现(「第一个 ≥ x」和「第一个 > x」到底差在哪);while(l <= r) 和 while(l < r) 两种写法各自的边界该怎么定;r = mid 配向下取整、l = mid 配向上取整」这条铁律;while(r - l > eps) 更稳。明天四道题:P2249 查找、P1102 A-B 数对、P1678 烦恼的高考志愿、P1024 一元三次方程求解。今天先把 BFS 的账结清,明天开始新的篇章。