1、中国数学建模-编程交流-贪婪算法_1.txt 如果我穷得还剩下一碗饭 我也会让你先吃饱全天下最好的东西都应该归我所有,包括你! 先说喜欢我能死啊?别闹,听话。 有本事你就照顾好自己,不然就老老实实地让我来照顾你! 中国数学建模-编程交流-贪婪算法贪婪算法第 1 章 贪婪算法虽然设计一个好的求解算法更像是一门艺术,而不像是技术,但仍然存在一些行之有效的能够用于解决许多问题的算法设计方法,你可以使用这些方法来设计算法,并观察这些算法是如何工作的。一般情况下,为了获得较好的性能,必须对算法进行细致的调整。但是在某些情况下,算法经过调整之后性能仍无法达到要求,这时就必须寻求另外的方法来求解该问题。本章
2、首先引入最优化的概念,然后介绍一种直观的问题求解方法:贪婪算法。最后,应用该算法给出货箱装船问题、背包问题、拓扑排序问题、二分覆盖问题、最短路径问题、最小代价生成树等问题的求解方案。1.1 最优化问题本章及后续章节中的许多例子都是最优化问题( optimization problem) ,每个最优化问题都包含一组限制条件( c o n s t r a i n t)和一个优化函数( optimization function) ,符合限制条件的问题求解方案称为可行解( feasible solution) ,使优化函数取得最佳值的可行解称为最优解(optimal solution) 。例 1-1
3、 渴婴问题 有一个非常渴的、聪明的小婴儿,她可能得到的东西包括一杯水、一桶牛奶、多罐不同种类的果汁、许多不同的装在瓶子或罐子中的苏打水,即婴儿可得到 n 种不同的饮料。根据以前关于这 n 种饮料的不同体验,此婴儿知道这其中某些饮料更合自己的胃口,因此,婴儿采取如下方法为每一种饮料赋予一个满意度值:饮用 1 盎司第 i 种饮料,对它作出相对评价,将一个数值 si 作为满意度赋予第 i 种饮料。通常,这个婴儿都会尽量饮用具有最大满意度值的饮料来最大限度地满足她解渴的需要,但是不幸的是:具有最大满意度值的饮料有时并没有足够的量来满足此婴儿解渴的需要。设 ai 是第 i 种饮料的总量(以盎司为单位)
4、,而此婴儿需要 t 盎司的饮料来解渴,那么,需要饮用 n 种不同的饮料各多少量才能满足婴儿解渴的需求呢?设各种饮料的满意度已知。令 xi 为婴儿将要饮用的第 i 种饮料的量,则需要解决的问题是:找到一组实数 xi(1in) ,使 n i = 1si xi 最大,并满足:n i=1xi =t 及 0xiai 。需要指出的是:如果 n i = 1ai k。寻找 1 ,n范围内最小的整数 j,使得 xjyj 。若没有这样的 j 存在,则 n i= 1xi =n i = 1yi 。如果有这样的 j 存在,则 jk,否则 y 就不是一个可行解,因为 xjyj ,xj = 1 且 yj = 0。令 yj
5、= 1,若结果得到的 y 不是可行解,则在 j+ 1 ,n范围内必有一个 l 使得 yl = 1。令 yl = 0,由于 wjwl ,则得到的 y 是可行的。而且,得到的新 y 至少与原来的 y 具有相同数目的 1。经过数次这种转化,可将 y 转化为 x。由于每次转化产生的新 y 至少与前一个 y 具有相同数目的 1,因此 x 至少与初始的 y 具有相同的数目 1。货箱装载算法的 C + +代码实现见程序 1 3 - 1。由于贪婪算法按货箱重量递增的顺序装载,程序 1 3 - 1 首先利用间接寻址排序函数 I n d i r e c t S o r t 对货箱重量进行排序(见 3 . 5 节间
6、接寻址的定义) ,随后货箱便可按重量递增的顺序装载。由于间接寻址排序所需的时间为 O (nl o gn)(也可利用 9 . 5 . 1 节的堆排序及第 2 章的归并排序) ,算法其余部分所需时间为 O (n),因此程序 1 3 - 1 的总的复杂性为 O (nl o gn)。程序 13-1 货箱装船templatevoid ContainerLoading(int x, T w, T c, int n)/ 货箱装船问题的贪婪算法/ xi = 1 当且仅当货箱 i 被装载, 10 时总的时间开销为 O (nk+1 )。实际观察到的性能要好得多。-1.3.3 拓扑排序一个复杂的工程通常可以分解成一
7、组小任务的集合,完成这些小任务意味着整个工程的完成。例如,汽车装配工程可分解为以下任务:将底盘放上装配线,装轴,将座位装在底盘上,上漆,装刹车,装门等等。任务之间具有先后关系,例如在装轴之前必须先将底板放上装配线。任务的先后顺序可用有向图表示称为顶点活动( Activity On Vertex, AOV)网络。有向图的顶点代表任务,有向边(i, j) 表示先后关系:任务 j 开始前任务 i 必须完成。图 1 - 4 显示了六个任务的工程,边( 1 , 4)表示任务 1 在任务 4 开始前完成,同样边( 4 , 6)表示任务 4 在任务 6 开始前完成,边(1 , 4)与(4 , 6)合起来可知
8、任务 1 在任务 6 开始前完成,即前后关系是传递的。由此可知,边(1 , 4)是多余的,因为边(1 , 3)和(3 , 4)已暗示了这种关系。在很多条件下,任务的执行是连续进行的,例如汽车装配问题或平时购买的标有“需要装配”的消费品(自行车、小孩的秋千装置,割草机等等) 。我们可根据所建议的顺序来装配。在由任务建立的有向图中,边( i, j)表示在装配序列中任务 i 在任务 j 的前面,具有这种性质的序列称为拓扑序列(topological orders 或 topological sequences)。根据任务的有向图建立拓扑序列的过程称为拓扑排序(topological sorting)
9、 。图 1 - 4 的任务有向图有多种拓扑序列,其中的三种为 1 2 3 4 5 6,1 3 2 4 5 6 和 2 1 5 3 4 6,序列 1 4 2 3 5 6 就不是拓扑序列,因为在这个序列中任务 4 在 3 的前面,而任务有向图中的边为( 3 , 4) ,这种序列与边( 3 , 4)及其他边所指示的序列相矛盾。可用贪婪算法来建立拓扑序列。算法按从左到右的步骤构造拓扑序列,每一步在排好的序列中加入一个顶点。利用如下贪婪准则来选择顶点:从剩下的顶点中,选择顶点 w,使得 w 不存在这样的入边( v,w) ,其中顶点 v 不在已排好的序列结构中出现。注意到如果加入的顶点 w 违背了这个准则
10、(即有向图中存在边( v,w)且 v 不在已构造的序列中) ,则无法完成拓扑排序,因为顶点 v 必须跟随在顶点 w 之后。贪婪算法的伪代码如图 1 3 - 5 所示。while 循环的每次迭代代表贪婪算法的一个步骤。现在用贪婪算法来求解图 1 - 4 的有向图。首先从一个空序列 V 开始,第一步选择 V 的第一个顶点。此时,在有向图中有两个候选顶点 1 和 2,若选择顶点 2,则序列 V = 2,第一步完成。第二步选择 V 的第二个顶点,根据贪婪准则可知候选顶点为 1 和 5,若选择 5,则 V = 2 5。下一步,顶点 1 是唯一的候选,因此 V = 2 5 1。第四步,顶点 3 是唯一的候
11、选,因此把顶点 3 加入 V得到 V = 2 5 1 3。在最后两步分别加入顶点 4 和 6 ,得 V = 2 5 1 3 4 6。1. 贪婪算法的正确性为保证贪婪算法算的正确性,需要证明: 1) 当算法失败时,有向图没有拓扑序列; 2) 若算法没有失败,V 即是拓扑序列。2) 即是用贪婪准则来选取下一个顶点的直接结果, 1) 的证明见定理 1 3 - 2,它证明了若算法失败,则有向图中有环路。若有向图中包含环 qj qj + 1.qk qj , 则它没有拓扑序列,因为该序列暗示了 qj 一定要在 qj 开始前完成。定理 1-2 如果图 1 3 - 5 算法失败,则有向图含有环路。证明注意到当
12、失败时| V | S;for (i = 1; i 0) 设 v 是具有最大的 N e w i 的顶点;C m + + = v ;for ( 所有邻接于 v 的顶点 j) if (!Covj) Covj= true;对于所有邻接于 j 的顶点,使其 N e w k 减 1 if (有些顶点未被覆盖) 失败else 找到一个覆盖图 1-8 图 1-7 的细化更新 N e w 的时间为 O (e),其中 e 为二分图中边的数目。若使用邻接矩阵,则需花(n2 ) 的时间来寻找图中的边,若用邻接链表,则需(n+e) 的时间。实际更新时间根据描述方法的不同为 O (n2 ) 或 O (n+e)。逐步选择顶
13、点所需时间为(S i z e O f A),其中 S i z e O f A=| A |。因为 A 的所有顶点都有可能被选择,因此所需步骤数为 O ( S i z e O f A ),覆盖算法总的复杂性为 O ( S i z e O f A 2+n2) = O ( n2)或 O (S i z e Of A2+n + e)。2. 降低复杂性通过使用有序数组 N e wi、最大堆或最大选择树(max selection tree)可将每步选取顶点 v 的复杂性降为( 1 )。但利用有序数组,在每步的最后需对 N e wi 值进行重新排序。若使用箱子排序,则这种排序所需时间为(S i z e O f
14、 B ) ( S i z e O fB =|B| ) (见 3 . 8 . 1 节箱子排序) 。由于一般 S i z e O f B 比 S i z e O f A 大得多,因此有序数组并不总能提高性能。如果利用最大堆,则每一步都需要重建堆来记录 N e w 值的变化,可以在每次 N e w 值减 1 时进行重建。这种减法操作可引起被减的 N e w 值最多在堆中向下移一层,因此这种重建对于每次 N e w 值减 1 需( 1 )的时间,总共的减操作数目为 O (e)。因此在算法的所有步骤中,维持最大堆仅需 O (e)的时间,因而利用最大堆时覆盖算法的总复杂性为 O (n2 )或 O (n+e
15、)。若利用最大选择树,每次更新 N e w 值时需要重建选择树,所需时间为(log S i z e O f A)。重建的最好时机是在每步结束时,而不是在每次 N e w 值减 1 时,需要重建的次数为 O (e),因此总的重建时间为 O (e log S i z e OfA),这个时间比最大堆的重建时间长一些。然而,通过维持具有相同 N e w 值的顶点箱子,也可获得和利用最大堆时相同的时间限制。由于 N e w 的取值范围为 0 到 S i z e O f B,需要 S i z e O f B+ 1 个箱子,箱子 i 是一个双向链表,链接所有 N e w 值为 i 的顶点。在某一步结束时,假
16、如 N e w 6 从 1 2 变到 4,则需要将它从第 1 2 个箱子移到第 4 个箱子。利用模拟指针及一个节点数组 n o d e(其中 n o d e i 代表顶点 i,n o d e i . l e f t 和 n o d e i . r i g h t 为双向链表指针) ,可将顶点 6 从第 1 2 个箱子移到第 4 个箱子,从第 1 2 个箱子中删除 n o d e 0 并将其插入第 4 个箱子。利用这种箱子模式,可得覆盖启发式算法的复杂性为 O (n2 )或 O(n+e)。 (取决于利用邻接矩阵还是线性表来描述图) 。3. 双向链接箱子的实现为了实现上述双向链接箱子,图 1 -
17、9 定义了类 U n d i r e c t e d 的私有成员。N o d e Ty p e 是一个具有私有整型成员 l e f t 和 r i g h t 的类,它的数据类型是双向链表节点,程序 1 3 - 3 给出了 U n d i r e c t e d 的私有成员的代码。void CreateBins (int b, int n)创建 b 个空箱子和 n 个节点void DestroyBins() delete node;delete bin;void InsertBins(int b, int v)在箱子 b 中添加顶点 vvoid MoveBins(int bMax, int T
18、oBin, int v)从当前箱子中移动顶点 v 到箱子 To B i nint *bin;b i n i 指向代表该箱子的双向链表的首节点N o d e Type *node;n o d e i 代表存储顶点 i 的节点图 1-9 实现双向链接箱子所需的 U n d i r e c t e d 私有成员程序 13-3 箱子函数的定义void Undirected:CreateBins(int b, int n)/ 创建 b 个空箱子和 n 个节点node = new NodeType n+1;bin = new int b+1;/ 将箱子置空for (int i = 1; i bMax |
19、binl != v) / 不是最左节点nodel.right = r;else binl = r; / 箱子 l 的最左边/ 添加到箱子 To B i nI n s e r t B i n s ( ToBin, v);函数 C r e a t e B i n s 动态分配两个数组: n o d e 和 b i n,n o d e i 表示顶点 i, bini 指向其 N e w 值为 i 的双向链表的顶点, f o r 循环将所有双向链表置为空。如果 b0,函数 InsertBins 将顶点 v 插入箱子 b 中。因为 b 是顶点 v 的 New 值,b = 0 意味着顶点 v 不能覆盖 B
20、中当前还未被覆盖的任何顶点,所以,在建立覆盖时这个箱子没有用处,故可以将其舍去。当 b0 时,顶点 n 加入 New 值为 b 的双向链表箱子的最前面,这种加入方式需要将 nodev 加入 binb 中第一个节点的左边。由于表的最左节点应指向它所属的箱子,因此将它的nodev.left 置为 b。若箱子不空,则当前第一个节点的 left 指针被置为指向新节点。nodev 的右指针被置为 b i n b ,其值可能为 0 或指向上一个首节点的指针。最后, b i n b 被更新为指向表中新的第一个节点。MoveBins 将顶点 v 从它在双向链表中的当前位置移到 New 值为 ToBin 的位置
21、上。其中存在bMa x,使得对所有的箱子 b i n j 都有:如 jbMa x,则 b i n j 为空。代码首先确定 n o d e v 在当前双向链表中的左右节点,接着从双链表中取出 n o d e v ,并利用 I n s e r t B i n s 函数将其重新插入到 b i n To B i n 中。4. Undirected:BipartiteCover 的实现函数的输入参数 L 用于分配图中的顶点(分配到集合 A 或 B) 。L i = 1表示顶点 i 在集合 A 中,L i = 2 则表示顶点在 B 中。函数有两个输出参数: C 和 m,m 为所建立的覆盖的大小, C 0 ,
22、 m - 1 是 A 中形成覆盖的顶点。若二分图没有覆盖,函数返回 f a l s e;否则返回 t r u e。完整的代码见程序 1 3 - 4。程序 13-4 构造贪婪覆盖bool Undirected:BipartiteCover(int L, int C, int/ 在 m 中返回覆盖的大小; 在 C 0 : m - 1 中返回覆盖int n = Ve r t i c e s ( ) ;/ 插件结构int SizeOfA = 0;for (int i = 1; i S;/ 初始化for (i = 1; i 0) / 搜索所有箱子/ 选择一个顶点if (binMaxBin) / 箱子不空
23、int v = binMaxBin; / 第一个顶点Cm+ = v; / 把 v 加入覆盖/ 标记新覆盖的顶点int j = Begin(v), k;while (j) if (!Covj) / j 尚未被覆盖Covj = true;c o v e r e d + + ;/ 修改 N e wk = Begin(j);while (k) Newk-; / j 不计入在内if (!Changek) S.Add(k); / 仅入栈一次Changek = true;k = NextVe r t e x ( j ) ; j = NextVe r t e x ( v ) ; / 更新箱子while (!S
24、.IsEmpty() S . D e l e t e ( k ) ;Changek = false;MoveBins(SizeOfB, Newk, k);else MaxBin-;D e a c t i v a t e P o s ( ) ;D e s t r o y B i n s ( ) ;delete New;delete Change;delete Cov;return (covered = SizeOfB);程序 1 3 - 4 首先计算出集合 A 和 B 的大小、初始化必要的双向链表结构、创建三个数组、初始化图遍历器、并创建一个栈。然后将数组 C o v 和 C h a n g e
25、 初始化为 f a l s e,并将 A 中的顶点根据它们覆盖 B 中顶点的数目插入到相应的双向链表中。为了构造覆盖,首先按 SizeOfB 递减至 1 的顺序检查双向链表。当发现一个非空的表时,就将其第一个顶点 v 加入到覆盖中,这种策略即为选择具有最大 New 值的顶点。将所选择的顶点加入覆盖数组 C 并检查 B 中所有与它邻接的顶点。若顶点 j 与 v 邻接且还未被覆盖,则将 C o v j 置为 t r u e,表示顶点 j 现在已被覆盖,同时将已被覆盖的 B 中的顶点数目加 1。由于 j 是最近被覆盖的,所有 A 中与 j 邻接的顶点的 New 值减 1。下一个 while 循环降低
26、这些 New 值并将 New 值被降低的顶点保存在一个栈中。当所有与顶点 v 邻接的顶点的 Cov 值更新完毕后,N e w 值反映了 A 中每个顶点所能覆盖的新的顶点数,然而 A 中的顶点由于 New 值被更新,处于错误的双向链表中,下一个 while 循环则将这些顶点移到正确的表中。-1.3.5 单源最短路径在这个问题中,给出有向图 G,它的每条边都有一个非负的长度(耗费) a i j ,路径的长度即为此路径所经过的边的长度之和。对于给定的源顶点 s,需找出从它到图中其他任意顶点(称为目的)的最短路径。图 13-10a 给出了一个具有五个顶点的有向图,各边上的数即为长度。假设源顶点 s 为
27、 1,从顶点 1 出发的最短路径按路径长度顺序列在图 13-10b 中,每条路径前面的数字为路径的长度。利用 E. Dijkstra 发明的贪婪算法可以解决最短路径问题,它通过分步方法求出最短路径。每一步产生一个到达新的目的顶点的最短路径。下一步所能达到的目的顶点通过如下贪婪准则选取:在还未产生最短路径的顶点中,选择路径长度最短的目的顶点。也就是说,D i j k s t r a 的方法按路径长度顺序产生最短路径。首先最初产生从 s 到它自身的路径,这条路径没有边,其长度为 0。在贪婪算法的每一步中,产生下一个最短路径。一种方法是在目前已产生的最短路径中加入一条可行的最短的边,结果产生的新路径
28、是原先产生的最短路径加上一条边。这种策略并不总是起作用。另一种方法是在目前产生的每一条最短路径中,考虑加入一条最短的边,再从所有这些边中先选择最短的,这种策略即是 D i j k s t r a 算法。可以验证按长度顺序产生最短路径时,下一条最短路径总是由一条已产生的最短路径加上一条边形成。实际上,下一条最短路径总是由已产生的最短路径再扩充一条最短的边得到的,且这条路径所到达的顶点其最短路径还未产生。例如在图 1 3 - 1 0 中,b 中第二条路径是第一条路径扩充一条边形成的;第三条路径则是第二条路径扩充一条边;第四条路径是第一条路径扩充一条边;第五条路径是第三条路径扩充一条边。通过上述观察
29、可用一种简便的方法来存储最短路径。可以利用数组 p,p i 给出从 s 到达 i 的路径中顶点 i 前面的那个顶点。在本例中 p 1 : 5 = 0 , 1 , 1 , 3 , 4 。从 s 到顶点 i 的路径可反向创建。从 i 出发按 pi,ppi,pppi, .的顺序,直到到达顶点 s 或 0。在本例中,如果从 i = 5 开始,则顶点序列为 pi=4, p4=3, p3=1=s,因此路径为 1 , 3 , 4 , 5。为能方便地按长度递增的顺序产生最短路径,定义 d i 为在已产生的最短路径中加入一条最短边的长度,从而使得扩充的路径到达顶点 i。最初,仅有从 s 到 s 的一条长度为 0
30、 的路径,这时对于每个顶点 i,d i 等于 a s i (a 是有向图的长度邻接矩阵) 。为产生下一条路径,需要选择还未产生最短路径的下一个节点,在这些节点中 d 值最小的即为下一条路径的终点。当获得一条新的最短路径后,由于新的最短路径可能会产生更小的 d 值,因此有些顶点的 d 值可能会发生变化。综上所述,可以得到图 1 3 - 11 所示的伪代码, 1) 将与 s 邻接的所有顶点的 p 初始化为 s,这个初始化用于记录当前可用的最好信息。也就是说,从 s 到 i 的最短路径,即是由 s 到它自身那条路径再扩充一条边得到。当找到更短的路径时, p i 值将被更新。若产生了下一条最短路径,需
31、要根据路径的扩充边来更新 d 的值。1) 初始化 di =as i (1in) ,对于邻接于 s 的所有顶点 i,置 pi =s, 对于其余的顶点置 pi = 0;对于 pi0 的所有顶点建立 L 表。2) 若 L 为空,终止,否则转至 3 )。3) 从 L 中删除 d 值最小的顶点。4) 对于与 i 邻接的所有还未到达的顶点 j,更新 d j 值为 m i nd j , di +ai j ;若 d j 发生了变化且 j 还未在 L 中,则置 p j = 1,并将 j 加入 L,转至 2。图 1 - 11 最短路径算法的描述1. 数据结构的选择我们需要为未到达的顶点列表 L 选择一个数据结构。
32、从 L 中可以选出 d 值最小的顶点。如果 L 用最小堆(见 9 . 3 节)来维护,则这种选取可在对数时间内完成。由于 3) 的执行次数为 O ( n ),所以所需时间为 O ( n l o g n )。由于扩充一条边产生新的最短路径时,可能使未到达的顶点产生更小的d 值,所以在 4) 中可能需要改变一些 d 值。虽然算法中的减操作并不是标准的最小堆操作,但它能在对数时间内完成。由于执行减操作的总次数为: O(有向图中的边数)= O ( n2 ),因此执行减操作的总时间为 O ( n2 l o g n )。若 L 用无序的链表来维护,则 3) 与 4) 花费的时间为 O ( n2 ),3)
33、的每次执行需 O(|L | ) =O( n )的时间,每次减操作需( 1 )的时间(需要减去 dj 的值,但链表不用改变) 。利用无序链表将图 1 - 11 的伪代码细化为程序 1 3 - 5,其中使用了 C h a i n (见程序 3 - 8 )和 C h a i n I t e r a t o r 类(见程序 3 - 1 8) 。程序 13-5 最短路径程序templatevoid AdjacencyWDigraph:ShortestPaths(int s, T d, int p)/ 寻找从顶点 s 出发的最短路径, 在 d 中返回最短距离/ 在 p 中返回前继顶点if (s n) th
34、row OutOfBounds();Chain L; / 路径可到达顶点的列表ChainIterator I;/ 初始化 d, p, Lfor (int i = 1; i di + aij) / 减小 d j dj = di + aij;/ 将 j 加入 Lif (!pj) L.Insert(0,j);pj = i;若 N o E d g e 足够大,使得没有最短路径的长度大于或等于 N o E d g e,则最后一个 for 循环的 i f 条件可简化为:if (dj di + aij) NoEdge 的值应在能使dj+aij 不会产生溢出的范围内。2. 复杂性分析程序 1 3 - 5 的复
35、杂性是 O ( n2 ),任何最短路径算法必须至少对每条边检查一次,因为任何一条边都有可能在最短路径中。因此这种算法的最小可能时间为 O ( e )。由于使用耗费邻接矩阵来描述图,仅决定哪条边在有向图中就需 O ( n2 )的时间。因此,采用这种描述方法的算法需花费 O ( n2 )的时间。不过程序 1 3 - 5 作了优化(常数因子级) 。即使改变邻接表,也只会使最后一个 f o r 循环的总时间降为 O ( e )(因为只有与 i 邻接的顶点的 d 值改变) 。从 L 中选择及删除最小距离的顶点所需总时间仍然是 O( n2 )。-1.3.6 最小耗费生成树在例 1 - 2 及 1 - 3
36、中已考察过这个问题。因为具有 n 个顶点的无向网络 G 的每个生成树刚好具有 n-1 条边,所以问题是用某种方法选择 n-1 条边使它们形成 G 的最小生成树。至少可以采用三种不同的贪婪策略来选择这n-1 条边。这三种求解最小生成树的贪婪算法策略是: K r u s k a l 算法,P r i m 算法和 S o l l i n 算法。1. Kruskal 算法(1) 算法思想K r u s k a l 算法每次选择 n- 1 条边,所使用的贪婪准则是:从剩下的边中选择一条不会产生环路的具有最小耗费的边加入已选择的边的集合中。注意到所选取的边若产生环路则不可能形成一棵生成树。K r u s
37、k a l 算法分 e 步,其中 e 是网络中边的数目。按耗费递增的顺序来考虑这 e 条边,每次考虑一条边。当考虑某条边时,若将其加入到已选边的集合中会出现环路,则将其抛弃,否则,将它选入。考察图 1-12a 中的网络。初始时没有任何边被选择。图 13-12b 显示了各节点的当前状态。边( 1 , 6)是最先选入的边,它被加入到欲构建的生成树中,得到图 1 3 - 1 2 c。下一步选择边( 3,4)并将其加入树中(如图 1 3 - 1 2 d 所示) 。然后考虑边( 2,7 ),将它加入树中并不会产生环路,于是便得到图 1 3 - 1 2 e。下一步考虑边( 2,3)并将其加入树中(如图 1
38、 3 - 1 2 f 所示) 。在其余还未考虑的边中, (7,4)具有最小耗费,因此先考虑它,将它加入正在创建的树中会产生环路,所以将其丢弃。此后将边( 5,4)加入树中,得到的树如图 13-12g 所示。下一步考虑边( 7,5) ,由于会产生环路,将其丢弃。最后考虑边( 6,5)并将其加入树中,产生了一棵生成树,其耗费为 9 9。图 1 - 1 3 给出了 K r u s k a l 算法的伪代码。/ /在一个具有 n 个顶点的网络中找到一棵最小生成树令 T 为所选边的集合,初始化 T=令 E 为网络中边的集合w h i l e(E )&(| T |n- 1 ) 令(u,v)为 E 中代价最
39、小的边E=E- (u,v) / /从 E 中删除边i f( (u,v)加入 T 中不会产生环路)将( u,v)加入 Ti f(| T | = =n-1) T 是最小耗费生成树e l s e 网络不是互连的,不能找到生成树图 13-13 Kruskao 算法的伪代码(2) 正确性证明利用前述装载问题所用的转化技术可以证明图 1 3 - 1 3 的贪婪算法总能建立一棵最小耗费生成树。需要证明以下两点: 1) 只要存在生成树,K r u s k a l 算法总能产生一棵生成树; 2) 产生的生成树具有最小耗费。令 G 为任意加权无向图(即 G 是一个无向网络)。从 1 2 . 11 . 3 节可知当
40、且仅当一个无向图连通时它有生成树。而且在 Kruskal 算法中被拒绝(丢弃)的边是那些会产生环路的边。删除连通图环路中的一条边所形成的图仍是连通图,因此如果 G 在开始时是连通的,则 T 与 E 中的边总能形成一个连通图。也就是若 G 开始时是连通的,算法不会终止于 E= 和| T |0) 为在 T 中而不在 U 中的边的个数,当然 k 也是在 U 中而不在 T 中的边的数目。通过把 U 变换为 T 来证明 U 与 T 具有相同的耗费,这种转化可在 k 步内完成。每一步使在 T 而不在 U 中的边的数目刚好减 1。而且 U 的耗费不会因为转化而改变。经过 k 步的转化得到的 U 将与原来的
41、U 具有相同的耗费,且转化后 U 中的边就是 T中的边。由此可知, T 具有最小耗费。每步转化包括从 T 中移一条边 e 到 U 中,并从 U 中移出一条边 f。边 e 与 f 的选取按如下方式进行:1) 令 e 是在 T 中而不在 U 中的具有最小耗费的边。由于 k 0,这条边肯定存在。2) 当把 e 加入 U 时,则会形成唯一的一条环路。令 f 为这条环路上不在 T中的任意一条边。由于 T 中不含环路,因此所形成的环路中至少有一条边不在 T 中。从 e 与 f 的选择方法中可以看出, V=U+ e - f 是一棵生成树,且 T中恰有 k- 1 条边不在 V 中出现。现在来证明 V 的耗费与
42、 U 的相同。显然,V 的耗费等于 U 的耗费加上边 e 的耗费再减去边 f 的耗费。若 e 的耗费比 f 的小,则生成树 V 的耗费比 U 的耗费小,这是不可能的。如果 e 的耗费高于 f,在 K r u s k a l 算法中 f 会在 e 之前被考虑。由于 f 不在 T 中,Kruskal 算法在考虑 f 能否加入 T 时已将f 丢弃,因此 f 和 T 中耗费小于或等于 f 的边共同形成环路。通过选择 e,所有这些边均在 U 中,因此 U 肯定含有环路,但是实际上这不可能,因为 U 是一棵生成树。e 的代价高于 f 的假设将会导致矛盾。剩下的唯一的可能是 e 与 f 具有相同的耗费,由此可知 V 与 U 的耗费相同。(3) 数据结构的选择及复杂性分析-