
1. 从“扩散”到“BFS”一道经典国赛题的解题脉络看到“扩散”这个题目很多参加过蓝桥杯的同学可能都会心一笑。这道来自第十一届蓝桥杯C国赛的B题可以说是BFS广度优先搜索算法在竞赛中的一个经典应用范本。它不像一些复杂的图论题那样需要精巧的建模也不像动态规划那样考验状态设计它的核心非常纯粹给你一个初始的“感染源”然后按照既定的规则向四周“扩散”问你某个时间点或者满足某个条件时被“感染”的范围有多大。题目本身描述可能就几行字但正是这种简洁让解题过程完全聚焦于对BFS算法本质的理解和实现细节的把握。我当年第一次接触这类题时觉得这不就是套模板吗后来自己踩过坑、帮别人调试过代码才发现越是看起来简单的题越容易在边界条件、状态表示和性能优化上栽跟头。这道“扩散”题恰恰是一个绝佳的练兵场它能清晰地检验你是否真的吃透了BFS而不是仅仅会背代码。今天我就结合这道国赛真题把BFS解决这类“扩散”问题的完整思路、代码实现中的关键陷阱以及一些能让你代码更稳健、更高效的实战技巧系统地梳理一遍。无论你是正在备赛蓝桥杯还是想巩固算法基础相信这篇内容都能给你带来实实在在的收获。2. 题目场景还原与核心问题抽象首先我们得把题目从抽象的“扩散”二字还原成一个具体的、可计算的问题。虽然原题的具体数字和网格大小可能因届次而异但核心模型万变不离其宗。典型的描述可能是在一个无限的二维网格平面上有若干个初始点称为“黑点”或“感染源”。每一分钟如果一个格子是黑色的那么它的上、下、左、右四个相邻的格子也会变成黑色。问题是经过指定的时间t比如t分钟后整个平面上有多少个格子是黑色的2.1 为什么是BFS“每一分钟”、“向四周扩散”这两个关键词直接指向了BFS。BFS天生就是用来处理“一层一层”向外探索的过程。我们可以把初始的黑点看作BFS的起点第0层。第一分钟从这些起点出发走到其四个邻居这些邻居就是第1层。第二分钟再从第1层的所有点出发走到它们未被访问过的邻居形成第2层以此类推。这个过程完美模拟了题目中的扩散规则。如果我们要求t分钟后的黑点总数实际上就是BFS搜索深度或层数不超过t的所有节点总数。2.2 关键问题抽象与输入输出界定在动手写代码前必须明确几个关键抽象状态表示每个网格点可以用一个坐标(x, y)来表示。由于平面是无限的我们无法开一个固定的二维数组来存储所有点。因此我们需要一个能够动态记录“某个点是否已被访问即变黑”的数据结构通常使用std::set或std::unordered_set需要自定义哈希函数来存储已访问的点坐标。扩散规则题目明确是四方向上、下、左、右对应的坐标变化是(dx, dy) {(0,1), (0,-1), (1,0), (-1,0)}。切记不是八方向。时间与层数的关系在BFS中我们使用队列。为了区分“层”有两种经典做法一是使用两个队列交替二是在每一层开始前记录当前队列的长度然后只处理这么多元素这些元素处理完后队列中新增的就是下一层的元素。我们要求的是“不超过t分钟”所以搜索的层数就是从0到t。结果计算最终结果是所有被访问过的、不重复的点的数量。因为不同起点扩散可能会覆盖到同一个点所以必须去重。一个典型的输入输出框架可能是输入初始点的坐标和扩散时间t输出黑点总数。例如初始点可能是(0,0), (2020,11), (11,14), (2000,2000)t2020。这个数据范围立刻提示我们暴力枚举所有可能的点是不现实的必须依赖BFS这种按需扩展的方式。3. BFS算法框架搭建与细节实现理解了问题本质接下来就是搭建代码骨架。这里我给出一个清晰、健壮且易于调试的C实现框架并逐一解释每个部分的设计考量。3.1 数据结构定义#include iostream #include queue #include set using namespace std; // 定义点的结构体用于表示坐标 struct Point { int x, y; // 重载小于运算符用于set排序set默认需要比较 bool operator(const Point other) const { if (x ! other.x) return x other.x; return y other.y; } // 也可以重载但set主要用来判等 }; // 方向数组上、下、左、右 const int dx[4] {0, 0, 1, -1}; const int dy[4] {1, -1, 0, 0};注意这里我选择了setPoint而不是unordered_set。虽然unordered_set的平均时间复杂度是O(1)但它需要为Point自定义哈希函数并且要处理可能的哈希冲突。在竞赛的紧张环境中使用需要重载的set更为稳妥代码更简洁且能保证元素唯一性。set的O(log n)查找和插入开销对于本题的数据规模通常扩散范围在数千是完全可接受的。3.2 BFS核心函数实现long long bfs(const vectorPoint starts, int t) { setPoint visited; // 记录所有已访问变黑的点 queuepairPoint, int q; // BFS队列存储点和该点被感染的时间层数 // 初始化将所有起点加入队列和已访问集合 for (const auto p : starts) { visited.insert(p); q.push({p, 0}); // 起点在第0分钟被感染 } long long total starts.size(); // 初始黑点数量 while (!q.empty()) { auto [current, minute] q.front(); q.pop(); // 如果当前点的时间已经达到t则不再从它向外扩散 // 因为t分钟后扩散停止所以时间等于t的点已经是最后一波源头 if (minute t) { continue; } // 向四个方向扩散 for (int i 0; i 4; i) { Point next{current.x dx[i], current.y dy[i]}; // 检查下一个点是否已经被访问过 if (visited.find(next) visited.end()) { // 未被访问则标记为已访问并加入队列 visited.insert(next); q.push({next, minute 1}); total; // 黑点总数增加 } } } return total; }3.3 代码细节剖析与避坑指南队列元素的设计队列中存储了pairPoint, int其中int代表该点被感染的时间分钟数。这是至关重要的一步。它让我们在从队列中取出一个点时能立刻知道它是在第几分钟被感染的从而判断是否还能继续从它扩散if (minute t)。如果没有这个时间信息我们将无法准确控制扩散的层数。终止条件if (minute t) continue;这一行是控制扩散深度的核心。为什么是而不是假设 t2。第0分钟的起点可以扩散到第1分钟的点第1分钟的点可以扩散到第2分钟的点。对于第2分钟的点当它从队列中取出时它的minute等于2。此时它不应该再向外扩散因为扩散到的新点将是第3分钟的这已经超过了时间限制。所以当minute t时扩散就应停止。去重检查的位置去重检查visited.find(next) visited.end()必须放在尝试扩散之后加入队列之前。这是BFS的标准操作确保每个点只被访问和处理一次。如果遗漏不仅会导致结果错误重复计数更严重的是会导致队列中充满重复点使得程序陷入近乎无限循环或严重超时。结果计数total的初始值是起点数量。之后每成功访问一个新点即visited.insert(next)成功执行total就加1。最终total就等于visited.size()。在循环中维护total可以避免最后再调用visited.size()但两者等价。数据类型注意total使用了long long。这是因为当t较大时黑点数量可能超过int的范围例如从单个点扩散t分钟理论最大点数约为2*t*(t1)1当t2020时这个值远超21亿。使用long long是竞赛中防止整数溢出的好习惯。4. 从正确到高效性能优化与边界思考上面的代码已经是一个正确的解法。但在蓝桥杯国赛的舞台上题目数据往往会给到极限考验你是否满足时间和内存限制。我们需要思考如何让它更快、更省空间。4.1 访问标记的优化用unordered_set替换set正如之前提到的set基于红黑树插入和查找是O(log n)。而unordered_set基于哈希表平均情况是O(1)。当visited集合变得很大时比如数十万、上百万个点这个差异会非常明显。让我们改造一下#include unordered_set // 为Point定义哈希函数 struct PointHash { size_t operator()(const Point p) const { // 一个简单的哈希策略将两个int合并成一个long long再哈希 // 注意要处理负数将其映射到非负范围 long long key ((long long)p.x 32) | ((long long)p.y 0xffffffff); return hashlong long()(key); } }; // 为Point定义相等比较 struct PointEqual { bool operator()(const Point a, const Point b) const { return a.x b.x a.y b.y; } }; // 在BFS函数中将set替换为 unordered_setPoint, PointHash, PointEqual visited;这个优化通常能带来显著的性能提升尤其是在扩散范围很广时。但代价是代码稍显复杂且哈希函数的设计需要保证较好的分布性以减少冲突。在竞赛中如果时间紧迫用set保底是更安全的选择。4.2 队列优化的误区有些同学可能会想是否能用循环队列或者deque来优化对于本题的BFS队列的操作就是简单的 push 和 popqueue已经足够高效。优化的重点不在队列本身而在于减少入队的次数也就是做好visited检查避免重复点入队。4.3 内存与时间的权衡visited集合的增长这是本题一个隐形的考点。在无限平面上扩散visited集合的大小会随着时间t的平方级增长近似于一个菱形区域。当t很大时比如题目中的2020这个集合可能包含数百万个点。每个点是一个pairint, int的结构加上set或unordered_set的内部开销内存占用可能达到几十甚至上百MB。虽然现代OJ机器的内存限制通常较宽如256MB或512MB但这仍然是一个需要考虑的因素。如何应对确保算法正确不正确的算法可能导致visited集合无限膨胀例如忘了去重这才是最危险的。使用更紧凑的结构如果坐标范围可以提前确定一个较大的边界理论上可以用二维布尔数组bool visited[M][N]并通过坐标偏移来访问。但这道题是“无限平面”且起点坐标可能很大如2000,2000再向外扩散2020格需要的数组大小是(20002020)*2的量级约8000*8000大约64MB是可行的。但这是一种“投机”优化依赖于对数据范围的预估并非通用解法。通用的、安全的做法还是使用set。理解问题对称性如果存在有些扩散问题如果起点关于原点对称可能可以利用对称性减少计算量但本题的起点是任意给定的一般不具备这种性质。4.4 输入处理与主函数逻辑一个健壮的主函数同样重要int main() { // 假设输入格式第一行是时间t第二行是起点个数n后面n行是起点坐标 int t, n; cin t n; vectorPoint starts(n); for (int i 0; i n; i) { cin starts[i].x starts[i].y; } long long ans bfs(starts, t); cout ans endl; return 0; }在实际比赛中务必仔细阅读题目输入输出格式可能没有明确的n而是固定几个点或者时间t是隐含在问题中的例如问第2020分钟。根据具体描述调整输入逻辑。5. 测试、调试与常见错误排查即使思路清晰代码也可能因为细节问题而出错。下面分享几个针对此类BFS扩散题的测试和调试方法。5.1 构造小规模测试用例先用极小的数据验证逻辑。测试1单个起点(0,0)t0。答案应为1。测试2单个起点(0,0)t1。答案应为5中心点上下左右。测试3两个起点(0,0)和(1,0)t1。手动画图(0,0)扩散到(0,1),(0,-1),(1,0),(-1,0)(1,0)扩散到(1,1),(1,-1),(2,0),(0,0)。去重后黑点包括(0,0),(1,0),(0,1),(0,-1),(-1,0),(1,1),(1,-1),(2,0)。共8个。用程序跑一遍看结果是否匹配。5.2 典型错误与排查答案偏大最常见的原因是去重失败。检查visited.find(next)的逻辑和visited.insert的调用是否确保了点唯一性。另一个原因是扩散层数控制错误比如终止条件用了minute t导致多扩散了一层。答案偏小检查方向数组是否正确是否是四个方向检查起点是否全部正确加入了队列和visited集合。程序运行超时或内存超限几乎可以肯定是没有去重导致同一个点被反复加入队列队列和集合大小爆炸式增长。立即检查去重代码。也可能是哈希表冲突严重如果用了unordered_set且哈希函数不好退化成链表导致性能低下。结果溢出检查total和可能涉及坐标计算的变量是否使用了足够大的数据类型如long long。5.3 调试技巧打印日志在扩散过程中打印出每分钟新加入的点数和坐标与手动模拟的小规模结果对比。// 在BFS循环中可以每分钟统计一次 int current_minute -1; long long minute_count 0; while (!q.empty()) { // ... 取出 current 和 minute ... if (minute ! current_minute) { if (current_minute ! -1) { cout Minute current_minute added minute_count points. endl; } current_minute minute; minute_count 0; } // ... 扩散逻辑 ... // 在成功加入新点后 // total; minute_count; }可视化对于小范围测试可以写一个简单的函数将网格打印出来直观看到扩散过程。使用调试器设置断点观察visited集合和队列的变化。6. 举一反三BFS扩散模型的变体与扩展掌握这道题后我们可以看看BFS扩散模型还能怎么变这有助于应对更灵活的题目。6.1 扩散速度不同如果不是每分钟扩散一格而是每分钟扩散k格呢这其实等价于将每一步的“距离”权重设为k。在标准的四方向BFS中每步代价是1。如果速度是k我们可以修改状态在队列中存储(点, 到达时间)。从点A到相邻点B如果A在时间ta被感染那么B最早可能在时间ta 1/k被感染不在离散网格和整数时间模型中这通常被转化为每次扩散不是走到相邻格而是可以走到曼哈顿距离小于等于k的格子。这时BFS的图模型就变了每个点的邻居变多了。更通用的解法是将其视为每一步代价为1但可以一次走多格的“跳跃”BFS或者使用优先队列Dijkstra算法如果速度k不是整数。6.2 存在障碍物如果网格中某些格子是障碍无法被扩散。这需要在尝试扩散到下一个点next时增加一个障碍物检查。通常障碍物信息会用一个二维数组或set给出。代码修改很简单if (!isObstacle(next) visited.find(next) visited.end())。6.3 求达到某个状态的最短时间这是BFS更经典的应用给定起点和终点或目标状态问最短需要多少分钟步数才能从起点扩散/走到终点。我们只需要在BFS过程中每次从队列取出点时判断它是否为目标点。如果是当前的时间minute就是最短时间。因为BFS是按层遍历的第一次到达目标点所在的层数就是最短路径。6.4 多源BFS的初始化技巧本题就是典型的多源BFSMultiple Source BFS。它的一个优美之处在于初始化将所有源头同时放入队列并标记为已访问距离时间设为0。这样BFS会自然地同时从所有源头开始扩散并且当不同源的扩散波前相遇时由于visited集合的去重它们会自动停止不会重复计算。这个技巧在很多“寻找离多个最近设施最短距离”的问题中非常有用。7. 实战心得与竞赛策略最后分享一些从这类题目中总结出的、在算法竞赛中通用的经验。7.1 读题与建模是关键像“扩散”这种题题目描述可能很短但你必须从中精准提取关键信息状态是什么网格点、初始状态是什么起点坐标、状态如何转移四方向相邻、目标是什么t分钟后的状态计数。一旦完成这个建模选择BFS就是水到渠成的事。花两分钟画个草图列一下输入输出样例远比直接闷头写代码有效。7.2 BFS模板的“活学活用”网上有很多BFS的模板代码。死记硬背模板行不通必须理解每一行的作用。比如队列里存什么为什么要存时间/层数visited集合为什么必须在入队前检查理解了这些你才能应对变体。我的建议是自己手敲一个最基础的、带层数记录的BFS模板反复练习形成肌肉记忆。7.3 调试能力是硬实力在竞赛中你的第一版代码很可能有bug。如何快速定位我常用的方法是先跑通题目给的样例。如果样例错了立刻用小数据比如t1,2手动模拟用打印日志的方式对比程序输出和你的预期。优先怀疑边界条件如t0起点重复坐标负数和去重逻辑。如果样例对了但提交错误可能是大数据溢出或性能问题检查数据类型和算法复杂度。7.4 关于STL容器的选择queueBFS标配就用它。setvsunordered_set求稳用set求快用unordered_set但要写好哈希函数。在时间紧迫的赛场如果对哈希没把握用set更保险。vector用于存储起点列表很好。pairint, int可以用来代替Point结构体但在set中也需要定义比较函数或使用setpairint,int因为pair默认有比较规则。使用结构体Point代码更清晰。这道“扩散”题就像一把尺子能量出你对BFS的理解深度。它不追求奇技淫巧只考验基本功是否扎实。把这里面的每一个细节都想明白、写清楚以后再遇到“感染”、“传播”、“最短时间占领”这类问题你都能一眼看穿它的BFS本质并快速写出稳健的代码。算法学习有时候就是把这种经典模型吃透然后举一反三。