算法导论(十六)--贪心算法,最小生成树

算法导论(十六)--贪心算法,最小生成树

图的表示方式

先回顾一下图(Graph)的基础知识,有向图(Digraph) G=(V, E),V是顶点集合,E是V*V条边的子集。无向图(Undirected graph)中E是无序的顶点对。
边的大小|E|=O(V2),如果G是连通的(图中任意顶点,都有路径到达其它顶点),那么|E|≥|V|-1 。

1.邻接矩阵
G=(V, E),G的顶点集V={1,2,…,n},那么G的邻接矩阵是一个n*n的矩阵A,A[i,j]={1(i,j)E0A[i, j] = \begin{cases} 1 & 如果(i,j)∈E \\ 0 & 其他 \end{cases}
它也就是说,如果边集里存在一条边eij,那么Aij=1。有时候,图的边是加权的,那么就用边的权重来替换1 。
举例:
在这里插入图片描述
上面这个有向图的邻接矩阵如下:

A 1 2 3 4
1 0 1 1 0
2 0 0 1 0
3 0 0 0 0
4 0 0 1 0

需要的存储空间是O(V2),这就是稠密表示。如果图是稠密的,即边的数量接近最大可能边数,效果会很好。但大多数图都是稀疏的,比如链表,平面图,树,都是稀疏图。稠密图的一个例子是完全图。

2.邻接表
如果不想耗费这么多空间来存一个稀疏图,那么邻接表是一种不错的方法。
一个给定顶点的邻接表Adj(v),记录了与V相邻的顶点。上面的例子中,
Adj[1]={2,3}
Adj[2]={3}
Adj[3]={ }
Adj[4]={3}

分析一个这种方法的存储空间:
对于一个顶点V,它的邻接表的长度就是它的度。即Adj[V]={degree(v)outdegree(v)|Adj[V]|= \begin{cases} degree(v) & 无向图中 \\ out-degree(v) &有向图中 \end{cases}

一个重要的引理,叫做握手引理:
无向图中,所有顶点的度加起来等于边数的两倍。

这种方法占的存储空间是2|E|+V(为什么要加V,想象一个只有顶点,没有边的图,它仍然要占用大小为|V|的空间),因此是O(V+E)。对有向图来说,要做的就是把所有的出度加起来,结果就是|E|。复杂度也是一样。

邻接法是一种稀疏表示法,一般都比邻接矩阵要好。邻接矩阵的一个优点是,每一条边都可以用一个单独的位来表示,而邻接表需要O(logV)位来表示每个邻接的边,因为需要logV个位来表示每个顶点。

最小生成树

这个算法有海量的应用,在分布式系统中非常重要(找出一棵最小生成树,所有节点在任意时刻都是活跃的),它也是AT&T的记账系统的基础。

问题定义:输入是一个连通的无向图G=(V, E),和一个给边加权的函数w(简单起见,我们假设所有边的权值都是互异的)。输出是一棵最小生成树,最小生成树是指连接了所有顶点,并且权重之和最小的连通的无环图。

举例:
在这里插入图片描述
上面这个图的MST:
在这里插入图片描述
从这幅图里,我们可以看出最优子结构的性质。
有一棵图G的MST,记作T(图里的其它边我们忽略不画):
在这里插入图片描述
观察方法是,任意的在MST中移除一条边(u, v) ,T就被分成了两棵子树T1和T2:
在这里插入图片描述
我们现在要证明它阐述了最优子结构的特性:T1是图G1的最小生成树,T2是图G2的最小生成树。其中G1是一个由T1的顶点导出的图G的子图,也就是说V1是T1中的顶点集,E1是T1中顶点对的集,(x, y)是E1里的边(x和y都是V1里的顶点)。同理T2也一样。

证明:
用剪贴法。
T的权值w(T)=W(u,v)+w(T1)+w(T2)w(T)=W(u,v)+w(T1)+w(T2),假设存在T1’ ,它比T1有更低的权重,那我们就能得到一棵树T’ ,它包含了边(u, v)∪T1’∪T2,那就得到了一个新的生成树,它比T的权重更小。

那么对于这类问题,会不会有重叠子问题的特性呢?试想一下,我们移除一条边,然后树就分成了两半,再选另一条边移除,然后再选一条边移除,会不会到最后有一堆相似的子问题?如果只是简单的改改取出的顺序,那么会得到一堆相似的子问题。因此我们想到用动态规划来解决。但最小生成树还有一个隐藏的特点,一个局部最优解也是全局最优解,这就是贪心算法的标志。

定理:设图G=(V, E)的MST为T,A是V的任意子集,假设边(u, v)是连接A和V-A的具有最小权值的边,那么边(u, v)属于最小生成树T。

看上面例子的图。选一个单独的顶点作为A,比如最上面的顶点,那么其它的所有顶点就是V-A,连接这两个集合的边中,那么6这条边肯定在T中。这个定理可以用剪贴法证明。

最小生成树的算法:Prim算法

思路:把V-A维护成一个优先队列Q,Q里的每一个顶点,都有一个键,就是连接A和V-A之间的权值最小的边的权值。

伪代码

Q <- V //一开始把A设成空集
v.key <-for ∀ v ∈ V //键初始化为无穷大
s.key <- 0 for s ∈ V //从V中任意选一顶点s

while(Q≠∅)
do u <- Extract_Min(Q) //每次从队列里取出最小的元素u
for each v ∈ Adj[u] //对于每一步,在邻接表里(也就是从v到u的边)
	do if v ∈ Q and w(u,v)<v.key //如果v仍属于集合V-A的话,就进行查看(所以取出来的元素就成了A的一部分)
		then v.key <- w(u,v) 
		T(v) <- u //每次加入A时都追踪哪一个是相关的顶点
At end, {(v,π[v]} forms MST

举例
在这里插入图片描述
顶点依次编好号。
一开始所有顶点的key都是无穷大;
接下来找一个顶点,s,比如是6号节点,把它加入集合A;
观察s的邻接表里的每条边,看是否在Q里;
发现3,7,8都在Q里,于是看它们的键是否小于边的权值。因此把3的键设为7,7的键设为15,8的键设为10;
回到队列里,找到键最小的顶点,是键值7对应的点3,取出,然后更新它的邻接点的键5,12,9(7不用再更新,因为它已经不是Q里的元素);
回到队列里,选择最小的,5对应的点2,更新它的邻接点为6,14,8;
回到队列里,选择最小的,6对应的点1,已经没有相关的顶点了,因为顶点2,3都在A里了。现在Q中最小的键就是8,于是更新顶点7的邻接点为3(覆盖14);
回到队列里,选择最小的,3对应的点5;
没有什么顶点好更新的了,最后加上9和15 。
最后结果就是边7,5,6,8,3,9,15。

分析
初始化的复杂度是O(V),while循环的复杂度O(V·TExtract_Min+E·TDecrease_key),具体是多少取决于实现方式,如果用无序数组,就是=O(V·O(V)+E·O(1))=O(V2);如果是二叉堆,就是=O(V·O(logV)+E·O(logV))=O(E·logV);如果是斐波那契堆,就是=O(V·O(logV)+E·O(1))=O(V·logV+E)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章