最小生成树有三个性质需要掌握:
一般来说,如果题目中涉及最小生成树本身的输出,为了让最小生成树唯一,一般都会直接给出根结点。
求解最小生成树一般有两种算法:Prim 算法和 Kruskal 算法,都采用了贪心的思想,只是贪心的策略不一样。
对于图 G(V, E) 设置集合 S,存放已经被访问的顶点,然后每次从集合 V-S 中选择集合 S 的最短距离最小的一个顶点(记为 u),访问并加入集合 S。之后,令顶点 u 为中介点,优化所有从 u 能到达的顶点 v 与集合 S 之间的最短距离。这样的操作执行 n 次(n为顶点个数),直到集合 S 已经包含所有顶点。
也就是执行 n 次下面两个步骤:
可以看到 Prim 算法的思想与最短路径中 Dijkstra 算法的思想几乎相同,只是在设计最短距离的时候用集合 S 代替了 Dijkstra 中的起点 s。
prim 算法需要实现两个关键的概念,即集合 S 的实现、顶点Vi 与集合 S 的最短距离。
可以看到 prim 算法与 **Dijkstra **算法使用的思想几乎完全相同,只有在数组 d[] 的含义上有所区别。在 Dijkstra 中 d[] 含义为起点 s 到达 Vi 的最短距离,在 prim 算法中 d[] 含义为顶点 Vi 到集合 S 的最短距离,二者的区别仅仅在于最短距离是顶点 Vi 针对的是起点s还是集合V。
所以伪代码和 Dijkstra 很像:
// 数组 d 为顶点与集合 S 的最短距离
void Prim(G, d[]) {
初始化;
for(循环 n 次) {
u = 使 d[u] 最小的还未访问的顶点的标号;
记 u 已经被访问;
for(从 u 出发能到达的所有顶点 v) {
if(v 未被访问 && 以 u 为中介点使得 v 与集合 S 的最短距离 d[v] 更优) {
将 G[u][v] 赋值给 v 与集合 S 的最短距离 d[v];
}
}
}
}
具体实现:
const int maxn = 1000; // 最大顶点数
const INF = 1e9;
int n, G[maxn][maxn]; // n 为顶点数
int d[maxn]; // 顶点与 S 的最短距离
bool vis[maxn] = {false}; // 标记数组
int prim(int s)
{ // s 号为默认初始点,函数返回最小生成树的边权之和
fill(d, d+maxn, INF); // fill 函数整个数组赋值为 INF
d[s] = 0; // 只有顶点到集合 S 的距离为 0,其余全为 INF
int ans = 0; // 存放最小生成树的边权之和
for(int i = 0; i < n; i++) {
int u = -1, MIN = INF; // u 使得 d[u] 最小,MIN 存放该最小的 d[u]
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1) return -1;
vis[u] = true; // 标记为已访问
ans += d[u]; // 将与集合 S 距离最小的表加入到最小生成树
for(int v = 0; v < n; v++) {
// v 未访问 && u 能到达 v && 以 u 为中介可以使 v 距离集合 s 更近
if(vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
d[v] = G[u][v]; // 将 G[u][v]赋值给 d[v]
}
}
}
return ans;
}
struct Node {
int v, dis; // v 为目标边顶点,dis 为边权
};
vector<Node> Adj[maxn];
int n;
int d[maxn];
bool vis[maxn] = {false};
int prim(int s) {
fill(d, d+maxn, INF);
d[s] = 0;
int ans = 0; // 最小生成树边权之和
for(int i = 0; i < n; i++) {
int u = -1, MIN = INF;
for(int j = 0; i < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
// 找不到小于INF的 d[u],剩下的顶点和集合 S 不连通
if(u == -1) return -1;
vis[u] = true;
ans += d[u];
for(int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; // 通过邻接表直接获得 u 能到达的顶点 v
if(vis[v] == false && Adj[u][j].dis < d[v]) {
// v 未访问 && 以 u 为中介点可以使得 v 距离集合 S 更近
d[v] = G[u][v]; // 将G[u][v]赋值给 d[v]
}
}
}
return ans;
}
Kruskal 使用了边贪心的策略,思想很简洁。
基本思想为:在初始状态隐去图中的所有边,这样图中每个顶点都自成一个连通块。之后执行下面的步骤:
简单来说就是:每次选择图中最小边权的边,如果两段的顶点在不同的连通块中,就把这条边加入到最小生成树中。
伪代码:
int Kruskal() {
令最小生成树的边权之和为 ans,最小生成树的当前边数为 Num_Edge;
将所有边按边权从小到大排序;
for(从小到大枚举所有边) {
if(当前测试的两个端点在不同的连通块中) {
将该测试边加入最小生成树中;
ans += 测试边的边权;
最小生成树的当前边数 Num_Edge + 1;
当边数 Num_Edge 等于顶点数减1 时结束循环;
}
}
return ans;
}
伪代码中有两个细节需要注意:
如果把每个连通块当成一个集合,那么就转化成判断两个端点是否在同一个集合中,可以使用并查集,而合并刚好可以解决第二个问题,那么具体实现如下:
const int maxn = 1000;
struct egde {
int u, v;
int cost; // 边权
}E[maxn];
// 按照边的权重排序,小权重放在前面
bool cmp(edge a, edge b) {
return a.cost < b.cost;
}
int father[N]; // 并查集数组
int findFather(int x) { // 并查集查询函数
while(x != father[x]) {
x = father[x];
}
return x;
}
// kruksal 函数返回最小生成边权之和,参数 n 为顶点个数,m 为图的边数
int krukal(int n, int m) {
// ans 为所求边权之和,Num_Edge 为当前生成树的边数
int ans = 0, Num_Edge = 0;
for(int i = 1; i <= n; i++) {
father[i] = i;
} // 并查集初始化
sort(E, E+m, cmp);
for(int i = 0; i < m; i++) { // 枚举所有边
int faU = findFather(E[i].v);
int faV = findFather(E[i].u);
if(faU != faV) { // 不在一个集合中
father[faU] = faV;
ans += E[i].cost; // 边权之和增加测试边的边权
Num_Edge++;
if(Num_Edge == n - 1) break; // 边数等于顶点数减 1 时结束算法
}
}
if(Num_Edge != n-1) return -1;
else return ans;
}
可以看到,kruksal 适合顶点数目比较多,边数较少的情况,这和 prim 算法正好相反。
如果是 稠密图,则用 prim 算法,如果是 稀疏图,则用 kruksal 算法。
如果图本身不连通,使用 kruksal 算法必须在连通图下讨论,不然一定不能形成一颗完整的最小生成树。
浙大的还是畅通工程 是一个很好的例子。