Kruskal算法详细分析

【贪心法求解最小生成树之Kruskal算法详细分析】---Greedy Algorithmfor MST

初衷:

最近在看算法相关的东西,看到贪心法解决mst的问题,可惜树上讲解的不是很清新,到网上找了很多资料讲解的也不透彻

只是随便带过就草草了事、这几天抽空看了下,总算基本思路理清楚了

主要还是得感谢强大的google,帮我找到一个很好的英文资料。(下面有链接,有兴趣的同学可以看看)

理顺了思路,就和大家分享下~希望对学习贪心法的同学会有所帮助。

 

这篇博客的主要内容是贪心法求解Minimum Spanning Tree (MST)(最小生成树)的问题

贪心法求解最小生成树常用的有两种算法,分别是Prim’s MST algorithmKruskal's MST algorithm(prim算法和kruskal算法)

这里主要说的是kruskal算法

最小生成树的简单定义:

给定一股无向联通带权图G(V,E).E 中的每一条边(v,w)权值位C(v,w)。如果G的子图G'是一个包含G中所有定点的子图,那么G'称为G生成树,如果G'的边的权值最小

那么G'称为G最小生成树

kruskal算法的基本思想:

1.首先将Gn个顶点看成n个孤立的连通分支(n个孤立点)并将所有的边按权从小大排序。

2.按照边权值递增顺序,如果加入边后存在圈则这条边不加,直到形成连通图

2的解释:如果加入边的两个端点位于不同的连通支,那么这条边可以顺利加入而不会形成圈

本例中用到的图:

权值递增排序:

kruskal加边后情况:

所以对于任意边(u,v)要判断这两个点是不是存在于同一个连通支里。

  如果是,则舍弃这条边,接着判断另一条边

  如果不是,则把这条边加入到图中,并且把uv属于的连通支合并

然后操作下一条边

这个算法执行的过程就是按照规定一个个连通支合并的过程,使最后只剩一个连通支。

What kind of data structure supportssuch operations?

这是一个值得思考的问题、、、逛社区的时候,有用链表的、二维数组的、、这里不讨论这些存储结构的可行性

这里要讨论的是有向树的存储

some implementation details(基本操作)

makeset(x): create a singleton setcontaining just x            //初始化的时候把整个图分为n个独立连通块

find(x): to which set does x belong?                     //对于任意给定点x,判断x属于哪一个连通块

union(x, y): merge the sets containing xand y    //合并两个连通块其中,xy为某边的两个端点,如果通过上面的find操作属于不同的连通块才把他们合并

 

Algorithm(算法实现)                                  

Kruskal(G)

1.For all uV do

         makeset(u);            //初始化,让每个点成为独立的连通块

2. X={Æ};                     

3. Sort the edges E by weight;            //按边的权值大小排序

4. For all edges (u, v) E in increasingorder of weight do       //对于条边e(u,v)(权值递增顺序)判断能否加入到图中

       iffind(u) ≠find(v) then                        //如两个端点不在同一个连通块,则合并这两个连通块

          add edge (u, v) to X;

          union(u, v);

下面是算法中的实现细节

How to store a set? (如何存储连通块)

例子;

{B, E}

      

{A, C, D, F, G, H}

对于每一个联通块,还有两个需要保存的,也就是树的根节点rank和树高height

Root: its parent pointer points toitself.

Rank: the height of subtree hanging fromthat node.

还有一个会用到的关系,对于树中的点xp(x)表示x的父节点

下面是函数实现

Makeset(x)

1.P(x)=x;                     //Constant time operation

2.Rank(x)=0;

Find(x)

1.While x ≠P(x)  do         //The time taken is proportional to the height of the tree.

  x=P(x);

 2. return(x);

 执行上述操作后的实例:

After makeset(A), makeset(B), …,makeset(G).(执行makeset后)

 每个点成为了孤立的连通支,右上角的数字代表树的rank

After union(A,D), union(B,E), union(C,F).(合并ADBECF后)

After union(C,G), union(E,A).(合并CGEA后)

注意看新的连通支右上角的rank有变化,合并的过程中尽量使得rank达到最小

After union(B,G).

关于Rank的几点说明:

Property 1: For any x, rank(x) 对于任意xxrank小于他的父节点的rank

Property 2: Any root node of rank k hasat least 2k nodes in its tree.    任何rank k 的连通支至少有2k个节点

Property 3: If there are n elementsoverall, there can be at most n/2k nodes of rank k.        如果一共有n个节点,那么rank k的连通支一共有n/2k

property2的解释:因为union的原则是让union后的树rank最小,所以union后的树至少是二叉树,也就是说除叶子节点外的节点至少有两个孩子。

property3的解释:因为rankk 的树至少有2k 个节点,所以最多有n/2k

 

算法效率分析:

Kruskal(G)

1.For all uV do

         makeset(u);            //初始化,让每个点成为独立的连通块

2. X={Æ};                     

3. Sort the edges E by weight;            //按边的权值大小排序

4. For all edges (u, v) E in increasingorder of weight do       //对于条边e(u,v)(权值递增顺序)判断能否加入到图中

       iffind(u) ≠find(v) then                        //如两个端点不在同一个连通块,则合并这两个连通块

          add edge (u, v) to X;

          union(u, v);

 

上面的算法中

makeset():可以在常数时间内完成

sort edges :对边的权值进行排序的效率O(|E|log|V|)   (排序算法的时间效率、自己google不啰嗦)

find():由给定的点往上查找,直到树根为止所花时间为树的高度即log|V|

注意:如何确定find()执行的次数是一个值得考虑的问题

如果从点的角度,是很难得到准确答案的,因为每个对于某一个点,和他相连的点是不确定的,即不通过的点情况不同,要逐一考虑岂不很麻烦

其实find()执行的次数是和边数紧密相连的。请看算法中,循环的体是依据边的权值顺序展开的。而对于每一条我们考虑的边,都要考虑它连接的两个点

所以,find()的执行次数就是边数的两倍。执行一个finad()的效率是log|V|,而union基本可在常数时间内完成

所以

Union and Find operations: O(|E|log|V|)

 

 

 其实这个算法的思想很简单、每一次选最小的,如果符合条件就把它加入到我们的结果中,如果不满足条件,则选下一个最小的

只是实现起来考虑的需要多一些、比如用何种数据结构存储、判断两个点是不是属于同一个连通支等等

可见,贪心法只是提供一种解决的基本思路,要真正解决问题还要考虑如何实现、这也是很关键的一点。

 

如果你看到这里,可能会发现这个算法的效率不是很可观、在最后的union and find 中所用的时间和点的数量n有很大的关系。

如果n很大的话,就会花很多时间。

那么、这个算法的效率可以提高吗?

答案是肯定的。用到的技术为

Amortized Analysis   平摊分析(也叫摊销分析),这可以说是一种很神奇的思想,先透露一下吧

对于这个问题的union and find 操作,本文中的效率为O(|E|log|V|)Amortized Analysis后,可以让复杂度为:O(|E|Log*n)

Log*n是个什么东西呢?当n为宇宙中所有物质的数量的时候,Log*n<=8

也就是让上文中的log(n)的最大值降到8.

如何?是不是很客观的效率提升。。。。

 

 

最小生成树

在含有n个顶点的连通图中选择n-1条边,构成一棵极小连通子图,并使该连通子图中n-1条边上权值之和达到最小,则称其为连通网的最小生成树。 

例如,对于如上图G4所示的连通网可以有多棵权值总和不相同的生成树。

克鲁斯卡尔算法介绍

克鲁斯卡尔(Kruskal)算法,是用来求加权连通图的最小生成树的算法。

基本思想:按照权值从小到大的顺序选择n-1条边,并保证这n-1条边不构成回路。 
具体做法:首先构造一个只含n个顶点的森林,然后依权值从小到大从连通网中选择边加入到森林中,并使森林中不产生回路,直至森林变成一棵树为止。

克鲁斯卡尔算法图解

以上图G4为例,来对克鲁斯卡尔进行演示(假设,用数组R保存最小生成树结果)。



第1步:将边加入R中。 
    边的权值最小,因此将它加入到最小生成树结果R中。 
第2步:将边加入R中。 
    上一步操作之后,边的权值最小,因此将它加入到最小生成树结果R中。 
第3步:将边加入R中。 
    上一步操作之后,边的权值最小,因此将它加入到最小生成树结果R中。 
第4步:将边加入R中。 
    上一步操作之后,边的权值最小,但会和已有的边构成回路;因此,跳过边。同理,跳过边。将边加入到最小生成树结果R中。 
第5步:将边加入R中。 
    上一步操作之后,边的权值最小,因此将它加入到最小生成树结果R中。 
第6步:将边加入R中。 
    上一步操作之后,边的权值最小,但会和已有的边构成回路;因此,跳过边。同理,跳过边。将边加入到最小生成树结果R中。

此时,最小生成树构造完成!它包括的边依次是:

克鲁斯卡尔算法分析

根据前面介绍的克鲁斯卡尔算法的基本思想和做法,我们能够了解到,克鲁斯卡尔算法重点需要解决的以下两个问题: 
问题一 对图的所有边按照权值大小进行排序。 
问题二 将边添加到最小生成树中时,怎么样判断是否形成了回路。

问题一很好解决,采用排序算法进行排序即可。

问题二,处理方式是:记录顶点在"最小生成树"中的终点,顶点的终点是"在最小生成树中与它连通的最大顶点"(关于这一点,后面会通过图片给出说明)。然后每次需要将一条边添加到最小生存树时,判断该边的两个顶点的终点是否重合,重合的话则会构成回路。 以下图来进行说明:

在将 加入到最小生成树R中之后,这几条边的顶点就都有了终点:

(01) C的终点是F。 
(02) D的终点是F。 
(03) E的终点是F。 
(04) F的终点是F。

关于终点,就是将所有顶点按照从小到大的顺序排列好之后;某个顶点的终点就是"与它连通的最大顶点"。 因此,接下来,虽然是权值最小的边。但是C和E的重点都是F,即它们的终点相同,因此,将加入最小生成树的话,会形成回路。这就是判断回路的方式。

克鲁斯卡尔算法的代码说明

有了前面的算法分析之后,下面我们来查看具体代码。这里选取"邻接矩阵"进行说明,对于"邻接表"实现的图在后面的源码中会给出相应的源码。

1. 基本定义

// 边的结构体
class EData
{
    public:
        char start; // 边的起点
        char end;   // 边的终点
        int weight; // 边的权重
 
    public:
        EData(){}
        EData(char s, char e, int w):start(s),end(e),weight(w){}
};

EData是邻接矩阵边对应的结构体。

class MatrixUDG {
    #define MAX    100
    #define INF    (~(0x1<<31))        // 无穷大(即0X7FFFFFFF)
    private:
        char mVexs[MAX];    // 顶点集合
        int mVexNum;             // 顶点数
        int mEdgNum;             // 边数
        int mMatrix[MAX][MAX];   // 邻接矩阵
 
    public:
        // 创建图(自己输入数据)
        MatrixUDG();
        // 创建图(用已提供的矩阵)
        //MatrixUDG(char vexs[], int vlen, char edges[][2], int elen);
        MatrixUDG(char vexs[], int vlen, int matrix[][9]);
        ~MatrixUDG();
 
        // 深度优先搜索遍历图
        void DFS();
        // 广度优先搜索(类似于树的层次遍历)
        void BFS();
        // prim最小生成树(从start开始生成最小生成树)
        void prim(int start);
        // 克鲁斯卡尔(Kruskal)最小生成树
        void kruskal();
        // 打印矩阵队列图
        void print();
 
    private:
        // 读取一个输入字符
        char readChar();
        // 返回ch在mMatrix矩阵中的位置
        int getPosition(char ch);
        // 返回顶点v的第一个邻接顶点的索引,失败则返回-1
        int firstVertex(int v);
        // 返回顶点v相对于w的下一个邻接顶点的索引,失败则返回-1
        int nextVertex(int v, int w);
        // 深度优先搜索遍历图的递归实现
        void DFS(int i, int *visited);
        // 获取图中的边
        EData* getEdges();
        // 对边按照权值大小进行排序(由小到大)
        void sortEdges(EData* edges, int elen);
        // 获取i的终点
        int getEnd(int vends[], int i);
};

MatrixUDG是邻接矩阵对应的结构体。 
mVexs用于保存顶点,mVexNum是顶点数,mEdgNum是边数;mMatrix则是用于保存矩阵信息的二维数组。例如,mMatrix[i][j]=1,则表示"顶点i(即mVexs[i])"和"顶点j(即mVexs[j])"是邻接点;mMatrix[i][j]=0,则表示它们不是邻接点。

2. 克鲁斯卡尔算法

/*
 * 克鲁斯卡尔(Kruskal)最小生成树
 */
void MatrixUDG::kruskal()
{
    int i,m,n,p1,p2;
    int length;
    int index = 0;          // rets数组的索引
    int vends[MAX]={0};     // 用于保存"已有最小生成树"中每个顶点在该最小树中的终点。
    EData rets[MAX];        // 结果数组,保存kruskal最小生成树的边
    EData *edges;           // 图对应的所有边
 
    // 获取"图中所有的边"
    edges = getEdges();
    // 将边按照"权"的大小进行排序(从小到大)
    sortEdges(edges, mEdgNum);
 
    for (i=0; i
    {
        p1 = getPosition(edges[i].start);      // 获取第i条边的"起点"的序号
        p2 = getPosition(edges[i].end);        // 获取第i条边的"终点"的序号
 
        m = getEnd(vends, p1);                 // 获取p1在"已有的最小生成树"中的终点
        n = getEnd(vends, p2);                 // 获取p2在"已有的最小生成树"中的终点
        // 如果m!=n,意味着"边i"与"已经添加到最小生成树中的顶点"没有形成环路
        if (m != n)
        {
            vends[m] = n;                       // 设置m在"已有的最小生成树"中的终点为n
            rets[index++] = edges[i];           // 保存结果
        }
    }
    delete[] edges;
 
    // 统计并打印"kruskal最小生成树"的信息
    length = 0;
    for (i = 0; i < index; i++)
        length += rets[i].weight;
    cout << "Kruskal=" << length << ": ";
    for (i = 0; i < index; i++)
        cout << "(" << rets[i].start << "," << rets[i].end << ") ";
    cout << endl;
}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

我们在前面讲过的《克里姆算法》是以某个顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。同样的思路,我们也可以直接就以边为目标去构建,因为权值为边上,直接找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环而已,此时我们就用到了图的存储结构中的边集数组结构,如图7-6-7

假设现在我们已经通过邻接矩阵得到了边集数组edges并按权值从小到大排列如上图。

下面我们对着程序和每一步循环的图示来看:

算法代码:(改编自《大话数据结构》)

 C++ Code 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

typedef struct
{
    int begin;
    int end;
    int weight;
} Edge;

/* 查找连线顶点的尾部下标 */
int Find(int *parent, int f)
{
    while (parent[f] > 0)
        f = parent[f];

    return f;
}
/* 生成最小生成树 */
void MiniSpanTree_Kruskal(MGraph G)
{
    int i, j, n , m;
    int k = 0;
    int parent[MAXVEX];/* 定义一数组用来判断边与边是否形成环路 */

    Edge edges[MAXEDGE];/* 定义边集数组,edge的结构为begin,end,weight,均为整型 */

    /* 此处省略将邻接矩阵G转换为边集数组edges并按权由小到大排列的代码*/
    for (i = 0; i < G.numVertexes; i++)
        parent[i] = 0;

    cout << "打印最小生成树:" << endl;
    for (i = 0; i < G.numEdges; i++)/* 循环每一条边 */
    {
        n = Find(parent, edges[i].begin);
        m = Find(parent, edges[i].end);
        if (n != m)/* 假如nm不等,说明此边没有与现有的生成树形成环路 */
        {
            parent[n] = m;/* 将此边的结尾顶点放入下标为起点的parent中。 */
            /* 表示此顶点已经在生成树集合中 */

            cout << "(" << edges[i].begin << ", " << edges[i].end << ") "
                 << edges[i].weight << endl;
        }
    }

}

 

1、程序 第17~28行是初始化操作,中间省略了一些存储结构转换代码。

2、第30~42行,i = 0 第一次循环,n = Find( parent,4) = 4; 同理 m = 7; 因为 n != m 所以parent[4] = 7, 并且打印 “ (4, 7) 7  ” 。此时我们已经将边(v4, v7)纳入到最小生成树中,如下图的第一个小图。

3、继续循环,当i从1 至 6 时,分别把(v2, v8), (v0, v1),(v0, v5), (v1, v8), (v3, v7), (v1, v6)纳入到最小生成树中,如下图所示,此时parent数组为

{ 1, 5, 8, 7, 7, 8, 0, 0, 6 },如何解读现在这些数字的意义呢?从图i = 6来看,其实是有两个连通的边集合A与B 纳入到最小生成树找中的,如图7-6-12所示。parent[0] = 1表示v0 和v1 已经在生成树的边集合A中,将parent[0] = 1中的 1 改成下标,由parent[1] = 5 ,表示v1 和v5 已经在生成树的边集合A中,parent[5] = 8 ,表示v5 和v8 已经在生成树的边集合A中,parent[8] = 6 ,表示v8 和v6 已经在生成树的边集合A中,parent[6] = 0 表示集合A暂时到头,此时边集合A有 v0, v1, v5, v6,v8。查看parent中没有查看的值,parent[2] = 8,表明v2 和 v8在一个集合中,即A中。再由parent[3] = 7, parent[4] = 7 和 parent[7] = 0 可知v3, v4, v7 在一个边集合B中。

4、当i = 7时, 调用Find函数,n = m = 6,不再打印,继续下一循环,即告诉我们,因为(v5, v6) 使得边集合A形成了回路,因此不能将其纳入生成树中,如图7-6-12所示。

5、当i = 8时与上面相同,由于边(v1, v2) 使得边集合A形成了回路,因此不能将其纳入到生成树中,如图7-6-12所示。

6、当i = 9时,n = 6, m = 7, 即parent[6] = 7,打印“(6, 7)19” ,此时parent数组为{ 1, 5, 8, 7, 7,8, 7, 0, 6 } ,如图7-6-13所示。

 



最后,我们来总结一下克鲁斯卡尔算法的定义:

假设 N = (V, {E} )是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T { V, {} },图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将其加入到 T 中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。

此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次,所以克鲁斯卡尔算法的时间复杂度为O(eloge)。

 

对比普里姆和克鲁斯卡尔算法,克鲁斯卡尔算法主要针对边来展开,边数少时效率比较高,所以对于稀疏图有较大的优势;而普里姆算法对于稠密图,即边数非常多的情况下更好一些。

 

你可能感兴趣的:(算法)