1. 问题重述与核心思路拆解
“扩散”这道题,是2020年蓝桥杯国赛C++ B组的一道经典题目。很多初次接触的同学,看到“扩散”二字,可能会联想到物理现象或者图像处理,但在这道题里,它被抽象成了一个非常典型的计算几何与图论搜索相结合的问题。我们先抛开代码,用最直白的话把题目意思和我们要做的事情讲清楚。
想象一个无限大的网格纸,每一个格子用一个整数坐标(x, y)来表示。最开始,有四个点被“感染”了,或者说被“点亮”了。题目告诉我们,每一分钟,每一个已被感染的点,会向它的上、下、左、右四个相邻的格子(即曼哈顿距离为1的格子)扩散感染。一个新格子被感染后,在下一分钟也会加入扩散的行列。
我们的目标很简单:计算出在第2020分钟结束时(或者说,从第0分钟开始,经过2020分钟后),有多少个格子被感染了。
这听起来是不是有点像“细胞分裂”或者“病毒传播”?没错,其本质就是一个在离散网格上的传播过程。最直接的思路就是模拟:我们有一个集合来存放所有已被感染的点,每分钟遍历这个集合,把每个点的四个邻居加入集合,直到时间到2020分钟,最后统计集合的大小。
但是,这里有一个巨大的陷阱。如果真去模拟一个无限大的平面,哪怕只模拟2020分钟,感染范围也会是一个边长超过4000的巨大正方形区域,点数量级在千万以上。在比赛环境下,无论是时间还是内存,直接进行BFS(广度优先搜索)模拟都是不可行的。我们必须找到更巧妙的方法。
这道题的精髓就在于,它考察你是否能跳出“模拟”的惯性思维,转而利用曼哈顿距离和离散几何的性质,将问题转化为一个判断点是否可达的数学问题。核心思路如下:
任何一个坐标点
(x, y),如果它能被初始的某个感染点(x0, y0)在2020分钟内“扩散”到,那么它们之间的曼哈顿距离必须满足:|x - x0| + |y - y0| <= 2020。
曼哈顿距离,也叫出租车距离,就是两点在标准坐标系上的绝对轴距之和。为什么是这个条件?因为每分钟扩散一步,每一步只能改变一个坐标值1个单位(上/下/左/右)。所以,从(x0, y0)走到(x, y),最短路径就是曼哈顿距离。如果这个最短距离都超过了2020,那肯定无法在2020步内到达。
因此,问题瞬间简化了:我们不需要模拟过程,只需要枚举一个有限区域内的所有整数点,然后判断这个点是否至少与一个初始感染点的曼哈顿距离<= 2020。如果是,则该点被感染。
接下来的关键就是:这个需要枚举的“有限区域”有多大?我们不能枚举无限平面,但我们可以确定一个边界框。由于初始点坐标是(0,0), (2020,11), (11,14), (2000,2000),扩散范围是2020,那么所有可能被感染的点,其坐标一定满足:min_x - 2020 <= x <= max_x + 2020min_y - 2020 <= y <= max_y + 2020其中min_x, max_x是四个初始点x坐标的最小最大值,y坐标同理。
计算一下:x坐标最小是0,最大是2000;y坐标最小是0,最大是2000。所以枚举范围大约是x ∈ [-2020, 4020],y ∈ [-2020, 4020]。这个区域大约是6000*6000=3600万个点。对于每个点,我们需要计算它与4个初始点的曼哈顿距离。3600万 * 4 = 约1.44亿次距离计算。每次计算就是两次绝对值加法,这是一个常数级别的操作。在C++中,1.44亿次简单运算是完全可以在1秒内完成的(通常现代CPU每秒可进行数十亿次运算)。因此,枚举判断法是此题的可行且高效的解决方案。
2. 曼哈顿距离与BFS算法的本质联系
在深入代码之前,我们有必要厘清曼哈顿距离判断法和BFS模拟法之间的深层联系。这能帮助我们理解为什么前者能替代后者,以及在什么情况下它们等价。
BFS(广度优先搜索)模拟,是从源点一层层向外“涟漪式”地探索。在第0分钟,只有初始点。第1分钟,是所有初始点的曼哈顿距离为1的点。第2分钟,是所有曼哈顿距离为2的点(且未被之前感染的点)…… 以此类推。BFS天然地按照曼哈顿距离的层次来遍历节点。
在无障碍物的无限网格上(本题正是如此),从单源点(x0, y0)进行BFS,第t分钟(或第t步)访问到的所有新节点,恰好就是所有满足曼哈顿距离= t的节点。而t分钟内能访问到的所有节点的集合,就是所有满足曼哈顿距离<= t的节点。
这是一个非常重要的性质。它意味着,在这种理想网格上,BFS的“前沿”就是一个以源点为中心的菱形(或者说旋转了45度的正方形)。这个菱形上的所有点到源点的曼哈顿距离相等。
对于多源点BFS(本题有4个初始感染点),情况类似。任何一个点(x, y),只要它到任意一个源点的曼哈顿距离<= t,那么它一定会在t分钟内被感染。因为多源BFS可以看作是同时从多个源点开始的BFS,每个点被感染的时间,等于它到所有源点的曼哈顿距离的最小值。
因此,min_distance_to_any_source(x, y) <= 2020这个判断条件,完全等价于对这个多点系统进行2020分钟的BFS模拟后,该点是否被访问到的结果。这就是我们能用数学计算替代模拟过程的理论基础。
思考:如果题目中的扩散规则变了,比如每分钟可以走“斜角”(像国际象棋里的国王),那么最短距离就变成了切比雪夫距离
max(|x-x0|, |y-y0|)。判断条件也要相应改变。所以,理解“距离”的定义如何由“移动规则”决定,是解决这类扩散/传播问题的关键。
3. 从暴力枚举到代码实现
理论清晰了,现在我们来动手实现。我们将采用最直接的枚举判断法。代码结构非常清晰:
- 定义初始点:将四个初始感染点存入一个数组或向量。
- 确定枚举边界:计算所有初始点x和y坐标的最大最小值,然后分别减去和加上扩散时间2020,得到我们要枚举的x和y的范围。
- 双重循环枚举:遍历边界范围内的每一个整数坐标
(i, j)。 - 判断感染:对于每个
(i, j),计算它到四个初始点的曼哈顿距离。如果有一个距离<= 2020,则计数器加1。 - 输出结果:循环结束后,计数器中的值就是答案。
这里有一个极其重要的细节:初始的四个点本身,在第0分钟就已经被感染了。在我们的判断条件距离 <= 2020中,它们到自身的距离是0,显然满足条件,所以会被自然计数在内。我们不需要单独处理。
让我们来看核心代码实现:
#include <iostream> #include <vector> #include <cmath> // 用于abs函数,但也可以自己实现 using namespace std; // 定义点的结构体 struct Point { int x, y; Point(int _x, int _y) : x(_x), y(_y) {} }; int main() { // 1. 初始化四个源点 vector<Point> sources; sources.emplace_back(0, 0); sources.emplace_back(2020, 11); sources.emplace_back(11, 14); sources.emplace_back(2000, 2000); int time_limit = 2020; int cnt = 0; // 感染点计数器 // 2. 计算枚举的边界 int min_x = 0, max_x = 2000; // 已知的初始点坐标范围 int min_y = 0, max_y = 2000; // 实际上,我们需要根据初始点动态计算,这里直接写出已知值。更通用的写法是遍历sources数组求最值。 // int min_x = sources[0].x, max_x = sources[0].x; // int min_y = sources[0].y, max_y = sources[0].y; // for (const auto& p : sources) { // min_x = min(min_x, p.x); max_x = max(max_x, p.x); // min_y = min(min_y, p.y); max_y = max(max_y, p.y); // } int start_x = min_x - time_limit; int end_x = max_x + time_limit; int start_y = min_y - time_limit; int end_y = max_y + time_limit; // 3. 双重循环枚举所有点 for (int x = start_x; x <= end_x; ++x) { for (int y = start_y; y <= end_y; ++y) { // 4. 判断当前点(x,y)是否能在time_limit内被任一源点感染 bool infected = false; for (const auto& source : sources) { int distance = abs(x - source.x) + abs(y - source.y); if (distance <= time_limit) { infected = true; break; // 找到一个可达源点就跳出内层循环 } } if (infected) { cnt++; } } } // 5. 输出结果 cout << cnt << endl; return 0; }这段代码逻辑正确,但我们可以做一些优化和补充说明:
- 边界计算:代码中我注释掉了动态计算边界的部分,直接用了已知值。在正式解题时,建议使用动态计算,这样即使初始点改变,代码也无需修改。
abs()函数:计算曼哈顿距离需要用到绝对值。<cmath>或<cstdlib>中的abs()函数对整数是有效的。也可以自己写一个:int d = (x > source.x) ? (x - source.x) : (source.x - x);,但使用标准库函数更简洁。- 循环优化:对于最内层循环要判断4个源点,我们采用了
break提前退出。这是一个小的优化。因为一旦发现某个点可达,就没必要再计算它到其他源点的距离了。 - 时间复杂度:如前所述,枚举点数量约
(4000+2020*2)^2 ≈ 6000^2 = 36M,每个点最多4次距离计算,总共约1.44亿次操作,在竞赛的1秒/2秒时限内是可行的。
运行这段代码,你会得到最终的答案。这里我不直接写出答案,希望你能够自己运行并验证。
4. 算法优化与边界情况探讨
虽然枚举法已经可以解决问题,但我们还可以思考一下有无优化空间,以及一些特殊的边界情况。
优化思路:减少枚举量我们枚举了一个矩形区域,但实际感染点分布在一个类似“四个菱形并集”的形状里。矩形的四个角有很大一片区域是绝对不可能被感染的(距离所有源点都太远)。有没有办法只枚举可能的区域呢?
一个思路是,对于每个源点,感染区域是一个菱形。我们可以枚举每个源点对应的菱形区域,然后取这些区域的并集。但计算并集本身比较麻烦,可能需要使用扫描线或者更复杂的几何计算,其编程复杂度远高于简单的矩形枚举。在竞赛中,“矩形枚举+距离判断”在实现速度和思维难度上取得了最佳平衡,是最推荐的做法。
另一种思路是,只枚举y轴方向,然后对于每个y,计算x的取值范围。因为对于一个给定的y,到某个固定源点(x0, y0)曼哈顿距离<= T的点,其x坐标必须满足|x - x0| <= T - |y - y0|。即x0 - (T - |y-y0|) <= x <= x0 + (T - |y-y0|)。对于多个源点,x的取值范围就是这些区间的并集。我们可以遍历y,然后计算每个y对应的x区间并集的总长度,最后求和。这种方法可以将复杂度从O(N^2)降到O(N * K),其中N是坐标范围,K是源点数量(这里K=4)。实现起来比双重循环稍复杂,但效率更高。不过对于本题规模,双重循环足矣。
边界情况与易错点
- 坐标范围与整数溢出:本题坐标和扩散时间都在2000左右,加减后范围在-2020到4020之间,用
int类型完全足够,不会溢出。 - 时间从0开始:第0分钟已经有4个点。我们的算法中,距离
<=2020的点包含了第0到第2020分钟所有被感染的点,计算是正确的。如果题目问“第2020分钟结束后”,或者“经过2020分钟后”,通常都包括初始状态。 - 源点重复:题目保证了源点不重复。如果源点重复,我们的算法依然正确,因为重复判断并不会增加计数(一个点不会被重复感染)。
- 答案的表示:最终答案是一个整数,可能比较大(百万级别),用
int或long long存储均可。
踩坑实录:我最初实现时,曾想当然地认为感染区域是一个大矩形,于是尝试计算矩形面积。但很快发现,四个菱形区域的并集并不是一个简单的矩形,它的边界是凹凸不平的。直接计算面积会漏掉一些凹进去的格子,也会多算一些凸出来的角。这个错误让我意识到,在离散网格上,基于曼哈顿距离的形状,其面积不能直接用连续几何的公式计算,必须通过离散点的枚举来精确计数。这是此类题目一个经典的思维陷阱。
5. 从“扩散”到更广泛的搜索问题
解决这道“扩散”题,我们掌握的核心武器是:将BFS的“过程模拟”问题,转化为基于“距离”的“状态判断”问题。这个思想可以推广到许多类似场景:
- 多源点BFS最短路径:在一个无权图中(或网格中),求所有点到一组起点的最近距离。可以直接初始化所有起点距离为0,然后一次性放入队列进行BFS。这本质上就是多源扩散。
- 障碍物环境:如果网格中存在障碍物,曼哈顿距离的最短路径性质就被破坏了。因为两点之间的直线路径可能被挡住。此时就必须老老实实进行BFS或Dijkstra算法(如果边权不同)来模拟。本题没有障碍物,是简化情况的关键。
- 不同距离度量:如果扩散规则是八方向(米字型),则使用切比雪夫距离;如果是在三维空间,曼哈顿距离定义为
|dx|+|dy|+|dz|。规则决定距离度量。 - 时间作为距离:在这类每分钟扩散一格的题目中,“时间”和“最短路径长度”是等价的。这启发我们,在一些问题中,可以把时间维度转化为图论中的路径长度来思考。
理解了这个本质后,你再看到类似的“扩散”、“感染”、“传播”问题,首先应该问自己:移动规则是什么?有没有障碍?如果移动规则简单(如四方向/八方向)且没有障碍,那么很可能可以绕过模拟,直接用距离公式进行高效计算。如果规则复杂或有障碍,那么BFS/DFS模拟就是唯一可靠的方法。
最后,关于这道题的答案,我强烈建议你根据上面的思路,自己动手编写代码,运行并得出结果。编程竞赛的魅力就在于思考和实现的过程。如果你在实现中遇到任何问题,比如边界条件没处理好导致答案差了几个,或者循环写错了,那正是加深理解的好机会。调试的过程,就是对你思维严密性的一次最好训练。