1. 最小生成树:从生活场景到代码实战
想象一下,你是一个城市规划师,要在几个新建的居民区之间铺设光纤网络。每个居民区就是一个顶点,连接两个居民区的光纤线路就是一条边,铺设线路的成本就是边的权值。你的预算有限,目标是用最低的总成本,让所有居民区都能通过网络连通(任意两个区之间可以间接连通)。这个“用最省钱的方式把所有点连起来”的问题,就是图论中经典的最小生成树问题。
在数据结构与算法(DS图)的学习中,最小生成树是一个绕不开的实战课题。它不仅仅是理论,更是解决实际连通性优化问题的利器。今天,我就带你用两种最主流的算法——Prim算法和Kruskal算法,亲手构建一棵最小生成树。我会用一个完整的代码实例,把两种算法的每一步掰开揉碎讲清楚,让你不仅知道它们怎么用,更能理解它们为什么这么用,以及它们构建树的“生长顺序”有何不同。很多初学者觉得这两种算法容易混淆,其实只要抓住它们的核心视角:Prim是**“从点出发,步步为营”,而Kruskal是“按边排序,合纵连横”**。接下来,我们就从一个具体的图数据输入开始,看看这两种思路迥异的算法,如何最终得到同一棵最经济的树。
为了让你有最直观的感受,我们全程会使用题目中的那个输入样例。这个图有6个顶点(v1到v6),10条边。我们的任务就是分别用Prim和Kruskal算法,找出连接这6个点的最小成本方案,并输出每一步具体添加了哪条边。你会发现,虽然最终的总权重都是15,但两条路径的“生长过程”截然不同,这其中的差异正是算法的精髓所在。
2. 环境与数据准备:构建我们的“施工地图”
在开始算法施工前,我们得先把“施工地图”,也就是图的数据结构搭建好。这里我们选择用邻接矩阵来存储这个无向网。邻接矩阵就像一个二维表格,行和列都代表顶点,表格里的值代表对应两个顶点之间边的权值。如果两点之间没有直接边,我们就用一个非常大的数(比如 0x3f3f3f3f,一个接近无穷大的数)来表示。
让我们看看代码中是如何构建这个图的。首先,我们定义了一个 MGraph 类。在它的构造函数里,我们按顺序读入顶点数 n、每个顶点的名称、边数 m,以及每一条边的具体信息(顶点1、顶点2、权值)。读入每一条边时,我们需要根据顶点名称,找到它们在顶点数组 vexs 中的下标位置,然后在邻接矩阵 edges 的对应位置存入权值。因为是无向图,所以 edges[pos1][pos2] 和 edges[pos2][pos1] 都要赋值。
这里有个细节需要注意,也是新手容易踩坑的地方:图的存储一定要初始化。在代码里,我们用 memset(edges, 0x3f, sizeof(edges)) 把整个矩阵初始化为“无穷大”。这样,后续在判断两点是否连通时,只要看权值是否小于这个“无穷大”即可。最后,我们还会读入Prim算法的起点 v,并找到它的下标 startpos。这个起点可以是图中任意一个顶点,最终生成的最小生成树总权重是一样的,但树的形状和构建顺序可能会因起点不同而略有差异。
class MGraph {
public:
int n; // 顶点数
int m; // 边数
string vexs[MAXN]; // 顶点名称数组
int edges[MAXN][MAXN]; // 邻接矩阵
string v;
int startpos; // Prim算法起点下标
MGraph() {
cin >> n;
for (int i = 0; i < n; i++) {
cin >> vexs[i];
}
cin >> m;
memset(edges, 0x3f, sizeof(edges)); // 初始化为“无穷大”
for (int i = 0; i < m; i++) {
string ch1, ch2;
int weight;
cin >> ch1 >> ch2 >> weight;
int pos1 = -1, pos2 = -1;
// 根据顶点名查找下标
for (int j = 0; j < n; j++) {
if (vexs[j] == ch1) pos1 = j;
if (vexs[j] == ch2) pos2 = j;
}
// 无向图,矩阵对称赋值
edges[pos1][pos2] = edges[pos2][pos1] = weight;
}
cin >> v;
for (int i = 0; i < n; i++) {
if (vexs[i] == v) {
startpos = i;
break;
}
}
}
// ... 后续算法函数
};
数据准备好后,我们的“地图”就清晰了。以输入样例为例,v1 到 v6 这6个点,以及它们之间10条带权重的边,都被准确地记录在了 edges 矩阵里。有了这张地图,Prim和Kruskal两位“工程师”就可以开始他们的工作了。
3. Prim算法实战:像“种树”一样从起点生长
Prim算法的思路非常直观,它模拟的是一棵树从一颗种子(起点)开始,不断向外“生长”的过程。你可以把它想象成铺设网络:我们先选定一个机房作为核心(起点),然后每次都寻找距离当前已连通网络最近的一个未连通的站点,用成本最低的线路把它接进来。就这样,一个一个点,逐步把整个网络连通。
3.1 核心数据结构与初始化
Prim算法需要几个关键的辅助数组来记录状态:
visited:布尔数组,标记每个顶点是否已经加入最小生成树集合。dis:整型数组,记录每个未加入的顶点到当前已生成树集合的最短距离。注意,这个距离不是到起点的距离,而是到整个已连通集合任意一点的最短边权。adj:整型数组,记录当某个顶点通过一条边加入生成树时,它是从已生成树集合中的哪个顶点连接过来的。这用于最后输出边的构成。
算法开始时,我们把起点 startpos 标记为已访问 (visited[startpos] = true),并将它到自己的距离设为0 (dis[startpos] = 0)。对于其他所有顶点,我们初始化它们到生成树集合的距离 dis[i] 为起点到它们的直接边权(如果存在),否则就是无穷大 (INF)。同时,adj[i] 暂时指向起点(如果边存在)。
void prim() {
vector<bool> visited(n, false);
vector<int> dis(n, INF);
vector<int> adj(n, -1); // 记录前驱顶点
// 用于按顺序保存输出的边
string vex1[100], vex2[100];
int dist[100];
int total = 0; // 总权重
// 初始化起点
dis[startpos] = 0;
// 循环n次,每次加入一个顶点
for (int i = 0; i < n; i++) {
// 步骤1:从未加入的顶点中,选出距离生成树集合最近的那个
int u = select(visited, dis, n);
visited[u] = true;
total += dis[u]; // 累加这条边的权重
// 记录输出的边信息(起点不是第一个加入的顶点时)
if (adj[u] != -1) {
vex1[i] = vexs[adj[u]];
vex2[i] = vexs[u];
dist[i] = dis[u];
}
// 步骤2:用新加入的顶点u,更新其他未加入顶点到生成树集合的距离
for (int v = 0; v < n; v++) {
if (!visited[v] && edges[u][v] < dis[v]) {
dis[v] = edges[u][v];
adj[v] = u; // 记录v是通过u连接进来的
}
}
}
// 输出总权重和边的生长顺序
cout << total << endl;
cout << "prim:" << endl;
for (int i = 1; i < n; i++) { // 第一个加入的起点没有对应的边,从i=1开始输出
cout << vex1[i] << " " << vex2[i] << " " << dist[i] << endl;
}
}
这里的 select 函数是一个简单的工具函数,负责从 dis 数组中找出未被访问且距离值最小的那个顶点的下标。
3.2 一步步拆解生长过程
让我们用输入样例,以 v1 为起点,手动推演一遍Prim算法的过程,这比看代码直观得多。
- 初始状态:已生成树集合
S = {v1}。dis数组记录其他点到S的距离:v2:6,v3:1,v4:5,v5:INF,v6:INF。 - 第1轮:从未加入的顶点中,找到
dis最小的v3(距离为1)。将v3加入S,添加边(v1, v3, 1)。然后用v3更新其他点的dis:发现v3到v6的权值为4,小于v6当前的INF,所以更新dis[v6]=4,adj[v6]=v3;v3到v2权值5,小于v2当前的6,更新dis[v2]=5,adj[v2]=v3;v3到v4权值5,等于v4当前的5,不更新;v3到v5权值6,更新dis[v5]=6。 - 第2轮:当前
S = {v1, v3}。dis最小的是v6(距离4)。加入v6,添加边(v3, v6, 4)。用v6更新:v6到v4权值2,小于v4当前的5,更新dis[v4]=2,adj[v4]=v6;v6到v5权值6,等于v5当前的6,不更新。 - 第3轮:
S = {v1, v3, v6}。dis最小的是v4(距离2)。加入v4,添加边(v6, v4, 2)。用v4更新,没有更优的边。 - 第4轮:
S = {v1, v3, v6, v4}。dis最小的是v2(距离5)。加入v2,添加边(v3, v2, 5)。用v2更新:v2到v5权值3,小于v5当前的6,更新dis[v5]=3,adj[v5]=v2。 - 第5轮:
S = {v1, v3, v6, v4, v2}。dis最小的是v5(距离3)。加入v5,添加边(v2, v5, 3)。
至此,所有顶点加入完毕。总权重 = 1+4+2+5+3 = 15。输出的边顺序正是我们一步步“生长”出来的顺序:v1 v3 1 -> v3 v6 4 -> v6 v4 2 -> v3 v2 5 -> v2 v5 3。你可以看到,Prim算法构建的树,是从起点开始,像水波纹一样一圈圈扩散出去的。
4. Kruskal算法实战:像“拼图”一样合并连通块
如果说Prim算法是“种树”,那Kruskal算法就是“拼图”。它完全不关心起点,它的核心思想是:每次都从剩下的边里,挑选一条权重最小且不会让已选择的边形成环的边,加入到生成树中。直到挑选出 n-1 条边为止。这里的关键在于如何判断加入一条边是否会形成环,这就用到了并查集这个高效的数据结构。
4.1 核心思想与并查集的作用
Kruskal算法第一步是把所有的边按照权重从小到大排序。然后我们准备一个空的边集合(未来的最小生成树)。我们按顺序检查每一条边,如果这条边连接的两个顶点目前还不属于同一个连通分量(即加入这条边不会形成环),那么我们就选中这条边,并把这两个顶点所在的连通分量合并。如果它们已经属于同一个连通分量,那么加入这条边就会形成环,我们跳过它。
并查集就是用来高效管理“连通分量”的神器。初始时,每个顶点自成一个集合(父节点指向自己)。find 函数用来查找一个顶点所在集合的“根”(代表元)。union 操作(在代码里体现为 parent[x] = y)用来合并两个集合。判断两个顶点是否属于同一集合,就看它们的 find 结果是否相同。
4.2 代码实现与过程推演
我们来看Kruskal在代码中是如何实现的:
void kruskal() {
// 步骤1:收集所有边,并按权值排序
vector<Edge> allEdges;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) { // 无向图,只取上三角,避免重复
if (edges[i][j] != INF) {
allEdges.push_back({i, j, edges[i][j]});
}
}
}
sort(allEdges.begin(), allEdges.end());
// 步骤2:初始化并查集,每个顶点都是独立的集合
int *parent = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
int edgeCount = 0; // 已选边数
cout << "kruskal:" << endl;
// 步骤3:遍历排序后的边
for (auto &edge : allEdges) {
int rootU = find(edge.u, parent);
int rootV = find(edge.v, parent);
// 如果边的两端不在同一集合,则选中
if (rootU != rootV) {
// 按题目要求,输出时顶点按数组序号升序,这里vexs[edge.u]已经保证了顺序
cout << vexs[edge.u] << " " << vexs[edge.v] << " " << edge.w << endl;
parent[rootU] = rootV; // 合并集合
if (++edgeCount == n - 1) break; // 已选够n-1条边,结束
}
}
delete[] parent;
}
int find(int i, int parent[]) {
// 递归查找根节点,带路径压缩的优化写法通常是 find(parent[i]),这里为清晰用了简单递归
return i == parent[i] ? i : find(parent[i], parent);
}
现在,我们用同一张图来推演Kruskal算法的执行过程。首先,把所有边按权重排序:
(v1,v3,1), (v4,v6,2), (v2,v5,3), (v3,v6,4), (v1,v4,5), (v2,v3,5), (v3,v4,5), (v1,v2,6), (v3,v5,6), (v5,v6,6)。
- 第1条边 (v1,v3,1):
v1和v3不在同一集合,选中。合并{v1,v3}。输出。 - 第2条边 (v4,v6,2):
v4和v6不在同一集合,选中。合并{v4,v6}。输出。 - 第3条边 (v2,v5,3):
v2和v5不在同一集合,选中。合并{v2,v5}。输出。 - 第4条边 (v3,v6,4):
v3属于集合{v1,v3},v6属于集合{v4,v6},不在同一集合,选中。合并后得到{v1,v3,v4,v6}。输出。 - 第5条边 (v1,v4,5):
v1和v4现在都在大集合{v1,v3,v4,v6}中,属于同一集合,加入会形成环,跳过。 - 第6条边 (v2,v3,5):
v2属于集合{v2,v5},v3属于大集合{v1,v3,v4,v6},不在同一集合,选中。合并后所有顶点连通!输出。此时已选中5条边 (n-1=5),算法结束。
最终Kruskal输出的边顺序是:v1 v3 1 -> v4 v6 2 -> v2 v5 3 -> v3 v6 4 -> v2 v3 5。总权重同样是15。可以看到,Kruskal的输出顺序完全由边的权重排序决定,它是一块一块地合并连通分量,最后拼成整棵树。
5. 双算法对比与核心差异深度解析
通过上面的代码和推演,Prim和Kruskal的差异已经非常明显了。但为了让你在实战中能根据场景灵活选择,我们还得再深入对比一下。
| 特性维度 | Prim算法 | Kruskal算法 |
|---|---|---|
| 核心思想 | 顶点驱动。从一个根开始,逐步扩张生成树。 | 边驱动。全局贪心,每次选最小权边且不构成环。 |
| 数据结构 | 需要邻接矩阵/邻接表存储图,并维护visited、dis、adj数组。 | 需要存储所有边的列表,并依赖并查集判断环。 |
| 时间复杂度 | 邻接矩阵实现为 O(V²),适合稠密图。使用优先队列优化可达 O(E log V)。 | 排序边耗时 O(E log E),并查集操作近似常数,整体 O(E log E),适合稀疏图。 |
| 空间复杂度 | O(V²)(矩阵)或 O(V+E)(邻接表+堆)。 | O(E) 存储边,外加 O(V) 的并查集。 |
| 输出顺序特点 | 顺序反映了树的生长过程,与起点和图的连接紧密相关。 | 顺序基本由边权重大小决定,反映了贪心选择的次序。 |
| 适用场景 | 更适合边多顶点少的稠密图,尤其是图本身用邻接矩阵给出时。 | 更适合边少顶点多的稀疏图,或者边已经预先给出的情况。 |
为什么输出顺序不同? 这是由算法本质决定的。Prim的每一步都依赖于当前已构建的子树,它寻找的是“离树最近的点”,因此边的选择具有局部依赖性。而Kruskal的视野是全局的,它只关心边的权重和是否成环,因此它的选择顺序就是全局权重排序的顺序(在权重相同的情况下,可能因输入顺序或排序稳定性而不同,但题目通常假设唯一)。
在实战中如何选择? 我个人的经验是:如果题目给的是邻接矩阵,且顶点数不大(比如V<500),用Prim写起来非常直白,不容易出错。如果给的是边列表,或者顶点数巨大但边数相对不多(稀疏),Kruskal配合并查集是更优雅高效的选择。另外,Kruskal算法在需要按权重顺序处理边的场景下天然有优势。
6. 常见“坑点”与调试技巧
就算理解了原理,自己实现的时候也难免会遇到问题。这里我分享几个在实现这两个算法时最容易踩的“坑”,以及我是怎么调试的。
Prim算法的坑点:
dis数组的含义:一定要明确,dis[i]存储的是顶点i到当前已生成树集合的最短距离,而不是到起点startpos的距离。更新dis时,是用新加入的顶点u到邻居v的边权edges[u][v]去和dis[v]比较。- 起点处理:第一个被选中的起点,其
dis[startpos]为0,且adj[startpos]为-1。在输出边的时候,记得跳过这条不存在的“边”,所以循环通常从i=1开始输出。 - 图不连通:如果图不是连通的,Prim算法在某一轮会找不到
dis有限的未访问顶点(select函数返回-1)。一个健壮的实现应该检查这种情况并做出处理(比如提前结束循环)。
Kruskal算法的坑点:
- 并查集的实现与优化:上面代码中的
find函数是简单递归,在数据量大时可能导致栈溢出或效率低。实战中强烈建议使用路径压缩和按秩合并的优化版本。路径压缩就是在find时把沿途节点的父节点都直接指向根。 - 边的去重:对于无向图,邻接矩阵中
(i, j)和(j, i)是同一条边。在收集所有边时,务必只取一半(如j > i),否则排序和遍历时会处理重复边,虽然因为并查集的存在不会导致错误结果,但浪费了时间。 - 输出顺序要求:题目有时会要求当边权相同时,按顶点编号字典序输出。这就需要我们在定义
Edge结构体的比较运算符<时,不仅比较权重w,在w相等时还要比较u和v。
调试技巧:
- 打印中间状态:在算法循环中,打印出每一轮选择的顶点或边,以及关键数组(如Prim的
dis、visited,Kruskal的parent数组)的变化。这是最直接的调试方法。 - 构造小样例:不要一上来就用复杂的数据。自己画一个4-5个顶点的小图,手动算出最小生成树和算法每一步应该的结果,然后用你的程序跑,对比输出。
- 验证总权重:Prim和Kruskal跑出来的总权重必须相等。这是一个快速检验程序是否基本正确的有效方法。如果不相等,肯定是算法实现有根本性错误。
7. 从理解到应用:算法思想的延伸
掌握了这两种经典算法,你收获的不仅仅是两个模板。它们背后蕴含的贪心思想和并查集的应用,在解决其他问题时威力巨大。
Prim算法的核心是 “局部最优扩展全局” 。这种思想在构建网络、聚类分析等领域都有应用。比如,在一些近似算法中,我们可能需要从一个点集逐步构建一个连接结构,Prim的思路就可以被借鉴。
Kruskal算法的核心是 “排序+并查集判环” 。这个组合拳是解决许多连通性问题的法宝。例如,判断一个图是否有环、求图的所有连通分量、甚至是解决一些离线查询问题,并查集都是首选数据结构。我印象很深的是在一次项目中,需要动态处理大量的“合并集合”和“查询是否同属一个集合”的操作,直接套用并查集模板,效率提升非常明显。
把这次实战的代码吃透,自己默写几遍,再找几道在线判题系统(OJ)上的题目练练手,比如“城市道路建设”、“网络布线成本”这类应用题,你就能真正把最小生成树从知识点变成解决实际问题的工具。算法的学习,最终是为了在合适的场景,写出高效可靠的代码。Prim和Kruskal,就是你这个工具箱里两把趁手的利器。

709

被折叠的 条评论
为什么被折叠?



