BFS算法实战:从蓝桥杯“扩散”题掌握广度优先搜索核心技巧
2026/9/4 15:05:17 网站建设 项目流程

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 关键问题抽象与输入输出界定

在动手写代码前,必须明确几个关键抽象:

  1. 状态表示:每个网格点可以用一个坐标(x, y)来表示。由于平面是无限的,我们无法开一个固定的二维数组来存储所有点。因此,我们需要一个能够动态记录“某个点是否已被访问(即变黑)”的数据结构,通常使用std::setstd::unordered_set(需要自定义哈希函数)来存储已访问的点坐标。
  2. 扩散规则:题目明确是四方向(上、下、左、右),对应的坐标变化是(dx, dy) = {(0,1), (0,-1), (1,0), (-1,0)}。切记不是八方向。
  3. 时间与层数的关系:在BFS中,我们使用队列。为了区分“层”,有两种经典做法:一是使用两个队列交替;二是在每一层开始前记录当前队列的长度,然后只处理这么多元素,这些元素处理完后,队列中新增的就是下一层的元素。我们要求的是“不超过t分钟”,所以搜索的层数就是从0到t。
  4. 结果计算:最终结果是所有被访问过的、不重复的点的数量。因为不同起点扩散可能会覆盖到同一个点,所以必须去重。

一个典型的输入输出框架可能是:输入初始点的坐标和扩散时间t,输出黑点总数。例如,初始点可能是(0,0), (2020,11), (11,14), (2000,2000),t=2020。这个数据范围立刻提示我们,暴力枚举所有可能的点是不现实的,必须依赖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};

注意:这里我选择了set<Point>而不是unordered_set。虽然unordered_set的平均时间复杂度是O(1),但它需要为Point自定义哈希函数,并且要处理可能的哈希冲突。在竞赛的紧张环境中,使用需要重载<set更为稳妥,代码更简洁,且能保证元素唯一性。set的O(log n)查找和插入开销对于本题的数据规模(通常扩散范围在数千)是完全可接受的。

3.2 BFS核心函数实现

long long bfs(const vector<Point>& starts, int t) { set<Point> visited; // 记录所有已访问(变黑)的点 queue<pair<Point, 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 代码细节剖析与避坑指南

  1. 队列元素的设计:队列中存储了pair<Point, int>,其中int代表该点被感染的时间(分钟数)。这是至关重要的一步。它让我们在从队列中取出一个点时,能立刻知道它是在第几分钟被感染的,从而判断是否还能继续从它扩散(if (minute >= t))。如果没有这个时间信息,我们将无法准确控制扩散的层数。

  2. 终止条件if (minute >= t) continue;这一行是控制扩散深度的核心。为什么是>=而不是>?假设 t=2。第0分钟的起点可以扩散到第1分钟的点,第1分钟的点可以扩散到第2分钟的点。对于第2分钟的点,当它从队列中取出时,它的minute等于2。此时它不应该再向外扩散,因为扩散到的新点将是第3分钟的,这已经超过了时间限制。所以,当minute == t时,扩散就应停止。

  3. 去重检查的位置:去重检查visited.find(next) == visited.end()必须放在尝试扩散之后,加入队列之前。这是BFS的标准操作,确保每个点只被访问和处理一次。如果遗漏,不仅会导致结果错误(重复计数),更严重的是会导致队列中充满重复点,使得程序陷入近乎无限循环或严重超时。

  4. 结果计数total的初始值是起点数量。之后,每成功访问一个新点(即visited.insert(next)成功执行),total就加1。最终total就等于visited.size()。在循环中维护total可以避免最后再调用visited.size(),但两者等价。

  5. 数据类型:注意total使用了long long。这是因为当t较大时,黑点数量可能超过int的范围(例如,从单个点扩散t分钟,理论最大点数约为2*t*(t+1)+1,当t=2020时,这个值远超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 hash<long 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_set<Point, PointHash, PointEqual> visited;

这个优化通常能带来显著的性能提升,尤其是在扩散范围很广时。但代价是代码稍显复杂,且哈希函数的设计需要保证较好的分布性以减少冲突。在竞赛中,如果时间紧迫,用set保底是更安全的选择。

4.2 队列优化的误区

有些同学可能会想,是否能用循环队列或者deque来优化?对于本题的BFS,队列的操作就是简单的 push 和 pop,queue已经足够高效。优化的重点不在队列本身,而在于减少入队的次数,也就是做好visited检查,避免重复点入队。

4.3 内存与时间的权衡:visited集合的增长

这是本题一个隐形的考点。在无限平面上扩散,visited集合的大小会随着时间t的平方级增长(近似于一个菱形区域)。当t很大时(比如题目中的2020),这个集合可能包含数百万个点。每个点是一个pair<int, int>的结构,加上setunordered_set的内部开销,内存占用可能达到几十甚至上百MB。虽然现代OJ机器的内存限制通常较宽(如256MB或512MB),但这仍然是一个需要考虑的因素。

如何应对?

  1. 确保算法正确:不正确的算法可能导致visited集合无限膨胀(例如忘了去重),这才是最危险的。
  2. 使用更紧凑的结构:如果坐标范围可以提前确定一个较大的边界,理论上可以用二维布尔数组bool visited[M][N],并通过坐标偏移来访问。但这道题是“无限平面”,且起点坐标可能很大(如2000,2000),再向外扩散2020格,需要的数组大小是(2000+2020)*2的量级,约8000*8000,大约64MB,是可行的。但这是一种“投机”优化,依赖于对数据范围的预估,并非通用解法。通用的、安全的做法还是使用set
  3. 理解问题对称性(如果存在):有些扩散问题如果起点关于原点对称,可能可以利用对称性减少计算量,但本题的起点是任意给定的,一般不具备这种性质。

4.4 输入处理与主函数逻辑

一个健壮的主函数同样重要:

int main() { // 假设输入格式:第一行是时间t,第二行是起点个数n,后面n行是起点坐标 int t, n; cin >> t >> n; vector<Point> 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),t=0。答案应为1。
  • 测试2:单个起点(0,0),t=1。答案应为5(中心点+上下左右)。
  • 测试3:两个起点(0,0)和(1,0),t=1。手动画图:(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 典型错误与排查

  1. 答案偏大:最常见的原因是去重失败。检查visited.find(next)的逻辑和visited.insert的调用是否确保了点唯一性。另一个原因是扩散层数控制错误,比如终止条件用了minute > t,导致多扩散了一层。
  2. 答案偏小:检查方向数组是否正确(是否是四个方向),检查起点是否全部正确加入了队列和visited集合。
  3. 程序运行超时或内存超限:几乎可以肯定是没有去重,导致同一个点被反复加入队列,队列和集合大小爆炸式增长。立即检查去重代码。也可能是哈希表冲突严重(如果用了unordered_set且哈希函数不好),退化成链表导致性能低下。
  4. 结果溢出:检查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的初始化技巧

本题就是典型的多源BFS(Multiple Source BFS)。它的一个优美之处在于初始化:将所有源头同时放入队列,并标记为已访问,距离(时间)设为0。这样BFS会自然地同时从所有源头开始扩散,并且当不同源的扩散波前相遇时,由于visited集合的去重,它们会自动停止,不会重复计算。这个技巧在很多“寻找离多个最近设施最短距离”的问题中非常有用。

7. 实战心得与竞赛策略

最后,分享一些从这类题目中总结出的、在算法竞赛中通用的经验。

7.1 读题与建模是关键

像“扩散”这种题,题目描述可能很短,但你必须从中精准提取关键信息:状态是什么?(网格点)初始状态是什么?(起点坐标)状态如何转移?(四方向相邻)目标是什么?(t分钟后的状态计数)。一旦完成这个建模,选择BFS就是水到渠成的事。花两分钟画个草图,列一下输入输出样例,远比直接闷头写代码有效。

7.2 BFS模板的“活学活用”

网上有很多BFS的模板代码。死记硬背模板行不通,必须理解每一行的作用。比如队列里存什么?为什么要存时间/层数?visited集合为什么必须在入队前检查?理解了这些,你才能应对变体。我的建议是,自己手敲一个最基础的、带层数记录的BFS模板,反复练习,形成肌肉记忆。

7.3 调试能力是硬实力

在竞赛中,你的第一版代码很可能有bug。如何快速定位?我常用的方法是:先跑通题目给的样例。如果样例错了,立刻用小数据(比如t=1,2)手动模拟,用打印日志的方式对比程序输出和你的预期。优先怀疑边界条件(如t=0,起点重复,坐标负数)和去重逻辑。如果样例对了但提交错误,可能是大数据溢出或性能问题,检查数据类型和算法复杂度。

7.4 关于STL容器的选择

  • queue:BFS标配,就用它。
  • setvsunordered_set:求稳用set,求快用unordered_set(但要写好哈希函数)。在时间紧迫的赛场,如果对哈希没把握,用set更保险。
  • vector:用于存储起点列表,很好。
  • pair<int, int>:可以用来代替Point结构体,但在set中也需要定义比较函数(或使用set<pair<int,int>>,因为pair默认有比较规则)。使用结构体Point代码更清晰。

这道“扩散”题,就像一把尺子,能量出你对BFS的理解深度。它不追求奇技淫巧,只考验基本功是否扎实。把这里面的每一个细节都想明白、写清楚,以后再遇到“感染”、“传播”、“最短时间占领”这类问题,你都能一眼看穿它的BFS本质,并快速写出稳健的代码。算法学习,有时候就是把这种经典模型吃透,然后举一反三。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询