信息学奥赛经典题解:从流感传染问题掌握多源BFS与网格建模
1. 项目概述从一道题看信息学奥赛的解题思维最近在辅导学生准备信息学奥赛时又翻出了“流感传染”这道经典题目。这道题本身难度不算顶尖但它像一面镜子清晰地照出了新手和有经验的选手在解题思维上的核心差异。很多初学者拿到题目第一反应是去模拟“人”的移动和状态变化很容易陷入复杂的边界判断和状态更新逻辑里代码写出来又长又容易出错。而实际上这道题考察的核心是对“过程”的抽象建模能力以及如何将现实问题转化为计算机擅长的“批量计算”模式。今天我就结合这道题把信息学奥赛里这种“降维打击”式的解题思路掰开揉碎了讲清楚并附上可以直接“抄作业”的C代码。无论你是正在备赛的学生还是对算法感兴趣的开发者相信这种从问题本质入手的分析方法比单纯背模板要有用得多。这道题描述了一个典型的网格扩散场景在一个N*N的房间矩阵里初始有一些人患病用‘’表示健康人用‘.’表示空房间用‘#’表示。流感的传播规则是如果一个人当天患病那么第二天他的上、下、左、右四个方向的健康邻居也会被传染。题目会给定一个天数M要求计算在第M天结束时总共会有多少患病者。这个模型本质上和森林火灾模拟、细胞自动机、甚至是一些图论中的广度优先搜索BFS问题内核是相通的。关键在于我们能否跳出“模拟每一天每个人”的微观视角找到一种更宏观、更高效的描述整个系统状态演变的方法。2. 核心思路拆解为什么“模拟”是下策“状态扩散”才是正解2.1 新手易陷的“逐人模拟”陷阱及其问题很多人的第一直觉是模拟开一个二维数组room记录当前状态然后循环M天每天遍历整个网格找到今天新生病的人再去更新他们周围明天要生病的人的状态。这个思路听起来很直接但实现起来坑非常多。最致命的问题是状态更新的时序冲突。举个例子假设第1天位置(1,1)的人生病了。按照遍历顺序当你检查到(1,2)这个健康人时发现他的邻居(1,1)今天生病了于是你把(1,2)标记为“明天生病”。但如果你在同一天后续的遍历中又检查到了这个刚刚被标记为“明天生病”的(1,2)程序可能会错误地认为“(1,2)今天也生病了”进而去传染(1,3)这就导致了“提前传染”或“连锁传染”的错误一天之内传播了多格违背了题目“一天只传播一格”的规则。为了避免这个你可能需要引入“今天生病”和“明天生病”两个状态数组每天结束后同步一次。代码会变得冗长且容易在同步时出错。另一个问题是效率如果M很大比如上千N也很大这种每天全盘扫描的O(M*N^2)复杂度可能会面临性能压力。虽然对于本题常规数据范围可能够用但思维模式没有提升。2.2 降维打击将“时间”转化为“距离”更优的解法是转换视角。我们把初始的患病者看作“传染源”。对于网格中的任何一个位置它被传染所需的天数其实就是从它出发离它最近的传染源的“曼哈顿距离”。曼哈顿距离对于两点(x1, y1)和(x2, y2)其曼哈顿距离为 |x1 - x2| |y1 - y2|。在这道题里因为每天只向上下左右传播一格所以传播天数正好等于曼哈顿距离。举个例子如果一个健康格子离它最近的初始病源需要走3步格才能到达那么这个格子将在第3天被传染假设第0天是初始状态。这样一来整个问题就被转化了输入一个包含初始病源()、健康人(.)、空房(#)的网格。处理计算每一个健康人(.)格子其到最近初始病源的曼哈顿距离。如果这个距离值d满足1 d M因为距离为0表示自己就是初始病源距离超过M表示M天内传不到那么这个健康人就会在M天内被传染。输出初始病源数 在M天内会被传染的健康人数。这个思路的精妙之处在于它一次性计算出了每个格子“被感染的时间”完全避免了逐天模拟的复杂状态维护和时序问题。算法核心变成了一个多源点的最短距离计算问题。这正是广度优先搜索BFS的经典应用场景。2.3 算法选择为什么BFS比朴素计算更优你可能会问既然知道了是曼哈顿距离我直接对每个健康格子遍历所有初始病源计算最小距离不行吗我们来算笔账假设网格大小N100初始病源K50个健康人位置P个最多约10000个。朴素方法复杂度是O(KP)在最坏情况下是5010000 50万次计算似乎可以接受。但这是一种静态思维。BFS的动态优势在于更低的实际复杂度BFS从所有源点同时开始扩散每个格子只被访问和标记一次。其时间复杂度是O(N^2)因为每个格子只入队出队一次。在N较大时这比朴素方法更稳定可靠。更自然的“扩散”模拟BFS的层序遍历特性正好对应了流感“一天传播一圈”的过程。第一层距离为0是初始病源第二层距离为1是第一天被传染的人以此类推。这种过程直观易于理解和调试。更强的扩展性如果题目规则变化比如传染需要时间成本不同格子间传播天数不同BFS或更通用的Dijkstra算法可以很容易地扩展而朴素计算法则需要彻底重写。所以选择多源BFS是效率、清晰度和扩展性上的全面胜利。3. 代码实现与逐行解析理解了核心思路我们来看C代码实现。我会将代码分成几个逻辑模块并详细解释每一部分的作用和注意事项。#include iostream #include queue #include cstring // 用于memset using namespace std; // 定义方向数组上、下、左、右。这是处理网格类问题的常用技巧。 const int dirs[4][2] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; int main() { int n, m; cin n; // 网格存储原始字符 char room[105][105]; // 距离数组记录每个位置到最近病源的距离-1表示未访问或不可达空房间 int dist[105][105]; // 初始化距离数组为-1 memset(dist, -1, sizeof(dist)); queuepairint, int q; // BFS使用的队列 // 1. 读入数据并初始化BFS队列 for (int i 0; i n; i) { for (int j 0; j n; j) { cin room[i][j]; if (room[i][j] ) { // 找到初始病源入队并设置距离为0 q.push({i, j}); dist[i][j] 0; } else if (room[i][j] #) { // 空房间设置为不可达距离保持-1 dist[i][j] -2; // 用-2明确标识空房间避免与未访问的-1混淆非必须但清晰 } // 健康人‘.’的dist值保持初始的-1表示尚未被访问传染 } } cin m; // 读入天数 // 2. 多源BFS过程 while (!q.empty()) { auto [x, y] q.front(); // C17结构化绑定方便。如环境不支持可改为 int x q.front().first, y ... q.pop(); // 遍历四个方向 for (int d 0; d 4; d) { int nx x dirs[d][0]; int ny y dirs[d][1]; // 检查新位置是否在网格内 if (nx 0 || nx n || ny 0 || ny n) continue; // 检查新位置是否为空房间不可传播 if (room[nx][ny] #) continue; // 检查新位置是否已经被访问过dist ! -1 if (dist[nx][ny] ! -1) continue; // 符合条件进行传染 dist[nx][ny] dist[x][y] 1; // 距离比父节点多1 q.push({nx, ny}); } } // 3. 统计结果 int sickCount 0; for (int i 0; i n; i) { for (int j 0; j n; j) { // 如果该位置是初始病源或者是在m天内被传染的健康人 // 注意dist[i][j] 0 对应初始病源 // dist[i][j] 0 且 m-1 对应在m天内被传染的人因为第0天是初始状态 // 更精确地说从第1天开始传染所以第k天被传染的人其dist值为k。 // 题目问第m天结束时所以dist值需要 m if (room[i][j] ! # dist[i][j] ! -1 dist[i][j] m) { sickCount; } } } // 4. 输出结果 cout sickCount endl; return 0; }3.1 关键变量与数据结构设计char room[105][105]存储原始的房间布局。题目通常给出N100我们习惯开105的数组这是一种防止数组越界的常见技巧多开一点避免边界判断时出错。int dist[105][105]这是整个算法的核心数组。它有三个状态-1初始值表示该位置是健康人(.)且尚未被BFS访问到即还未计算出距离。0表示该位置是初始病源()。正数k表示该位置健康人是在第k天被传染的。注意k也等于从该位置到最近病源的曼哈顿距离。可选-2我用来明确标记空房间(#)使其与未访问的健康人区分开逻辑更清晰。queuepairint, int qBFS的标准队列存储待处理的网格坐标(x, y)。为什么用pairint, int而不用两个队列一个队列存储坐标对能保证x和y的同步性代码更简洁。在C11及以上配合auto或结构化绑定访问起来很方便。3.2 多源BFS的初始化与执行初始化在读入数据时我们同步完成两件事将字符存入room数组。一旦遇到就将其坐标(i, j)压入队列q并在dist数组中将其距离标记为0。这就是“多源”的起点。遇到#在dist中将其标记为特殊值如-2表示传播的障碍。执行过程标准的BFS层序遍历。从队列取出一个节点(x, y)。查看其上下左右四个邻居(nx, ny)。对每个邻居进行三层“剪枝”判断边界检查是否还在网格内。障碍检查是否是空房间#。访问检查是否已经被访问过dist[nx][ny] ! -1。这一步保证了每个点只被访问一次且第一次被访问时记录的距离就是最短距离曼哈顿距离。如果邻居通过检查则其距离dist[nx][ny]等于当前节点距离dist[x][y] 1并将其入队。这个过程就像在水池中同时投入多个石子涟漪BFS的波前从每个石子处同时扩散当涟漪相遇时它们不会互相覆盖因为先到达的涟漪已经标记了该区域的最短时间。3.3 结果统计的逻辑与边界处理这是容易出错的一步。关键是要理解dist数组值的含义与题目中“第M天结束时”的关系。dist[i][j] 0初始病源从第0天起就生病自然计入总数。dist[i][j] k (1 k)表示这个人在第k天被传染。题目问第M天结束时。那么在第M天结束时所有在第M天及之前被传染的人都应该被统计。因此条件就是dist[i][j] M吗仔细想如果M3第3天被传染的人在第3天结束时是生病的应该计入。所以条件是dist[i][j] M。再看我们统计的条件dist[i][j] m。这里m是输入的天数。为什么是 m而不是 m这里有一个“天数偏移”的细节。 在我们的BFS定义中dist值表示“被传染所需的天数”。初始病源dist0意味着“需要0天就被传染”即第0天已生病。 那么dist1意味着需要1天被传染即在第1天结束时被传染。 所以distk对应的是在第k天结束时被传染。 题目输入的天数m如果表示“第m天结束后”那么distk的人被计入的条件是k m。 但是很多此类题目的描述和测试数据是将“初始状态”视为第1天开始前。输入的天数m表示“经过m天之后”。这时dist1表示经过1天后即第1天结束时被传染。那么经过m天后被传染的条件是dist m。 为了统一和避免混淆最稳妥的方法是理解dist数组记录的就是“从初始时刻到被传染所经过的天数”。统计时只要这个天数输入的天数m就应该计入。我代码中写dist[i][j] m是基于我对题目“第m天结束时”这个输入m的特定理解假设m是从1开始计的天数编号。在实际竞赛中务必仔细阅读题目对“天数”的定义这是常见的坑点。一个更鲁棒的做法是在统计时明确判断dist[i][j] ! -1 dist[i][j] m如果m表示经过的天数或dist[i][j] m如果m表示第m天。我代码中的写法采用了后者的一种常见理解。为了绝对清晰我们可以这样统计int sickCount 0; for (int i 0; i n; i) { for (int j 0; j n; j) { if (room[i][j] #) continue; // 空房间跳过 if (dist[i][j] ! -1 dist[i][j] m) { // 假设m是经过的天数 sickCount; } } }并在注释中说明对m含义的假设。这是与出题人意图保持一致的关键。4. 深度扩展与变式思考掌握了基础解法我们来看看这道题可以如何变化以及如何应对。这才是信息学奥赛训练的核心——举一反三。4.1 变式一不同传播速度与概率原题是每天必然传染给邻居。如果变化为传播概率每天只有一定的概率P传染给健康邻居。不同抵抗力网格中的人有不同的抵抗力需要被传染多次才会生病。解法思路这时BFS的“距离”不再是简单的步数。我们可以将dist数组的含义变为“被传染的期望天数”或“累计感染剂量”。可能需要使用**优先队列BFS的变种类似于Dijkstra算法**来保证每次扩展的都是当前“时间”最早或“剂量”最先达到阈值的节点。对于概率模型可能需要进行多次模拟蒙特卡洛方法或使用概率DP来计算期望值。4.2 变式二动态障碍物或隔离措施假设在传播过程中某些空房间(#)会被清理成可居住的(.)或者某些健康人被隔离暂时变成无法被传染的障碍物。解法思路这变成了一个随时间变化的图上的最短路径问题。一种方法是进行“时间分层BFS”。我们不再只用一个二维的dist数组而是用一个三维数组dist[x][y][t]表示在时刻t到达(x,y)的状态。或者如果变化是已知且离散的事件可以在BFS过程中当时间t到达事件发生时动态修改图网格的结构再继续BFS。这类问题难度会显著增加。4.3 变式三求最后被传染的人的时间原题是求M天后的总数。如果问题是流感完全停止传播没有健康人可被传染需要多少天解法思路这其实就是求所有dist值中的最大值排除空房间和初始病源。因为BFS结束后dist数组中的最大值就代表了距离最远的那个健康人被传染所需的时间也就是整个传播过程持续的时间。只需要在BFS结束后遍历dist数组找最大值即可。int maxDays 0; for (int i 0; i n; i) { for (int j 0; j n; j) { if (room[i][j] ! # dist[i][j] ! -1) { maxDays max(maxDays, dist[i][j]); } } } cout 疫情持续天数为: maxDays endl;5. 调试技巧与常见错误实录即便思路清晰代码实现时也难免踩坑。下面是我和学生们在解决这类问题时总结的几个“血泪教训”。5.1 数组越界——永恒的痛// 错误示例 if (nx 0 || nx n || ny 0 || ny n) continue; // 错误C数组下标通常从0开始 // 正确示例 if (nx 0 || nx n || ny 0 || ny n) continue; // 使用0-based索引心得在开数组时如room[105][105]我们习惯用0到n-1来索引实际数据。边界判断必须是 0或 n。我强烈建议在定义数组时多开几行几列如[n5][n5]并在循环时严格使用[0, n)的范围可以心理上减少压力但逻辑判断不能松懈。5.2 BFS中状态重复入队与访问标记时机// 一个易错点标记访问的时机 // 错误示例标记时机过晚 q.push({nx, ny}); dist[nx][ny] dist[x][y] 1; // 如果在这行之前同一个点被另一个源点再次发现它又会被入队一次 // 正确示例标记时机在入队前 dist[nx][ny] dist[x][y] 1; // 先标记已访问 q.push({nx, ny}); // 再入队心得在BFS中一个节点一旦被赋予距离或标记为已访问就应该立刻进行标记然后再将其子节点入队。这保证了每个节点只被处理一次。上面的“正确示例”是更安全的写法。在我的主代码中我是先计算dist再push顺序是正确的。5.3 输入格式与字符读取的坑题目输入网格时通常是一个n*n的字符矩阵每行字符之间可能没有空格。使用cin room[i][j]可以自动跳过空白字符如空格、换行正好适用。但如果字符之间包含空格或者需要读入整行就要用cin.get()或getline()并小心处理行末换行符。一个常见陷阱cin n; for(int i0; in; i) { for(int j0; jn; j) { cin room[i][j]; } } // 如果输入是 // 3 // .. // .#. // ... // 这样读是没问题的。但如果n后面紧跟换行上面的代码也能正确工作因为cin 会跳过前导空白符。5.4 距离数组初始化的必要性dist数组必须初始化为-1或其他非法值用以区分“未访问的健康人”、“空房间”和“已访问的节点”。使用memset是高效的做法。memset(dist, -1, sizeof(dist))会将数组中的每个字节设置为0xFF对于int类型通常是4字节结果就是每个int被设置为-1。5.5 使用结构体还是pair对于简单的二维坐标pairint, int足够轻量。如果节点信息更复杂比如还需要携带时间、状态等定义一个struct Node会更清晰。struct Node { int x, y; int day; // 当前天数 // 或者 int dist; // 距离 }; queueNode q;心得在竞赛中追求代码速度和简洁性pair更常用。在工程项目或复杂算法中struct的可读性和可扩展性更好。6. 从这道题延伸的算法学习路径“流感传染”这道题像一把钥匙可以打开好几扇算法之门。广度优先搜索BFS这是最直接的应用。通过这道题你要彻底理解BFS的队列操作、层序遍历、访问标记、以及多源BFS的初始化。可以接着去刷“迷宫最短路径”、“01矩阵”、“腐烂的橘子”等题目它们都是BFS的模板题。图论建模网格本身就是一张图每个格子是节点上下左右相邻是边。这道题就是在无向无权图上求多源点到其他所有点的最短路径。理解了这一点当遇到非网格的图结构时你也能自然地想到BFS。动态规划DP的对比有同学可能会想能不能用DP比如dp[i][j][k]表示第k天(i,j)位置是否生病。这理论上可以但复杂度是O(N^2 * M)而且状态转移麻烦要看前一天周围的状态。相比之下BFS的O(N^2)复杂度更优。这说明了选择合适算法的重要性。BFS在这里更优是因为它利用到了问题“边权为1”的特殊性。算法优化思维从“模拟每一天”的O(M*N^2)到“计算最短距离”的O(N^2)这种思维跃迁是算法竞赛的核心乐趣。以后遇到问题不要急于开始编码先问自己问题的本质是什么有没有更数学化、更宏观的模型时间能否转化为空间过程能否转化为状态这道题的代码不长但背后的思维训练价值很高。它教会我们面对一个复杂的动态过程有时跳出过程本身从最终状态或全局关系的角度去思考会找到更简洁高效的解决方案。在信息学奥赛乃至实际的软件开发中这种化动为静、寻找不变量或等价关系的思维能力都是极其宝贵的。