ACM 一个月学习计划 · DAY 21

Day 21 · BFS 进阶 + 第二/三周复盘

BFS 不只是「在网格上走」——今天把它升级成三个通用套路:多源起点、带时间的环境、以及「搜一次、答很多次」的预处理

今日目标:掌握多源 BFS、带时间维度的状态预处理、连通块染色记忆化;并用递归分治开个新脑洞
预计用时:约 4 小时(含做题与复盘)  难度:普及/提高-
题目来源:洛谷 P1141 / P1332 / P2895 / P1228
← Day 20 · 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 用对地方。

1先想清楚两件事:状态是什么,代价有多大

「状态」= 你站在哪,用一个变量组合表示出来

BFS 的本质是「在一张图上按层扩散」。图上的每个结点叫一个状态,每条边叫一次转移。

Day 20 的迷宫题里,状态就是 (x, y) 这一个坐标对。但状态不一定是坐标,它可以是任何「能唯一描述当前局面」的东西:

题目状态是什么状态总数转移是什么
P1443 马的遍历棋盘上的格子 (x, y)n × m马的 8 种走法
P1135 奇怪的电梯当前楼层 fn上楼 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⁶,差了十万倍。
差别只在于:你是在「每次询问都重新搜一遍」,还是「搜一次,把答案存下来给所有询问用」。

2多源 BFS:把多个起点一次性全塞进队列

先看场景(P1332 血色先锋队)

军团是 n 行 m 列的一片区域,每格一个人。有 a 个感染源,瘟疫每小时向四周扩散一圈。问 b 个领主各自在第几小时被感染。

如果只有一个感染源,那就是 Day 20 的模板:源点入队,dist[源] = 0,正常 BFS。

现在有 a 个源。最笨的想法是:对每个领主,从每个源各搜一次取最小值 —— 那是 a × b 次 BFS,直接爆炸。

🎯 多源 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⁵,随便过。

3带时间维度的 BFS:先造一张「危险时刻表」

P2895 陨石:环境是会变的

贝茜站在原点 (0,0)。有 M 颗流星会砸下来,第 i 颗在时刻 Tᵢ 砸在 (Xᵢ, Yᵢ),并把落点以及上下左右四格全部烧成焦土。贝茜每单位时间能走一格,问最少多久能走到一个「永远不会被烧」的格子上,不可能则输出 −1。

⚠️ 和 Day 20 的迷宫最大的不同

Day 20 的迷宫,障碍是固定的:0 永远是 0,1 永远是 1。
这里障碍是随时间出现的:t = 1 时那格还能站,t = 2 时那格已经烧了。

所以「能不能进某个格子」不再只看坐标,还要看你几点到。

做法一(错的方向):把时间放进状态

很自然会想:状态写成 (x, y, t) 不就行了?

但这样状态总数变成「格子数 × 时刻数」。坐标最多 300×300,时刻最多 1000,那就是 9×10⁷ 个状态,内存和时间都吃紧,而且——根本没必要。

做法二(正确):先预处理,再普通 BFS

🎯 关键观察:「越早到越好」

危险区只会越来越大,不会缩小。所以如果你在时刻 t 能站在格子 (x,y),那么在 t′ < t 的时刻你当然也能站在这里;而且早到意味着你之后的选择只多不少。

既然「早到」永远不吃亏,那我们只需要知道到达每一格的最早时刻——这正是 BFS 求出来的 dist!

所以时间根本不用进状态,只要在「能不能踏进去」这一步加一个时刻判断即可。

分两步走:

  1. 预处理出一张危险时刻表 burn[x][y]:这个格子最早在第几时刻变成焦土。没被烧过就记成一个很大的数(表示永远安全)。
    对每颗流星,更新它的落点和四个邻居,取最小值(同一格可能被多颗流星砸到,最早那次说话)。
  2. 普通 BFS,唯一区别是扩展时的条件从「没访问过」变成「没访问过 并且 我要到的那一刻还没被烧」。
// 第一步:造危险时刻表
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。

什么时候算安全、什么时候输出 −1

BFS 按时间从小到大出队。每弹出一个格子,先看它的 burn 是不是「永远安全」那个大数:

队列空了还没找到安全格 → 她被困死在焦土里了 → 输出 −1。

另外还有个特判:如果起点 (0,0) 在时刻 0 就被流星砸中,那她一开始就没地方站,答案也是 −1。

4★ 连通块染色 + 预处理答案(P1141 的核心)

这一节是今天的重头戏,请一定看懂再往下写代码。

题目长什么样

有一个 n × n 的迷宫,每格是 0 或 1。规则很特别:

🎯 走法规则

站在 0 上,只能走到相邻四格中的 1;站在 1 上,只能走到相邻四格中的 0。

换句话说:只能从一格走到「数字和它不同」的相邻格。

然后有 m 次询问,每次问「从第 i 行第 j 列出发,能移动到多少个格子(含自身)」。

⚠️ 数据范围(这题的全部难度都在这)

1 ≤ n ≤ 1000,1 ≤ m ≤ 100000。

格子总数 10⁶,询问次数 10⁵。两者都很大,这才是本题的考点。

为什么「每次询问单独 BFS」一定超时

最直接的想法:读一次询问,就从那个格子 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 的迷宫:

0101 1010 0101 1010

这是一张「棋盘式」的交错图:每个格子的上下左右都是和自己不同的数字,所以整张图 16 个格子全部互通,构成 1 个连通块,块大小 16。

于是不管问哪个格子,答案都是 16。10⁵ 次询问全部 O(1) 回答。

再看一个被「隔断」的例子(第 2 行的两个 1 挡住了):

0111 1111 0111 1111

左上角 3 个格(0,1,1 竖着)互相可达;右下角那一片同理自成一块。块与块之间过不去,答案就不同。

算法:一次「染色」扫全图

  1. 开一个 id[i][j] 数组,记录「第 i 行第 j 列属于几号连通块」,初值 0 表示还没访问过(所以连通块编号从 1 开始,0 空出来当哨兵)。
  2. 从小到大扫每个格子。如果它还没被染色,说明发现了一个新连通块:编号 +1,然后从这个格子开始 BFS。
  3. BFS 时,走到一个格子就把它染成当前编号,并统计这一块总共多少个格子。
  4. BFS 结束,把这块的大小记进 cnt[块编号]。
  5. 扫完整个棋盘,所有格子都有编号了。之后每个询问:读入 (i, j),直接输出 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⁶ 次操作,几十毫秒就完事了。

💡 这个思想的通用名字:「预处理 / 记忆化」

把「会被反复问到的同一个问题」的答案提前算好存起来,之后直接查表。

你会发现这个套路到处都在用:

  • Day 6 的桶计数:把「值 v 出现几次」提前统计好,之后 O(1) 查。
  • Day 15 的前缀和:把「区间和」提前累加好,之后 O(1) 查。
  • Day 17 的记忆化搜索:把算过的 f(n) 存下来,不再重算。
  • 今天:把「连通块大小」提前算好,之后 O(1) 查。

看到「询问次数很多」,第一反应就应该是:能不能提前预处理?

⚠️ 实现上的三个细节

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,很安全。

5顺手开个新脑洞:递归分治(P1228)

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 个格子。

这样一来:

  • 另外三块小正方形,各自都多了一个「被盖住的格子」——正好可以当成它们自己的公主格(也就是它们的洞)。
  • 公主所在的那一块,洞还是原来那个公主格。

四块小正方形,全部变成了「有一个洞、需要铺满其余部分」的同类问题,只是规模减半。递归下去就行。

画出来看

左上左上TRTR 左上*■TR BL■■BR BLBLBRBR

* = 公主(洞,不能被盖) ■ = 正中间那块毯子盖住的 3 格
公主在左上象限,所以中间这块毯子盖住 TR、BL、BR 三块各 1 格。

递归的边界和终止

输出格式怎么理解(这题最容易卡的地方)

每行输出 x y c。其中:

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。

拿样例对着表格验一遍,比背十遍都管用。

6四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行过,「实测结果」里写的是我真实跑出来的输出。

P1141 01迷宫 普及/提高- · 连通块染色 + 记忆化

题意:n × n 的 0/1 迷宫,只能走到相邻的、数字与自己不同的格子。给 m 次询问,每次问从某格出发能到达多少格(含自身)。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 1000,1 ≤ m ≤ 100000。

m 可以到 10⁵,而每次询问单独 BFS 是 O(n²) = 10⁶,乘起来 10¹¹——必然超时。
必须用第 4 节讲的「连通块染色 + 预处理答案」。

思路

「能互相到达」把格子分成若干连通块,同块内所有格子的答案都相等,等于块的大小。所以:

  1. 一次全图扫描,用 BFS 给每个格子染色(id[i][j]),同时统计每块大小(cnt[块号])。
  2. 每次询问直接输出 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」的暴力程序逐行比对,结果完全一致

易错点

  • 每个询问都单独 BFS → 10¹¹ 次操作,必 TLE。这是本题唯一的门槛。
  • 转移条件写错:是「两格数字不同才能走」,写成「必须是 1」或「必须是 0」就错了。
  • 用 0 号连通块:id 初值 0 用来表示「没访问过」,所以块编号必须从 1 开始。
  • 询问坐标忘减 1:题目行列从 1 开始,数组从 0 开始,要写 id[i-1][j-1]。
  • 不用 cin/cout 加同步:n = 10⁶ 级别输入 + 10⁵ 输出,建议直接用 scanf/printf,或者开头加 ios::sync_with_stdio(false);。
P1332 血色先锋队 普及/提高- · 多源 BFS

题意: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 一次、再取最小值」的暴力程序逐行比对,结果完全一致

易错点

  • 对每个领主单独 BFS:b ≤ 10⁵,直接爆炸。多源 BFS 只需要搜一遍。
  • 感染源重复入队:a 可能远大于格子数,加 if(dist[x][y] == -1) 去重。
  • 输出顺序:题目要求「与输入顺序一致」,所以不能排序、不能存起来乱输。
  • 坐标从 1 开始:数组按 1..n、1..m 使用,memset(dist, -1, ...) 把整个数组设为 −1 正好当哨兵。
  • 编码坐标时用的是 x * (m + 1) + y,别写成 x * m + y(列下标是 1..m,用 m 做基数会撞车)。
P2895 [USACO08FEB] Meteor Shower S 普及/提高- · BFS + 时间维度

题意:贝茜在 (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。

到达时刻必须严格小于被烧时刻(< 而不是 <=)。

思路

  1. 预处理:burn[x][y] = 该格最早被烧的时刻(没被烧记成 INF)。对每颗流星更新落点 + 四邻,取 min。
  2. BFS:从 (0,0) 出发,进入 (nx,ny) 的条件是 dist[x][y] + 1 < burn[nx][ny]。
  3. 每弹出一个格子,如果它的 burn 是 INF,说明站在安全格上,而 BFS 保证这是最早时刻 → 输出 dist 并结束。
  4. 队列空还没找到 → 输出 −1。另外起点若在时刻 0 就被砸,直接 −1。
🎯 为什么不用把「时间」放进状态

因为危险区只会越来越大,早到永远不吃亏:早到意味着之后的选择更多。

所以「到达某格的最早时刻」就足够描述一切,而这个最早时刻正是 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 之前出现」,所以取等号是错的。
  • 数组只开到 301:烧焦会波及坐标 301,必须留出空间(开 305)。
  • 忘记处理起点:burn[0][0] == 0 时要直接输出 −1,否则会输出 0(错误答案)。
  • 把时间当成状态:(x, y, t) 状态数会膨胀到 9×10⁷,既费内存又没必要。
  • 忘了 burn 要取 min:同一格可能被多颗流星波及,写 burn[x][y] = t 会被后面的流星覆盖成较晚的时刻,导致答案错误。
P1228 地毯填补问题 普及/提高- · 递归分治

题意: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
样例输出(共 21 行)
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(只剩洞那一格,没得盖)。
  • 坐标从 1 开始:递归入口是 solve(1, 1, 2^k, px, py),减半时用 len >> 1,别写成除以 2 后又少算一格。
  • 这题不需要开二维数组存棋盘,只要按「象限 → 中心三格」的规律直接输出坐标就行了。

★第三周复盘(连第二周一起补)

Day 21 是第二个复盘日。先把这两周学过的东西串成一条线。

第三周主线:从「暴力搜索」到「有组织地搜索」

Day主题一句话核心
15前缀和与差分把「反复求区间和」变成「提前累加 + 两次相减」
16递归函数自己调用自己,一定要有终止条件
17递推与记忆化把算过的结果存下来,别重复算(今天 P1141 用的就是它)
18DFS 入门一条路走到黑,走不通再回头
19DFS 回溯「恢复现场」是回溯的灵魂
20BFS 入门一层一层扩散,天然求出最短步数
21BFS 进阶多源起点 / 预处理环境 / 把答案复用给很多次询问

第二周的一句话回顾

Day主题一句话核心
8字符与字符串基础字符本质是数字,c - 'a' 就能当下标
9字符串进阶进制转换、前导零、格式细节
10函数把重复的代码包起来,起个名字
11结构体把一组相关数据打包成一个整体
12排序sort 加自定义比较器,先排序再处理是万能开局
13 - 14贪心每步都取当前最优,但必须先论证它是对的

★ 本周复盘任务(照着做,别跳过)

  1. 默写模板:不看笔记,在白纸上写出「网格版 DFS」和「网格版 BFS」两个模板,包括边界判断、访问标记、方向数组。写不出来就回去看 Day 18 和 Day 20。
  2. 重做本周卡住的 2 道搜索题。挑你当时看题解才过的题目,隔一天再做一遍——如果还是要想很久,说明这个点没真懂。
  3. 自测:能不能在 60 分钟内独立 AC「P1605 迷宫」和「P1443 马的遍历」?这两题分别是 DFS 回溯和 BFS 的基础题,是这个专题的地板线。
💡 复盘的正确姿势

复盘不是「把笔记再看一遍」,而是合上笔记,凭记忆把东西重新写出来。看笔记时觉得「我会了」,写的时候才发现位置全对不上——这才是需要补的地方。

✗今日常见错误汇总

错误写法后果正确写法
P1141 每个询问单独 BFS10⁵ × 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 直接赋值后面的流星覆盖掉更早的时刻 → WAif(t < burn[x][y]) burn[x][y] = t; 取 min
P1228 输出用 endl35 万行反复刷缓冲,慢用 printf("...\n")
连通块编号从 0 开始和「未访问」的哨兵 0 撞车编号从 1 开始,0 留给未访问
询问时忘了坐标减 1整体偏移一行一列 → WA/REid[i-1][j-1]
用 BFS 求「最多能走多少步」概念混淆:BFS 求的是最短距离求最长路要用 DP / 拓扑(后面会学)

✓自测:你真的会了吗

第 1 题:多源 BFS 和普通 BFS,代码上到底差在哪一步?

答:只差在初始化那一步。普通 BFS 是「一个起点入队,dist = 0」;多源 BFS 是「把 a 个起点全部入队,每个的 dist 都是 0」,之后的主循环一行都不用改。

原理上它等价于:凭空加一个「超级源点」,它到每个真实源点的距离都是 0,然后跑普通 BFS。

第 2 题:P1141 为什么不能每个询问搜一次?算一下量级。

答: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 秒。

第 3 题:P2895 为什么可以不用把「时间」放进状态?

答:因为危险区只增不减,早到永远不比晚到差——早到只会让之后的选择更多。既然「越早越好」,那每个格子只需要记「最早能到达的时刻」,而这个值正好就是 BFS 的 dist。

所以只要预先算出 burn[x][y],BFS 时用 dist + 1 < burn[ nx ][ ny ] 判断能不能进就行,状态仍然是 (x, y)。

第 4 题:P2895 里,一个格子在时刻 3 被烧,贝茜能在时刻 3 踏上去吗?

答:不能。题目写的是「如果一个格子在时刻 t 被流星撞击或烧焦,那么贝茜只能在 t 之前的时刻在这个格子里出现」。

所以判断条件是 到达时刻 < burn,写 <= 就会错。

第 5 题:P1228 里,为什么毯子数量一定是 (4ᵏ−1)/3?

答:棋盘有 4ᵏ 个格子,公主占 1 格不能盖,需要盖 4ᵏ − 1 格;每块毯子盖 3 格,所以需要 (4ᵏ − 1) ÷ 3 块。

而 4ᵏ − 1 一定能被 3 整除(因为 4ᵏ − 1 = (4 − 1)(4ᵏ⁻¹ + …… + 1) = 3 × 整数),所以这个数量总是整数。k = 10 时是 349525 块。

第 6 题(手写代码):写出「多源 BFS 的初始化」三行代码。
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)!

【复杂度估算】
  操作数 ≈ 状态总数 × 每个状态的转移数
  状态数先估出来,再决定要不要预处理

→明天预告 · Day 22

二分查找:在有序里快速定位

今天的 P1141 你已经体会过「预处理换时间」的威力了。明天是另一种「用聪明换取时间」的经典手段:每次砍掉一半的搜索范围。

会学到下面的东西:

明天四道题:P2249 查找、P1102 A-B 数对、P1678 烦恼的高考志愿、P1024 一元三次方程求解。今天先把 BFS 的账结清,明天开始新的篇章。