
1. 项目概述从一道国赛真题看BFS与模拟的经典结合最近在整理历年蓝桥杯国赛的真题发现“扩散”这道题第十一届国赛B组试题B的出镜率特别高很多朋友在备赛时都会拿它来练手。这道题初看描述很简单就是在二维网格上模拟几个点的扩散过程但真要高效、准确地解出来里面涉及到的算法思想和代码实现细节一点也不简单。它完美地结合了广度优先搜索BFS和模拟两大核心考点是检验选手对基础算法掌握程度和代码实现能力的绝佳试金石。我自己带学生备赛蓝桥杯时这道题是必讲的例题。它不像一些偏门的难题那样需要奇技淫巧而是扎扎实实地考察你对BFS队列操作、边界判断、状态记录等基本功的理解。很多同学第一次做要么超时要么答案不对根本原因往往是对“扩散”这个过程的理解停留在表面没有抓住“同时扩散”和“时间戳”这两个关键。今天我就结合自己多次讲解和代码调试的经验把这道题从题意理解、思路分析、代码实现到优化技巧掰开揉碎了讲清楚。无论你是正在备赛的选手还是对算法感兴趣的开发者相信都能从中获得可以直接“抄作业”的解题思路和避坑指南。2. 题意深度解析与核心难点定位2.1 题目场景还原与抽象建模我们先来回顾一下题目描述基于记忆和常见表述还原原题可在官方试题集中找到 在一个无限的二维方格图中有四个点初始时被染色可以理解为感染源或扩散源。每一分钟每个已染色的格子会向上、下、左、右四个方向相邻的格子扩散即将其染色。问题是经过2020分钟后有多少个格子被染色这个描述非常生活化就像一滴墨水滴在宣纸上慢慢晕开或者一个消息在社交网络中传播。但我们要把它抽象成计算机能处理的模型。核心抽象如下空间模型一个无限的二维整数坐标平面。虽然说是“无限”但在有限时间内扩散范围是有限的我们只需要考虑可能被染到的区域即可。时间模型离散的时间步每分钟为一个单位。状态模型每个格子只有两种状态——“已染色”或“未染色”。规则模型在每一分钟所有“已染色”的格子同时向四邻域扩散。这是关键意味着新一分钟的扩散是基于上一分钟结束时的整体状态而不是边扩散边影响。2.2 关键难点与常见误解很多同学一看觉得直接用循环模拟不就行了但一写就发现问题重重。主要的难点和易错点集中在以下几点难点一“同时扩散”的理解这是最大的陷阱。如果写成顺序遍历所有已染色格子遍历到一个就立刻将其邻居染色那么在这个分钟之内刚被染色的新格子又会立即去染它的邻居这就导致了“连锁反应”扩散速度比实际规则快了一倍甚至更多。正确的理解是每一分钟开始时我们有一批“源头”。这一分钟内只有这批“源头”参与扩散新被染色的格子要等到下一分钟才能成为新的源头。这本质上就是BFS中“一层一层”遍历的思想。难点二无限平面与边界处理题目说平面是无限的但我们不能真的模拟一个无限数组。我们需要确定一个有限的搜索范围。2020分钟每个点每分钟最多向外走一格那么从任意初始点出发最远曼哈顿距离就是2020。因此所有可能被染色的点其坐标一定落在由初始点坐标加减2020所构成的矩形区域内。我们需要提前计算好这个区域或者更常见的在BFS过程中通过判断当前时间步数是否超过2020来终止。难点三坐标偏移与去重初始点的坐标可能是负数例如常见的数据是(0,0), (2020,11), (11,14), (2000,2000)。在编程中如果我们想用二维数组如vis访问标记数组来记录某个坐标是否被访问就需要将坐标进行平移映射到数组下标。同时一个格子只能被计算一次必须进行去重。使用std::set或std::unordered_set存储坐标点是一种方法但在大规模点数时此题最终点数上万set的查找和插入效率可能成为瓶颈而使用二维数组则需要解决坐标映射和内存开销的问题。难点四结果的数据类型经过2020分钟的扩散被染色的格子数量是数万量级需要用long long或int64_t来存储结果避免整型溢出。3. 算法思路抉择为什么BFS是正解面对模拟扩散问题我们有几个候选算法暴力循环模拟、深度优先搜索DFS、广度优先搜索BFS。我们来逐一分析为什么BFS是最优解。方案一暴力按时间步模拟伪代码思路初始化一个集合S包含初始四个点。 for t from 1 to 2020: 新建一个集合newS。 对于S中的每一个点p 将p的上、下、左、右四个邻居点加入newS。 将newS中的所有点并入S去重。 最后输出S的大小。这个思路直接反映了“同时扩散”的规则逻辑上是正确的。但是它的效率很低。每一轮都要遍历当前所有已染色点集合S而S的大小随时间增长非常快。在后期S可能有数万个点遍历和去重合并集合的操作代价很高很容易超时尤其是在比赛的环境下。方案二深度优先搜索DFSDFS倾向于“一条路走到黑”不适合模拟这种均匀向四周扩散的场景。它难以自然地处理“同时”和“层”的概念并且需要手动控制搜索深度2020层代码写起来反而复杂容易出错。方案三广度优先搜索BFSBFS天然适合这种“一层一层”扩散的场景。队列中的元素天然具有“时间先后”的顺序。我们可以将初始点放入队列并记录它们被染色的时间为0。然后每次从队列中取出一个点如果它的时间t 2020就检查它的四个邻居。如果邻居未被染色则将其染色并将其入队同时记录时间为t1。当队列为空时所有在时间2020及以内能被染色的点都已被访问。BFS方案的优势自动处理“同时性”队列保证了所有“第t分钟”的点都会在“第t1分钟”的点之前被处理完。当我们处理一个时间为t的点时由它扩散出的邻居时间就是t1这些t1的点会在同一轮被陆续处理完美符合“同时扩散”的语义。效率高每个点最多入队一次出队一次检查四个邻居。时间复杂度是O(N)其中N是最终被染色的格子数。这比暴力模拟中每一轮都要遍历全集要高效得多。逻辑清晰代码结构是标准的BFS模板易于编写和调试。因此我们毫不犹豫地选择BFS作为核心算法。接下来的问题就是如何高效地实现它特别是处理坐标和去重。4. 代码实现与细节雕琢这里我给出一个用C实现的、经过实战检验的版本并逐段解释关键细节。我们假设初始点为(0, 0), (2020, 11), (11, 14), (2000, 2000)。4.1 数据结构定义与坐标映射首先我们需要表示一个格子的状态它的坐标(x, y)以及它被染色的时间。#include iostream #include queue #include unordered_set using namespace std; // 定义一个结构体表示网格点 struct Point { int x, y, time; // time表示该点在第几分钟被染色 Point(int _x, int _y, int _t) : x(_x), y(_y), time(_t) {} };去重是关键。使用unordered_set需要为自定义类型Point提供哈希函数和相等比较比较麻烦。更简单高效的方法是使用一个大的二维布尔数组visited来标记。但坐标有负数且范围很大从-2020到20002020≈4000直接开数组可能很大8000*8000≈64M布尔型可以接受但内存访问效率要考虑。一个更精妙的技巧坐标压缩与偏移我们并不需要开一个覆盖所有可能坐标的矩形数组因为扩散区域可能不是规整的矩形。但为了教学清晰我们采用一种稳健且省事的方法使用unordered_set存储坐标的唯一编码。我们可以将二维坐标(x, y)编码成一个long long类型的整数。例如long long id (long long)x * 1000000 y。只要乘数足够大大于y的最大绝对值范围这个映射就是唯一的。这种方法避免了自定义哈希的复杂性且查找、插入效率是O(1)平均复杂度。// 将坐标编码为唯一ID long long getID(int x, int y) { // 使用一个足够大的偏移量确保编码唯一。这里1e7足够覆盖本题坐标范围。 return (long long)(x 10000) * 10000000LL (y 10000); }这里给x和y加了10000的偏移确保即使坐标是负数编码后的值也是正数方便处理。4.2 BFS核心框架实现接下来是BFS的主函数。我们使用queuePoint作为队列unordered_setlong long作为已访问集合。int main() { // 初始点坐标和时间 vectorPoint starts { {0,0,0}, {2020,11,0}, {11,14,0}, {2000,2000,0} }; queuePoint q; unordered_setlong long visited; long long ans 0; // 使用long long存储答案 // 方向数组上、下、左、右 int dirs[4][2] { {-1, 0}, {1, 0}, {0, -1}, {0, 1} }; // 初始化将起点入队并标记 for (auto p : starts) { long long id getID(p.x, p.y); if (!visited.count(id)) { visited.insert(id); q.push(p); ans; // 起点本身也算一个 } } // BFS遍历 while (!q.empty()) { Point cur q.front(); q.pop(); // 如果当前点的时间已经达到2020则它不能再扩散了 if (cur.time 2020) { continue; } // 遍历四个方向 for (int i 0; i 4; i) { int nx cur.x dirs[i][0]; int ny cur.y dirs[i][1]; int nt cur.time 1; long long nid getID(nx, ny); // 如果新点未被访问过则标记、入队、计数 if (!visited.count(nid)) { visited.insert(nid); q.push(Point(nx, ny, nt)); ans; } } } cout 经过2020分钟后被染色的格子数量为: ans endl; return 0; }4.3 关键代码段解析与注意事项入队时去重在初始化起点时我们使用了if (!visited.count(id))的判断。虽然题目给的四个起点是互不相同的但养成先判断再操作的习惯是好的。在扩散过程中一个格子可能被多个源头在同一分钟扩散到这个判断保证了它只被计数一次。时间判断的位置if (cur.time 2020) { continue; }。这个判断放在出队之后遍历邻居之前。这意味着一个时间为2020的点即在第2020分钟被染色的点仍然会被从队列中取出并被计数但它不能再进行扩散了。这符合题意在第2020分钟结束时那些在第2020分钟被染色的点是算在内的但它们没有机会再去染第2021分钟的格子。计数时机我们在将一个点加入已访问集合即第一次被染色时就立即将ans加1。这保证了每个点只被计数一次并且计数是准确的。队列中存储时间Point结构体中的time字段至关重要。它记录了该点是在第几分钟被染色的是我们控制扩散轮次不超过2020的依据。5. 优化策略与内存时间分析上述代码已经可以正确运行并得到答案。但我们可以从时间和空间上分析其效率并探讨可能的优化方向。时间复杂度每个格子最多入队、出队一次每次出队检查4个邻居。因此时间复杂度是O(4 * N) ≈ O(N)N为最终染色格子数大约在数万级别对于计算机来说是瞬间完成的。空间复杂度主要消耗在visited集合和队列q。visited存储了所有N个点的编码IDlong long类型8字节。队列q在最坏情况下可能存储接近一层的所有点但峰值空间也是O(N)。总空间复杂度O(N)完全在可接受范围内。潜在优化点编码函数优化getID函数中的乘法和加法是常数时间已经很快。确保偏移量这里的10000足够大覆盖所有可能坐标。使用数组替代哈希集合如果能够精确计算出坐标的范围可以定义一个二维布尔数组vis[rows][cols]并通过一个固定的偏移量将坐标(x,y)映射到数组下标(xOFFSET, yOFFSET)。数组的访问速度O(1)通常比哈希集合的O(1)平均复杂度更稳定、更快。但前提是能确定rows和cols并且数组大小在内存允许范围内。对于本题坐标范围在[-2020, 20002020]即[-2020, 4020]之间每个维度跨度约6041。开一个bool vis[6041][6041]的数组大约是36MB6041*6041 ≈ 36.5Mbool在C中通常为1字节这在比赛允许的内存内通常256MB或512MB是可行的且速度会有提升。双向BFS本题扩散源是多个。但从算法竞赛角度普通BFS已经足够快双向BFS实现复杂提升不明显不推荐。注意在蓝桥杯等竞赛中使用unordered_set通常就能AC。如果追求极致速度可以尝试数组法。但数组法需要注意偏移计算容易因下标算错导致访问越界调试起来更麻烦。对于初次解题清晰正确比极致优化更重要。6. 调试技巧与常见错误实录即便思路清晰实现时也难免踩坑。下面是我和学生们在解这道题时遇到过的典型问题及解决方法。问题一答案比标准答案小可能原因1没有理解“同时扩散”用成了DFS或顺序模拟导致扩散速度变慢2020分钟染到的格子数变少。检查确认使用了BFS队列并且每个点的time字段正确递增。可能原因2初始点没有全部正确加入或去重逻辑有误导致起点丢失。检查打印初始点入队后的visited集合大小应为4。可能原因3时间判断逻辑错误。比如错误地将if (cur.time 2020)写成了if (cur.time 2020)导致第2020分钟被染色的点没有机会入队或入队后不能扩散是合理的但关键是其本身要被计数。在我们的代码中计数发生在入队时所以只要第2020分钟的点能入队就行。确保nt新时间在cur.time为2019时等于2020并且能被加入。检查可以输出最后几个入队的点的时间和坐标看看时间是否有2020的。问题二答案比标准答案大可能原因1去重失败。同一个点被多次加入visited和ans。检查visited.count(nid)判断逻辑是否正确是否在插入前判断。可能原因2时间限制逻辑错误导致扩散超过了2020分钟。例如错误地将判断写在了入队之后或者time递增逻辑有误。检查确保if (cur.time 2020) { continue; }这行代码存在且位置正确。问题三程序运行超时或内存超限可能原因1使用了set而非unordered_set。set基于红黑树的插入和查找是O(log N)在数据量数万时比unordered_set基于哈希表平均O(1)慢得多。解决换用unordered_set并确保为long long类型提供了有效的哈希内置类型long longSTL有标准哈希函数。可能原因2编码函数冲突。如果getID函数设计的乘数或偏移量太小可能导致不同坐标映射到同一个ID造成错误去重或逻辑混乱虽然此题范围下不易发生但需注意。解决确保乘数远大于坐标的绝对值范围。例如x和y的范围在-5000到5000之间乘数至少需要10001。问题四输出结果不稳定可能原因unordered_set的遍历顺序是不确定的但这不影响计数结果。如果结果不稳定一定是程序逻辑有未定义行为比如数组越界、使用了未初始化的变量等。解决使用调试器或添加打印语句检查边界情况。一个实用的调试方法小数据测试将2020改为一个较小的数比如2或3手动模拟扩散过程画出网格图与程序输出结果对比。这是验证算法逻辑最直接有效的方法。7. 算法扩展与思维提升“扩散”问题本质上是图上的广度优先搜索其中的“图”就是网格节点是格子边是相邻关系。理解了这一点我们可以将问题扩展到更多变种扩散速度变化如果不是每分钟扩散一格而是每分钟扩散k格曼哈顿距离k的格子都被染色该如何修改BFS这时从当前点出发需要将其距离k以内的所有未访问点都标记。这仍然可以用BFS但每一层不是只走一步而是走k步或者更高效地在入队时直接计算并填充一个菱形区域。更通用的方法是将“扩散”视为该点在第t分钟激活那么在第t分钟所有与其曼哈顿距离k的未染色点都会被染色。我们可以在BFS中当处理一个点时遍历一个菱形区域内的所有点进行标记。但需要注意去重和效率。带权扩散不同方向速度不同如果向上、下、左、右扩散的速度不同例如上下每分钟1格左右每分钟2格这就变成了在加权图上的最短路径问题。BFS适用于边权为1的图对于边权不同的情况需要使用Dijkstra算法或SPFA算法来求单源最短路径。多个源点同时开始就是多源最短路问题。最终所有在距离时间2020的格子都被染色。三维空间扩散如果将网格扩展到三维x, y, z扩散规则变为上下左右前后六个方向。算法框架完全不变只需要将方向数组从4个方向扩展到6个方向坐标编码从二维变成三维即可。getID函数可以设计为id ((xOFF)*M (yOFF)) * M (zOFF)其中M是一个足够大的常数。动态障碍物如果网格中存在一些格子始终无法被染色障碍物在BFS中当检查邻居时只需要额外判断该邻居坐标不是障碍物即可。通过这道题我们巩固了BFS处理“层序”、“最短步数”问题的模板也学习了如何将无限空间问题通过分析约束条件转化为有限空间问题以及使用哈希表或数组处理离散二维坐标的技巧。这些技能在解决迷宫问题、网络爬虫、图像填充、社交网络分析等领域都有广泛应用。下次再遇到“传染”、“传播”、“填充”这类关键词的题目不妨先想想是不是又能套用这个BFS的“万能”模板了。