最小生成树
相关概念
-
连通图:对于图G = {V, {E}}(V表示顶点,E表示边)。任意两个顶点v,u都存在从v到u的路径,则称G为连通图,如果图是有向图则称为强连通图。
-
子图:对于图G1 = {V1,{E1}}和图G = {V, {E}},如果V1 ⊆ V,E1 ⊆ E。则称G1为G的子图。
-
极小连通子图:对于图G的一个连通子图,当删除该子图中的任意一条边(弧)后,该子图将不再连通。可见该子图类似于树的结构,每个节点将只有一个入度和一个出度。(只有n - 1条边)
-
生成树:包括图G所有顶点的极小连通子图(只有n - 1条边),可能有多个。因为每个顶点只有一条入度和出度,因此相当于是树的结构,所以可以将该子图看成是一棵树。
-
最小生成树:该生成树的边的权值加起来最小的那一个生成树。
(注,对于生成树而言,上述中的图G应该说成是网更加准确,因为求最小生成树的一个前提是边要有权值,下面将用网这个概念)
求最小生成树的准则
- 最小生成树的边是网G中的边。
- 仅且仅有n-1条边来连接最小生成树中的顶点。
- 不是使用产生回路的边。
明确三点
- 使用不同的图的遍历的方法,得到的生成树将会不一样。
- 从不同的顶点出发,得到的生成树也不一样。
- 生成树中有n个顶点,n-1条边。
目标
在网G的多个生成树中,找到一个权值和最小的生成树,即为最小生成树。
构造最小生成树的算法有很多,下来介绍两种分别采用贪心和动态规划思想的算法。Prim算法和克鲁斯卡尔算法(Kruskal).
普里姆算法
Prim算法或称DJP算法,采用的就是一种贪心的思想,其主要的操作过程是在每次遍历中选取一条权值最小的边实现的。具体过程如下:
Prim算法基本思想
- 设有网络G = {V, E},T是G上最小生成树边的集合,集合U是G上顶点的边在T上顶点子集,即是最小生成树的顶点集。初始时T = {},U = {u0},u0为生成树的起始点
- 从起始点u0的所有边中选择一条权值最小的边(u0, v),将该边加入到T中,顶点v加入到集合U中。
- 接下去的每一步,在所有的u∈U,v∈V-U的边(u,v)中选择一条权值最小的边,把它的顶点v加入集合U中,该边加入集合T中
- 重复2过程,直到集合U=V为止。
从上面的过程可以见得其每一步都是取的权值最小的边,这用的就是贪心的思想
图示
从顶点0开始,即此时U={v0},然后选取与关联的边的权重的最小的为v5,将v5添加到U中,边(v0,v5)添加到T中。接着,在于U中顶点v0和v5相关联的边(v0,v1),(v5,v4)中权值最小的为边(v5,v4),因此将v4添加到集合U中,如图重复上述过程。
代码实现
#include <iostream>
#include <cstring>
using namespace std;
int graph[2015][2015]; //使用邻接矩阵的方式保留图结构
/**
* 获取当前边权重的最小值的顶点位置
* minWeight:用来保存顶点的最小边的值
* n:顶点的数量
* v:顶点是否已经遍历
*/
int getMinWeight(int *minWeight, int n, int *v) {
int min = 1 << 30; //设置一个极大值
int index = 0;
for (int i = 1; i <= n; i++) {
if (v[i] == 0 && minWeight[i] < min) {
min = minWeight[i];
index = i;
}
}
return index;
}
int prim(int n, int m) {
int sum = 0;
int minWeight[n + 1];
memset(minWeight, 127 / 3, sizeof(minWeight)); //给保存边的最小值的数组初始化一个极大值
int v[n] = {0}; //创建v数组用来保存该节点是否被遍历过
minWeight[1] = 0; //确保在获取最小值的时候第一个顶点会被遍历到
for (int i = 1; i <= n; i++) {
int node = getMinWeight(minWeight, n, v); //获取选中的节点 -- 第一次将获取第一个节点
v[node] = 1; //设置该节点为遍历过了
sum += minWeight[node];
for (int j = 1; j <= n; j++) { //更新最小边数组
if (v[j] == 0 && graph[node][j] < minWeight[j]) {
minWeight[j] = graph[node][j];
}
}
}
return sum;
}
int main() {
int n, m;
cin >> n >> m; //输入顶点树n和边m的数量
memset(graph, 127/3, sizeof(graph));
for (int i = 1; i <= m; i++) {
int x, y, z;
cin >> x >> y >> z; //输入边的依赖关系即权值,(x, y)= z
if (x == y) { //可能有自环的情况出现,不保存这种情况的数据
continue;
}
graph[x][y] = graph[y][x] = min(graph[x][y], z); //可能会出现重边,即两条边的顶点一样,此时保存最小的边即可。
}
int sum = prim(n, m); //获取最小生成树的总路径和
cout << sum << endl;
}
测试用例:
输入:
7 12
1 2 9
1 5 2
1 6 3
2 3 5
2 6 7
3 4 6
3 7 3
4 5 6
4 7 2
5 6 3
5 7 6
6 7 1
输出:
16
克鲁斯卡尔算法
与普里姆算法使用贪心思想不同的是,克鲁斯科尔算法采用的是动态规划是思想,其基本思想如下:
克鲁斯卡尔算法基本思想
- 有网G = {V, E},
- 构造一个只有n个顶点,没有边的非连通图,即边的数量=0。T = {V, {}}表示没有边非连通图,图中每一顶点自成一个连通图。
- 在边集合E中选取最小权值的边,如果该边的两个顶点坐落于不同的连通图上,则将该边加入T的边集合中。否则的话舍弃选择下一条代价最小的边(所以并不是每次都选取权值最小的边,和贪心不同)。
- 重复2过程,知道所有的顶点都在同一个连通分量上,这个时候即构成一棵最小生成树。
图示
如上图(b)所示,先创建一个没有边的集合T= {V,{}}。接着选取边权值最小的为(v0,v5)值为10,将该边加入T中。下一步,选取最小的权值边(v2,v3)值为12,将该边添加到T中。如图重复上述过程,直到所有的顶点都在同一个连通图上,这个时候构造的树就是最下生成树了。
代码示例
#include <iostream>
#include <algorithm>
using namespace std;
struct _node {
int x, y;
int weight;
bool operator(_node a) {
return weight < a.weight
}
}edge[1024];
int node[1024], v[1024];
/**
* 使用并查集的方式查找没有相邻的两个顶点
*/
int findIndex(int x) {
if (x == v[x]) {
return x;
}
return v[x] = findIndex(v[x]);
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) { //初始化并查集数组
v[i] = i;
}
for (int i = 1; i <= m; i++) {
cin >> edge[i].x >> edge[i].y >> edge[i].weight;
}
sort(e + 1, e + m + 1); //由于是每次取最小的边,先将边集合进行排序
sum += 0;
for (int i = 1; i <= m; i++) {
//获取边中两个节点在并查集中的情况,
int node1 = findIndex(edge[i].x);
int node2 = findIndex(edge[i].y);
if (node1 != node2) { //如果不相等则说明两个顶点不连通,则取这条边
sum += edge[i].weight;
v[node1] = node2; //设置两个顶点连通
}
}
cout << sum;
}
测试案例如上。
由于克鲁斯卡尔算法中对于图中两个顶点是否连通是基于并查集的方式进行判断的,可以手动按照代码模拟一遍,看看效果。或者使用搜索的方法来判断两个顶点是否是连通的。