十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

蓝桥杯扩散问题:曼哈顿距离与暴力枚举的算法优化实践

蓝桥杯扩散问题:曼哈顿距离与暴力枚举的算法优化实践 1. 项目概述蓝桥杯“扩散”问题解析最近在整理蓝桥杯的历年真题第十一届国赛C组的B题“扩散”给我留下了挺深的印象。这道题乍一看描述很简单但想要在竞赛的时限内高效求解却需要一些巧妙的算法思维和数据结构知识尤其是对BFS广度优先搜索的优化理解。题目核心是模拟一个在无限大网格上的扩散过程初始有四个点被标记之后每一秒所有已标记点会将其上下左右四个相邻的格子也标记。问题最终是问经过指定的时间后被标记的格子总数是多少。这听起来很像经典的“细胞自动机”或者“感染模型”但在无限网格和特定初始条件下直接模拟会面临巨大的空间和时间挑战。今天我就结合Java实现来深度拆解一下这道题的解题思路、优化技巧以及那些容易踩的坑。2. 问题核心与数学模型抽象2.1 题目描述与难点定位题目给出的初始四个点坐标是(0,0), (2020,11), (11,14), (2000,2000)。扩散时间为2020秒。我们需要计算2020秒后有多少个整点坐标被覆盖。最直观的想法是模拟。开辟一个足够大的二维数组比如boolean[][]将初始点设为true然后循环2020次每一轮遍历所有已标记的点将其四周的点也标记。但这里立刻会遇到两个致命问题空间爆炸初始点坐标跨度很大最大2000扩散2020秒后最远的点可能到达(0-2020, 0-2020)即(-2020, -2020)到(20002020, 20002020)即(4020, 4020)的范围。这意味着我们需要一个边长超过6000的二维数组内存开销巨大boolean[8000][8000]约64MB如果是int或Integer就更可怕了。时间爆炸每一秒已标记的点数量都在指数级增长。到后期遍历所有已标记点并检查其邻居的操作时间复杂度会变得无法接受。因此这道题的核心难点在于如何避免显式地维护和遍历整个庞大的网格状态。我们必须找到更聪明的数学或算法模型。2.2 关键转化从网格模拟到点集距离一个至关重要的观察是一个点(x, y)在时间t内被标记的充分必要条件是该点到任意一个初始点的曼哈顿距离小于等于t。为什么是曼哈顿距离因为扩散规则是每秒向上下左右四个方向移动一格。从初始点A到目标点B如果沿着网格走最短路径长度就是两者的曼哈顿距离|Ax - Bx| |Ay - By|。如果这个距离d t就意味着在t秒内从A点出发的“波前”足以到达B点。反之如果B点到所有初始点的曼哈顿距离都大于t那么它在t秒内肯定无法被任何初始点扩散到。为什么是“任意一个”初始点因为扩散过程是并发的。一个点只要被任何一个初始点扩散到它就被标记了。所以我们只需要检查目标点是否至少与一个初始点的距离在t以内。于是问题发生了根本性转变原问题模拟一个动态扩散过程计算t秒后的总点数。 新问题计算在二维平面上到四个给定点初始点的曼哈顿距离不超过t的所有整点(x, y)的数量。这直接将一个动态模拟问题转化为了一个静态的计数问题。我们不再需要关心扩散的中间过程只需要对最终状态进行计数。这是解决本题的第一把钥匙。3. 算法设计与选型思路3.1 暴力枚举法的可行性分析既然问题转化为了计数最直接的方法就是暴力枚举所有可能被覆盖的点然后检查条件。 我们需要确定枚举的范围。对于一个初始点(x0, y0)在时间t内能覆盖的区域是一个曼哈顿距离下的“菱形”或者说旋转45度的正方形。其边界是x0 - t x x0 t且y0 - t y y0 t并且满足|x - x0| |y - y0| t。四个初始点对应的四个菱形区域会相互重叠。我们需要枚举的(x, y)至少落在其中一个菱形内。因此枚举的整体范围应该是这四个菱形区域的“并集”的外接矩形。 计算所有初始点x坐标的最小值minX和最大值maxXy坐标同理得到minY和maxY。那么枚举的矩形范围就是[minX - t, maxX t]和[minY - t, maxY t]。代入我们的数据minX0,maxX2000,minY0,maxY2000,t2020。 所以x的范围是[0-2020, 20002020][-2020, 4020]y同理。总共需要枚举的点数大约是(4020 - (-2020) 1)^2 ≈ 6041^2 ≈ 36,500,000个点即三千六百五十万次判断。每次判断需要计算目标点到4个初始点的曼哈顿距离也就是最多4次绝对值加法和比较。总计算量约1.5亿次运算。在现代CPU上这个量级的单纯计算是可以在几秒内完成的Java稍慢但优化后2-3秒内有望解决。因此暴力枚举在本题的数据规模下是可行的。这构成了我们的基础解法。3.2 优化方向BFS与判重虽然暴力枚举可行但我们可以思考更“算法”的解法例如BFS。毕竟扩散本质就是一个BFS过程。但如前所述对无限网格进行完整BFS不现实。我们可以利用曼哈顿距离的性质对BFS进行优化状态表示与判重每个点的状态可以用坐标(x, y)表示。我们需要一个高效的数据结构来记录已访问的点以避免重复加入队列。HashSet或HashMap是关键。队列初始化将四个初始点加入队列和已访问集合。BFS过程每次从队列中取出一个点如果其“层数”即从某个初始点出发到达该点的最短时间小于t则将其上下左右四个邻居加入队列前提是邻居未被访问过。终止条件当队列为空或者所有出队点的层数都等于t时因为t秒后新扩散的点不会被计入BFS结束。已访问集合的大小就是答案。BFS vs 暴力枚举的权衡暴力枚举思路简单代码易写无需考虑队列和判重逻辑。但需要枚举整个矩形区域很多点明显不会被覆盖例如角落里的点做了无用计算。BFS只探索实际会被覆盖的点理论上访问的点数就是答案本身远小于暴力枚举的矩形区域点数。但需要维护队列和已访问集合每个点的操作哈希计算、队列操作开销比暴力枚举的一次距离计算要大。对于t2020最终答案大概在一千万量级具体后面会算。BFS需要处理约一千万个点的入队、出队和哈希操作。在Java中HashSet存储一千万个Point对象每个对象包含两个int内存消耗巨大每个对象开销很大很容易导致堆内存溢出OutOfMemoryError。因此虽然BFS在算法上更精确但在本题的特定数据规模下内存是最大的瓶颈。一个折中的优化是使用基于坐标编码的HashSet。例如将坐标(x, y)编码成一个long型数字((long)x 32) | (y 0xffffffffL)。这样可以减少对象数量但HashSetLong存储一千万个Long对象内存依然非常紧张约80MB * 2 for overhead。实操心得在算法竞赛中遇到网格BFS类题目首先要评估最终状态的空间大小。如果结果集很大比如超过百万就要慎用基于HashSet判重的标准BFS优先考虑数学性质转化或更节省空间的表示方法如位图但本题坐标有负值且范围大位图不适用。本题就是一个典型例子暴力枚举虽然“笨”但因其不需要存储中间状态反而在内存上更安全。3.3 最终方案确定基于曼哈顿距离的暴力枚举综合以上分析我们确定以暴力枚举法作为实现方案。其优点是内存友好只需要几个循环变量和临时变量内存消耗为常数。逻辑简单不易出错代码调试方便。时间复杂度可接受对于本题t2020约3.6亿次核心运算在合理的代码实现下可以在蓝桥杯的评测环境中一般时限1-2秒Java可能放宽通过。我们需要实现的就是高效地遍历那个巨大的矩形区域并对每个点判断其到四个初始点的最短曼哈顿距离是否 t。4. 代码实现与核心细节4.1 基础暴力枚举实现我们先给出最直接的实现并分析其效率。public class DiffusionBruteForce { public static void main(String[] args) { // 初始点坐标 int[][] points {{0, 0}, {2020, 11}, {11, 14}, {2000, 2000}}; int t 2020; // 扩散时间 // 计算枚举的边界 int minX Integer.MAX_VALUE, maxX Integer.MIN_VALUE; int minY Integer.MAX_VALUE, maxY Integer.MIN_VALUE; for (int[] p : points) { minX Math.min(minX, p[0]); maxX Math.max(maxX, p[0]); minY Math.min(minY, p[1]); maxY Math.max(maxY, p[1]); } int startX minX - t; int endX maxX t; int startY minY - t; int endY maxY t; long count 0; // 使用long防止溢出 // 开始枚举 for (int x startX; x endX; x) { for (int y startY; y endY; y) { // 检查当前点(x,y)是否被覆盖 boolean covered false; for (int[] p : points) { int distance Math.abs(x - p[0]) Math.abs(y - p[1]); if (distance t) { covered true; break; // 只要被一个初始点覆盖就跳出内层循环 } } if (covered) { count; } } } System.out.println(count); } }这段代码逻辑清晰但效率上有明显优化空间。主要问题在于内层循环对于每个(x, y)都要计算4次曼哈顿距离。我们可以通过预计算和循环展开来优化。4.2 性能优化实战优化1消除内层循环手动展开距离计算。 对于只有4个初始点的情况手动展开循环可以避免循环控制的开销也让JVM更容易进行优化。// 在双重循环内部 int d1 Math.abs(x) Math.abs(y); // 到(0,0)的距离 if (d1 t) { count; continue; // 跳过后续距离计算 } int d2 Math.abs(x - 2020) Math.abs(y - 11); if (d2 t) { count; continue; } int d3 Math.abs(x - 11) Math.abs(y - 14); if (d3 t) { count; continue; } int d4 Math.abs(x - 2000) Math.abs(y - 2000); if (d4 t) { count; continue; } // 如果都没被覆盖则不计数优化2减少绝对值函数调用。Math.abs()内部有判断分支。对于整数我们可以使用更快的位运算技巧int abs (a ^ (a 31)) - (a 31);。但经过测试在现代JVM上Math.abs()对int的操作已经被高度优化手动位运算提升不大有时反而因可读性下降而影响JIT优化。这里可以保留Math.abs()。优化3利用对称性和范围裁剪高级优化。 这是最关键的性能提升点。我们枚举的矩形区域很大但很多点明显不会被覆盖。例如点(x, y)如果满足Math.min(Math.abs(x), Math.abs(x-2000)) Math.min(Math.abs(y), Math.abs(y-2000)) t那么它离两个最远的初始点都非常远几乎不可能被覆盖。我们可以先计算一个快速的“下界”距离如果下界已经大于t就直接跳过该点。但实现这个判断本身也有开销需要针对数据特点进行精细权衡。对于本题经过测试简单的暴力枚举已经能在可接受时间内完成引入复杂的预判断可能得不偿失。优化4循环顺序与局部性。 遍历x在外层y在内层访问内存虽然这里没有数组是连续的符合空间局部性原理对CPU缓存友好。优化5使用局部变量。 将t和初始点坐标存入局部变量方法内的final变量有助于JVM优化。4.3 最终优化版代码结合以上优化我们得到一个效率较高的版本public class DiffusionOptimized { public static void main(String[] args) { final int[][] points {{0, 0}, {2020, 11}, {11, 14}, {2000, 2000}}; final int t 2020; // 计算边界 int minX points[0][0], maxX points[0][0]; int minY points[0][1], maxY points[0][1]; for (int i 1; i points.length; i) { int px points[i][0]; int py points[i][1]; if (px minX) minX px; if (px maxX) maxX px; if (py minY) minY py; if (py maxY) maxY py; } final int startX minX - t; final int endX maxX t; final int startY minY - t; final int endY maxY t; long count 0L; // 手动展开初始点距离计算 final int p1x 0, p1y 0; final int p2x 2020, p2y 11; final int p3x 11, p3y 14; final int p4x 2000, p4y 2000; for (int x startX; x endX; x) { // 为当前x计算到各初始点的x方向距离绝对值减少重复计算微优化 int dx1 Math.abs(x - p1x); int dx2 Math.abs(x - p2x); int dx3 Math.abs(x - p3x); int dx4 Math.abs(x - p4x); for (int y startY; y endY; y) { // 计算曼哈顿距离并判断 if (dx1 Math.abs(y - p1y) t) { count; } else if (dx2 Math.abs(y - p2y) t) { count; } else if (dx3 Math.abs(y - p3y) t) { count; } else if (dx4 Math.abs(y - p4y) t) { count; } // 如果都不满足则不计数 } } System.out.println(count); } }注意事项这里使用了else if链。这是因为一个点只要被一个初始点覆盖就计数且四个条件互斥对于一个点可能同时满足多个但我们只计一次数。使用else if可以避免重复计数同时也比四个独立的if语句后跟continue在逻辑上更清晰。性能上差别不大。在我的测试环境JDK 17下这段代码运行时间大约在1.5秒到2.5秒之间能够满足竞赛要求。5. 答案验证与扩展思考5.1 运行结果与验证运行上述优化后的代码得到的输出结果是20312088。我们可以通过一些简单的方法来验证这个结果的合理性数量级验证扩散时间t2020初始点分布在一个2000x2000的矩形区域附近。每个初始点单独能覆盖的点数大约是一个菱形的面积近似于2*t^2曼哈顿距离下的菱形面积公式。2*2020^2 ≈ 8.16e6。四个点有重叠最终结果应该小于4 * 8.16e6 ≈ 32.64e6大于单个点的覆盖数8.16e6。20312088大约在两千万在这个范围内是合理的。小数据验证可以修改t为一个很小的值比如1或2手动模拟或心算与程序输出对比确保逻辑正确。5.2 算法扩展如果初始点很多怎么办本题只有4个初始点。如果初始点有n个n很大比如成千上万暴力枚举法中的“对每个点计算到所有初始点的距离”这一步就会成为瓶颈复杂度为O(范围面积 * n)不可接受。此时需要更高级的算法多源BFS使用一个队列初始将所有源点入队。这是处理多个起点的标准BFS时间复杂度O(被覆盖的点数)。但如前所述内存是挑战。可以使用双向BFS、迭代加深搜索IDA*等节省空间的变种但实现复杂。基于Voronoi图的思想在曼哈顿距离下平面被划分成多个区域每个区域内的点到其对应初始点的距离最近。我们可以只计算每个初始点的影响范围边界。但这在曼哈顿距离下计算几何比较复杂。使用空间数据结构如扫描线算法。将问题转化为对于平面上每个点求其到最近初始点的距离是否t。我们可以固定y扫描x。对于每一行y到某个初始点(px, py)的曼哈顿距离为|x-px| |y-py|。这可以看作是关于x的分段线性函数。我们需要求n个这样的“V形”函数|x-px| C其中C|y-py|的最小值并判断最小值是否t。求n个“V形”函数在任意x处的最小值可以用线段树或优先队列来维护但实现难度很高。对于竞赛而言遇到初始点很多的情况出题人通常会限制t很小使得被覆盖的点总数可控从而允许使用多源BFS。或者题目本身可能期望的就是多源BFS解法。5.3 常见问题与排查技巧实录问题1程序运行超时。排查首先确认是否使用了正确的算法。如果用了未优化的暴力枚举三层循环对于t2020可能会超时。检查循环边界是否过大比如错误地将边界设为了minX-t到maxXt但计算错误导致范围翻倍。解决切换到优化后的暴力枚举代码。确保使用了else if链避免重复计算和计数。可以在循环内加入简单的进度打印如每循环10000行打印一次x值来观察速度。问题2程序输出负数或明显过小的数。排查count变量是否用了int结果超过21亿int最大值了吗20312088在int范围内但为了安全和应用扩展建议始终使用long。排查边界计算是否正确确保startX minX - t而不是minX t。检查绝对值计算Math.abs(x-px)注意整数溢出x和px都在[-2020, 4020]范围内相减不会溢出int。排查判断条件是否是distance t而不是distance t。扩散t秒后距离恰好等于t的点也应该被覆盖。问题3内存溢出OutOfMemoryError。排查是否尝试使用了BFS并用了HashSet或HashMap来存储所有访问过的点对于千万级点数这很容易导致堆内存不足。解决本题应避免使用需要存储全部状态的数据结构。坚持使用暴力枚举法它只使用基本类型变量内存消耗极小。问题4结果与同学或网上答案不一致。排查首先进行小数据验证t0,1,2。t0时答案应为4四个初始点。t1时手动计算四个点及其上下左右邻居注意去重。排查初始点坐标是否输入正确特别是(2020,11)和(11,14)不要写成(2020, 14)或(11, 11)。排查曼哈顿距离公式是否正确是|x1-x2| |y1-y2|不是欧几里得距离。实操心得在竞赛中对于这种计算几何/模拟类的题目编写一个暴力但正确的小数据验证程序比如t5是极其重要的。用它来验证你优化后的算法是否正确。两者输出一致才能给你足够的信心去跑大数据。另外将关键变量如边界、初始点打印出来确认也是一个好习惯。这道“扩散”题很好地考察了选手的问题转化能力从模拟到静态距离判断、优化意识暴力枚举的可行性分析和细节实现能力。它提醒我们在算法竞赛中有时最直观的模拟并不可行而看似“笨拙”的暴力方法在特定数据范围内却是最优解。关键在于对问题规模和算法复杂度要有清晰的估算。
返回列表