【贪心法求解最小生成树之Kruskal算法详细分析】---Greedy Algorithmfor MST
初衷:
最近在看算法相关的东西,看到贪心法解决mst的问题,可惜树上讲解的不是很清新,到网上找了很多资料讲解的也不透彻
只是随便带过就草草了事、这几天抽空看了下,总算基本思路理清楚了
主要还是得感谢强大的google,帮我找到一个很好的英文资料。(下面有链接,有兴趣的同学可以看看)
理顺了思路,就和大家分享下~希望对学习贪心法的同学会有所帮助。
这篇博客的主要内容是贪心法求解Minimum Spanning Tree (MST)(最小生成树)的问题
贪心法求解最小生成树常用的有两种算法,分别是Prim’s MST algorithm和Kruskal'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.首先将G的n个顶点看成n个孤立的连通分支(n个孤立点)并将所有的边按权从小大排序。
2.按照边权值递增顺序,如果加入边后存在圈则这条边不加,直到形成连通图
对2的解释:如果加入边的两个端点位于不同的连通支,那么这条边可以顺利加入而不会形成圈
本例中用到的图:
权值递增排序:
kruskal加边后情况:
所以对于任意边(u,v)要判断这两个点是不是存在于同一个连通支里。
如果是,则舍弃这条边,接着判断另一条边
如果不是,则把这条边加入到图中,并且把u,v属于的连通支合并
然后操作下一条边
这个算法执行的过程就是按照规定一个个连通支合并的过程,使最后只剩一个连通支。
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 //合并两个连通块其中,x,y为某边的两个端点,如果通过上面的find操作属于不同的连通块才把他们合并
Algorithm(算法实现):
Kruskal(G)
1.For all u∈V 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.
还有一个会用到的关系,对于树中的点x,p(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).(合并AD,BE,CF后)
After union(C,G), union(E,A).(合并CG,EA后)
注意看新的连通支右上角的rank有变化,合并的过程中尽量使得rank达到最小
After union(B,G).
关于Rank的几点说明:
Property 1: For any x, rank(x)
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的解释:因为rank为k 的树至少有2k 个节点,所以最多有n/2k个
算法效率分析:
Kruskal(G)
1.For all u∈V 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步:将边
边
第2步:将边
上一步操作之后,边
第3步:将边
上一步操作之后,边
第4步:将边加入R中。
上一步操作之后,边
第5步:将边
上一步操作之后,边
第6步:将边加入R中。
上一步操作之后,边
根据前面介绍的克鲁斯卡尔算法的基本思想和做法,我们能够了解到,克鲁斯卡尔算法重点需要解决的以下两个问题:
问题一 对图的所有边按照权值大小进行排序。
问题二 将边添加到最小生成树中时,怎么样判断是否形成了回路。
问题一很好解决,采用排序算法进行排序即可。
问题二,处理方式是:记录顶点在"最小生成树"中的终点,顶点的终点是"在最小生成树中与它连通的最大顶点"(关于这一点,后面会通过图片给出说明)。然后每次需要将一条边添加到最小生存树时,判断该边的两个顶点的终点是否重合,重合的话则会构成回路。 以下图来进行说明:
在将
(01) C的终点是F。
(02) D的终点是F。
(03) E的终点是F。
(04) F的终点是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 |
typedef struct |
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)。
对比普里姆和克鲁斯卡尔算法,克鲁斯卡尔算法主要针对边来展开,边数少时效率比较高,所以对于稀疏图有较大的优势;而普里姆算法对于稠密图,即边数非常多的情况下更好一些。