最小生成树——Kruskal算法

 

前面介绍了最小生成树和Prim算法,这篇博客继续记录Kruskal算法的相关内容。

 

算法思想:

        1. 先将所有边按权值由小到大排序;

        2. 从边集中选出第一条边(即权值最小的边),如果与树中现有的边不构成环,则将其加入树中;

        3. 重复步骤2直至树中有n-1条边。

 

在实现上述算法之前,要先解决三个问题:

 

1. 如何表示一条边?

        虽然我们尽量简化情景方便实现,但是边还是不能像节点一样简单地用一个数表示,因为它有三个必备的属性:起点、终点和权值。因此,我们创建以下结构体来表示边:

1 // 定义表示边的结构体
2 typedef struct Edge
3 {
4     int u;            // 起始节点
5     int v;            // 终止节点
6     int weight;        // 权值
7 };

 

2. 如何将边按权值排序?

        虽然可以用普通的排序方法将边排序,但是移动结构体的时间代价有点大,影响性能;可以考虑使用一个整型数组保存边序列,排序只改变这个数组中元素的指向,类似指针的作用。这是空间换时间的方法。

        由于我们只是验证算法,纯属娱乐,因此就直接对结构体排序了。排序方法使用qsort()库函数,为此要定义一个比较函数:

1 int cmpEdge(const void *edge1, const void *edge2)
2 {
3     return ((struct Edge *)edge1)->weight - ((struct Edge *)edge2)->weight;
4 }

 

3. 如何判断树中的边是否会构成环?

        这个问题就有技术含量了,解决方法也很巧妙,使用的是不相交集(Disjoint sets),也叫并查集(Union-find set)

        不相交集(Disjoint sets):从字面上理解,就是每个集合都不相交,没有公共元素;至于为什么又叫并查集,大概是因为这种数据结构的查找(find)和合并(union)操作都比较非主流,所以特意拿出来作为标志吧。

 

        言归正传,不相交集的逻辑结构:所有在同一集合中的元素构成一棵树,互为父子关系。因为每棵树的根是唯一的,所以当要判断两个节点是否在同一集合中时,只要递归地查找出他们的根节点,看是不是同一个就能区分了。而要合并两个集合时,只要将一棵树的根挂到另一棵树的任意一个节点上就行。

 

        应用到Kruskal算法中,是这样的:

        1. 用一个数组保存每个节点的父节点信息,初始都为-1表示每个节点都是根节点,也就是有n棵单节点树。我们的目的是要得到一棵有n个节点的树。

        2. 每次用上面的算法找到一条边,先判断两端是否在同一棵树中,如果是,则不能再将这条边加到树中,否则会构成环;如果不是,则将一个节点的根挂到另一棵树之下,合成一个集合。至于判断两个节点是不是在同一棵树中的方法,前面已经说了,就看两个节点的根节点是不是一样的。

        找出一个节点所在树的根节点很简单:

1 int find(int *parents, int pos)
2 {
3     while (parents[pos] >= 0)
4     {
5         pos = parents[pos];
6     }
7 
8     return pos;
9 }

 

上面三个问题都已经解决了,下面就可以着手实现Kruskal算法了:

最小生成树——Kruskal算法最小生成树——Kruskal算法
 1 int Kruskal(struct Edge *edges, int numEdges, int numNodes)
 2 {
 3     int *parents;            // 保存每个节点的父节点
 4     int i, rootU, rootV;
 5     int totalWeight = 0;
 6     int numFoundEdge = 0;    // 已找到边的数量,用于控制循环
 7 
 8     parents = (int *)malloc(numNodes * sizeof(int));
 9     
10     // 初始化每个节点的父节点
11     for (i = 0; i < numNodes; ++i)
12     {
13         parents[i] = -1;
14     }
15 
16     // 将边集按权值由小到大排序
17     qsort(edges, numEdges, sizeof(struct Edge), cmpEdge);
18 
19     // 找出n-1条边
20     printf("\nEdge\tWeight\n");
21     i = 0;
22     while (numFoundEdge < numNodes - 1 && i < numEdges)
23     {
24         rootU = find(parents, edges[i].u);
25         rootV = find(parents, edges[i].v);
26 
27         // 如果两端点不在同一棵树中
28         if (rootU != rootV)
29         {
30             // 合并两棵树
31             parents[rootU] += parents[rootV];    // 根节点的parent保存树中节点数的相反数
32             parents[rootV] = rootU;                // 将rootV挂到rootU之下
33             totalWeight += edges[i].weight;
34             ++numFoundEdge;
35 
36             printf("%d->%d\t%3d\n", edges[i].u, edges[i].v, edges[i].weight);
37         }
38 
39         ++i;
40     }
41 
42     if (numFoundEdge < numNodes - 1)
43     {
44         printf("This is not a connected graph!\n");
45     }
46     else
47     {
48         printf("\nTotal Weight: %d\n\n", totalWeight);
49     }
50     
51     return totalWeight;
52 }
View Code

 

再编写一个测试函数:

最小生成树——Kruskal算法最小生成树——Kruskal算法
 1 int main()
 2 {
 3     int i, numNodes, numEdges;
 4     struct Edge *edges;
 5 
 6     printf("Num of Nodes and Edges: ");
 7     scanf("%d %d", &numNodes, &numEdges);
 8 
 9     edges = (struct Edge *)malloc(numEdges * sizeof(struct Edge));
10 
11     // 输入所有边
12     printf("Input %d Edges:\n", numEdges);
13     for (i = 0; i < numEdges; ++i)
14     {
15         scanf("%d %d %d", &edges[i].u, &edges[i].v, &edges[i].weight);
16     }
17 
18     Kruskal(edges, numEdges, numNodes);
19 
20     return 0;
21 }
View Code

 

同样使用上节的图:

最小生成树——Kruskal算法

那么应该输入11条边:

0 1 4
0 2 2
0 3 3
1 2 5
1 3 4
1 4 3
2 3 1
2 5 2
3 4 6
3 5 2
4 5 4

 

程序运行截图:

最小生成树——Kruskal算法

可以看到虽然选出来的边不同,但是总权值是一样的。

 

再来看一下Kruskal算法的效率:

        假设边数为e,使用快排对e条边排序的时间复杂度为O(eloge);e条边执行e次循环,循环内查找根节点的时间复杂度为O(loge);所以总时间复杂度为O(eloge)。

 

而Prim算法的时间复杂度是O(n^2),n是节点数。比较可以得出以下结论:

        Kruskal算法适用于边少的情况,即稀疏图;在边多的情况下,即稠密图中,Prim算法更具优势。

 

上一篇:《算法基础》——第2章 数值算法 2.1 随机化数据


下一篇:11.23直播预告|云原生多模数据库Lindorm全文索引技术介绍