资讯动态

BFS+Prim实战:网格图最小生成树组合算法详解

发布时间:2026/10/17 13:13:28 来源:尧图企业网站定制
1. 从一道题看两类算法的配合逻辑第一次看到“Borg Maze”这个标题很多人会以为是个迷宫搜索题点进去才发现它其实是一道经典的最小生成树MST应用题而且必须先用BFS把隐藏的图建出来再用Prim算法求总权值。这种“先搜索建模、再图论求解”的组合套路在图论题里出现频率极高但真正自己动手写的时候坑比想象中多得多。这篇文章面向的是已经学过BFS和Prim基本写法、但在综合题里不知道怎么把两者串起来的人。我会把这道题从读题、抽象、建模到编码、调试的完整链路拆开讲重点解释为什么这里必须用BFS而不是DFS、为什么Prim比Kruskal更顺手、坐标到节点的映射怎么做才不出错以及我在实际写这道题时踩过的几个典型坑。看完之后你拿到类似的“网格图 最小生成树”组合题应该能直接套用这套思路。先把这道题的核心设定说清楚给一张由墙、空格、起点标记和若干外星人标记组成的网格地图要求用最短的通道把所有标记点起点和所有外星人连通起来求连通所需的最小总步数。注意这里的“连通”不是让你真的去走迷宫而是求一棵最小生成树——每个标记点是树上的节点节点之间的边权是两点在网格中的最短距离。所以整道题被拆成了两个独立又衔接的阶段第一阶段求任意两个标记点之间的最短距离第二阶段用这些距离构成完全图求最小生成树的总权值。为什么这两个阶段能拆开因为题目只关心“连通所有标记点”的总代价而不关心具体路径怎么走、会不会重叠。最小生成树的定义本身就允许我们把每个点对的距离独立计算再挑出总权最小的边集。这个前提想通了整道题的骨架就立起来了。2. 核心思路拆解与方案选型2.1 为什么是BFS而不是DFS求网格中两点间最短距离标准答案就是BFS。原因很直接在无权图每走一步代价都是1里BFS按层扩展第一次到达某个点时走过的步数一定是最少的。DFS会一条路走到黑可能绕了远路才碰到目标得到的不是最短距离。这里有个容易忽略的细节BFS的起点不是某一个标记点而是每一个标记点都要作为起点跑一次。因为我们需要的是所有标记点两两之间的距离假设有k个标记点就要跑k次BFS。每次BFS从当前标记点出发遍历整张网格记录它到其他每个标记点的距离。我见过有人想偷懒只从一个点跑BFS然后指望用某种方式推出其他点对的距离——这是行不通的。网格里的最短路径不满足“可加性传递”A到C的最短路不一定经过B。所以老老实实每个标记点跑一次这是正确性的保证。2.2 为什么选Prim而不是Kruskal两个算法都能求最小生成树但在这道题里Prim更顺手原因在于图的稠密程度。假设有k个标记点建出来的是完全图边数是k(k-1)/2。当k比较大时这是个稠密图。Prim用邻接矩阵实现是O(k²)Kruskal要先把所有边排序是O(k²logk)。稠密图下Prim的常数更小写起来也更直接。更重要的是Prim的“每次找距离已选集合最近的点加入”这个逻辑和我们前面BFS算出来的距离矩阵能无缝对接。距离矩阵dist[i][j]直接就是Prim需要的边权不需要额外构造边列表再排序。代码结构上BFS阶段产出一个二维距离数组Prim阶段直接消费这个数组两个阶段的数据流非常干净。提示如果标记点数量很少比如个位数Prim和Kruskal随便选差别可以忽略。但养成按图稠密程度选算法的习惯遇到大规模数据时能省下不少时间。2.3 坐标与节点编号的映射设计这是整道题最容易出错的地方。网格是二维的但BFS和Prim处理的都是“节点”。我们需要一套映射规则把每个标记点的二维坐标转换成一个连续的一维编号。我的做法是在读入网格时遇到起点或外星人标记就把它记录到一个列表里同时用一个二维数组id[r][c]记录“坐标(r,c)对应的节点编号”。这样BFS从某个节点出发时通过列表拿到它的坐标BFS过程中碰到另一个标记点通过id数组反查它的编号就能把距离填进距离矩阵。这套双向映射必须保持一致否则会出现“距离填错位置”或者“节点编号越界”的问题。我建议在写代码前先把这两个数据结构画在纸上确认一遍一个是points列表编号到坐标一个是id二维数组坐标到编号。3. 核心细节解析与实操要点3.1 网格读入的坑空格与换行这道题的输入有个经典陷阱网格里包含空格字符而空格本身是有意义的表示可通行的空地。如果用常规的cin string按空白分割读入空格会被当成分隔符吃掉导致读进来的行不完整。正确做法是用getline逐行读或者用cin.get()逐字符读。但getline也有坑如果前面用cin读过整数比如测试用例数、行数列数缓冲区里会残留一个换行符第一个getline会读到空行。解决办法是在读整数后加一句cin.ignore()把残留换行符吃掉。我在第一次写的时候就是栽在这里网格第一行总是空的调了半天才发现是缓冲区残留。这个坑几乎每个写网格题的人都踩过记住“读整数后、读整行前先ignore”这条铁律。3.2 BFS的状态设计与距离初始化每次BFS前距离数组要重置为-1表示未访问起点距离设为0。BFS队列里存坐标每次取出一个点向上下左右四个方向扩展如果新位置在网格内、不是墙、且距离还是-1就更新距离并入队。这里有个优化点BFS不需要跑完整张网格。当我们已经找到了所有其他标记点就可以提前结束。不过实现上判断“是否找齐”会稍微增加复杂度如果标记点不多跑完整张图也无所谓。我一般先写完整版保证正确需要优化时再加提前终止。距离矩阵的填充时机是在BFS扩展过程中每到达一个标记点就通过id数组查到它的编号把当前距离写入dist[from][to]。注意距离矩阵是对称的dist[i][j]和dist[j][i]相等但为了代码简单我通常两个方向都填反正BFS会从每个点各跑一次。3.3 Prim的实现细节与边界处理Prim的核心是维护一个lowcost数组表示每个未选节点到已选集合的最小边权。初始时随便选一个节点比如0号加入集合lowcost[i]就是dist[0][i]。然后循环k-1次每次找出lowcost最小且未选的节点加入累加这个最小值到答案再用新加入的节点更新其他未选节点的lowcost。边界情况要特别注意如果某个节点到已选集合不可达怎么办在这道题里不会出现因为网格是连通的题目保证有解任意两个标记点之间都有路径。但如果换成不保证连通的情况就要判断lowcost最小值是否为无穷大是的话说明图不连通无解。还有一个细节节点数为1时只有起点没有外星人最小生成树权值为0直接输出0。这种退化情况容易被忽略导致数组越界或循环不执行。4. 完整实操流程与关键代码4.1 整体代码骨架先把整个程序的流程列出来方便对照读入测试用例数对每个测试用例读入行数列数读入网格扫描网格收集所有标记点建立坐标与编号的双向映射对每个标记点跑一次BFS填充距离矩阵用距离矩阵跑Prim累加最小生成树权值输出结果下面分段给出关键代码。先看数据结构定义和网格读入#include bits/stdc.h using namespace std; const int MAXN 105; char grid[MAXN][MAXN]; int id[MAXN][MAXN]; int dist[105][105]; int rows, cols; vectorpairint,int points; int dir[4][2] {{-1,0},{1,0},{0,-1},{0,1}};id数组初始化为-1表示该位置不是标记点。points列表按发现顺序存储标记点坐标编号就是它在列表中的下标。读入部分cin rows cols; cin.ignore(); // 吃掉残留换行符 for (int i 0; i rows; i) { string line; getline(cin, line); for (int j 0; j cols; j) { grid[i][j] line[j]; } }注意getline读进来的行长度可能因为行尾空格而不足cols稳妥做法是逐字符处理或者确保读满。实际测试中题目给的网格每行长度是固定的直接按列索引取字符即可。4.2 BFS求两点距离的实现void bfs(int sr, int sc, int from) { int d[MAXN][MAXN]; memset(d, -1, sizeof(d)); queuepairint,int q; q.push({sr, sc}); d[sr][sc] 0; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (id[r][c] ! -1) { dist[from][id[r][c]] d[r][c]; } for (int k 0; k 4; k) { int nr r dir[k][0]; int nc c dir[k][1]; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (grid[nr][nc] #) continue; if (d[nr][nc] ! -1) continue; d[nr][nc] d[r][c] 1; q.push({nr, nc}); } } }这里d数组是每次BFS的局部距离数组dist是全局的两两距离矩阵。每到达一个标记点就更新dist。注意起点自己到自己的距离是0BFS一开始就会填上。4.3 Prim求最小生成树int prim(int n) { if (n 1) return 0; vectorint lowcost(n, INT_MAX); vectorbool visited(n, false); visited[0] true; for (int i 1; i n; i) { lowcost[i] dist[0][i]; } int total 0; for (int cnt 1; cnt n; cnt) { int u -1, minv INT_MAX; for (int i 0; i n; i) { if (!visited[i] lowcost[i] minv) { minv lowcost[i]; u i; } } if (u -1) break; // 图不连通 visited[u] true; total minv; for (int i 0; i n; i) { if (!visited[i] dist[u][i] lowcost[i]) { lowcost[i] dist[u][i]; } } } return total; }这段代码里lowcost初始化为从0号节点出发的边权。每轮找最小的未访问节点加入累加权值再用它更新其他节点。n是标记点总数。4.4 主流程串联int main() { int T; cin T; while (T--) { cin rows cols; cin.ignore(); points.clear(); memset(id, -1, sizeof(id)); for (int i 0; i rows; i) { string line; getline(cin, line); for (int j 0; j cols; j) { grid[i][j] line[j]; if (line[j] A || line[j] S) { id[i][j] points.size(); points.push_back({i, j}); } } } int n points.size(); for (int i 0; i n; i) { bfs(points[i].first, points[i].second, i); } cout prim(n) endl; } return 0; }主流程清晰读网格、收集标记点、逐个BFS、跑Prim、输出。每个测试用例之间要重置id数组和points列表否则会串数据。5. 常见问题与排查技巧实录5.1 距离矩阵填错位置最常见的bug是dist[from][id[r][c]]里的from和id搞反或者BFS起点编号传错。表现是答案偏大或偏小甚至出现负数。排查方法在BFS里打印每次填的距离和手算结果对比。小规模测试用例比如2x2网格只有两个标记点最容易验证。5.2 网格读入不完整前面提过的getline缓冲区问题表现是第一行网格为空或者错位。另一个变体是网格行尾有不可见字符导致line[j]越界。稳妥做法是读入后检查line.length()不足cols时补空格。5.3 Prim的lowcost更新遗漏Prim里更新lowcost时条件必须是!visited[i] dist[u][i] lowcost[i]。如果漏了!visited[i]已访问节点的lowcost会被改小虽然不影响结果因为不会再选它但逻辑上不严谨。如果漏了比较直接赋值会把lowcost改大导致选错边。5.4 常见问题速查表问题现象可能原因排查方法答案偏大距离矩阵有未填的项默认值参与计算检查BFS是否覆盖所有标记点答案偏小距离填成了0或错误的小值打印距离矩阵核对程序崩溃数组越界id映射错误检查网格边界和id初始化第一行网格为空getline读到残留换行符读整数后加cin.ignore()多测试用例结果串了全局数组未重置每个用例前memset5.5 独家避坑技巧我个人的习惯是先用一个最小用例手算一遍把期望的距离矩阵和MST权值写下来再和程序输出对比。比如3x3网格起点在左上两个外星人分别在右上和左下手算距离矩阵应该是[[0,2,2],[2,0,4],[2,4,0]]MST权值是4。程序跑出来对不上就逐项排查。另一个技巧是把BFS和Prim分开测试。先只跑BFS打印距离矩阵确认无误后再加Prim。这样出问题时能快速定位是哪个阶段错了不用在整段代码里大海捞针。6. 复杂度分析与优化方向6.1 时间与空间复杂度设网格大小为R×C标记点数量为k。BFS阶段每个标记点跑一次BFS每次遍历整张网格是O(RC)总共O(kRC)。Prim阶段O(k²)。总时间复杂度O(kRC k²)。空间上距离矩阵O(k²)网格和id数组O(RC)BFS队列最坏O(RC)。对于题目常见的规模网格几十乘几十标记点十几个这个复杂度绰绰有余。如果网格很大但标记点很少BFS阶段是瓶颈如果标记点很多但网格很小Prim阶段是瓶颈。6.2 可选的优化手段如果标记点数量很大BFS阶段可以考虑多源BFS的变体或者从每个点出发时提前终止。提前终止的逻辑是维护一个计数器每找到一个未访问的标记点就减一减到0就退出BFS。这样平均能省下不少时间。Prim阶段如果k很大可以用优先队列优化到O(klogk E)但完全图的E是k²优化效果有限。稠密图下朴素Prim反而更快因为优先队列的常数大。注意优化前先确认瓶颈在哪。我见过有人上来就写优先队列Prim结果k只有10优化完全没必要还增加了出错概率。先写对再写快。7. 这类组合题的通用套路把这道题抽象一下它代表了一类非常典型的图论组合题原始数据不是图需要先通过搜索把图建出来再在图上跑经典算法。类似的还有“网格中若干特殊点求连接它们的最小代价”“地图上有若干城市求修路连通它们的最少花费”等等。这类题的通用解题框架是三步第一步识别出哪些是节点、哪些是边、边权怎么定义第二步用BFS或DFS把边权算出来构建距离矩阵或边列表第三步根据图的稠密程度和问题要求选MST、最短路或其他图论算法求解。第一步最关键也最容易错。节点往往不是网格里的每个格子而是题目指定的特殊位置。边权往往不是直接给的而是需要搜索算出来的最短距离。把这两点想清楚后面就是套模板。我在实际写这类题时习惯先在草稿纸上画出节点和边的示意图标上编号和权值确认建模无误后再动手写代码。这个习惯帮我省下了大量调试时间——很多bug其实在建模阶段就埋下了代码只是把错误放大而已。最后分享一个我自己的检查清单每次写完这类题都过一遍标记点是否全部收集、id映射是否双向一致、BFS距离是否每个点对都填了、Prim是否处理了n1的退化情况、多测试用例是否重置了全局状态。这五条过完基本不会有大问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑