最小生成树

本文介绍了最小生成树的概念和性质,包括它是无环且连通的无向图。Prim算法和Kruskal算法作为两种找到最小生成树的贪心策略被详细阐述,其中Prim算法利用优先级队列选择最小横跨切割的边,Kruskal算法则借助不相交集合数据结构避免形成环路。这两种算法的时间复杂度均为O(ElogE)。
摘要由CSDN通过智能技术生成

一、最小生成树的定义和性质

        首先介绍生成树的定义和性质。

        生成树是一个连通的、无环的无向图。 令G=(V,E)是一个无向图,则有如下等价定义:

1. G是最小生成树

2. G中任何两个顶点由唯一的简单路径相连

3. G是连通的,但是从图中移出任意一条变得到的图均不连通

4. G是连通的,且|E| = |V|-1

5. G是无环的,且|E| = |V|-1

6. G是无环的,但是如果向E中添加任何一条边,均会造成图包含一个环

         上图是一棵生成树,每个顶点从其他顶点都是可达的,并且没有形成环。只要在生成树中任意添加一条边,都会形成环;只要在生成树中任意删除一条边,图就不再连通。

        对于一个连通无向图G=(V,E),对于每条边(u,v)∈E,我们为其赋予权重w(u,v),我们希望找到一个无环子集T⊆E,既能将所有顶点连接起来,又具有最小的权重 ,即w(T) = \sum_{(u,v)\in T}^{}w(u,v)最小。由于T连通且无环,所以T一定是一棵树,我们称这样的树为图G的最小生成树。

二、最小生成树的形成

        假定有一个连通无向图G=(V,E)和权重函数w,我们希望找出图G的一棵最小生成树,可以使用贪心策略来解决这个问题。

        这里采用的贪心策略是在每个时刻获得最小生成树的一条边,并且在执行过程中有满足如下循环不变式的边的集合A存在:

每次循环前,A是一棵最小生成树的边的子集

        于是,我们每次需要选取一条边(u,v),将其加入到集合A中,使得A∪(u,v)也是某棵最小生成树的子集。我们称这样的边(u,v)为集合A的安全边。当循环结束时,集合A就是所求最小生成树。

         下面是判别安全边的规则:

设G = (V,E)是一个在边E上定义了权重函数w的连通无向图。设集合A为E的一个子集,且该子集包含图G中的某棵最小生成树,设(S,V-S)是一个对点集V的一个切割,并且集合A中的所有边都不横跨该切割。若边(u,v)是横跨切割(S,V-S)的权重值最小的边,那么(u,v)是集合A的一条安全边。

         如下图,在G = (V,E)中,当切割(S,V-S)如下时,集合A中的边(图中红色边)全部在S中,没有横跨切割,在边集E中,有三条横跨切割的边,其中权值w最小的边(u,v)是集合A的安全边,也就是(u,v)是构成最小生成树的一条边。

         证明如下:采用反证法,假设T是图G一棵包含A的最小生成树,并且T中不包含边(u,v)。由第一节中生成树的第二条等价定义(G中任何两个顶点由唯一的简单路径相连),边(u,v)与T中从节点u到节点v的简单路径形成一条环路。如下图:图中画出的是属于最小生成树T的边,绿色节点表示点集S中的节点,白色节点表示集合V-S的节点,红色边是边集A中的边,设(x,y)为该简单路径上一条横跨切割(S,V-S)的边,且(x,y)不在集合A中,那么,通过交换边(u,v)和(x,y),我们可以得到一棵新树T',由于两边(u,v)和(x,y)都横跨切割,且(u,v)是横跨切割的权重值最小的边,于是有:

w(T') = w(T) + w(u,v) - w(x,y) <= w(T)

        这与T是最小生成树相矛盾,于是上述判定规则得证。 

三、 Prim算法和Kruskal算法

3.1 Prim算法

        Prim算法的特点是,集合A总是构成一棵树,这颗树从根节点开始,一直长大到覆盖V中的所有节点为止。算法的每一步在连接集合A和A之外的节点的所有边中,选择一条横跨切割且权值最小的边加入集合A中,根据第二节的判定安全便的规则,所有加入的边都是A的安全边,当算法结束时,A就是一棵最小生成树。

        为了每次都可以高效地选出安全边,我们采用优先级队列这一数据结构来维护权值最小的横跨切割的边。在算法执行过程中,所有不在树A中的节点存放在一个基于key属性的最小优先队列Q中。对于每个节点v,有两个属性,v.key保存的是连接v和树中的所有边中最小边的权重,若不存在这样的边,则v.key为无穷大;v.π是节点v在树中的父节点。Prim算法中集合A始终保持如下状态:A = \left \{ (v,v.\pi ):v\in V-\left \{r \right \} -Q\right \}。当结束时,Q为空:A = \left \{ (v,v.\pi ):v\in V-\left \{r \right \} \right \}

      下面是Prim算法的伪代码:

         算法的1-5行将除根节点外的每个节点的key值设置为无穷大,将根节点r的key设为0以便该节点可以成为第一个被处理的节点,然后将所有节点的父节点设置为NULL,并且对最小优先队列进行初始化,使其包含所有节点。该算法的循环不变式由三部分组成:

在算法6-11行while循环的每边循环开始前,都有:

1. A = \left \{ (v,v.\pi ):v\in V-\left \{r \right \} -Q\right \}

2. 已经加入到最小生成树的节点集合为V-Q

3. 对于所有的节点v∈Q,如果v.π不为空,那么v.key不是无穷大而是一个具体的权值,并且v.key是连接节点v和最小生成树中节点某个节点的权值最小的边(v,v.π)的权值。

         该循环不变式的证明很显然,算法第七行找出的是横跨切割(V-Q,Q)且权值最小的边的一个端点u,接着将u从队列中删除,加入到集合V-Q中,也就是将边(u,u.π)加入到边集A中。算法第8-11行,维护了所有与u邻接但是不在树中的节点v的key属性和π属性。

        下图是Prim算法的执行流程:初始的根节点为a。绿色节点表示V-Q中的节点,同时也是加入最小生成树中的节点。在算法的每一步,树中的节点构成和非树中的节点构成了一个对点集V的切割,横跨该切割的每条权值最小的边被加入到A中。

        时间复杂度分析:如果用二叉堆实现最小优先队列,那么1-5行可以通过BUILD-MIN-HEAP方法实现,其时间复杂度为O(V)。while循环里的语句一共执行 |V| 次,由于每个EXTREACT-MIN方法的时间复杂度为O(lgV),因此EXTREACT-MIN总操作时间为O(VlgV)。由于邻接链表长度和为2|E|,算法9-11行的总执行次数为O(E),并且第11行隐含了一个DECREASE-KEY的操作,该操作在二叉堆的执行时间成本为O(lgV),因9-11行总执行时间为O(ElgV)。综上,Prim算法的时间复杂度为O(VlgV+Elgv) = O(Elgv)。

        下面给出Prim算法的实现方式。在实际写代码时,我们发现伪代码中第11行对优先队列的隐式调整不好实现,因此,我们做出如下调整:用优先队列维护最小的横跨切割的边。具体实现思路如下:初始时,将r的所有邻接边加入队列,然后进入循环,每次从队列中取出最小边,然后将该边不位于最小生成树的那个端点的所有横跨切割的邻接边加入队列,直到最小生成树形成。为了快速判断一条边是否横跨切割,我们对节点进行标记,如果该边指向的节点在队列中,那么该边横跨切割。下面是C语言代码:

int MST_Prim(Graph G, int root) {
    // 创建最小优先队列
	PriorityQueue pq;
	int cost = 0;
	int edgeNum = 0;

	G.vertices[root].visited = true;
	EdgeNode* cur = G.vertices[root].firstEdge;
	while (cur) {
		insert(&pq, cur);
		cur = cur->next;
	}
	
    // 最小生成树的性质,|E|=|V|-1 
	while (edgeNum < G.V - 1) {
		EdgeNode* min = extractMin(&pq);
		if (G.vertices[min->adjvex].visited)   continue;
		edgeNum++;
		cost += min->weight;
		G.vertices[min->adjvex].visited = true;
		EdgeNode* cur = G.vertices[min->adjvex].firstEdge;
		while (cur) {
			if (!G.vertices[cur->adjvex].visited)
				insert(&pq, cur);
			cur = cur->next;
		}
	}

	return cost;
}

3.2 Kruskal算法

        Kruskal算法选取安全边的办法是,在所有连接森林中两棵不同树的边里面,找到权重最小的边(u,v)。设C1和C2为边(u,v)连接的两棵树,由于(u,v)是连接C1和另一棵树的横跨切割(C1,V-C1)的权值最小的边,按照第二节安全边的判定规则,(u,v)一定是C1的一棵安全边。

        Kruskal每次都选择权值最小的边,并且选取的边不能与当前最小生成树节点集合形成环状,因此选用一个不相交集合数据结构(disjoint set)来维护集合不相交顶点集合。每个集合代表当前森林中的一棵树,操作FIND_SET(u)返回包含元素u的集合的代表,我们可以通过FIND-SET(u)和FIND-SET(v)来判断两个节点是否属于同一树树,从而判断边(u,v)是否是C1的一条安全边(让C1不形成环状结构)。

        下面是Kruskal算法的伪代码:

         算法的1-3行将集合A初始化为一个空集合,并创建|V|棵树,每棵树仅包含一个节点。算法的5-8行的for循环按照权重从低到高的顺序依次对每条边逐一检查。对于边(u,v)来说,该循环将检查端点u和v是否属于同一棵树,如果是,则不能将边加入森林里,否则形成环路,如果不是,则两个端点分别属于两棵不同的树,算法的第7行将边(u,v)加入到集合A中,第8行对两棵树中的节点进行合并。

        执行过程如下图:

         时间复杂度分析:Kruskal算法的运行时间依赖于不相交集合数据结构的实现方式,假定采用按秩合并和路径压缩的不相交集合森林的实现方式。算法第一行初始化消耗为O(1),第四行排序的消耗为O(ElgE),然后考虑2-3行和5-8行循环对不相交集合数据结构的操作:2-3行包含|V|个MAKE-SET操作,5-8行总共执行O(E)个FIND-SET和UNION操作,这些操作运行时间总和为O((V+E)α(V) ​​​​​​),这里的α是一个增长及其缓慢的函数。假定图G是连通的,那么有|E|>=|V|-1,所以不相交集合操作的时间代价总和为O((E)α(V) ​​​​​​),由于α(V) = O(lgV),因此Krusal算法的时间复杂度为O(ElgE+ElgV) = O(ElgE)。

         下面给出Krusal的具体代码实现,假定边集以数组edges的形式给出,edge是一个按照如下方式定义的结构体:

typedef struct edge{
    int v1, v2;
    int weight;
}edge;
int cmp(const void* e1, const void* e2) {
	return ((edge*)e1)->weight > ((edge*)e2)->weight;
}

int findSet(int* parent, int x) {
	if (x != parent[x])
		x = findSet(parent, parent[x]);
	return parent[x];
}

void Link(int* parent, int x, int y) {
	parent[x] = y;
}

void Union(int* parent, int x, int y) {
	Link(parent, findSet(parent, x), findSet(parent, y));
}

int MST_Kruskal(int* edges, int edgesSize) {
	int* parent = malloc(sizeof(int) * G.V);
	qsort(edges, edgesSize, sizeof(edge), cmp);
	int cost = 0;
	for (int i = 0, treeEdge = 1; treeEdge < G.V; i++) {
		int v1 = edges[i].v1;
		int v2 = edges[i].v2;
		if (findSet(parent, v1) != findSet(parent, v2)) {
			cost += edges[i].weight;
			Union(parent, v1, v2);
			treeEdge++;
		}
	}
	return cost;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ChenxuanRao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值