☰
BFS+Prim实战:网格图最小生成树组合算法详解
2026/10/17 13:13:26 网站建设 项目流程

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 整体代码骨架

先把整个程序的流程列出来,方便对照:

  1. 读入测试用例数
  2. 对每个测试用例:读入行数列数,读入网格
  3. 扫描网格,收集所有标记点,建立坐标与编号的双向映射
  4. 对每个标记点跑一次BFS,填充距离矩阵
  5. 用距离矩阵跑Prim,累加最小生成树权值
  6. 输出结果

下面分段给出关键代码。先看数据结构定义和网格读入:

#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; vector<pair<int,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)); queue<pair<int,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。注意起点自己到自己的距离是0,BFS一开始就会填上。

4.3 Prim求最小生成树

int prim(int n) { if (n <= 1) return 0; vector<int> lowcost(n, INT_MAX); vector<bool> 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()
多测试用例结果串了全局数组未重置每个用例前memset

5.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是否处理了n=1的退化情况、多测试用例是否重置了全局状态。这五条过完,基本不会有大问题。

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

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

立即咨询