蓝桥杯扩散题解析:多源BFS算法核心与网格问题实战
2026/8/28 12:17:41 网站建设 项目流程

1. 项目概述:从“扩散”到“广度优先搜索”的思维跃迁

“第十一届蓝桥杯C++B组国赛试题B:扩散”,这个标题对于参加过蓝桥杯的选手来说,瞬间就能勾起一系列回忆和思考。蓝桥杯作为国内覆盖面极广的大学生IT赛事,其国赛题目往往代表着当届竞赛的最高难度和最新颖的思维考察点。这道“扩散”题,正是这样一个典型。它初看可能让人联想到物理现象或数学模型,但实际上,它是一道经典的、披着“模拟”外衣的图论搜索问题,核心考察的是选手对广度优先搜索(BFS)算法的深刻理解与灵活应用能力,以及对坐标系处理边界判断的编程基本功。

这道题之所以令人印象深刻,是因为它用一个非常生活化的概念——“扩散”,包装了一个需要严谨算法逻辑才能高效解决的问题。题目通常会描述:在一个无限的二维网格平面上,有若干个初始点(称为“黑点”)在时刻0被“感染”。此后,每一秒,每个黑点会向上、下、左、右四个方向扩散一格,将其相邻的网格也变为黑点。问题最终会问:在某个特定的时刻T(比如2020秒),平面上有多少个网格点被染黑?或者,所有初始点形成的连通区域,需要多少秒才能覆盖一个指定范围?这种模型在计算机科学中无处不在,例如网络传播模拟、图像处理中的区域生长、游戏中的地图探索(战争迷雾)等。

解决它,蛮力模拟在无限平面上是不可行的,必须抓住“扩散”的波阵面是均匀推进的这一本质,将其转化为从多个源点同时开始的BFS过程。BFS的“队列”天然地刻画了时间顺序,队列中每一“层”的点,就对应着某一秒新被扩散到的点。理解到这一步,就从“模拟题”跨入了“算法题”的门槛。接下来,我们将彻底拆解这道题,不仅给出标准解法,更会深入探讨其中的优化技巧、易错细节,以及如何将这种思维迁移到其他相似场景中。

2. 核心思路解析:为什么BFS是唯一正解?

面对“扩散”问题,新手最容易陷入的误区就是尝试直接模拟整个无限平面。他们会想:开一个足够大的二维数组(比如map[10000][10000]),把初始点放进去,然后循环T次,每次遍历所有黑点并向四周扩散。这种方法在T很小、初始点很少时勉强可行,但一旦T达到题目常见的上千量级,无论时间还是空间复杂度都是灾难性的。时间复杂度是O(T * N * M)(N、M为数组维度),空间则是O(N*M),极易超时和内存超限。

因此,我们必须转换视角。关键洞察在于:一个点被染黑的时刻,等于它到任意一个初始点的最短曼哈顿距离。曼哈顿距离,即两点在标准坐标系下横纵坐标差的绝对值之和。为什么?因为扩散每次只能向四邻域移动一格,所以从初始点“走”到目标点所需的最少步数(时间),就是曼哈顿距离。一个点可能被多个初始点扩散到,它被染黑的时刻就是所有初始点中,距离它最近的那个曼哈顿距离。

于是,问题转化为:给定平面上若干个初始点,对于平面上某个点(或在某个范围内所有点),求其到所有初始点的最小曼哈顿距离。如果问题是求T时刻有多少个点被覆盖,那就是统计所有满足min_distance <= T的点数。

那么,如何高效计算呢?这就是BFS登场的时候。BFS非常适合求解这种“从多个源点出发,每一步代价相同”的最短路径问题。我们可以将所有初始点同时放入队列,并标记其距离(时间)为0。然后进行标准的BFS:每次从队列取出一个点,检查其上下左右四个邻居。如果邻居未被访问过,则其距离等于当前点距离+1,将其入队。这个过程会像水波一样一圈圈荡开,当队列为空,或者当我们扩展到足够的时间T时,所有被访问到的点及其对应的“被感染时间”就都计算出来了。

为什么不用深度优先搜索(DFS)?DFS会一条路走到黑,无法保证最先找到的解就是最短路径(最小时间),它求出的“距离”没有意义。而BFS按层推进的特性,保证了第一次访问到某个节点时,所用的步数一定是最少的。

为什么不用直接计算曼哈顿距离?对于“统计T时刻黑点数量”这类问题,如果平面范围是无限的,我们确实可以通过数学方法计算。例如,一个初始点在第T秒后,会形成一个中心在初始点、曼哈顿距离为T的菱形(或称正方形旋转45度)区域。多个初始点形成的区域会有重叠。计算多个菱形区域的并集面积,涉及计算几何,非常复杂且容易出错,尤其是在需要处理整数格点的情况下。而BFS模拟扩散过程,思路直观,实现相对稳健,是竞赛中的首选方法。

注意:BFS解法隐含了一个前提——平面在理论上是无限的,但在计算机中我们必须设定一个搜索范围。这个范围需要根据初始点坐标和最大时间T来估算,通常是[min_x - T, max_x + T][min_y - T, max_y + T]这个矩形区域。这是将无限问题有限化的关键一步,也是容易出错的地方,范围估小了会漏点,估大了可能超时或超内存。

3. 算法实现细节与关键步骤拆解

理解了BFS是核心,接下来我们深入到代码层面,拆解每一个关键步骤。我将以C++为例,因为这是蓝桥杯C++B组的比赛语言。我们会从数据结构选择、坐标处理、去重判断到完整代码框架,一步步说明。

3.1 数据结构设计与坐标映射

在网格BFS中,我们通常需要记录某个坐标点是否被访问过,以及被访问时的时间(距离)。由于坐标可能是负数(初始点可能在原点四周),而C++数组下标不能为负,我们需要进行坐标映射

一种常见且安全的方法是使用std::unordered_setstd::set来存储已访问的点。我们可以将二维坐标编码成一个long long类型的整数。例如,对于一个点(x, y),我们可以将其编码为((long long)x << 32) | (y & 0xffffffff),或者更简单地,使用std::pair<int, int>作为键。但pair作为unordered_set的键需要自定义哈希函数,稍显麻烦。在竞赛中,为了追求速度,我们更倾向于使用二维数组,这就必须进行坐标平移。

坐标平移策略

  1. 找到所有初始点的最小横坐标min_x和最小纵坐标min_y
  2. 设定最大扩散时间T
  3. 确定我们需要搜索的网格范围:横坐标从min_x - Tmax_x + T,纵坐标从min_y - Tmax_y + T。其中max_xmax_y是初始点的最大坐标。
  4. 定义平移量offset_x = -(min_x - T)offset_y = -(min_y - T)。这样,平移后的新坐标nx = x + offset_xny = y + offset_y就都变成了非负数,可以作为数组下标。

例如,min_x = -1, T=5,那么最小需要覆盖的x-1-5=-6。令offset_x = 6,则x=-6映射为0x=-1映射为5x=0映射为6,以此类推。

我们需要声明一个二维数组visiteddistdist可以同时记录距离和访问状态(-1表示未访问)。

int width = (max_x + T) - (min_x - T) + 1; int height = (max_y + T) - (min_y - T) + 1; vector<vector<int>> dist(height, vector<int>(width, -1)); // 初始化为-1,表示未访问

这里widthheight可能很大(如果T很大),使用vector动态分配更安全。如果经过估算后大小可控(比如几百万个点),也可以用静态数组。

3.2 BFS队列的操作与层数记录

BFS需要一个队列,我们使用std::queue。队列的元素需要包含点的坐标信息。我们可以用一个结构体,或者直接用pair<int, int>

层数记录技巧: BFS计算扩散到每个点的时间。在将初始点入队时,将其距离设为0。当从队列中取出一个点(x, y)时,设其距离为d。我们检查其四个邻居(nx, ny)。如果dist[ny][nx]为-1(未访问),则设置dist[ny][nx] = d + 1,并将其坐标入队。这样,每个点第一次被访问时记录的距离,就是它被扩散到的最早时间。

如果题目只要求计算T时刻的黑点数量,那么当从队列中取出的点的距离d已经等于T时,实际上这一层之后的点时间都会大于T,对答案没有贡献。我们可以选择不再将新的点入队,但已经入队的本层点仍需处理完。更简单的方法是让BFS正常进行,最后遍历dist数组,统计所有值不为-1且<=T的格子数量。

一个关键的优化:如果初始点很多,且T很大,最终黑点数量会非常多,遍历整个dist数组可能很慢。我们可以在BFS过程中直接计数。初始化答案ans为初始点个数。每当成功访问一个新邻居(即dist[ny][nx] = d+1)时,如果d+1 <= T,则ans++。这样BFS结束,答案也就出来了。

3.3 边界判断与无限平面的处理

在我们平移后的数组dist中,下标范围是[0, height-1][0, width-1]。在BFS中,每次生成邻居坐标(nx, ny)后,必须检查其是否在我们定义的数组范围内。

if(nx >= 0 && nx < width && ny >= 0 && ny < height && dist[ny][nx] == -1) { // 执行访问和入队操作 }

这个判断至关重要,它确保了搜索不会越界,同时也隐含了我们对“无限平面”的假设:我们只关心在时间T内,从初始点出发曼哈顿距离不超过T的这个菱形区域所覆盖的矩形范围。这个范围之外的区域,在T时刻不可能被扩散到,因此无需考虑。这就是将无限问题有界化的核心逻辑。

3.4 完整代码框架与注释

下面给出一个解决“计算T时刻黑点数量”问题的通用C++代码框架。假设输入格式为:第一行是初始点个数n和时间T,接下来n行是每个初始点的坐标(x, y)。

#include <iostream> #include <vector> #include <queue> #include <algorithm> #include <climits> using namespace std; // 方向数组:上、下、左、右 const int dx[4] = {0, 0, -1, 1}; const int dy[4] = {-1, 1, 0, 0}; int main() { int n, T; cin >> n >> T; vector<pair<int, int>> points(n); int min_x = INT_MAX, max_x = INT_MIN; int min_y = INT_MAX, max_y = INT_MIN; // 读入初始点,并计算坐标范围 for(int i = 0; i < n; ++i) { cin >> points[i].first >> points[i].second; min_x = min(min_x, points[i].first); max_x = max(max_x, points[i].first); min_y = min(min_y, points[i].second); max_y = max(max_y, points[i].second); } // 计算搜索的矩形边界和平移量 int left = min_x - T; int right = max_x + T; int bottom = min_y - T; // 注意:这里bottom对应min_y,是y的最小值 int top = max_y + T; // top对应y的最大值 int width = right - left + 1; int height = top - bottom + 1; int offset_x = -left; // 将left映射到0 int offset_y = -bottom; // 将bottom映射到0 // 初始化距离数组 vector<vector<int>> dist(height, vector<int>(width, -1)); queue<pair<int, int>> q; // 将初始点入队 for(auto& p : points) { int nx = p.first + offset_x; int ny = p.second + offset_y; dist[ny][nx] = 0; // 时间为0 q.push({nx, ny}); } long long ans = n; // 起始时就有n个黑点 // 开始BFS while(!q.empty()) { auto [x, y] = q.front(); q.pop(); int current_dist = dist[y][x]; // 如果当前点的时间已经等于T,其邻居时间将是T+1,超过限制,无需继续从此点扩散? // 注意:不能直接break,因为队列里可能还有时间等于current_dist的点。 // 更准确地说,如果current_dist >= T,那么从这个点扩展出去的邻居时间肯定>T,对答案无贡献,所以可以跳过扩展。 if(current_dist >= T) { continue; } for(int i = 0; i < 4; ++i) { int nx = x + dx[i]; int ny = y + dy[i]; // 检查边界和是否访问过 if(nx >= 0 && nx < width && ny >= 0 && ny < height && dist[ny][nx] == -1) { dist[ny][nx] = current_dist + 1; // 如果新点的时间没有超过T,则计入答案 if(current_dist + 1 <= T) { ans++; } q.push({nx, ny}); } } } cout << ans << endl; return 0; }

实操心得:在竞赛中,ans使用long long是很好的习惯,因为当T很大时,黑点数量可能超出int范围。坐标平移的计算要仔细检查加减号,一个快速的验证方法是:取一个初始点(x,y),计算其平移后的坐标(x+offset_x, y+offset_y),看是否在数组范围内且dist值被正确设为0。

4. 性能优化与边界情况探讨

上述框架是标准解法,但对于极端数据(比如T非常大,达到10^9级别),我们定义的二维数组将大到无法存储。这时就必须换用其他方法,例如使用unordered_set只存储黑点,或者寻找数学规律。但蓝桥杯国赛题通常会将T控制在一个使得BFS可解的范围内(比如几千以内),重点考察的是BFS的实现和优化细节。即便如此,我们仍需关注一些优化点和边界情况。

4.1 使用unordered_set的稀疏存储方案

如果初始点非常稀疏,而T又比较大,导致widthheight巨大,但实际黑点数量(答案)可能远小于网格总数。这时用二维数组会浪费大量空间。我们可以用unordered_set来存储所有黑点的坐标(编码后)。

编码方法:为了将二维坐标(x, y)作为unordered_set的键,我们需要一个哈希函数。一个简单可靠的方法是:

struct PairHash { size_t operator()(const pair<int, int>& p) const { // 假设坐标在[-1e5, 1e5]之间,可以映射到[0, 2e5] // 使用一个大于坐标范围的大质数进行混合 return (static_cast<size_t>(p.first + 100000) << 20) ^ static_cast<size_t>(p.second + 100000); } }; unordered_set<pair<int, int>, PairHash> black_set;

或者,更简单地,将坐标转换为字符串:

auto encode = [](int x, int y) -> string { return to_string(x) + "," + to_string(y); }; unordered_set<string> black_set;

字符串编码简单但效率略低。在BFS时,我们不再需要dist数组记录距离,而是需要两个集合:current(当前时刻的黑点)和next(下一时刻将新增的黑点)。我们还需要记录当前时间t。每一秒,遍历current中的所有点,生成它们的四个邻居。如果邻居不在black_set中(即从未被染黑过),则将其加入black_setnext。然后current = nextnext清空,t++,直到t == T。最后black_set的大小就是答案。

这种方法节省了空间,但每次判断“是否访问过”需要哈希查找,时间开销比数组的O(1)访问要大。它适用于“稀疏扩散”的场景。

4.2 多源BFS的初始化与去重

当多个初始点重合或彼此非常接近时,在数组方案的BFS初始化中,直接设置dist为0并入队即可,队列会自然处理重复。在集合方案中,初始化时需要将所有初始点加入black_set和初始的current集合。集合本身具有去重功能,所以即使有重复坐标也没关系。

一个易错点:在数组BFS中,如果多个初始点在同一起始时间入队,BFS过程会正确合并它们的扩散波阵面。不需要特殊处理。

4.3 时间复杂度的估算与控制

设最终黑点数量为M。对于数组BFS,我们至多访问M个点,每个点尝试扩展4个方向,总操作次数约为4M。加上初始化数组O(width*height),如果width*height远大于M,则初始化是主要开销。对于集合BFS,操作次数也是O(M),但每次哈希查找/插入有常数开销。

如果题目中T非常大,导致M也极大(例如接近width*height),那么两种方法的时间复杂度都可以接受,但数组法的常数更小。如果T很大但初始点很少,导致M远小于网格总数,则集合法在空间上占优。

竞赛策略:通常优先实现数组BFS,因为它编码简单、运行快。只有在内存计算明显不足时(例如width*height > 1e7且内存限制严格),才考虑集合BFS。

4.4 当问题变种:求覆盖指定区域的最短时间

“扩散”问题另一个常见的变种是:求所有初始点扩散出的黑点,需要多少秒才能完全覆盖一个给定的矩形区域(或所有点)?这时,BFS过程需要持续进行,直到目标条件满足。

解法:我们不再以时间T为限制进行BFS,而是让BFS一直进行下去。我们需要额外维护一个计数器或一个判断条件。例如,如果目标是覆盖一个矩形区域[X1, X2] x [Y1, Y2]内的所有整点,我们可以在BFS过程中,每访问到一个新点,就检查它是否在该矩形内。如果该矩形内总共有K个点,我们可以用一个变量covered记录已经被访问到的矩形内点的数量。当covered == K时,当前从队列中取出的点的dist值(即当前时间),就是覆盖整个矩形所需的最短时间。

这里的关键是,BFS是按时间顺序扩展的,所以第一个满足“矩形被完全覆盖”的时刻,就是最短时间。实现时,需要在BFS的主循环中增加条件判断和提前退出。

5. 从“扩散”到更广泛的BFS应用场景

解完这道题,我们掌握的不仅仅是一道题的答案,而是一种强大的建模工具——多源广度优先搜索。这种“波阵面推进”的模型可以解决大量看似不同的问题。

1. 地图探索与最短路径:在网格游戏中,多个单位同时从不同位置出发,探索未知区域,求最早相遇时间或覆盖全图的时间。这本质上就是多源BFS。

2. 火灾模拟或病毒传播:多个火源同时开始燃烧,火势每步向四邻域蔓延,求某个位置被点燃的时间,或者所有可燃物被点燃的时间。这就是“扩散”问题的直接应用。

3. 图像处理中的区域生长:在二值图像中,从多个种子像素开始,将颜色相似的相邻像素合并进来。可以使用BFS或DFS,但BFS能保证生长区域是均匀扩大的。

4. 网络爬虫的层级抓取:从一批初始URL(源点)开始,抓取网页并提取其中的新链接,放入队列继续抓取。这里BFS的“层”对应着链接的跳转深度。

5. 社交网络中的信息传播:假设一个人发布一条消息,他的所有朋友下一秒都会看到,朋友的朋友再下一秒看到,求消息传遍整个网络的时间。这可以用图上的BFS来模拟(虽然现实网络更复杂)。

掌握多源BFS的关键,在于识别出问题的“每一步代价相同”和“需要最短时间/距离”这两个特征。一旦识别出来,剩下的就是熟练的编码和对边界条件的仔细处理。

回到蓝桥杯这道题,它之所以经典,就在于它用一个简单的场景,清晰地传达了这种算法思想。在竞赛和实际编程中,遇到“扩散”、“蔓延”、“传播”、“覆盖”这类关键词时,BFS应该成为你条件反射般的首选思路之一。通过精确的坐标处理、严谨的边界判断和清晰的状态记录,你就能将这种思路转化为高效的代码,解决一系列复杂的问题。

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

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

立即咨询