贪婪算法

cqm 贡献于2011-05-20

作者 jokey  创建于2006-05-08 15:27:00   修改者jokey  修改于2006-05-09 06:45:00字数115395

文档摘要:虽然设计一个好的求解算法更像是一门艺术,而不像是技术,但仍然存在一些行之有效的能够用于解决许多问题的算法设计方法,你可以使用这些方法来设计算法,并观察这些算法是如何工作的。一般情况下,为了获得较好的性能,必须对算法进行细致的调整。但是在某些情况下,算法经过调整之后性能仍无法达到要求,这时就必须寻求另外的方法来求解该问题。本章首先引入最优化的概念,然后介绍一种直观的问题求解方法:贪婪算法。最后,应用该算法给出货箱装船问题、背包问题、拓扑排序问题、二分覆盖问题、最短路径问题、最小代价生成树等问题的求解方案。
关键词:

第 1 章 贪婪算法 虽然设计一个好的求解算法更像是一门艺术,而不像是技术,但仍然存在一些行之有效的能够用于解决许多问题的算法设计方法,你可以使用这些方法来设计算法,并观察这些算法是如何工作的。一般情况下,为了获得较好的性能,必须对算法进行细致的调整。但是在某些情况下,算法经过调整之后性能仍无法达到要求,这时就必须寻求另外的方法来求解该问题。 本章首先引入最优化的概念,然后介绍一种直观的问题求解方法:贪婪算法。最后,应用该算法给出货箱装船问题、背包问题、拓扑排序问题、二分覆盖问题、最短路径问题、最小代价生成树等问题的求解方案。 1.1 最优化问题 本章及后续章节中的许多例子都是最优化问题( optimization problem),每个最优化问题都包含一组限制条件( c o n s t r a i n t)和一个优化函数( optimization function),符合限制条件的问题求解方案称为可行解( feasible solution),使优化函数取得最佳值的可行解称为最优解(optimal solution)。 例1-1 [ 渴婴问题] 有一个非常渴的、聪明的小婴儿,她可能得到的东西包括一杯水、一桶牛奶、多罐不同种类的果汁、许多不同的装在瓶子或罐子中的苏打水,即婴儿可得到n 种不同的饮料。根据以前关于这n 种饮料的不同体验,此婴儿知道这其中某些饮料更合自己的胃口,因此,婴儿采取如下方法为每一种饮料赋予一个满意度值:饮用1盎司第i 种饮料,对它作出相对评价,将一个数值si 作为满意度赋予第i种饮料。 通常,这个婴儿都会尽量饮用具有最大满意度值的饮料来最大限度地满足她解渴的需要,但是不幸的是:具有最大满意度值的饮料有时并没有足够的量来满足此婴儿解渴的需要。设ai是第i 种饮料的总量(以盎司为单位),而此婴儿需要t 盎司的饮料来解渴,那么,需要饮用n种不同的饮料各多少量才能满足婴儿解渴的需求呢? 设各种饮料的满意度已知。令xi 为婴儿将要饮用的第i 种饮料的量,则需要解决的问题是: 找到一组实数xi(1≤i≤n),使n ?i = 1si xi 最大,并满足:n ?i=1xi =t 及0≤xi≤ai 。 需要指出的是:如果n ?i = 1ai < t,则不可能找到问题的求解方案,因为即使喝光所有的饮料也不能使婴儿解渴。 对上述问题精确的数学描述明确地指出了程序必须完成的工作,根据这些数学公式,可以对输入/ 输出作如下形式的描述: 输入:n,t,si ,ai(其中1≤i≤n,n 为整数,t、si 、ai 为正实数)。 输出:实数xi(1≤i≤n),使n ?i= 1si xi 最大且n ?i=1xi =t(0≤xi≤ai)。如果n ?i = 1ai k。寻找[ 1 ,n]范围内最小的整数j,使得xj≠yj 。若没有这样的j 存在,则n ?i= 1xi =n ?i = 1yi 。如果有这样的j 存在,则j≤k,否则y 就不是一个可行解,因为xj≠yj ,xj = 1且yj = 0。令yj = 1,若结果得到的y 不是可行解,则在[ j+ 1 ,n]范围内必有一个l 使得yl = 1。令yl = 0,由于wj≤wl ,则得到的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节间接寻址的定义),随后货箱便可按重量递增的顺序装载。由于间接寻址排序所需的时间为O (nl o gn)(也可利用9 . 5 . 1节的堆排序及第2章的归并排序),算法其余部分所需时间为O (n),因此程序1 3 - 1的总的复杂性为O (nl o gn)。 程序13-1 货箱装船 template void ContainerLoading(int x[], T w[], T c, int n) {// 货箱装船问题的贪婪算法 // x[i] = 1 当且仅当货箱i被装载, 1<=i<=n // c是船的容量, w 是货箱的重量 // 对重量按间接寻址方式排序 // t 是间接寻址表 int *t = new int [n+1]; I n d i r e c t S o r t ( w, t, n); // 此时, w[t[i]] <= w[t[i+1]], 1<=i0时总的时间开销为O (nk+1 )。实际观察到的性能要好得多。 1.3.3 拓扑排序 一个复杂的工程通常可以分解成一组小任务的集合,完成这些小任务意味着整个工程的完成。例如,汽车装配工程可分解为以下任务:将底盘放上装配线,装轴,将座位装在底盘上,上漆,装刹车,装门等等。任务之间具有先后关系,例如在装轴之前必须先将底板放上装配线。任务的先后顺序可用有向图表示——称为顶点活动( Activity On Vertex, AOV)网络。有向图的顶点代表任务,有向边(i, j) 表示先后关系:任务j 开始前任务i 必须完成。图1 - 4显示了六个任务的工程,边( 1 , 4)表示任务1在任务4开始前完成,同样边( 4 , 6)表示任务4在任务6开始前完成,边(1 , 4)与(4 , 6)合起来可知任务1在任务6开始前完成,即前后关系是传递的。由此可知,边(1 , 4)是多余的,因为边(1 , 3)和(3 , 4)已暗示了这种关系。 在很多条件下,任务的执行是连续进行的,例如汽车装配问题或平时购买的标有“需要装配”的消费品(自行车、小孩的秋千装置,割草机等等)。我们可根据所建议的顺序来装配。在由任务建立的有向图中,边( i, j)表示在装配序列中任务i 在任务j 的前面,具有这种性质的序列称为拓扑序列(topological orders或topological sequences)。根据任务的有向图建立拓扑序列的过程称为拓扑排序(topological sorting)。图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违背了这个准则(即有向图中存在边( 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是唯一的候选,因此把顶点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算法失败,则有向图含有环路。 证明注意到当失败时| V | S; for (i = 1; i <= n; i++) if (!InDegree[i]) S.Add(i); // 产生拓扑次序 i = 0; // 数组v 的游标 while (!S.IsEmpty()) {// 从堆栈中选择 int w; // 下一个顶点 S . D e l e t e ( w ) ; v[i++] = w; int u = Begin(w); while (u) {// 修改入度 I n D e g r e e [ u ] - - ; if (!InDegree[u]) S.Add(u); u = NextVe r t e x ( w ) ; } } D e a c t i v a t e P o s ( ) ; delete [] InDegree; return (i == n); } 1.3.4 二分覆盖 二分图是一个无向图,它的n 个顶点可二分为集合A和集合B,且同一集合中的任意两个顶点在图中无边相连(即任何一条边都是一个顶点在集合A中,另一个在集合B中)。当且仅当B中的每个顶点至少与A中一个顶点相连时,A的一个子集A' 覆盖集合B(或简单地说,A' 是一个覆盖)。覆盖A' 的大小即为A' 中的顶点数目。当且仅当A' 是覆盖B的子集中最小的时,A' 为最小覆盖。 例1-10 考察如图1 - 6所示的具有1 7个顶点的二分图,A={1, 2, 3, 16, 17}和B={4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15},子集A' = { 1 , 1 6 , 1 7 }是B的最小覆盖。在二分图中寻找最小覆盖的问题为二分覆盖( b i p a r t i t e - c o v e r)问题。在例1 2 - 3中说明了最小覆盖是很有用的,因为它能解决“在会议中使用最少的翻译人员进行翻译”这一类的问题。 二分覆盖问题类似于集合覆盖( s e t - c o v e r)问题。在集合覆盖问题中给出了k 个集合S= {S1 , S2 ,., Sk },每个集合Si 中的元素均是全集U中的成员。当且仅当èi S'Si =U时,S的子集S' 覆盖U,S '中的集合数目即为覆盖的大小。当且仅当没有能覆盖U的更小的集合时,称S' 为最小覆盖。可以将集合覆盖问题转化为二分覆盖问题(反之亦然),即用A的顶点来表示S1 , ., Sk ,B中的顶点代表U中的元素。当且仅当S的相应集合中包含U中的对应元素时,在A与B的顶点之间存在一条边。 例1 - 11 令S= {S1,. . .,S5 }, U= { 4,5,. . .,15}, S1 = { 4,6,7,8,9,1 3 },S2 = { 4,5,6,8 },S3 = { 8,1 0,1 2,1 4,1 5 },S4 = { 5,6,8,1 2,1 4,1 5 },S5 = { 4,9,1 0,11 }。S ' = {S1,S4,S5 }是一个大小为3的覆盖,没有更小的覆盖, S' 即为最小覆盖。这个集合覆盖问题可映射为图1-6的二分图,即用顶点1,2,3,1 6和1 7分别表示集合S1,S2,S3,S4 和S5,顶点j 表示集合中的元素j,4≤j≤1 5。 集合覆盖问题为N P-复杂问题。由于集合覆盖与二分覆盖是同一类问题,二分覆盖问题也是N P-复杂问题。因此可能无法找到一个快速的算法来解决它,但是可以利用贪婪算法寻找一种快速启发式方法。一种可能是分步建立覆盖A' ,每一步选择A中的一个顶点加入覆盖。顶点的选择利用贪婪准则:从A中选取能覆盖B中还未被覆盖的元素数目最多的顶点。 例1-12 考察图1 - 6所示的二分图,初始化A' = 且B中没有顶点被覆盖,顶点1和1 6均能覆盖B中的六个顶点,顶点3覆盖五个,顶点2和1 7分别覆盖四个。因此,在第一步往A' 中加入顶点1或1 6,若加入顶点1 6,则它覆盖的顶点为{ 5 , 6 , 8 , 1 2 , 1 4 , 1 5 },未覆盖的顶点为{ 4 , 7 , 9 , 1 0 , 11 , 1 3 }。顶点1能覆盖其中四个顶点( { 4 , 7 , 9 , 1 3 }),顶点2 覆盖一个( { 4 } ),顶点3覆盖一个({ 1 0 }),顶点1 6覆盖零个,顶点1 7覆盖四个{ 4 , 9 , 1 0 , 11 }。下一步可选择1或1 7加入A' 。若选择顶点1,则顶点{ 1 0 , 11} 仍然未被覆盖,此时顶点1,2,1 6不覆盖其中任意一个,顶点3覆盖一个,顶点1 7覆盖两个,因此选择顶点1 7,至此所有顶点已被覆盖,得A' = { 1 6 , 1 , 1 7 }。 图1 - 7给出了贪婪覆盖启发式方法的伪代码,可以证明: 1) 当且仅当初始的二分图没有覆盖时,算法找不到覆盖;2) 启发式方法可能找不到二分图的最小覆盖。 1. 数据结构的选取及复杂性分析 为实现图13 - 7的算法,需要选择A' 的描述方法及考虑如何记录A中节点所能覆盖的B中未覆盖节点的数目。由于对集合A' 仅使用加法运算,则可用一维整型数组C来描述A ',用m 来记录A' 中元素个数。将A' 中的成员记录在C[ 0 :m-1] 中。对于A中顶点i,令N e wi 为i 所能覆盖的B中未覆盖的顶点数目。逐步选择N e wi 值最大的顶点。由于一些原来未被覆盖的顶点现在被覆盖了,因此还要修改各N e wi 值。在这种更新中,检查B中最近一次被V覆盖的顶点,令j 为这样的一个顶点,则A中所有覆盖j 的顶点的N e wi 值均减1。 例1-13 考察图1 - 6,初始时(N e w1 , N e w2 , N e w3 , N e w16 , N e w17 ) = ( 6 , 4 , 5 , 6 , 4 )。假设在例1 - 1 2中,第一步选择顶点1 6,为更新N e wi 的值检查B中所有最近被覆盖的顶点,这些顶点为5 , 6 , 8 , 1 2 , 1 4和1 5。当检查顶点5时,将顶点2和1 6的N e wi 值分别减1,因为顶点5不再是被顶点2和1 6覆盖的未覆盖节点;当检查顶点6时,顶点1 , 2 ,和1 6的相应值分别减1;同样,检查顶点8时,1,2,3和1 6的值分别减1;当检查完所有最近被覆盖的顶点,得到的N e wi 值为(4,1,0,4)。下一步选择顶点1,最新被覆盖的顶点为4,7,9和1 3;检查顶点4时,N e w1 , N e w2, 和N e w1 7 的值减1;检查顶点7时,N e w1 的值减1,因为顶点1是覆盖7的唯一顶点。 为了实现顶点选取的过程,需要知道N e wi 的值及已被覆盖的顶点。可利用一个二维数组来达到这个目的,N e w是一个整型数组,New[i] 即等于N e wi,且c o v为一个布尔数组。若顶点i未被覆盖则c o v [ i ]等于f a l s e,否则c o v [ i ]为t r u e。现将图1 - 7的伪代码进行细化得到图1 - 8。 m=0; //当前覆盖的大小 对于A中的所有i,New[i]=Degree[i] 对于B中的所有i,C o v [ i ] = f a l s e while (对于A中的某些i,New[i]>0) { 设v是具有最大的N e w [ i ]的顶点; C [ m + + ] = v ; for ( 所有邻接于v的顶点j) { if (!Cov[j]) { Cov[j]= 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)。逐步选择顶点所需时间为(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 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)。 若利用最大选择树,每次更新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 的顶点。在某一步结束时,假如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 - 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中添加顶点v void MoveBins(int bMax, int ToBin, int v) 从当前箱子中移动顶点v到箱子To B i n int *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 <= b; i++) bin[i] = 0; } void Undirected::InsertBins(int b, int v) {// 若b不为0,则将v 插入箱子b if (!b) return; // b为0,不插入 node[v].left = b; // 添加在左端 if (bin[b]) node[bin[b]].left = v; node[v].right = bin[b]; bin[b] = v; } void Undirected::MoveBins(int bMax, int ToBin, int v) {// 将顶点v 从其当前所在箱子移动到To B i n . // v的左、右节点 int l = node[v].left; int r = node[v].right; // 从当前箱子中删除 if (r) node[r].left = node[v].left; if (l > bMax || bin[l] != v) // 不是最左节点 node[l].right = r; else bin[l] = r; // 箱子l的最左边 // 添加到箱子To B i n I 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, bin[i ]指向其N e w值为i的双向链表的顶点, f o r循环将所有双向链表置为空。如果b≠0,函数InsertBins 将顶点v 插入箱子b 中。因为b 是顶点v 的New 值,b = 0意味着顶点v 不能覆盖B中当前还未被覆盖的任何顶点,所以,在建立覆盖时这个箱子没有用处,故可以将其舍去。当b≠0时,顶点n 加入New 值为b 的双向链表箱子的最前面,这种加入方式需要将node[v] 加入bin[b] 中第一个节点的左边。由于表的最左节点应指向它所属的箱子,因此将它的node[v].left 置为b。若箱子不空,则当前第一个节点的left 指针被置为指向新节点。node[v] 的右指针被置为b i n [ b ],其值可能为0或指向上一个首节点的指针。最后, b i n [ b ]被更新为指向表中新的第一个节点。MoveBins 将顶点v 从它在双向链表中的当前位置移到New 值为ToBin 的位置上。其中存在bMa x,使得对所有的箱子b i n[ j ]都有:如j>bMa 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 , 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) {// 寻找一个二分图覆盖 // L 是输入顶点的标号, L[i] = 1 当且仅当i 在A中 // C 是一个记录覆盖的输出数组 // 如果图中不存在覆盖,则返回f a l s e // 如果图中有一个覆盖,则返回t r u e ; // 在m中返回覆盖的大小; 在C [ 0 : m - 1 ]中返回覆盖 int n = Ve r t i c e s ( ) ; // 插件结构 int SizeOfA = 0; for (int i = 1; i <= n; i++) // 确定集合A的大小 if (L[i] == 1) SizeOfA++; int SizeOfB = n - SizeOfA; CreateBins(SizeOfB, n); int *New = new int [n+1]; / /顶点i覆盖了B中N e w [ i ]个未被覆盖的顶点 bool *Change = new bool [n+1]; // Change[i]为t r u e当且仅当New[i] 已改变 bool *Cov = new bool [n+1]; // Cov[i] 为true 当且仅当顶点i 被覆盖 I n i t i a l i z e P o s ( ) ; LinkedStack S; // 初始化 for (i = 1; i <= n; i++) { Cov[i] = Change[i] = false; if (L[i] == 1) {// i 在A中 New[i] = Degree(i); // i 覆盖了这么多 InsertBins(New[i], i);}} // 构造覆盖 int covered = 0, // 被覆盖的顶点 MaxBin = SizeOfB; // 可能非空的最大箱子 m = 0; // C的游标 while (MaxBin > 0) { // 搜索所有箱子 // 选择一个顶点 if (bin[MaxBin]) { // 箱子不空 int v = bin[MaxBin]; // 第一个顶点 C[m++] = v; // 把v 加入覆盖 // 标记新覆盖的顶点 int j = Begin(v), k; while (j) { if (!Cov[j]) {// j尚未被覆盖 Cov[j] = true; c o v e r e d + + ; // 修改N e w k = Begin(j); while (k) { New[k]--; // j 不计入在内 if (!Change[k]) { S.Add(k); // 仅入栈一次 Change[k] = true;} k = NextVe r t e x ( j ) ; } } j = NextVe r t e x ( v ) ; } // 更新箱子 while (!S.IsEmpty()) { S . D e l e t e ( k ) ; Change[k] = false; MoveBins(SizeOfB, New[k], 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初始化为f a l s e,并将A中的顶点根据它们覆盖B中顶点的数目插入到相应的双向链表中。 为了构造覆盖,首先按SizeOfB 递减至1的顺序检查双向链表。当发现一个非空的表时,就将其第一个顶点v 加入到覆盖中,这种策略即为选择具有最大Ne o v [ j ]置为t r u e,表示顶点j 现在已被覆盖,同时将已被覆盖的B中的顶点数目加1。由于j 是最近被覆w 值的顶点。将所选择的顶点加入覆盖数组C并检查B中所有与它邻接的顶点。若顶点j 与v 邻接且还未被覆盖,则将C盖的,所有A中与j 邻接的顶点的New 值减1。下一个while 循环降低这些New 值并将New 值被降低的顶点保存在一个栈中。当所有与顶点v邻接的顶点的Cov 值更新完毕后,N e w值反映了A中每个顶点所能覆盖的新的顶点数,然而A中的顶点由于New 值被更新,处于错误的双向链表中,下一个while 循环则将这些顶点移到正确的表中。 1.3.5 单源最短路径 在这个问题中,给出有向图G,它的每条边都有一个非负的长度(耗费) a [i ][ j ],路径的长度即为此路径所经过的边的长度之和。对于给定的源顶点s,需找出从它到图中其他任意顶点(称为目的)的最短路径。图13-10a 给出了一个具有五个顶点的有向图,各边上的数即为长度。假设源顶点s 为1,从顶点1出发的最短路径按路径长度顺序列在图13-10b 中,每条路径前面的数字为路径的长度。 利用E. Dijkstra发明的贪婪算法可以解决最短路径问题,它通过分步方法求出最短路径。每一步产生一个到达新的目的顶点的最短路径。下一步所能达到的目的顶点通过如下贪婪准则选取:在还未产生最短路径的顶点中,选择路径长度最短的目的顶点。也就是说, D i j k s t r a的方法按路径长度顺序产生最短路径。 首先最初产生从s 到它自身的路径,这条路径没有边,其长度为0。在贪婪算法的每一步中,产生下一个最短路径。一种方法是在目前已产生的最短路径中加入一条可行的最短的边,结果产生的新路径是原先产生的最短路径加上一条边。这种策略并不总是起作用。另一种方法是在目前产生的每一条最短路径中,考虑加入一条最短的边,再从所有这些边中先选择最短的,这种策略即是D i j k s t r a算法。 可以验证按长度顺序产生最短路径时,下一条最短路径总是由一条已产生的最短路径加上一条边形成。实际上,下一条最短路径总是由已产生的最短路径再扩充一条最短的边得到的,且这条路径所到达的顶点其最短路径还未产生。例如在图1 3 - 1 0中,b 中第二条路径是第一条路径扩充一条边形成的;第三条路径则是第二条路径扩充一条边;第四条路径是第一条路径扩充一条边;第五条路径是第三条路径扩充一条边。 通过上述观察可用一种简便的方法来存储最短路径。可以利用数组p,p [ i ]给出从s 到达i的路径中顶点i 前面的那个顶点。在本例中p [ 1 : 5 ] = [ 0 , 1 , 1 , 3 , 4 ]。从s 到顶点i 的路径可反向创建。从i 出发按p[i],p[p[i]],p[p[p[i]]], .的顺序,直到到达顶点s 或0。在本例中,如果从i = 5开始,则顶点序列为p[i]=4, p[4]=3, p[3]=1=s,因此路径为1 , 3 , 4 , 5。 为能方便地按长度递增的顺序产生最短路径,定义d [ i ]为在已产生的最短路径中加入一条最短边的长度,从而使得扩充的路径到达顶点i。最初,仅有从s 到s 的一条长度为0的路径,这时对于每个顶点i,d [ i ]等于a [ s ] [ i ](a 是有向图的长度邻接矩阵)。为产生下一条路径,需要选择还未产生最短路径的下一个节点,在这些节点中d值最小的即为下一条路径的终点。当获得一条新的最短路径后,由于新的最短路径可能会产生更小的d值,因此有些顶点的d值可能会发生变化。 综上所述,可以得到图1 3 - 11所示的伪代码, 1) 将与s 邻接的所有顶点的p 初始化为s,这个初始化用于记录当前可用的最好信息。也就是说,从s 到i 的最短路径,即是由s到它自身那条路径再扩充一条边得到。当找到更短的路径时, p [ i ]值将被更新。若产生了下一条最短路径,需要根据路径的扩充边来更新d 的值。 1) 初始化d[i ] =a[s] [i ](1≤i≤n), 对于邻接于s的所有顶点i,置p[i ] =s, 对于其余的顶点置p[i ] = 0; 对于p[i]≠0的所有顶点建立L表。 2) 若L为空,终止,否则转至3 )。 3) 从L中删除d值最小的顶点。 4) 对于与i 邻接的所有还未到达的顶点j,更新d[ j ]值为m i n{d[ j ], d[i ] +a[i ][ j ] };若d[ j ]发生了变化且j 还未 在L中,则置p[ j ] = 1,并将j 加入L,转至2。 图1 - 11 最短路径算法的描述 1. 数据结构的选择 我们需要为未到达的顶点列表L选择一个数据结构。从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) 的每次执行需O(|L | ) =O( n )的时间,每次减操作需( 1 )的时间(需要减去d[j] 的值,但链表不用改变)。利用无序链表将图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 最短路径程序 template void AdjacencyWDigraph::ShortestPaths(int s, T d[], int p[]) {// 寻找从顶点s出发的最短路径, 在d中返回最短距离 // 在p中返回前继顶点 if (s < 1 || s > n) throw OutOfBounds(); Chain L; // 路径可到达顶点的列表 ChainIterator I; // 初始化d, p, L for (int i = 1; i <= n; i++){ d[i] = a[s][i]; if (d[i] == NoEdge) p[i] = 0; else {p[i] = s; L . I n s e r t ( 0 , i ) ; } } // 更新d, p while (!L.IsEmpty()) {// 寻找具有最小d的顶点v int *v = I.Initialize(L); int *w = I.Next(); while (w) { if (d[*w] < d[*v]) v = w; w = I.Next();} // 从L中删除通向顶点v的下一最短路径并更新d int i = *v; L . D e l e t e ( * v ) ; for (int j = 1; j <= n; j++) { if (a[i][j] != NoEdge && (!p[j] || d[j] > d[i] + a[i][j])) { // 减小d [ j ] d[j] = d[i] + a[i][j]; // 将j加入L if (!p[j]) L.Insert(0,j); p[j] = i;} } } } 若N o E d g e足够大,使得没有最短路径的长度大于或等于N o E d g e,则最后一个for 循环的i f条件可简化为:if (d[j] > d[i] + a[i][j])) NoEdge 的值应在能使d[j]+a[i][j] 不会产生溢出的范围内。 2. 复杂性分析 程序1 3 - 5的复杂性是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中已考察过这个问题。因为具有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 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 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中代价最小的边 E=E- { (u,v) } / /从E中删除边 i f( (u,v)加入T中不会产生环路)将( u,v)加入T } i 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节可知当且仅当一个无向图连通时它有生成树。而且在Kruskal 算法中被拒绝(丢弃)的边是那些会产生环路的边。删除连通图环路中的一条边所形成的图仍是连通图,因此如果G在开始时是连通的,则T与E中的边总能形成一个连通图。也就是若G开始时是连通的,算法不会终止于E= 和| T |< n- 1。 现在来证明所建立的生成树T具有最小耗费。由于G具有有限棵生成树,所以它至少具有一棵最小生成树。令U为这样的一棵最小生成树, T与U都刚好有n- 1条边。如果T=U, 则T就具有最小耗费,那么不必再证明下去。因此假设T≠U,令k(k >0) 为在T中而不在U中的边的个数,当然k 也是在U中而不在T中的边的数目。 通过把U变换为T来证明U与T具有相同的耗费,这种转化可在k 步内完成。每一步使在T而不在U中的边的数目刚好减1。而且U的耗费不会因为转化而改变。经过k 步的转化得到的U将与原来的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的耗费与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) 数据结构的选择及复杂性分析 为了按耗费非递减的顺序选择边,可以建立最小堆并根据需要从堆中一条一条地取出各边。当图中有e 条边时,需花(e) 的时间初始化堆及O ( l o ge) 的时间来选取每一条边。边的集合T与G中的顶点一起定义了一个由至多n 个连通子图构成的图。用顶点集合来描述每个子图,这些顶点集合没有公共顶点。为了确定边( u,v)是否会产生环路,仅需检查u,v 是否在同一个顶点集中(即处于同一子图)。如果是,则会形成一个环路;如果不是,则不会产生环路。因此对于顶点集使用两个F i n d操作就足够了。当一条边包含在T中时,2个子图将被合并成一个子图,即对两个集合执行U n i o n操作。集合的F i n d和U n i o n操作可利用8 . 1 0 . 2节的树(以及加权规则和路径压缩)来高效地执行。F i n d操作的次数最多为2e,Un i o n操作的次数最多为n- 1(若网络是连通的,则刚好是n- 1次)。加上树的初始化时间,算法中这部分的复杂性只比O (n+e) 稍大一点。 对集合T所执行的唯一操作是向T中添加一条新边。T可用数组t 来实现。添加操作在数组 的一端进行,因为最多可在T中加入n- 1条边,因此对T的操作总时间为O (n)。 总结上述各个部分的执行时间,可得图1 3 - 1 3算法的渐进复杂性为O (n+el o ge)。 (4) 实现 利用上述数据结构,图1 - 1 3可用C + +代码来实现。首先定义E d g e N o d e类(见程序1 3 - 6 ),它是最小堆的元素及生成树数组t 的数据类型。 程序13-6 Kruskal算法所需要的数据类型 template class EdgeNode { p u b l i c : operator T() const {return weight;} p r i v a t e : T weight;//边的高度 int u, v;//边的端点 } ; 为了更简单地使用8 . 1 0 . 2节的查找和合并策略,定义了U n i o n F i n d类,它的构造函数是程序8 - 1 6的初始化函数,U n i o n是程序8 - 1 6的加权合并函数,F i n d是程序8 - 1 7的路径压缩搜索函数。 为了编写与网络描述无关的代码,还定义了一个新的类U N e t Wo r k,它包含了应用于无向网络的所有函数。这个类与U n d i r e c t e d类的差别在于U n d i r e c t e d类中的函数不要求加权边,而U N e t Wo r k要求边必须带有权值。U N e t Wo r k中的成员需要利用N e t w o r k类中定义的诸如B e g i n和N e x t Ve r t e x的遍历函数。不过,新的遍历函数不仅需要返回下一个邻接的顶点,而且要返回到达这个顶点的边的权值。这些遍历函数以及有向和无向加权网络的其他函数一起构成了W N e t w o r k类(见程序1 3 - 7)。 程序13-7 WNetwork类 template class WNetwork : virtual public Network { public : virtual void First(int i, int& j, T& c)=0; virtual void Next(int i, int& j, T& c)=0; } ; 象B e g i n和N e x t Ve r t e x一样,可在A d j a c e n c y W D i g r a p h及L i n k e d W D i g r a p h类中加入函数F i r s t与N e x t。现在A d j a c e n c y W D i g r a p h及L i n k e d W D i g r a p h类都需要从W N e t Wo r k中派生而来。由于A d j a c e n c y W G r a p h类和L i n k e d W G r a p h类需要访问U N e t w o r k的成员,所以这两个类还必须从U N e t Wo r k中派生而来。U N e t Wo r k : : K r u s k a l的代码见程序1 3 - 8,它要求将Edges() 定义为N e t Work 类的虚成员,并且把U N e t Wo r k定义为E d g e N o d e的友元)。如果没有生成树,函数返回f a l s e,否则返回t r u e。注意当返回true 时,在数组t 中返回最小耗费生成树。 程序13-8 Kr u s k a l算法的C + +代码 template bool UNetwork::Kruskal(EdgeNode t[]) {// 使用K r u s k a l算法寻找最小耗费生成树 // 如果不连通则返回false // 如果连通,则在t [ 0 : n - 2 ]中返回最小生成树 int n = Ve r t i c e s ( ) ; int e = Edges(); / /设置网络边的数组 InitializePos(); // 图遍历器 EdgeNode *E = new EdgeNode [e+1]; int k = 0; // E的游标 for (int i = 1; i <= n; i++) { // 使所有边附属于i int j; T c; First(i, j, c); while (j) { // j 邻接自i if (i < j) {// 添加到达E的边 E[++k].weight = c; E[k].u = i; E[k].v = j;} Next(i, j, c); } } // 把边放入最小堆 MinHeap > H(1); H.Initialize(E, e, e); UnionFind U(n); // 合并/搜索结构 // 根据耗费的次序来抽取边 k = 0; // 此时作为t的游标 while (e && k < n - 1) { // 生成树未完成,尚有剩余边 EdgeNode x; H.DeleteMin(x); // 最小耗费边 e - - ; int a = U.Find(x.u); int b = U.Find(x.v); if (a != b) {// 选择边 t[k++] = x; U . U n i o n ( a , b ) ; } } D e a c t i v a t e P o s ( ) ; H . D e a c t i v a t e ( ) ; return (k == n - 1); } 2. Prim算法 与Kr u s k a l算法类似,P r i m算法通过每次选择多条边来创建最小生成树。选择下一条边的贪婪准则是:从剩余的边中,选择一条耗费最小的边,并且它的加入应使所有入选的边仍是一棵树。最终,在所有步骤中选择的边形成一棵树。相反,在Kruskal 算法中所有入选的边集合最终形成一个森林。 P r i m算法从具有一个单一顶点的树T开始,这个顶点可以是原图中任意一个顶点。然后往T中加入一条代价最小的边( u , v)使Tè{ (u , v) }仍是一棵树,这种加边的步骤反复循环直到T中包含n- 1条边。注意对于边( u , v),u、v 中正好有一个顶点位于T中。P r i m算法的伪代码如图1 -1 4所示。在伪代码中也包含了所输入的图不是连通图的可能,在这种情况下没有生成树。图1 - 1 5显示了对图1-12a 使用P r i m算法的过程。把图1 - 1 4的伪代码细化为C + +程序及其正确性的证明留作练习(练习3 1)。 / /假设网络中至少具有一个顶点 设T为所选择的边的集合,初始化T= 设T V为已在树中的顶点的集合,置T V= { 1 } 令E为网络中边的集合 w h i l e(E< > ) & & (| T | < > n-1) { 令(u , v)为最小代价边,其中u T V, v T V i f(没有这种边) b re a k E=E- { (u,v) } / /从E中删除此边 在T中加入边( u , v) } if (| T | = =n- 1 ) T是一棵最小生成树 else 网络是不连通的,没有最小生成树 图13-14 Prim最小生成树算法 如果根据每个不在T V中的顶点v 选择一个顶点n e ar (v),使得n e ar (v) ? TV 且c o st (v, n e ar (v) )的值是所有这样的n e ar (v) 节点中最小的,则实现P r i m算法的时间复杂性为O (n2 )。下一条添加到T中的边是这样的边:其cost (v, near (v)) 最小,且v T V。 3. Sollin算法 S o l l i n算法每步选择若干条边。在每步开始时,所选择的边及图中的n个顶点形成一个生成树的森林。在每一步中为森林中的每棵树选择一条边,这条边刚好有一个顶点在树中且边的代价最小。将所选择的边加入要创建的生成树中。注意一个森林中的两棵树可选择同一条边,因此必须多次复制同一条边。当有多条边具有相同的耗费时,两棵树可选择与它们相连的不同的边,在这种情况下,必须丢弃其中的一条边。开始时,所选择的边的集合为空。若某一步结束时仅剩下一棵树或没有剩余的边可供选择时算法终止。 图1 - 6给出了初始状态为图1-12a 时,使用S o l l i n算法的步骤。初始入选边数为0时的情形如图13-12a 时,森林中的每棵树均是单个顶点。顶点1,2,.,7所选择的边分别是(1.6), (2,7),(3,4), (4,3), (5,4), (6,1), (7,2),其中不同的边是( 1 , 6 ),( 2 , 7 ),(3,4) 和( 5 , 4 ),将这些边加入入选边的集合后所得到的结果如图1 3 - 1 6 a所示。下一步具有顶点集{ 1 , 6 }的树选择边( 6 , 5 ),剩下的两棵树选择边( 2 , 3 ),加入这两条边后已形成一棵生成树,构建好的生成树见图1 3 - 6 b。S o l l i n算法的C + +程序实现及其正确性证明留作练习(练习3 2 )。 第 2 章 分而治之算法 君主和殖民者们所成功运用的分而治之策略也可以运用到高效率的计算机算法的设计过程中。本章将首先介绍怎样在算法设计领域应用这一古老的策略,然后将利用这一策略解决如下问题:最小最大问题、矩阵乘法、残缺棋盘、排序、选择和计算一个几何问题——找出二维空间中距离最近的两个点。 本章给出了用来分析分而治之算法复杂性的数学方法,并通过推导最小最大问题和排序问题的复杂性下限来证明分而治之算法对于求解这两种问题是最优的(因为算法的复杂性与下限一致)。 2.1 算法思想 分而治之方法与软件设计的模块化方法非常相似。为了解决一个大的问题,可以: 1) 把它分成两个或多个更小的问题; 2) 分别解决每个小问题; 3) 把各小问题的解答组合起来,即可得到原问题的解答。小问题通常与原问题相似,可以递归地使用分而治之策略来解决。 例2-1 [找出伪币] 给你一个装有1 6个硬币的袋子。1 6个硬币中有一个是伪造的,并且那个伪造的硬币比真的硬币要轻一些。你的任务是找出这个伪造的硬币。为了帮助你完成这一任务,将提供一台可用来比较两组硬币重量的仪器,利用这台仪器,可以知道两组硬币的重量是否相同。比较硬币1与硬币2的重量。假如硬币1比硬币2轻,则硬币1是伪造的;假如硬币2比硬币1轻,则硬币2是伪造的。这样就完成了任务。假如两硬币重量相等,则比较硬币3和硬币4。同样,假如有一个硬币轻一些,则寻找伪币的任务完成。假如两硬币重量相等,则继续比较硬币5和硬币6。按照这种方式,可以最多通过8次比较来判断伪币的存在并找出这一伪币。 另外一种方法就是利用分而治之方法。假如把1 6硬币的例子看成一个大的问题。第一步,把这一问题分成两个小问题。随机选择8个硬币作为第一组称为A组,剩下的8个硬币作为第二组称为B组。这样,就把1 6个硬币的问题分成两个8硬币的问题来解决。第二步,判断A和B组中是否有伪币。可以利用仪器来比较A组硬币和B组硬币的重量。假如两组硬币重量相等,则可以判断伪币不存在。假如两组硬币重量不相等,则存在伪币,并且可以判断它位于较轻的那一组硬币中。最后,在第三步中,用第二步的结果得出原先1 6个硬币问题的答案。若仅仅判断硬币是否存在,则第三步非常简单。无论A组还是B组中有伪币,都可以推断这1 6个硬币中存在伪币。因此,仅仅通过一次重量的比较,就可以判断伪币是否存在。 现在假设需要识别出这一伪币。把两个或三个硬币的情况作为不可再分的小问题。注意如果只有一个硬币,那么不能判断出它是否就是伪币。在一个小问题中,通过将一个硬币分别与其他两个硬币比较,最多比较两次就可以找到伪币。这样,1 6硬币的问题就被分为两个8硬币(A组和B组)的问题。通过比较这两组硬币的重量,可以判断伪币是否存在。如果没有伪币,则算法终止。否则,继续划分这两组硬币来寻找伪币。假设B是轻的那一组,因此再把它分成两组,每组有4个硬币。称其中一组为B1,另一组为B2。比较这两组,肯定有一组轻一些。如果B1轻,则伪币在B1中,再将B1又分成两组,每组有两个硬币,称其中一组为B1a,另一组为B1b。比较这两组,可以得到一个较轻的组。由于这个组只有两个硬币,因此不必再细分。比较组中两个硬币的重量,可以立即知道哪一个硬币轻一些。较轻的硬币就是所要找的伪币。 例2-2 [金块问题] 有一个老板有一袋金块。每个月将有两名雇员会因其优异的表现分别被奖励一个金块。按规矩,排名第一的雇员将得到袋中最重的金块,排名第二的雇员将得到袋中最轻的金块。根据这种方式,除非有新的金块加入袋中,否则第一名雇员所得到的金块总是比第二名雇员所得到的金块重。如果有新的金块周期性的加入袋中,则每个月都必须找出最轻和最重的金块。假设有一台比较重量的仪器,我们希望用最少的比较次数找出最轻和最重的金块。 假设袋中有n 个金块。可以用函数M a x(程序1 - 3 1)通过n-1次比较找到最重的金块。找到最重的金块后,可以从余下的n-1个金块中用类似的方法通过n-2次比较找出最轻的金块。这样,比较的总次数为2n-3。程序2 - 2 6和2 - 2 7是另外两种方法,前者需要进行2n-2次比较,后者最多需要进行2n-2次比较。 下面用分而治之方法对这个问题进行求解。当n很小时,比如说, n≤2,识别出最重和最轻的金块,一次比较就足够了。当n 较大时(n>2),第一步,把这袋金块平分成两个小袋A和B。第二步,分别找出在A和B中最重和最轻的金块。设A中最重和最轻的金块分别为HA 与LA,以此类推,B中最重和最轻的金块分别为HB 和LB。第三步,通过比较HA 和HB,可以找到所有金块中最重的;通过比较LA 和LB,可以找到所有金块中最轻的。在第二步中,若n>2,则递归地应用分而治之方法。 假设n= 8。这个袋子被平分为各有4个金块的两个袋子A和B。为了在A中找出最重和最轻的金块,A中的4个金块被分成两组A1和A2。每一组有两个金块,可以用一次比较在A中找出较重的金块HA1和较轻的金块LA1。经过另外一次比较,又能找出HA 2和LA 2。现在通过比较HA1和HA2,能找出HA;通过LA 1和LA2的比较找出LA。这样,通过4次比较可以找到HA 和LA。同样需要另外4次比较来确定HB 和LB。通过比较HA 和HB(LA 和LB),就能找出所有金块中最重和最轻的。因此,当n= 8时,这种分而治之的方法需要1 0次比较。如果使用程序1 - 3 1,则需要1 3次比较。如果使用程序2 - 2 6和2 - 2 7,则最多需要1 4次比较。设c(n)为使用分而治之方法所需要的比较次数。为了简便,假设n是2的幂。当n= 2时,c(n) = 1。对于较大的n,c(n) = 2c(n/ 2 ) + 2。当n是2的幂时,使用迭代方法(见例2 - 2 0)可知 c(n) = 3n/ 2 - 2。在本例中,使用分而治之方法比逐个比较的方法少用了2 5%的比较次数。 例2-3 [矩阵乘法] 两个n×n 阶的矩阵A与B的乘积是另一个n×n 阶矩阵C,C可表示为假如每一个C(i, j) 都用此公式计算,则计算C所需要的操作次数为n 3 m+n2 (n- 1) a,其中m表示一次乘法,a 表示一次加法或减法。 为了得到两个矩阵相乘的分而治之算法,需要: 1) 定义一个小问题,并指明小问题是如何进行乘法运算的; 2) 确定如何把一个大的问题划分成较小的问题,并指明如何对这些较小的问题进行乘法运算; 3) 最后指出如何根据小问题的结果得到大问题的结果。为了使讨论简便,假设n 是2的幂(也就是说, n是1,2,4,8,1 6,.)。 首先,假设n= 1时是一个小问题,n> 1时为一个大问题。后面将根据需要随时修改这个假设。对于1×1阶的小矩阵,可以通过将两矩阵中的两个元素直接相乘而得到结果。 考察一个n> 1的大问题。可以将这样的矩阵分成4个n/ 2×n/ 2阶的矩阵A1,A2,A3,和A4。当n 大于1且n 是2的幂时,n/ 2也是2的幂。因此较小矩阵也满足前面对矩阵大小的假设。矩阵Bi 和Ci 的定义与此类似. 根据上述公式,经过8次n/ 2×n/ 2阶矩阵乘法和4次n/ 2×n/ 2阶矩阵的加法,就可以计算出A与B的乘积。因此,这些公式能帮助我们实现分而治之算法。在算法的第二步,将递归使用分而治之算法把8个小矩阵再细分(见程序2 - 1 9)。算法的复杂性为(n3 ),此复杂性与程序2 - 2 4直接使用公式(2 - 1)所得到的复杂性是一样的。事实上,由于矩阵分割和再组合所花费的额外开销,使用分而治之算法得出结果的时间将比用程序2 - 2 4还要长。 为了得到更快的算法,需要简化矩阵分割和再组合这两个步骤。一种方案是使用S t r a s s e n方法得到7个小矩阵。这7个小矩阵为矩阵D, E, ., J,矩阵D到J可以通过7次矩阵乘法, 6次矩阵加法,和4次矩阵减法计算得出。前述的4个小矩阵可以由矩阵D到J通过6次矩阵加法和两次矩阵减法得出. 用上述方案来解决n= 2的矩阵乘法。将某矩阵A和B相乘得结果C,如下所示: 因为n> 1,所以将A、B两矩阵分别划分为4个小矩阵,每个矩阵为1×1阶,仅包含一个元素。1×1阶矩阵的乘法为小问题,因此可以直接进行运算。利用计算D~J的公式,得: D= 1(6-8)=-2 E= 4(7-5)= 8 F=(3 + 4)5 = 3 5 G=(1 + 2)8 = 2 4 H=(3-1)(5 + 6)= 2 2 I=(2-4)(7 + 8)=-3 0 J=(1 + 4)(5 + 8)= 6 5 根据以上结果可得: 对于上面这个2×2的例子,使用分而治之算法需要7次乘法和1 8次加/减法运算。而直接使用公式(2 - 1),则需要8次乘法和7次加/减法。要想使分而治之算法更快一些,则一次乘法所花费的时间必须比11次加/减法的时间要长。假定S t r a s s e n矩阵分割方案仅用于n≥8的矩阵乘法,而对于n<8的矩阵乘法则直接利用公式(2 - 1)进行计算。则n= 8时,8×8矩阵相乘需要7次4×4矩阵乘法和1 8次4×4矩阵加/减法。每次矩阵乘法需花费6 4m+ 4 8a次操作,每次矩阵加法或减法需花费1 6a次操作。因此总的操作次数为7 ( 6 4m+ 4 8a) + 1 8 ( 1 6a) = 4 4 8m+ 6 2 4a。而使用直接计算方法,则需要5 1 2m+ 4 4 8a次操作。要使S t r a s s e n方法比直接计算方法快,至少要求5 1 2-4 4 8次乘法的开销比6 2 4-4 4 8次加/减法的开销大。或者说一次乘法的开销应该大于近似2 . 7 5次加/减法的开销。假定n<1 6的矩阵是一个“小”问题,S t r a s s e n的分解方案仅仅用于n≥1 6的情况,对于n<1 6的矩阵相乘,直接利用公式( 2 - 1)。则当n= 1 6时使用分而治之算法需要7 ( 5 1 2m+ 4 4 8a) +1 8 ( 6 4a) = 3 5 8 4m+ 4 2 8 8a次操作。直接计算时需要4 0 9 6m+ 3 8 4 0a次操作。若一次乘法的开销与一次加/减法的开销相同,则S t r a s s e n方法需要7 8 7 2次操作及用于问题分解的额外时间,而直接计算方法则需要7 9 3 6次操作加上程序中执行f o r循环以及其他语句所花费的时间。即使直接计算方法所需要的操作次数比St r a s s e n方法少,但由于直接计算方法需要更多的额外开销,因此它也不见得会比S t r a s s e n方法快。n 的值越大,Strassen 方法与直接计算方法所用的操作次数的差异就越大,因此对于足够大的n,Strassen 方法将更快。设t (n) 表示使用Strassen 分而治之方法所需的时间。因为大的矩阵会被递归地分割成小矩阵直到每个矩阵的大小小于或等于k(k至少为8,也许更大,具体值由计算机的性能决定). 用迭代方法计算,可得t(n) = (nl og27 )。因为l og27 ≈2 . 8 1,所以与直接计算方法的复杂性(n3 )相比,分而治之矩阵乘法算法有较大的改进。 注意事项 分而治之方法很自然地导致了递归算法的使用。在许多例子里,这些递归算法在递归程序中得到了很好的运用。实际上,在许多情况下,所有为了得到一个非递归程序的企图都会导致采用一个模拟递归栈。不过在有些情况下,不使用这样的递归栈而采用一个非递归程序来完成分而治之算法也是可能的,并且在这种方式下,程序得到结果的速度会比递归方式更快。解决金块问题的分而治之算法(例2 - 2)和归并排序方法( 2 . 3节)就可以不利用递归而通过一个非递归程序来更快地完成。 例2-4 [金块问题] 用例2 - 2的算法寻找8个金块中最轻和最重金块的工作可以用二叉树来表示。这棵树的叶子分别表示8个金块(a, b,., h),每个阴影节点表示一个包含其子树中所有叶子的问题。因此,根节点A表示寻找8个金块中最轻、最重金块的问题,而节点B表示找出a,b,c 和d 这4个金块中最轻和最重金块的问题。算法从根节点开始。由根节点表示的8金块问题被划分成由节点B和C所表示的两个4金块问题。在B节点,4金块问题被划分成由D和E所表示的2金块问题。可通过比较金块a 和b 哪一个较重来解决D节点所表示的2金块问题。在解决了D和E所表示的问题之后,可以通过比较D和E中所找到的轻金块和重金块来解决B表示的问题。接着在F,G和C上重复这一过程,最后解决问题A。 可以将递归的分而治之算法划分成以下的步骤: 1) 从图2 - 2中的二叉树由根至叶的过程中把一个大问题划分成许多个小问题,小问题的大小为1或2。 2) 比较每个大小为2的问题中的金块,确定哪一个较重和哪一个较轻。在节点D、E、F和G上完成这种比较。大小为1的问题中只有一个金块,它既是最轻的金块也是最重的金块。 3) 对较轻的金块进行比较以确定哪一个金块最轻,对较重的金块进行比较以确定哪一个金块最重。对于节点A到C执行这种比较。 根据上述步骤,可以得出程序1 4 - 1的非递归代码。该程序用于寻找到数组w [ 0 : n - 1 ]中的最小数和最大数,若n < 1,则程序返回f a l s e,否则返回t r u e。 当n≥1时,程序1 4 - 1给M i n和M a x置初值以使w [ M i n ]是最小的重量,w [ M a x ]为最大的重量。 首先处理n≤1的情况。若n>1且为奇数,第一个重量w [ 0 ]将成为最小值和最大值的候选值,因此将有偶数个重量值w [ 1 : n - 1 ]参与f o r循环。当n 是偶数时,首先将两个重量值放在for 循环外进行比较,较小和较大的重量值分别置为Min和Max,因此也有偶数个重量值w[2:n-1]参与for循环。 在for 循环中,外层if 通过比较确定( w [ i ] , w [ i + 1 ] )中的较大和较小者。此工作与前面提到的分而治之算法步骤中的2) 相对应,而内层的i f负责找出较小重量值和较大重量值中的最小值和 最大值,这个工作对应于3 )。for 循环将每一对重量值中较小值和较大值分别与当前的最小值w [ M i n ]和最大值w [ M a x ]进行比较,根据比较结果来修改M i n和M a x(如果必要)。 下面进行复杂性分析。注意到当n为偶数时,在for 循环外部将执行一次比较而在f o r循环内部执行3 ( n / 2 - 1 )次比较,比较的总次数为3 n / 2 - 2。当n 为奇数时,f o r循环外部没有执行比较,而内部执行了3(n-1)/2次比较。因此无论n 为奇数或偶数,当n>0时,比较的总次数为「3n/2ù-2次。 程序14-1 找出最小值和最大值的非递归程序 template bool MinMax(T w[], int n, T& Min, T& Max) {// 寻找w [ 0 : n - 1 ]中的最小和最大值 // 如果少于一个元素,则返回f a l s e // 特殊情形: n <= 1 if (n < 1) return false; if (n == 1) {Min = Max = 0; return true;} / /对Min 和M a x进行初始化 int s; // 循环起点 if (n % 2) {// n 为奇数 Min = Max = 0; s = 1;} else {// n为偶数,比较第一对 if (w[0] > w[1]) { Min = 1; Max = 0;} else {Min = 0; Max = 1;} s = 2;} // 比较余下的数对 for (int i = s; i < n; i += 2) { // 寻找w[i] 和w [ i + 1 ]中的较大者 // 然后将较大者与w [ M a x ]进行比较 // 将较小者与w [ M i n ]进行比较 if (w[i] > w[i+1]) { if (w[i] > w[Max]) Max = i; if (w[i+1] < w[Min]) Min = i + 1;} else { if (w[i+1] > w[Max]) Max = i + 1; if (w[i] < w[Min]) Min = i;} } return true; } 练习 1. 将例1 4 - 1的分而治之算法扩充到n> 1个硬币的情形。需要进行多少次重量的比较? 2. 考虑例1 4 - 1的伪币问题。假设把条件“伪币比真币轻”改为“伪币与真币的重量不同”,同样假定袋中有n 个硬币。 1) 给出相应分而治之算法的形式化描述,该算法可输出信息“不存在伪币”或找出伪币。算法应递归地将大的问题划分成两个较小的问题。需要多少次比较才能找到伪币(如果存在伪币)? 2) 重复1) ,但把大问题划分为三个较小问题。 3. 1) 编写一个C++ 程序,实现例1 4 - 2中寻找n 个元素中最大值和最小值的两种方案。使用递归来完成分而治之方案。 2) 程序2 - 2 6和2 - 2 7是另外两个寻找n 个元素中最大值和最小值的代码。试分别计算出每段程序所需要的最少和最大比较次数。 3) 在n 分别等于1 0 0,1 0 0 0或10 000的情况下,比较1)、2)中的程序和程序1 4 - 1的运行时间。对于程序2 - 2 7,使用平均时间和最坏情况下的时间。1)中的程序和程序2 - 2 6应具有相同的平均时间和最坏情况下的时间。 4) 注意到如果比较操作的开销不是很高,分而治之算法在最坏情况下不会比其他算法优越,为什么?它的平均时间优于程序2 - 2 7吗?为什么? 4. 证明直接运用公式(1 4 -2)~(1 4 - 5)得出结果的矩阵乘法的分而治之算法的复杂性为(n3 )。因此相应的分而治之程序将比程序2 - 2 4要慢。 5. 用迭代的方法来证明公式(1 4 - 6)的递归值为(n l og27)。 *6. 编写S t r a s s e n矩阵乘法程序。利用不同的k 值(见公式(1 4 - 6))进行实验,以确定k 为何值时程序性能最佳。比较程序及程序2 - 2 4的运行时间。可取n 为2的幂来进行比较。 7. 当n 不是2的幂时,可以通过增加矩阵的行和列来得到一个大小为2的幂的矩阵。假设使用最少的行数和列数将矩阵扩充为m 阶矩阵,其中m 为2的幂。 1) 求m / n。 2) 可使用哪些矩阵项组成新的行和列,以使新矩阵A' 和B' 相乘时,原来的矩阵A和B相乘的结果会出现在C' 的左上角? 3) 使用S t r a s s e n方法计算A' * B' 所需要的时间为(m2.81 )。给出以n 为变量的运行时间表达式。 2.2 应用 2.2.1 残缺棋盘 残缺棋盘(defective chessboard)是一个有2k×2k 个方格的棋盘,其中恰有一个方格残缺。图2 - 3给出k≤2时各种可能的残缺棋盘,其中残缺的方格用阴影表示。注意当k= 0时,仅存在一种可能的残缺棋盘(如图1 4 - 3 a所示)。事实上,对于任意k,恰好存在22k 种不同的残缺棋盘。 残缺棋盘的问题要求用三格板(t r i o m i n o e s)覆盖残缺棋盘(如图1 4 - 4所示)。在此覆盖中,两个三格板不能重叠,三格板不能覆盖残缺方格,但必须覆盖其他所有的方格。在这种限制条件下,所需要的三格板总数为( 22k -1 ) / 3。可以验证( 22k -1 ) / 3是一个整数。k 为0的残缺棋盘很容易被覆盖,因为它没有非残缺的方格,用于覆盖的三格板的数目为0。当k= 1时,正好存在3个非残缺的方格,并且这三个方格可用图1 4 - 4中的某一方向的三格板来覆盖。 用分而治之方法可以很好地解决残缺棋盘问题。这一方法可将覆盖2k×2k 残缺棋盘的问题转化为覆盖较小残缺棋盘的问题。2k×2k 棋盘一个很自然的划分方法就是将它划分为如图1 4 - 5 a所示的4个2k - 1×2k - 1 棋盘。注意到当完成这种划分后, 4个小棋盘中仅仅有一个棋盘存在残缺方格(因为原来的2k×2k 棋盘仅仅有一个残缺方格)。首先覆盖其中包含残缺方格的2k - 1×2k - 1 残缺棋盘,然后把剩下的3个小棋盘转变为残缺棋盘,为此将一个三格板放在由这3个小棋盘形成的角上,如图14-5b 所示,其中原2k×2k 棋盘中的残缺方格落入左上角的2k - 1×2k - 1 棋盘。可以采用这种分割技术递归地覆盖2k×2k 残缺棋盘。当棋盘的大小减为1×1时,递归过程终止。此时1×1的棋盘中仅仅包含一个方格且此方格残缺,所以无需放置三格板。 可以将上述分而治之算法编写成一个递归的C++ 函数Ti l e B o a r d (见程序1 4 - 2 )。该函数定义了一个全局的二维整数数组变量B o a r d来表示棋盘。B o a r d [ 0 ] [ 0 ]表示棋盘中左上角的方格。该函数还定义了一个全局整数变量t i l e,其初始值为0。函数的输入参数如下: ? tr 棋盘中左上角方格所在行。 ? tc 棋盘中左上角方格所在列。 ? dr 残缺方块所在行。 ? dl 残缺方块所在列。 ? size 棋盘的行数或列数。 Ti l e B o a r d函数的调用格式为Ti l e B o a r d(0,0, dr, dc,size),其中s i z e = 2k。覆盖残缺棋盘所需要的三格板数目为( s i z e2 -1 ) / 3。函数TileBoard 用整数1到( s i z e2-1 ) / 3来表示这些三格板,并用三格板的标号来标记被该三格板覆盖的非残缺方格。 令t (k) 为函数Ti l e B o a r d覆盖一个2k×2k 残缺棋盘所需要的时间。当k= 0时,s i z e等于1,覆盖它将花费常数时间d。当k > 0时,将进行4次递归的函数调用,这些调用需花费的时间为4t (k-1 )。除了这些时间外, if 条件测试和覆盖3个非残缺方格也需要时间,假设用常数c 表示这些额外时间。可以得到以下递归表达式: 程序14-2 覆盖残缺棋盘 void TileBoard(int tr, int tc, int dr, int dc, int size) {// 覆盖残缺棋盘 if (size == 1) return; int t = tile++, // 所使用的三格板的数目 s = size/2; // 象限大小 / /覆盖左上象限 if (dr < tr + s && dc < tc + s) // 残缺方格位于本象限 Ti l e B o a r d ( t r, tc, dr, dc, s); else {// 本象限中没有残缺方格 // 把三格板t 放在右下角 Board[tr + s - 1][tc + s - 1] = t; // 覆盖其余部分 Ti l e B o a r d ( t r, tc, tr+s-1, tc+s-1, s);} / /覆盖右上象限 if (dr < tr + s && dc >= tc + s) // 残缺方格位于本象限 Ti l e B o a r d ( t r, tc+s, dr, dc, s); else {// 本象限中没有残缺方格 // 把三格板t 放在左下角 Board[tr + s - 1][tc + s] = t; // 覆盖其余部分 Ti l e B o a r d ( t r, tc+s, tr+s-1, tc+s, s);} / /覆盖左下象限 if (dr >= tr + s && dc < tc + s) // 残缺方格位于本象限 TileBoard(tr+s, tc, dr, dc, s); else {// 把三格板t 放在右上角 Board[tr + s][tc + s - 1] = t; // 覆盖其余部分 TileBoard(tr+s, tc, tr+s, tc+s-1, s);} // 覆盖右下象限 if (dr >= tr + s && dc >= tc + s) // 残缺方格位于本象限 TileBoard(tr+s, tc+s, dr, dc, s); else {// 把三格板t 放在左上角 Board[tr + s][tc + s] = t; // 覆盖其余部分 TileBoard(tr+s, tc+s, tr+s, tc+s, s);} } void OutputBoard(int size) { for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) cout << setw (5) << Board[i][j]; cout << endl; } } 可以用迭代的方法来计算这个表达式(见例2 - 2 0),可得t (k )= ( 4k )= (所需的三格板的数目)。由于必须花费至少( 1 )的时间来放置每一块三格表,因此不可能得到一个比分而治之算法更快的算法。 2.2.2 归并排序 可以运用分而治之方法来解决排序问题,该问题是将n 个元素排成非递减顺序。分而治之方法通常用以下的步骤来进行排序算法:若n 为1,算法终止;否则,将这一元素集合分割成两个或更多个子集合,对每一个子集合分别排序,然后将排好序的子集合归并为一个集合。 假设仅将n 个元素的集合分成两个子集合。现在需要确定如何进行子集合的划分。一种可能性就是把前面n- 1个元素放到第一个子集中(称为A),最后一个元素放到第二个子集里(称为B)。按照这种方式对A递归地进行排序。由于B仅含一个元素,所以它已经排序完毕,在A排完序后,只需要用程序2 - 1 0中的函数i n s e r t将A和B合并起来。把这种排序算法与I n s e r t i o n S o r t(见程序2 - 1 5)进行比较,可以发现这种排序算法实际上就是插入排序的递归算法。该算法的复杂性为O (n 2 )。把n 个元素划分成两个子集合的另一种方法是将含有最大值的元素放入B,剩下的放入A中。然后A被递归排序。为了合并排序后的A和B,只需要将B添加到A中即可。假如用函数M a x(见程序1 - 3 1)来找出最大元素,这种排序算法实际上就是S e l e c t i o n S o r t(见程序2 - 7)的递归算法。 假如用冒泡过程(见程序2 - 8)来寻找最大元素并把它移到最右边的位置,这种排序算法就是B u b b l e S o r t(见程序2 - 9)的递归算法。这两种递归排序算法的复杂性均为(n2 )。若一旦发现A已经被排好序就终止对A进行递归分割,则算法的复杂性为O(n2 )(见例2 - 1 6和2 - 1 7)。 上述分割方案将n 个元素分成两个极不平衡的集合A和B。A有n- 1个元素,而B仅含一个元素。下面来看一看采用平衡分割法会发生什么情况: A集合中含有n/k 个元素,B中包含其余的元素。递归地使用分而治之方法对A和B进行排序。然后采用一个被称之为归并( m e rg e)的过程,将已排好序的A和B合并成一个集合。 例2-5 考虑8个元素,值分别为[ 1 0,4,6,3,8,2,5,7 ]。如果选定k = 2,则[ 1 0 , 4 , 6 , 3 ]和[ 8 , 2 , 5 , 7 ]将被分别独立地排序。结果分别为[ 3 , 4 , 6 , 1 0 ]和[ 2 , 5 , 7 , 8 ]。从两个序列的头部开始归并这两个已排序的序列。元素2比3更小,被移到结果序列;3与5进行比较,3被移入结果序列;4与5比较,4被放入结果序列;5和6比较,.。如果选择k= 4,则序列[ 1 0 , 4 ]和[ 6 , 3 , 8 , 2 , 5 , 7 ]将被排序。排序结果分别为[ 4 , 1 0 ]和[ 2 , 3 , 5 , 6 , 7 , 8 ]。当这两个排好序的序列被归并后,即可得所需要的排序序列。 图2 - 6给出了分而治之排序算法的伪代码。算法中子集合的数目为2,A中含有n/k个元素。 template void sort( T E, int n) { / /对E中的n 个元素进行排序, k为全局变量 if (n >= k) { i = n/k; j = n-i; 令A 包含E中的前i 个元素 令B 包含E中余下的j 个元素 s o r t ( A , i ) ; s o r t ( B , j ) ; m e rge(A,B,E,i,j,); //把A 和B 合并到E } else 使用插入排序算法对E 进行排序 } 图14-6 分而治之排序算法的伪代码 从对归并过程的简略描述中,可以明显地看出归并n个元素所需要的时间为O (n)。设t (n)为分而治之排序算法(如图1 4 - 6所示)在最坏情况下所需花费的时间,则有以下递推公式: 其中c 和d 为常数。当n / k≈n-n / k 时,t (n) 的值最小。因此当k= 2时,也就是说,当两个子集合所包含的元素个数近似相等时, t (n) 最小,即当所划分的子集合大小接近时,分而治之算法通常具有最佳性能。可以用迭代方法来计算这一递推方式,结果为t(n)= (nl o gn)。虽然这个结果是在n为2的幂时得到的,但对于所有的n,这一结果也是有效的,因为t(n) 是n 的非递减函数。t(n) =(nl o gn) 给出了归并排序的最好和最坏情况下的复杂性。由于最好和最坏情况下的复杂性是一样的,因此归并排序的平均复杂性为t (n)= (nl o gn)。图2 - 6中k= 2的排序方法被称为归并排序( m e rge sort ),或更精确地说是二路归并排序(two-way merge sort)。下面根据图1 4 - 6中k= 2的情况(归并排序)来编写对n 个元素进行排序的C + +函数。一种最简单的方法就是将元素存储在链表中(即作为类c h a i n的成员(程序3 -8))。在这种情况下,通过移到第n/ 2个节点并打断此链,可将E分成两个大致相等的链表。归并过程应能将两个已排序的链表归并在一起。如果希望把所得到C + +程序与堆排序和插入排序进行性能比较,那么就不能使用链表来实现归并排序,因为后两种排序方法中都没有使用链表。为了能与前面讨论过的排序函数作比较,归并排序函数必须用一个数组a来存储元素集合E,并在a 中返回排序后的元素序列。为此按照下述过程来对图1 4 - 6的伪代码进行细化:当集合E被化分成两个子集合时,可以不必把两个子集合的元素分别复制到A和B中,只需简单地在集合E中保持两个子集合的左右边界即可。接下来对a 中的初始序列进行排序,并将所得到的排序序列归并到一个新数组b中,最后将它们复制到a 中。图1 4 - 6的改进版见图1 4 - 7。 template M e rgeSort( T a[], int left, int right) { / /对a [ l e f t : r i g h t ]中的元素进行排序 if (left < right) {//至少两个元素 int i = (left + right)/2; //中心位置 M e rgeSort(a, left, i); M e rgeSort(a, i+1, right); M e rge(a, b, left, i, right); //从a 合并到b Copy(b, a, left, right); //结果放回a } } 图14-7 分而治之排序算法的改进 可以从很多方面来改进图1 4 - 7的性能,例如,可以容易地消除递归。如果仔细地检查图1 4 - 7中的程序,就会发现其中的递归只是简单地重复分割元素序列,直到序列的长度变成1为止。当序列的长度变为1时即可进行归并操作,这个过程可以用n 为2的幂来很好地描述。长度为1的序列被归并为长度为2的有序序列;长度为2的序列接着被归并为长度为4的有序序列;这个过程不断地重复直到归并为长度为n 的序列。图1 4 - 8给出n= 8时的归并(和复制)过程,方括号表示一个已排序序列的首和尾。 初始序列[8] [4] [5] [6] [2] [1] [7] [3] 归并到b [4 8] [5 6] [1 2] [3 7] 复制到a [4 8] [5 6] [1 2] [3 7] 归并到b [4 5 6 8] [1 2 3 7] 复制到a [4 5 6 8] [1 2 3 7] 归并到b [1 2 3 4 5 6 7 8] 复制到a [1 2 3 4 5 6 7 8] 图14-8 归并排序的例子 另一种二路归并排序算法是这样的:首先将每两个相邻的大小为1的子序列归并,然后对上一次归并所得到的大小为2的子序列进行相邻归并,如此反复,直至最后归并到一个序列,归并过程完成。通过轮流地将元素从a 归并到b 并从b 归并到a,可以虚拟地消除复制过程。二路归并排序算法见程序1 4 - 3。 程序14-3 二路归并排序 template void MergeSort(T a[], int n) {// 使用归并排序算法对a[0:n-1] 进行排序 T *b = new T [n]; int s = 1; // 段的大小 while (s < n) { MergePass(a, b, s, n); // 从a归并到b s += s; MergePass(b, a, s, n); // 从b 归并到a s += s; } } 为了完成排序代码,首先需要完成函数M e rg e P a s s。函数M e rg e P a s s(见程序1 4 - 4)仅用来确定欲归并子序列的左端和右端,实际的归并工作由函数M e rg e (见程序1 4 - 5 )来完成。函数M e rg e要求针对类型T定义一个操作符< =。如果需要排序的数据类型是用户自定义类型,则必须重载操作符< =。这种设计方法允许我们按元素的任一个域进行排序。重载操作符< =的目的是用来比较需要排序的域。 程序14-4 MergePass函数 template void MergePass(T x[], T y[], int s, int n) {// 归并大小为s的相邻段 int i = 0; while (i <= n - 2 * s) { // 归并两个大小为s的相邻段 Merge(x, y, i, i+s-1, i+2*s-1); i = i + 2 * s; } // 剩下不足2个元素 if (i + s < n) Merge(x, y, i, i+s-1, n-1); else for (int j = i; j <= n-1; j++) // 把最后一段复制到y y[j] = x[j]; } 程序14-5 Merge函数 template void Merge(T c[], T d[], int l, int m, int r) {// 把c[l:m]] 和c[m:r] 归并到d [ l : r ] . int i = l, // 第一段的游标 j = m+1, // 第二段的游标 k = l; // 结果的游标 / /只要在段中存在i和j,则不断进行归并 while ((i <= m) && (j <= r)) if (c[i] <= c[j]) d[k++] = c[i++]; else d[k++] = c[j++]; // 考虑余下的部分 if (i > m) for (int q = j; q <= r; q++) d[k++] = c[q]; else for (int q = i; q <= m; q++) d[k++] = c[q]; } 自然归并排序(natural merge sort)是基本归并排序(见程序1 4 - 3)的一种变化。它首先对输入序列中已经存在的有序子序列进行归并。例如,元素序列[ 4,8,3,7,1,5,6,2 ]中包含有序的子序列[ 4,8 ],[ 3,7 ],[ 1,5,6 ]和[ 2 ],这些子序列是按从左至右的顺序对元素表进行扫描而产生的,若位置i 的元素比位置i+ 1的元素大,则从位置i 进行分割。对于上面这个元素序列,可找到四个子序列,子序列1和子序列2归并可得[ 3 , 4 , 7 , 8 ],子序列3和子序列4归并可得[ 1 , 2 , 5 , 6 ],最后,归并这两个子序列得到[ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ]。因此,对于上述元素序列,仅仅使用了两趟归并,而程序1 4 - 3从大小为1的子序列开始,需使用三趟归并。作为一个极端的例子,假设输入的元素序列已经排好序并有n个元素,自然归并排序法将准确地识别该序列不必进行归并排序,但程序1 4 - 3仍需要进行[ l o g2 n] 趟归并。因此自然归并排序将在(n) 的时间内完成排序。而程序1 4 - 3将花费(n l o gn) 的时间。 2.2.3 快速排序 分而治之方法还可以用于实现另一种完全不同的排序方法,这种排序法称为快速排序(quick sort)。在这种方法中, n 个元素被分成三段(组):左段l e f t,右段r i g h t和中段m i d d l e。中段仅包含一个元素。左段中各元素都小于等于中段元素,右段中各元素都大于等于中段元素。因此l e f t和r i g h t中的元素可以独立排序,并且不必对l e f t和r i g h t的排序结果进行合并。m i d d l e中的元素被称为支点( p i v o t )。图1 4 - 9中给出了快速排序的伪代码。 / /使用快速排序方法对a[ 0 :n- 1 ]排序 从a[ 0 :n- 1 ]中选择一个元素作为m i d d l e,该元素为支点把余下的元素分割为两段left 和r i g h t,使得l e f t中的元素都小于等于支点,而right 中的元素都大于等于支点 递归地使用快速排序方法对left 进行排序 递归地使用快速排序方法对right 进行排序 所得结果为l e f t + m i d d l e + r i g h t 图14-9 快速排序的伪代码 考察元素序列[ 4 , 8 , 3 , 7 , 1 , 5 , 6 , 2 ]。假设选择元素6作为支点,则6位于m i d d l e;4,3,1,5,2位于l e f t;8,7位于r i g h t。当left 排好序后,所得结果为1,2,3,4,5;当r i g h t排好序后,所得结果为7,8。把right 中的元素放在支点元素之后, l e f t中的元素放在支点元素之前,即可得到最终的结果[ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ]。把元素序列划分为l e f t、m i d d l e和r i g h t可以就地进行(见程序1 4 - 6)。在程序1 4 - 6中,支点总是取位置1中的元素。也可以采用其他选择方式来提高排序性能,本章稍后部分将给出这样一种选择。 程序14-6 快速排序 template void QuickSort(T*a, int n) {// 对a[0:n-1] 进行快速排序 {// 要求a[n] 必需有最大关键值 quickSort(a, 0, n-1); template void quickSort(T a[], int l, int r) {// 排序a [ l : r ], a[r+1] 有大值 if (l >= r) return; int i = l, // 从左至右的游标 j = r + 1; // 从右到左的游标 T pivot = a[l]; // 把左侧>= pivot的元素与右侧<= pivot 的元素进行交换 while (true) { do {// 在左侧寻找>= pivot 的元素 i = i + 1; } while (a[i] < pivot); do {// 在右侧寻找<= pivot 的元素 j = j - 1; } while (a[j] > pivot); if (i >= j) break; // 未发现交换对象 Swap(a[i], a[j]); } // 设置p i v o t a[l] = a[j]; a[j] = pivot; quickSort(a, l, j-1); // 对左段排序 quickSort(a, j+1, r); // 对右段排序 } 若把程序1 4 - 6中d o - w h i l e条件内的<号和>号分别修改为< =和> =,程序1 4 - 6仍然正确。实验结果表明使用程序1 4 - 6的快速排序代码可以得到比较好的平均性能。为了消除程序中的递归,必须引入堆栈。不过,消除最后一个递归调用不须使用堆栈。消除递归调用的工作留作练习(练习1 3)。程序1 4 - 6所需要的递归栈空间为O (n)。若使用堆栈来模拟递归,则可以把这个空间减少为O ( l o gn)。在模拟过程中,首先对left 和right 中较小者进行排序,把较大者的边界放入堆栈中。在最坏情况下l e f t总是为空,快速排序所需的计算时间为(n2 )。在最好情况下, l e f t和r i g h t中的元素数目大致相同,快速排序的复杂性为(nl o gn)。令人吃惊的是,快速排序的平均复杂性也是(nl o gn)。 定理2-1 快速排序的平均复杂性为(nl o gn)。 证明用t (n) 代表对含有n 个元素的数组进行排序的平均时间。当n≤1时,t (n)≤d,d为某一常数。当n <1时,用s 表示左段所含元素的个数。由于在中段中有一个支点元素,因此右段中元素的个数为n-s- 1。所以左段和右段的平均排序时间分别为t (s), t (n-s- 1 )。分割数组中元素所需要的时间用cn 表示,其中c 是一个常数。因为s 有同等机会取0 ~n- 1中的任何一个值. 如对(2 - 8)式中的n 使用归纳法,可得到t (n)≤kn l o ge n,其中n> 1且k=2(c+d),e~2 . 7 1 8为自然对数的基底。在归纳开始时首先验证n= 2时公式的正确性。根据公式( 1 4 - 8),可以得到t( 2 )≤2c+ 2d≤k nl o ge 2。在归纳假设部分,假定t(n)≤kn l o ge n(当2≤n<m 时,m 是任意一个比2大的整数=. 图1 4 - 1 0对本书中所讨论的算法在平均条件下和最坏条件下的复杂性进行了比较。 方法最坏复杂性平均复杂性 冒泡排序n2 n2 计数排序n2 n2 插入排序n2 n2 选择排序n2 n2 堆排序nl o gn nl o gn 归并排序nl o gn nl o gn 快速排序n2 nl o gn 图14-10 各种排序算法的比较 中值快速排序( median-of-three quick sort)是程序1 4 - 6的一种变化,这种算法有更好的平均性能。注意到在程序1 4 - 6中总是选择a [ 1 ]做为支点,而在这种快速排序算法中,可以不必使用a [ 1 ]做为支点,而是取{a[1],a[(1+r)/2],a[r]} 中大小居中的那个元素作为支点。例如,假如有三个元素,大小分别为5,9,7,那么取7为支点。为了实现中值快速排序算法,一种最简单的方式就是首先选出中值元素并与a[1] 进行交换,然后利用程序1 4 - 6完成排序。如果a [ r ]是被选出的中值元素,那么将a[1] 与a[r] 进行交换,然后将a [ 1 ](即原来的a [ r ])赋值给程序1 4 - 6中的变量p i v o t,之后继续执行程序1 4 - 6中的其余代码。 图2 - 11中分别给出了根据实验所得到的归并排序、堆排序、插入排序、快速排序的平均时间。对于每一个不同的n, 都随机产生了至少1 0 0组整数。随机整数的产生是通过反复调用s t d l i b . h库中的r a n d o m函数来实现的。如果对一组整数进行排序的时间少于1 0个时钟滴答,则继续对其他组整数进行排序,直到所用的时间不低于1 0个时钟滴答。在图2 - 11中的数据包含产生随机整数的时间。对于每一个n,在各种排序法中用于产生随机整数及其他开销的时间是相同的。因此,图2 - 11中的数据对于比较各种排序算法是很有用的。 对于足够大的n,快速排序算法要比其他算法效率更高。从图中可以看到快速排序曲线与插入排序曲线的交点横坐标比2 0略小,可通过实验来确定这个交点横坐标的精确值。可以分别用n = 1 5 , 1 6 , 1 7 , 1 8 , 1 9进行实验,以寻找精确的交点。令精确的交点横坐标为nBr e a k。当n≤nBreak 时,插入排序的平均性能最佳。当n>nBreak 时,快速排序性能最佳。当n>nBreak 时,把插入排序与快速排序组合为一个排序函数,可以提高快速排序的性能,实现方法是把程序1 4 - 6中的以下语句: if(l >= r)r e t u r n ; 替换为 if (r-1 这里I n s e r t i o n S o r t ( a , l , r )用来对a [ 1 : r ]进行插入排序。测量修改后的快速排序算法的性能留作练习(练习2 0)。用更小的值替换n B r e a k有可能使性能进一步提高(见练习2 0)。 大多数实验表明,当n>c时(c为某一常数),在最坏情况下归并排序的性能也是最佳的。而当n≤c时,在最坏情况下插入排序的性能最佳。通过将插入排序与归并排序混合使用,可以提高归并排序的性能(练习2 1)。 2.2.4 选择 对于给定的n 个元素的数组a [ 0 : n - 1 ],要求从中找出第k小的元素。当a [ 0 : n - 1 ]被排序时,该元素就是a [ k - 1 ]。假设n = 8,每个元素有两个域k e y和I D,其中k e y是一个整数,I D是一个字符。假设这8个元素为[ ( 1 2 ,a),( 4 ,b),( 5 ,c),( 4 ,d),( 5 ,e),( 1 0 ,f),( 2 ,g),( 2 0 ,h)], 排序后得到数组[ ( 2 ,g),( 4 ,d),( 4 ,b),( 5 ,c),( 5 ,e),( 1 0 ,f),( 1 2 ,a),( 2 0 ,h) ]。如果k = 1,返回I D为g 的元素;如果k = 8,返回I D为h 的元素;如果k = 6,返回是I D为f 的元素;如果k = 2,返回I D为d 的元素。实际上,对最后一种情况,所得到的结果可能不唯一,因为排序过程中既可能将I D为d 的元素排在a [ 1 ],也可能将I D为b 的元素排在a [ 1 ],原因是它们具有相同大小的k e y,因而两个元素中的任何一个都有可能被返回。但是无论如何,如果一个元素在k = 2时被返回,另一个就必须在k = 3时被返回。 选择问题的一个应用就是寻找中值元素,此时k = [n / 2 ]。中值是一个很有用的统计量,例如中间工资,中间年龄,中间重量。其他k值也是有用的。例如,通过寻找第n / 4 , n / 2和3 n / 4这三个元素,可将人口划分为4份。 选择问题可在O ( n l o g n )时间内解决,方法是首先对这n个元素进行排序(如使用堆排序式或归并排序),然后取出a [ k - 1 ]中的元素。若使用快速排序(如图1 4 - 11所示),可以获得更好的平均性能,尽管该算法有一个比较差的渐近复杂性O( n2 )。 可以通过修写程序1 4 - 6来解决选择问题。如果在执行两个w h i l e循环后支点元素a [ l ]被交换到a [ j ] ,那么a [ l ]是a [ l : j ]中的第j - l + 1个元素。如果要寻找的第k 个元素在a [ l : r ]中,并且j - l + 1等于k,则答案就是a [ l ];如果j - l + 1 < k,那么寻找的元素是r i g h t中的第k - j + l - 1个元素,否则要寻找的元素是left 中的第k个元素。因此,只需进行0次或1次递归调用。新代码见程序1 4 - 7。S e l e c t中的递归调用可用f o r或w h i l e循环来替代(练习2 5)。 程序14-7 寻找第k 个元素 template T Select(T a[], int n, int k) {// 返回a [ 0 : n - 1 ]中第k小的元素 // 假定a[n] 是一个伪最大元素 if (k < 1 || k > n) throw OutOfBounds(); return select(a, 0, n-1, k); } template T select(T a[], int l, int r, int k) {// 在a [ l : r ]中选择第k小的元素 if (l >= r) return a[l]; int i = l, // 从左至右的游标 j = r + 1; // 从右到左的游标 T pivot = a[l]; // 把左侧>= pivot的元素与右侧<= pivot 的元素进行交换 while (true) { do {// 在左侧寻找>= pivot 的元素 i = i + 1; } while (a[i] < pivot); do {// 在右侧寻找<= pivot 的元素 j = j - 1; } while (a[j] > pivot); if (i >= j) break; // 未发现交换对象 Swap(a[i], a[j]); } if (j - l + 1 == k) return pivot; // 设置p i v o t a[l] = a[j]; a[j] = pivot; // 对一个段进行递归调用 if (j - l + 1 < k) return select(a, j+1, r, k-j+l-1); else return select(a, l, j-1, k); } 程序1 4 - 7在最坏情况下的复杂性是( n2 ),此时left 总是为空,而且第k个元素总是位于r i g h t. 如果假定n 是2的幂,则可以取消公式(2 - 1 0)中的向下取整操作符。通过使用迭代方法,可以得到t (n) = (n)。若仔细地选择支点元素,则最坏情况下的时间开销也可以变成(n)。一种选择支点元素的方法是使用“中间的中间( m e d i a n - o f - m e d i a n)”规则,该规则首先将数组a中的n 个元素分成n/r 组,r 为某一整常数,除了最后一组外,每组都有r 个元素。然后通过在每组中对r 个元素进行排序来寻找每组中位于中间位置的元素。最后根据所得到的n/r 个中间元素,递归使用选择算法,求得所需要的支点元素。 例2-6 [中间的中间] 考察如下情形:r=5, n=27, 并且a= [ 2,6,8,1,4,1 0,2 0,6,2 2,11,9,8,4,3,7,8,1 6,11,1 0,8,2,1 4,1 5,1,1 2,5,4 ]。这2 7个元素可以被分为6组[ 2 , 6 , 8 , 1 , 4 ],[ 1 0 , 2 0 , 6 , 2 2 , 11 ],[ 9 , 8 , 4 , 3 , 7 ],[ 8 , 1 6 , 11 , 1 0 , 8 ],[ 2 , 1 4 , 1 5 , 1 , 1 2 ]和[ 5 , 4 ],每组的中间元素分别为4 , 11 , 7 , 1 0 , 1 2和4。[ 4 , 11 , 7 , 1 0 , 1 2 , 4 ]的中间元素为7。这个中间元素7被取为支点元素。由此可以得到l e ft= [ 2 , 6 , 1 , 4 , 6 , 4 , 3 , 2 , 1 , 5 , 4 ],m i d d l e= [ 7 ] ,r i g h t= [ 8 , 1 0 , 2 0 , 2 2 , 11 , 9 , 8 , 8 , 1 6 , 11 , 1 0 , 8 , 1 4 , 1 5 , 1 2 ]。 如果要寻找第k个元素且k< 1 2,则仅仅需要在l e f t中寻找;如果k= 1 2,则要找的元素就是支点元素;如果k> 1 2,则需要检查r i g h t中的1 5个元素。在最后一种情况下,需在r i g h t中寻找第(k- 1 2 )个元素。 定理2-2 当按“中间的中间”规则选取支点元素时,以下结论为真: 1) 若r=9, 那么当n≥9 0时,有m a x { |l e f e|, |r i g h t| }≤7n / 8。 2) 若r= 5,且a 中所有元素都不同,那么当n≥2 4时,有max{| left |, | right | }≤3n/ 4。 证明这个定理的证明留作练习2 3。 根据定理2 - 2和程序1 4 - 7可知,如果采用“中间的中间”规则并取r= 9,则用于寻找第k个元素的时间t (n)可按如下递归公式来计算: 在上述递归公式中,假设当n<9 0时使用复杂性为nl o gn的求解算法,当n≥9 0时,采用“中间的中间”规则进行分而治之求解。利用归纳法可以证明,当n≥1时有t (n)≤7 2cn (练习2 4 )。 当元素互不相同时,可以使用r= 5来得到线性时间性能。 2.2.5 距离最近的点对 给定n 个点(xi,yi)(1≤i≤n),要求找出其中距离最近的两个点。 例14-7 假设在一片金属上钻n 个大小一样的洞,如果洞太近,金属可能会断。若知道任意两个洞的最小距离,可估计金属断裂的概率。这种最小距离问题实际上也就是距离最近的点对问题。 通过检查所有的n(n- 1 ) / 2对点,并计算每一对点的距离,可以找出距离最近的一对点。这种方法所需要的时间为(n2 )。我们称这种方法为直接方法。图1 4 - 1 3中给出了分而治之求解算法的伪代码。该算法对于小的问题采用直接方法求解,而对于大的问题则首先把它划分为两个较小的问题,其中一个问题(称为A)的大小为「n /2ù,另一个问题(称为B)的大小为「n /2ù。初始时,最近的点对可能属于如下三种情形之一: 1) 两点都在A中(即最近的点对落在A中);2) 两点都在B中;3) 一点在A,一点在B。假定根据这三种情况来确定最近点对,则最近点对是所有三种情况中距离最小的一对点。在第一种情况下可对A进行递归求解,而在第二种情况下可对B进行递归求解。 if (n较小) {用直接法寻找最近点对 R e t u r n ; } // n较大 将点集分成大致相等的两个部分A和B 确定A和B中的最近点对 确定一点在A中、另一点在B中的最近点对 从上面得到的三对点中,找出距离最小的一对点 图14-13 寻找最近的点对 为了确定第三种情况下的最近点对,需要采用一种不同的方法。这种方法取决于点集是如何被划分成A、B的。一个合理的划分方法是从xi(中间值)处划一条垂线,线左边的点属于A,线右边的点属于B。位于垂线上的点可在A和B之间分配,以便满足A、B的大小。例2-8 考察图14-14a 中从a到n的1 4个点。这些点标绘在图14-14b 中。中点xi = 1,垂线x = 1如图14-14b 中的虚线所示。虚线左边的点(如b, c, h, n, i)属于A,右边的点(如a, e, f, j, k, l) 属于B。d, g, m 落在垂线上,可将其中两个加入A, 另一个加入B,以便A、B中包含相同的点数。假设d ,m加入A,g加入B。设是i 的最近点对和B的最近点对中距离较小的一对点。若第三种情况下的最近点对比小。则每一个点距垂线的距离必小于,这样,就可以淘汰那些距垂线距离≥ 的点。图1 4 - 1 5中的虚线是分割线。阴影部分以分割线为中线,宽为2 。边界线及其以外的点均被淘汰掉,只有阴影中的点被保留下来,以便确定是否存在第三类点对(对应于第三种情况)其距离小于。用RA、RB 分别表示A和B中剩下的点。如果存在点对(p,q),p?A, q?B且p, q 的距离小于,则p?RA,q?RB。可以通过每次检查RA 中一个点来寻找这样的点对。假设考察RA 中的p 点,p的y 坐标为p.y,那么只需检查RB 中满足p.y- <q.y<p.y+ 的q 点,看是否存在与p 间距小于的点。在图14-16a 中给出了包含这种q 点的RB 的范围。因此,只需将RB 中位于×2 阴影内的点逐个与p 配对,以判断p 是否是距离小于的第三类点。这个×2 区域被称为是p 的比较区(comparing region)。 例2-9 考察例2 - 8中的1 4个点。A中的最近点对为(b,h),其距离约为0 . 3 1 6。B中最近点对为(f, j),其距离为0 . 3,因此= 0 . 3。当考察是否存在第三类点时,除d, g, i, l, m 以外的点均被淘汰,因为它们距分割线x= 1的距离≥ 。RA ={d, i, m},RB= {g, l},由于d 和m 的比较区中没有点,只需考察i即可。i 的比较区中仅含点l。计算i 和l的距离,发现它小于,因此(i, l) 是最近的点对。 为了确定一个距离更小的第三类点,RA 中的每个点最多只需和RB 中的6个点比较,如图1 4 - 1 6所示。 1. 选择数据结构 为了实现图1 4 - 1 3的分而治之算法,需要确定什么是“小问题”以及如何表示点。由于集合中少于两点时不存在最近点对,因此必须保证分解过程不会产生少于两点的点集。如果将少于四点的点集做为“小问题”,就可以避免产生少于两点的点集。 每个点可有三个参数:标号, x 坐标,y 坐标。假设标号为整数,每个点可用P o i n t l类(见程序1 4 - 8)来表示。为了便于按x 坐标对各个点排序,可重载操作符<=。归并排序程序如1 4 -3所示。 程序14-8 点类 class Point1 { friend float dist(const Point1&, const Point1&); friend void close(Point1 *, Point2 *, Point2 *, int, int, Point1&, Point1&, float&); friend bool closest(Point1 *, int, Point1&, Point1&,float&); friend void main(); p u b l i c : int operator<=(Point1 a) const {return (x <= a.x);} p r i v a t e : int ID; // 点的编号 float x, y; // 点坐标 } ; class Point2 { friend float dist(const Point2&, const Point2&); friend void close(Point1 *, Point2 *, Point2 *, int, int, Point1&, Point1&, float&); friend bool closest(Point1 *, int, Point1&, Point1&, float&); friend void main(); p u b l i c : int operator<=(Point2 a) const {return (y <= a.y);} p r i v a t e : int p; // 数组X中相同点的索引 float x, y; // 点坐标 } ; 所输入的n 个点可以用数组X来表示。假设X中的点已按照x 坐标排序,在分割过程中如果当前考察的点是X [l :r],那么首先计算m= (l+r) / 2,X[ l:m]中的点属于A,剩下的点属于B。计算出A和B中的最近点对之后,还需要计算RA 和RB,然后确定是否存在更近的点对,其中一点属于RA,另一点属于RB。如果点已按y 坐标排序,那么可以用一种很简单的方式来测试图1 4 - 1 6。按y 坐标排序的点保存在另一个使用类P o i n t 2 (见程序14-8) 的数组中。注意到在P o i n t 2类中,为了便于y 坐标排序,已重载了操作符<=。成员p 用于指向X中的对应点。 确定了必要的数据结构之后,再来看看所要产生的代码。首先定义一个模板函数d i s t (见程序1 4 - 9 )来计算点a, b 之间的距离。T可能是P o i n t 1或P o i n t 2,因此d i s t必须是P o i n t 1和P o i n t 2类的友元。 程序14-9 计算两点距离 template inline float dist(const T& u, const T& v) { / /计算点u 和v之间的距离 float dx = u.x-v. x ; float dy = u.y-v. y ; return sqrt(dx * dx + dy * dy); } 如果点的数目少于两个,则函数c l o s e s t (见程序1 4 - 1 0 )返回f a l s e,如果成功时函数返回t r u e。当函数成功时,在参数a 和b 中返回距离最近的两个点,在参数d 中返回距离。代码首先验证至少存在两点,然后使用M e rg e S o r t函数(见程序14-3) 按x 坐标对X中的点排序。接下来把这些点复制到数组Y中并按y 坐标进行排序。排序完成时,对任一个i,有Y [i ] . y≤Y [i+ 1 ] . y,并且Y [i ] .p给出了点i 在X中的位置。上述准备工作做完以后,调用函数close (见程序1 4 - 11 ),该函数实际求解最近点对。 程序14-10 预处理及调用c l o s e bool closest(Point1 X[], int n, Point1& a, Point1& b, float& d) {// 在n >= 2 个点中寻找最近点对 // 如果少于2个点,则返回f a l s e // 否则,在a 和b中返回距离最近的两个点 if (n < 2) return false; // 按x坐标排序 M e r g e S o r t ( X , n ) ; // 创建一个按y坐标排序的点数组 Point2 *Y = new Point2 [n]; for (int i = 0; i < n; i++) { // 将点i 从X 复制到Y Y[i].p = i; Y[i].x = X[i].x; Y[i].y = X[i].y; } M e r g e S o r t ( Y,n); // 按y坐标排序 // 创建临时数组 Point2 *Z = new Point2 [n]; // 寻找最近点对 c l o s e ( X , Y, Z , 0 , n - 1 , a , b , d ) ; // 删除数组并返回 delete [] Y; delete [] Z; return true; } 程序1 4 - 11 计算最近点对 void close(Point1 X[], Point2 Y[], Point2 Z[], int l, int r, Point1& a, Point1& b, float& d) {//X[l:r] 按x坐标排序 //Y[l:r] 按y坐标排序 if (r-l == 1) {// 两个点 a = X[l]; b = X[r]; d = dist(X[l], X[r]); r e t u r n ; } if (r-l == 2) {// 三个点 // 计算所有点对之间的距离 float d1 = dist(X[l], X[l+1]); float d2 = dist(X[l+1], X[r]); float d3 = dist(X[l], X[r]); // 寻找最近点对 if (d1 <= d2 && d1 <= d3) { a = X[l]; b = X[l+1]; d = d1; r e t u r n ; } if (d2 <= d3) {a = X[l+1]; b = X[r]; d = d2;} else {a = X[l]; b = X[r]; d = d3;} r e t u r n ; } / /多于三个点,划分为两部分 int m = (l+r)/2; // X[l:m] 在A中,余下的在B中 // 在Z[l:m] 和Z [ m + 1 : r ]中创建按y排序的表 int f = l, // Z[l:m]的游标 g = m+1; // Z[m+1:r]的游标 for (int i = l; i <= r; i++) if (Y[i].p > m) Z[g++] = Y[i]; else Z[f++] = Y[i]; // 对以上两个部分进行求解 c l o s e ( X , Z , Y, l , m , a , b , d ) ; float dr; Point1 ar, br; c l o s e ( X , Z , Y, m + 1 , r, a r, b r, d r ) ; // (a,b) 是两者中较近的点对 if (dr < d) {a = ar; b = br; d = dr;} M e r g e ( Z , Y,l,m,r);// 重构Y / /距离小于d的点放入Z int k = l; // Z的游标 for (i = l; i <= r; i++) if (fabs(Y[m].x - Y[i].x) < d) Z[k++] = Y[i]; // 通过检查Z [ l : k - 1 ]中的所有点对,寻找较近的点对 for (i = l; i < k; i++){ for (int j = i+1; j < k && Z[j].y - Z[i].y < d; j + + ) { float dp = dist(Z[i], Z[j]); if (dp < d) {// 较近的点对 d = dp; a = X[Z[i].p]; b = X[Z[j].p];} } } } 函数c l o s e(见程序1 4 - 11)用来确定X[1:r] 中的最近点对。假定这些点按x 坐标排序。在Y [ 1 : r ]中对这些点按y 坐标排序。Z[ 1 : r ]用来存放中间结果。找到最近点对以后,将在a, b中返回最近点对,在d 中返回距离,数组Y被恢复为输入状态。函数并未修改数组X。 首先考察“小问题”,即少于四个点的点集。因为分割过程不会产生少于两点的数组,因此只需要处理两点和三点的情形。对于这两种情形,可以尝试所有的可能性。当点数超过三个时,通过计算m = ( 1 + r ) / 2把点集分为两组A和B,X [ 1 : m ]属于A,X [ m + 1 : r ]属于B。通过从左至右扫描Y中的点以及确定哪些点属于A,哪些点属于B,可以创建分别与A组和B组对应的,按y 坐标排序的Z [ 1 : m ]和Z [ m + 1 : r ]。此时Y和Z的角色互相交换,依次执行两个递归调用来获取A和B中的最近点对。在两次递归调用返回后,必须保证Z不发生改变,但对Y则无此要求。不过,仅Y [ l : r ]可能会发生改变。通过合并操作(见程序1 4 - 5)可以以Z [ 1 : r ]重构Y [ 1 : r ]。为实现图1 4 - 1 6的策略,首先扫描Y [ 1 : r ],并收集距分割线小于的点,将这些点存放在Z [ 1 : k - 1 ]中。可按如下两种方式来把RA中点p 与p 的比较区内的所有点进行配对:1) 与RB 中y 坐标≥p.y 的点配对;2) 与y 坐标≤p.y 的点配对。这可以通过将每个点Z [ i ](1≤i < k,不管该点是在RA还是在RB中)与Z[j] 配对来实现,其中i<j 且Z [ j ] . y - Z [ i ] . y< 。对每一个Z [ i ],在2 × 区域内所检查的点如图1 4 - 1 7所示。由于在每个2 × 子区域内的点至少相距。因此每一个子区域中的点数不会超过四个,所以与Z [ i ]配对的点Z [ j ]最多有七个。 2. 复杂性分析 令t (n) 代表处理n 个点时,函数close 所需要的时间。当n<4时,t (n) 等于某个常数d。当n≥4时,需花费(n) 时间来完成以下工作:将点集划分为两个部分,两次递归调用后重构Y,淘汰距分割线很远的点,寻找更好的第三类点对。两次递归调用需分别耗时t (「n /2ù」和t (?n /2?). 这个递归式与归并排序的递归式完全一样,其结果为t (n) = (nl o gn)。另外,函数c l o s e s t还需耗时(nl o gn)来完成如下额外工作:对X进行排序,创建Y和Z,对Y进行排序。因此分而治之最近点对求解算法的时间复杂性为(nl o gn)。 第 3 章 动态规划 动态规划是本书介绍的五种算法设计方法中难度最大的一种,它建立在最优原则的基础上。采用动态规划方法,可以优雅而高效地解决许多用贪婪算法或分而治之算法无法解决的问题。在介绍动态规划的原理之后,本章将分别考察动态规划方法在解决背包问题、图象压缩、矩阵乘法链、最短路径、无交叉子集和元件折叠等方面的应用。 3.1 算法思想 和贪婪算法一样,在动态规划中,可将一个问题的解决方案视为一系列决策的结果。不同的是,在贪婪算法中,每采用一次贪婪准则便做出一个不可撤回的决策,而在动态规划中,还要考察每个最优决策序列中是否包含一个最优子序列。 例3-1 [最短路经] 考察图1 2 - 2中的有向图。假设要寻找一条从源节点s= 1到目的节点d= 5的最短路径,即选择此路径所经过的各个节点。第一步可选择节点2,3或4。假设选择了节点3,则此时所要求解的问题变成:选择一条从3到5的最短路径。如果3到5的路径不是最短的,则从1开始经过3和5的路径也不会是最短的。例如,若选择的子路径(非最短路径)是3,2,5 (耗费为9 ),则1到5的路径为1,3,2,5 (耗费为11 ),这比选择最短子路径3,4,5而得到的1到5的路径1,3,4,5 (耗费为9) 耗费更大。所以在最短路径问题中,假如在的第一次决策时到达了某个节点v,那么不管v 是怎样确定的,此后选择从v 到d 的路径时,都必须采用最优策略。 例3-2 [0/1背包问题] 考察1 3 . 4节的0 / 1背包问题。如前所述,在该问题中需要决定x1 .. xn的值。假设按i = 1,2,.,n 的次序来确定xi 的值。如果置x1 = 0,则问题转变为相对于其余物品(即物品2,3,.,n),背包容量仍为c 的背包问题。若置x1 = 1,问题就变为关于最大背包容量为c-w1 的问题。现设r?{c,c-w1 } 为剩余的背包容量。在第一次决策之后,剩下的问题便是考虑背包容量为r 时的决策。不管x1 是0或是1,[x2 ,.,xn ] 必须是第一次决策之后的一个最优方案,如果不是,则会有一个更好的方案[y2,.,yn ],因而[x1,y2,.,yn ]是一个更好的方案。假设n=3, w=[100,14,10], p=[20,18,15], c= 11 6。若设x1 = 1,则在本次决策之后,可用的背包容量为r= 116-100=16 。[x2,x3 ]=[0,1] 符合容量限制的条件,所得值为1 5,但因为[x2,x3 ]= [1,0] 同样符合容量条件且所得值为1 8,因此[x2,x3 ] = [ 0,1] 并非最优策略。即x= [ 1,0,1] 可改进为x= [ 1,1,0 ]。若设x1 = 0,则对于剩下的两种物品而言,容量限制条件为11 6。总之,如果子问题的结果[x2,x3 ]不是剩余情况下的一个最优解,则[x1,x2,x3 ]也不会是总体的最优解。 例3-3 [航费] 某航线价格表为:从亚特兰大到纽约或芝加哥,或从洛杉矶到亚特兰大的费用为$ 1 0 0;从芝加哥到纽约票价$ 2 0;而对于路经亚特兰大的旅客,从亚特兰大到芝加哥的费用仅为$ 2 0。从洛杉矶到纽约的航线涉及到对中转机场的选择。如果问题状态的形式为(起点,终点),那么在选择从洛杉矶到亚特兰大后,问题的状态变为(亚特兰大,纽约)。从亚特兰大到纽约的最便宜航线是从亚特兰大直飞纽约,票价$ 1 0 0。而使用直飞方式时,从洛杉矶到纽约的花费为$ 2 0 0。不过,从洛杉矶到纽约的最便宜航线为洛杉矶-亚特兰大-芝加哥-纽约,其总花费为$ 1 4 0(在处理局部最优路径亚特兰大到纽约过程中选择了最低花费的路径:亚特兰大-芝加哥-纽约)。如果用三维数组(t a g,起点,终点)表示问题状态,其中t a g为0表示转飞, t a g为1表示其他情形,那么在到达亚特兰大后,状态的三维数组将变为( 0,亚特兰大,纽约),它对应的最优路径是经由芝加哥的那条路径。当最优决策序列中包含最优决策子序列时,可建立动态规划递归方程( d y n a m i c -programming recurrence equation),它可以帮助我们高效地解决问题。 例3-4 [0/1背包] 在例3 - 2的0 / 1背包问题中,最优决策序列由最优决策子序列组成。假设f (i,y) 表示例1 5 - 2中剩余容量为y,剩余物品为i,i + 1,.,n 时的最优解的值,即:和利用最优序列由最优子序列构成的结论,可得到f 的递归式。f ( 1 ,c) 是初始时背包问题的最优解。可使用( 1 5 - 2)式通过递归或迭代来求解f ( 1 ,c)。从f (n, * )开始迭式, f (n, * )由(1 5 - 1)式得出,然后由( 1 5 - 2)式递归计算f (i,*) ( i=n- 1,n- 2,., 2 ),最后由( 1 5 - 2)式得出f ( 1 ,c)。对于例1 5 - 2,若0≤y<1 0,则f ( 3 ,y) = 0;若y≥1 0,f ( 3 ,y) = 1 5。利用递归式(1 5 - 2),可得f (2, y) = 0 ( 0≤y<10 );f(2,y)= 1 5(1 0≤y<1 4);f(2,y)= 1 8(1 4≤y<2 4)和f(2,y)= 3 3(y≥2 4)。因此最优解f ( 1 , 11 6 ) = m a x {f(2,11 6),f(2,11 6 - w1)+ p1} = m a x {f(2,11 6),f(2,1 6)+ 2 0 } = m a x { 3 3,3 8 } = 3 8。现在计算xi 值,步骤如下:若f ( 1 ,c) =f ( 2 ,c),则x1 = 0,否则x1 = 1。接下来需从剩余容量c-w1中寻求最优解,用f (2, c-w1) 表示最优解。依此类推,可得到所有的xi (i= 1.n) 值。在该例中,可得出f ( 2 , 11 6 ) = 3 3≠f ( 1 , 11 6 ),所以x1 = 1。接着利用返回值3 8 -p1=18 计算x2 及x3,此时r = 11 6 -w1 = 1 6,又由f ( 2 , 1 6 ) = 1 8,得f ( 3 , 1 6 ) = 1 4≠f ( 2 , 1 6 ),因此x2 = 1,此时r= 1 6 -w2 = 2,所以f (3,2) =0,即得x3 = 0。 动态规划方法采用最优原则( principle of optimality)来建立用于计算最优解的递归式。所谓最优原则即不管前面的策略如何,此后的决策必须是基于当前状态(由上一次决策产生)的最优决策。由于对于有些问题的某些递归式来说并不一定能保证最优原则,因此在求解问题时有必要对它进行验证。若不能保持最优原则,则不可应用动态规划方法。在得到最优解的递归式之后,需要执行回溯(t r a c e b a c k)以构造最优解。 编写一个简单的递归程序来求解动态规划递归方程是一件很诱人的事。然而,正如我们将在下文看到的,如果不努力地去避免重复计算,递归程序的复杂性将非常可观。如果在递归程序设计中解决了重复计算问题时,复杂性将急剧下降。动态规划递归方程也可用迭代方式来求解,这时很自然地避免了重复计算。尽管迭代程序与避免重复计算的递归程序有相同的复杂性,但迭代程序不需要附加的递归栈空间,因此将比避免重复计算的递归程序更快。 3.2 应用 3.2.1 0/1背包问题 1. 递归策略 在例3 - 4中已建立了背包问题的动态规划递归方程,求解递归式( 1 5 - 2)的一个很自然的方法便是使用程序1 5 - 1中的递归算法。该模块假设p、w 和n 为输入,且p 为整型,F(1,c) 返回f ( 1 ,c) 值。 程序15-1 背包问题的递归函数 int F(int i, int y) {// 返回f ( i , y ) . if (i == n) return (y < w[n]) ? 0 : p[n]; if (y < w[i]) return F(i+1,y); return max(F(i+1,y), F(i+1,y-w[i]) + p[i]); } 程序1 5 - 1的时间复杂性t (n)满足:t ( 1 ) =a;t(n)≤2t(n- 1)+b(n>1),其中a、b 为常数。通过求解可得t (n) =O( 2n)。 例3-5 设n= 5,p= [ 6 , 3 , 5 , 4 , 6 ],w=[2,2,6,5,4] 且c= 1 0 ,求f ( 1 , 1 0 )。为了确定f ( 1 , 1 0 ),调用函数F ( 1 , 1 0 )。递归调用的关系如图1 5 - 1的树型结构所示。每个节点用y值来标记。对于第j层的节点有i=j,因此根节点表示F ( 1 , 1 0 ),而它有左孩子和右孩子,分别对应F ( 2 , 1 0 )和F ( 2 , 8 )。总共执行了2 8次递归调用。但我们注意到,其中可能含有重复前面工作的节点,如f ( 3 , 8 )计算过两次,相同情况的还有f ( 4 , 8 )、f ( 4 , 6 )、f ( 4 , 2 )、f ( 5 , 8 )、f ( 5 , 6 )、f ( 5 , 3 )、f (5,2) 和f ( 5 , 1 )。如果保留以前的计算结果,则可将节点数减至1 9,因为可以丢弃图中的阴影节点。 正如在例3 - 5中所看到的,程序1 5 - 1做了一些不必要的工作。为了避免f (i,y)的重复计算,必须定义一个用于保留已被计算出的f (i,y)值的表格L,该表格的元素是三元组(i,y,f (i,y) )。在计算每一个f (i,y)之前,应检查表L中是否已包含一个三元组(i,y, * ),其中*表示任意值。如果已包含,则从该表中取出f (i,y)的值,否则,对f (i,y)进行计算并将计算所得的三元组(i,y,f (i,y) )加入表L。L既可以用散列(见7 . 4节)的形式存储,也可用二叉搜索树(见11章)的形式存储。 2. 权为整数的迭代方法 当权为整数时,可设计一个相当简单的算法(见程序1 5 - 2)来求解f ( 1 ,c)。该算法基于例3 - 4所给出的策略,因此每个f (i,y) 只计算一次。程序1 5 - 2用二维数组f [ ][ ]来保存各f 的值。而回溯函数Tr a c e b a c k用于确定由程序1 5 - 2所产生的xi 值。函数K n a p s a c k的复杂性为( n c),而Tr a c e b a c k的复杂性为( n )。 程序15-2 f 和x 的迭代计算 template void Knapsack(T p[], int w[], int c, int n, T** f) {// 对于所有i和y计算f [ i ] [ y ] // 初始化f [ n ] [ ] for (int y = 0; y <= yMax; y++) f[n][y] = 0; for (int y = w[n]; y <= c; y++) f[n][y] = p[n]; // 计算剩下的f for (int i = n - 1; i > 1; i--) { for (int y = 0; y <= yMax; y++) f[i][y] = f[i+1][y]; for (int y = w[i]; y <= c; y++) f[i][y] = max(f[i+1][y], f[i+1][y-w[i]] + p[i]); } f[1][c] = f[2][c]; if (c >= w[1]) f[1][c] = max(f[1][c], f[2][c-w[1]] + p[1]); } template void Traceback(T **f, int w[], int c, int n, int x[]) {// 计算x for (int i = 1; i < n; i++) if (f[i][c] == f[i+1][c]) x[i] = 0; else {x[i] = 1; c -= w[i];} x[n] = (f[n][c]) ? 1 : 0; } 3. 元组方法( 选读) 程序1 5 - 2有两个缺点:1) 要求权为整数;2) 当背包容量c 很大时,程序1 5 - 2的速度慢于程序1 5 - 1。一般情况下,若c>2n,程序1 5 - 2的复杂性为W (n2n )。可利用元组的方法来克服上述两个缺点。在元组方法中,对于每个i,f (i, y) 都以数对(y, f (i, y)) 的形式按y的递增次序存储于表P(i)中。同时,由于f (i, y) 是y 的非递减函数,因此P(i) 中各数对(y, f (i, y)) 也是按f (i, y) 的递增次序排列的。 例3-6 条件同例3 - 5。对f 的计算如图1 5 - 2所示。当i= 5时,f 由数对集合P( 5 ) = [ ( 0 , 0 ) , ( 4 , 6 ) ]表示。而P( 4 )、P( 3 )和P( 2 )分别为[ ( 0 , 0 ) , ( 4 , 6 ) , ( 9 , 1 0 ) ]、[ ( 0 , 0 ) ( 4 , 6 ) , ( 9 , 1 0 ) , ( 1 0 , 11)] 和[ ( 0 , 0 ) ( 2 , 3 ) ( 4 , 6 ) ( 6 , 9 ) ( 9 , 1 0 ) ( 1 0 , 11 ) ]。为求f ( 1 , 1 0 ),利用式(1 5 - 2)得f ( 1 , 1 0 ) = m a x{f ( 2 , 1 0 ),f ( 2 , 8 ) + p 1}。由P( 2 )得f ( 2 , 1 0 ) = 11、f (2,8)=9 (f ( 2 , 8 ) = 9来自数对( 6,9 ) ),因此f ( 1 , 1 0 ) = m a x{11 , 1 5}= 1 5。现在来求xi 的值,因为f ( 1 , 1 0 ) =f ( 2 , 6 ) +p1,所以x1 = 1;由f ( 2 , 6 ) =f ( 3 , 6 - w 2 ) +p2 =f ( 3 , 4 ) +p2,得x2 = 1;由f ( 3 , 4 ) =f ( 4 , 4 ) =f ( 5 , 4 )得x3=x4 = 0;最后,因f ( 5 , 4 )≠0得x5= 1。检查每个P(i) 中的数对,可以发现每对(y,f (i,y)) 对应于变量xi , ., xn 的0/1 赋值的不同组合。设(a,b)和(c,d)是对应于两组不同xi , ., xn 的0 / 1赋值,若a≥c且b<d,则(a, b) 受(b, c) 支配。被支配者不必加入P(i)中。若在相同的数对中有两个或更多的赋值,则只有一个放入P(i)。假设wn≤C,P(n)=[(0,0), (wn , pn ) ],P(n)中对应于xn 的两个数对分别等于0和1。对于每个i,P(i)可由P(i+ 1 )得出。首先,要计算数对的有序集合Q,使得当且仅当wi≤s≤c且(s-wi , t-pi )为P(i+1) 中的一个数对时,(s,t)为Q中的一个数对。现在Q中包含xi = 1时的数对集,而P(i+ 1 )对应于xi = 0的数对集。接下来,合并Q和P(i+ 1 )并删除受支配者和重复值即可得到P(i)。 例3-7 各数据同例1 5 - 6。P(5)=[(0,0),(4,6)], 因此Q= [ ( 5 , 4 ) , ( 9 , 1 0 ) ]。现在要将P( 5 )和Q合并得到P( 4 )。因( 5 , 4 )受( 4 , 6 )支配,可删除( 5 , 4 ),所以P(4)=[(0,0), (4,6), (9,10)]。接着计算P( 3 ),首先由P( 4 )得Q=[(6,5), (10,11 ) ],然后又由合并方法得P(3)=[(0,0), (4,6), (9,10), (10,11 ) ]。最后计算P( 2 ):由P( 3 )得Q= [ ( 2 , 3 ),( 6 , 9 ) ],P( 3 )与Q合并得P(2)=[(0,0), (2,3), (4,6), (6,9), (9,10). (10,11 ) ]。因为每个P(i) 中的数对对应于xi , ., xn 的不同0 / 1赋值,因此P(i) 中的数对不会超过2n-i+ 1个。计算P(i) 时,计算Q需消耗( |P(i+ 1 ) |)的时间,合并P(i+1) 和Q同样需要( |P(i+ 1 ) | )的时间。计算所有P(i) 时所需要的总时间为: (n ?i=2|P(i + 1)|= O ( 2n )。当权为整数时,|P(i) |≤c+1, 此时复杂性为O ( m i n {n c, 2n } )。如6 . 4 . 3节定义的,数字化图像是m×m的像素阵列。假定每个像素有一个0 ~ 2 5 5的灰度值。因此存储一个像素至多需8位。若每个像素存储都用最大位8位,则总的存储空间为8m2 位。为了减少存储空间,我们将采用变长模式( variable bit scheme),即不同像素用不同位数来存储。像素值为0和1时只需1位存储空间;值2、3各需2位;值4,5,6和7各需3位;以此类推,使用变长模式的步骤如下: 1) 图像线性化根据图15-3a 中的折线将m×m维图像转换为1×m2 维矩阵。 2) 分段将像素组分成若干个段,分段原则是:每段中的像素位数相同。每个段是相邻像素的集合且每段最多含2 5 6个像素,因此,若相同位数的像素超过2 5 6个的话,则用两个以上的段表示。 3) 创建文件创建三个文件:S e g m e n t L e n g t h, BitsPerPixel 和P i x e l s。第一个文件包含在2 )中所建的段的长度(减1 ),文件中各项均为8位长。文件BitsPerPixel 给出了各段中每个像素的存储位数(减1),文件中各项均为3位。文件Pixels 则是以变长格式存储的像素的二进制串。 4) 压缩文件压缩在3) 中所建立的文件,以减少空间需求。 上述压缩方法的效率(用所得压缩率表示)很大程度上取决于长段的出现频率。 例3-8 考察图15-3b 的4×4图像。按照蛇形的行主次序,灰度值依次为1 0,9,1 2,4 0,5 0,3 5,1 5,1 2,8,1 0,9,1 5,11,1 3 0,1 6 0和2 4 0。各像素所需的位数分别为4,4,4,6,6,6,4,4,4,4,4,4,4,8,8和8,按等长的条件将像素分段,可以得到4个段[ 1 0,9,1 2 ]、[ 4 0,5 0,3 5 ]、[15, 12, 8, 10, 9, 15, 11] 和[130, 160, 240]。因此,文件SegmentLength 为2,2,6,2;文件BitsPerSegment 的内容为3,5,3,7;文件P i x e l s包含了按蛇形行主次序排列的1 6个灰度值,其中头三个各用4位存储,接下来三个各用6位,再接下来的七个各用4位,最后三个各用8位存储。因此存储单元中前3 0位存储了前六个像素: 1010 1001 1100 111000 110010 100011 这三个文件需要的存储空间分别为:文件SegmentLength 需3 2位;BitsPerSegment 需1 2位;Pixels 需8 2位,共需1 2 6位。而如果每个像素都用8位存储,则存储空间需8×1 6 = 1 2 8位,因而在本例图像中,节省了2位的空间。 假设在2) 之后,产生了n 个段。段标题(segment header)用于存储段的长度以及该段中每个像素所占用的位数。每个段标题需11位。现假设li 和bi 分别表示第i 段的段长和该段每个像素的长度,则存储第i 段像素所需要的空间为li *bi 。在2) 中所得的三个文件的总存储空间为11 n+n ?i = 1li bi。可通过将某些相邻段合并的方式来减少空间消耗。如当段i 和i+ 1被合并时,合并后的段长应为li +li + 1。此时每个像素的存储位数为m a x {bi,bi +1 } 位。尽管这种技术增加了文件P i x e l s的空间消耗,但同时也减少了一个段标题的空间。 例3-9 如果将例1 5 - 8中的第1段和第2段合并,合并后,文件S e g m e n t L e n g t h变为5,6,2,BitsPerSegment 变为5,3,7。而文件Pixels 的前3 6位存储的是合并后的第一段:001010 001001 001100 111000 110010 100011其余的像素(例1 5 - 8第3段)没有改变。因为减少了1个段标题,文件S e g m e n t L e n g t h和BitsPerPixel 的空间消耗共减少了11位,而文件Pixels 的空间增加6位,因此总共节约的空间为5位,空间总消耗为1 2 1位。 我们希望能设计一种算法,使得在产生n 个段之后,能对相邻段进行合并,以便产生一个具有最小空间需求的新的段集合。在合并相邻段之后,可利用诸如L Z W法(见7 . 5节)和霍夫曼编码(见9 . 5 . 3节)等其他技术来进一步压缩这三个文件。 令sq 为前q 个段的最优合并所需要的空间。定义s0 = 0。考虑第i 段(i>0 ),假如在最优合并C中,第i 段与第i- 1,i- 2,.,i-r+1 段相合并,而不包括第i-r 段。合并C所需要的空间消耗等于:第1段到第i-r 段所需空间+ l s u m (i-r+ 1 ,i) * b m a x (i-r+ 1 ,i) + 11 其中l s u m(a, b)=b ?j =a lj,bmax (a, b)= m a x {ba , ..., bb }。假如在C中第1段到第i-r 段的合并不是最优合并,那么需要对合并进行修改,以使其具有更小的空间需求。因此还必须对段1到段i-r 进行最优合并,也即保证最优原则得以维持。故C的空间消耗为: si = si-r +l s u m(i-r+1, i)*b m a x(i-r+1, i)+ 11 r 的值介于1到i 之间,其中要求l s u m不超过2 5 6 (因为段长限制在2 5 6之内)。尽管我们不知道如何选择r,但我们知道,由于C具有最小的空间需求,因此在所有选择中, r 必须产生最小的空间需求。 假定k a yi 表示取得最小值时k 的值,sn 为n 段的最优合并所需要的空间,因而一个最优合并可用kay 的值构造出来。 例3-10 假定在2) 中得到五个段,它们的长度为[ 6,3,1 0,2,3 ],像素位数为[ 1,2,3,2,1 ],要用公式(1 5 - 3)计算sn,必须先求出sn-1,.,s0 的值。s0 为0,现计算s1:s1 =s0 +l1 *b1+ 11 = 1 7k a y1 = 1s2 由下式得出: s2 = m i n {s1 +l2 b2 , s0 + (l1 +l2 ) * m a x {b1 , b2} } + 11 = m i n { 1 7 + 6 , 0 + 9 * 2 } + 11 = 2 9 k a y2 = 2 以此类推,可得s1.s5 = [ 1 7,2 9,6 7,7 3,82] ,k a y1.k a y5 = [ 1,2,2,3,4 ]。因为s5 = 8 2,所以最优空间合并需8 2位的空间。可由k a y5 导出本合并的方式,过程如下:因为k a y5 = 4,所以s5 是由公式(1 5 - 3)在k=4 时取得的,因而最优合并包括:段1到段( 5 - 4 ) = 1的最优合并以及段2,3,4和5的合并。最后仅剩下两个段:段1以及段2到段5的合并段。 1. 递归方法 用递归式(1 5 - 3)可以递归地算出si 和k a yi。程序1 5 - 3为递归式的计算代码。l,b,和k a y是一维的全局整型数组,L是段长限制( 2 5 6),h e a d e r为段标题所需的空间( 11 )。调用S ( n )返回sn 的值且同时得出k a y值。调用Tr a c e b a c k ( k a y, n )可得到最优合并。 现讨论程序1 5 - 3的复杂性。t( 0 ) =c(c 为一个常数): (n>0),因此利用递归的方法可得t (n) = O ( 2n )。Tr a c e b a c k的复杂性为(n)。 程序15-3 递归计算s , k a y及最优合并 int S(int i) { / /返回S ( i )并计算k a y [ i ] if (i == 0 ) return 0; //k = 1时, 根据公式( 1 5 - 3)计算最小值 int lsum = l[i],bmax = b[i]; int s = S(i-1) + lsum * bmax; kay[i] = 1; / /对其余的k计算最小值并求取最小值 for (int k = 2; k <= i && lsum+l[i-k+1] <= L; k++) { lsum += l[i-k+1]; if (bmax < b[i-k+1]) bmax = b[i-k+1]; int t = S(i-k); if (s > t + lsum * bmax) { s = t + lsum * bmax; kay[i] = k;} } return s + header; } void Traceback(int kay[], int n) {// 合并段 if (n == 0) return; Tr a c e b a c k ( k a y, n-kay[n]); cout << "New segment begins at " << (n - kay[n] + 1) << endl; } 2. 无重复计算的递归方法 通过避免重复计算si,可将函数S的复杂性减少到(n)。注意这里只有n个不同的si。 例3 - 11 再考察例1 5 - 1 0中五个段的例子。当计算s5 时,先通过递归调用来计算s4,.,s0。计算s4 时,通过递归调用计算s3,.,s0,因此s4 只计算了一次,而s3 计算了两次,每一次计算s3要计算一次s2,因此s2 共计算了四次,而s1 重复计算了1 6次!可利用一个数组s 来保存先前计算过的si 以避免重复计算。改进后的代码见程序1 5 - 4,其中s为初值为0的全局整型数组。 程序15-4 避免重复计算的递归算法 int S(int i) { / /计算S ( i )和k a y [ i ] / /避免重复计算 if (i == 0) return 0; if (s[i] > 0) return s[i]; //已计算完 / /计算s [ i ] / /首先根据公式(1 5 - 3)计算k = 1时最小值 int lsum = l[i], bmax = b[i]; s[i] =S(i-1) + lsum * bmax; kay[i] = 1; / /对其余的k计算最小值并更新 for (int k = 2; k <= i && lsum+l[i-k+1] <= L; k++) { lsum += l[i-k+1]; if (bmax < b[i-k+1]) bmax = b[i-k+1]; int t = S(i-k); if (s[i] > t + lsum * bmax) { s[i] = t + lsum * bmax; kay[i] = k;} } s[i] += header; return s[i]; } 为了确定程序1 5 - 4的时间复杂性,我们将使用分期计算模式( amortization scheme)。在该模式中,总时间被分解为若干个不同项,通过计算各项的时间然后求和来获得总时间。当计算si 时,若sj 还未算出,则把调用S(j) 的消耗计入sj ;若sj 已算出,则把S(j) 的消耗计入si (这里sj依次把计算新sq 的消耗转移至每个sq )。程序1 5 - 4的其他消耗也被计入si。因为L是2 5 6之内的常数且每个li 至少为1,所以程序1 5 - 4的其他消耗为( 1 ),即计入每个si 的量是一个常数,且si 数目为n,因而总工作量为(n)。 3. 迭代方法 倘若用式(1 5 - 3)依序计算s1 , ., sn,便可得到一个复杂性为(n)的迭代方法。在该方法中,在si 计算之前, sj 必须已计算好。该方法的代码见程序1 5 - 5,其中仍利用函数Tr a c e b a c k(见程序1 5 - 3)来获得最优合并。 程序15-5 迭代计算s和k a y void Vbits (int l[], int b[], int n, int s[], int kay[]) { / /计算s [ i ]和k a y [ i ] int L = 256, header = 11 ; s[0] = 0; / /根据式(1 5 - 3)计算s [ i ] for (int i = 1; i <= n; i++) { // k = 1时,计算最小值 int lsum = l{i}, bmax = b[i]; s[i] = s[i-1] + lsum * bmax; kay[i] = 1; / /对其余的k计算最小值并更新 for (int k=2; k<= i && lsum+l[i-k+1]<= L; k++) { lsum += l[i-k+1]; if (bmax < b[i-k+1]) bmax = b[i-k+1]; if (s[i] > s[i-k] + lsum * bmax){ s[i] = s[i-k] + lsum * bmax; kay[i] = k; } } s[i] += header; } } 3.2.3 矩阵乘法链 m×n矩阵A与n×p矩阵B相乘需耗费(m n p)的时间(见第2章练习1 6)。我们把m n p作为两个矩阵相乘所需时间的测量值。现假定要计算三个矩阵A、B和C的乘积,有两种方式计算此乘积。在第一种方式中,先用A乘以B得到矩阵D,然后D乘以C得到最终结果,这种乘法的顺序可写为(A*B) *C。第二种方式写为A* (B*C) ,道理同上。尽管这两种不同的计算顺序所得的结果相同,但时间消耗会有很大的差距。 例3-12 假定A为1 0 0×1矩阵,B为1×1 0 0矩阵,C为1 0 0×1矩阵,则A*B的时间耗费为10 0 0 0,得到的结果D为1 0 0×1 0 0矩阵,再与C相乘所需的时间耗费为1 000 000,因此计算(A*B) *C的总时间为1 010 000。B*C的时间耗费为10 000,得到的中间矩阵为1×1矩阵,再与A相乘的时间消耗为1 0 0,因而计算A*(B*C)的时间耗费竟只有10 100!而且,计算( A*B)*C时,还需10 000个单元来存储A*B,而A*(B*C)计算过程中,只需用1个单元来存储B*C。 下面举一个得益于选择合适秩序计算A*B*C矩阵的实例:考虑两个3维图像的匹配。图像匹配问题的要求是,确定一个图像需旋转、平移和缩放多少次才能逼近另一个图像。实现匹配的方法之一便是执行约1 0 0次迭代计算,每次迭代需计算1 2×1个向量T: T=?A(x, y, z) *B(x, y, z)*C(x, y, z ) 其中A,B和C分别为1 2×3,3×3和3×1矩阵。(x , y, z) 为矩阵中向量的坐标。设t 表示计算A(x , y, z) *B(x , y, z) *C(x , y, z)的计算量。假定此图像含2 5 6×2 5 6×2 5 6个向量,在此条件中,这1 0 0个迭代所需的总计算量近似为1 0 0 * 2 5 63 * t≈1 . 7 * 1 09 t。若三个矩阵是按由左向右的顺序相乘的,则t = 1 2 * 3 * 3 + 1 2 * 3 *1= 1 4 4;但如果从右向左相乘, t = 3 * 3 * 1 + 1 2 * 3 * 1 = 4 5。由左至右计算约需2 . 4 * 1 011个操作,而由右至左计算大概只需7 . 5 * 1 01 0个操作。假如使用一个每秒可执行1亿次操作的计算机,由左至右需4 0分钟,而由右至左只需1 2 . 5分钟。 在计算矩阵运算A*B*C时,仅有两种乘法顺序(由左至右或由右至左),所以可以很容易算出每种顺序所需要的操作数,并选择操作数比较少的那种乘法顺序。但对于更多矩阵相乘来说,情况要复杂得多。如计算矩阵乘积M1×M2×.×Mq,其中Mi 是一个ri×ri + 1 矩阵( 1≤i≤q)。不妨考虑q=4 的情况,此时矩阵运算A*B*C*D可按以下方式(顺序)计算: A* ( (B*C) *D) A* (B* (C*D)) (A*B) * (C*D) (A* (B*C) ) *D 不难看出计算的方法数会随q 以指数级增加。因此,对于很大的q 来说,考虑每一种计算顺序并选择最优者已是不切实际的。 现在要介绍一种采用动态规划方法获得矩阵乘法次序的最优策略。这种方法可将算法的时间消耗降为(q3 )。用Mi j 表示链Mi×.×Mj (i≤j)的乘积。设c(i,j) 为用最优法计算Mi j 的消耗,k a y(i, j) 为用最优法计算Mi j 的最后一步Mi k×Mk+1, j 的消耗。因此Mij 的最优算法包括如何用最优算法计算Mik 和Mkj 以及计算Mik×Mkj 。根据最优原理,可得到如下的动态规划递归式:k a y(i,i+s)= 获得上述最小值的k. 以上求c 的递归式可用递归或迭代的方法来求解。c( 1,q) 为用最优法计算矩阵链的消耗,k a y( 1 ,q) 为最后一步的消耗。其余的乘积可由k a y值来确定。 1. 递归方法 与求解0 / 1背包及图像压缩问题一样,本递归方法也须避免重复计算c (i, j) 和k a y(i, j),否则算法的复杂性将会非常高。 例3-13 设q= 5和r =(1 0 , 5 , 1 , 1 0 , 2 , 1 0),式中待求的c 中有四个c的s= 0或1,因此用动态规划方法可立即求得它们的值: c( 1 , 1 ) =c( 5 , 5 ) = 0 ;c(1,2)=50; c( 4 , 5 ) = 2 0 0。现计算C( 2,5 ):c( 2 , 5 ) = m i n {c( 2 , 2 ) +c(3,5)+50, c( 2 , 3 ) +c(4,5)+500, c( 2 , 4 ) +c( 5 , 5 ) + 1 0 0 } (1 5 - 5)其中c( 2 , 2 ) =c( 5 , 5 ) = 0;c( 2 , 3 ) = 5 0;c(4,5)=200 。再用递归式计算c( 3 , 5 )及c( 2 , 4 ) :c( 3 , 5 ) = m i n {c( 3 , 3 ) +c(4,5)+100, c( 3 , 4 ) +c( 5 , 5 ) + 2 0 } = m i n { 0 + 2 0 0 + 1 0 0 , 2 0 + 0 + 2 0 } = 4 0c( 2 , 4 ) = m i n {c( 2 , 2 ) +c( 3 , 4 ) + 1 0 ,c( 2 , 3 ) +c( 4 , 4 ) + 1 0 0 } = m i n { 0 + 2 0 + 1 0 , 5 0 + 1 0 + 2 0 } = 3 0由以上计算还可得k a y( 3 , 5 ) = 4,k ay( 2 , 4 ) = 2。现在,计算c(2,5) 所需的所有中间值都已求得,将它们代入式(1 5 - 5)得: c(2,5)=min{0+40+50, 50+200+500, 30+0+100}=90且k a y( 2 , 5 ) = 2 再用式(1 5 - 4)计算c( 1 , 5 ),在此之前必须算出c( 3 , 5 )、c(1,3) 和c( 1 , 4 )。同上述过程,亦可计算出它们的值分别为4 0、1 5 0和9 0,相应的k a y 值分别为4、2和2。代入式(1 5 - 4)得: c(1,5)=min{0+90+500, 50+40+100, 150+200+1000, 90+0+200}=190且k a y( 1 , 5 ) = 2 此最优乘法算法的消耗为1 9 0,由k a y(1,5) 值可推出该算法的最后一步, k a y(1,5) 等于2,因此最后一步为M1 2×M3 5,而M12 和M35 都是用最优法计算而来。由k a y( 1 , 2 ) = 1知M12 等于M11×M2 2,同理由k a y( 3 , 5) = 4得知M35 由M3 4×M55 算出。依此类推,M34 由M3 3×M44 得出。因而此最优乘法算法的步骤为: M11×M2 2 = M1 2 M3 3×M4 4 = M3 4 M3 4×M5 5 = M3 5 M1 2×M3 5 = M1 5 计算c(i, j) 和k a y (i, j) 的递归代码见程序1 5 - 6。在函数C中,r 为全局一维数组变量, k a y是全局二维数组变量,函数C返回c(i j) 之值且置k a y [a] [b] =k ay (a , b) (对于任何a , b),其中c(a , b)在计算c(i,j) 时皆已算出。函数Traceback 利用函数C中已算出的k a y值来推导出最优乘法算法的步骤。 设t(q)为函数C的复杂性,其中q=j-i+ 1(即Mij 是q个矩阵运算的结果)。当q为1或2时,t(q) =d,其中d 为一常数;而q> 2时,t (q)=2q-1?k = 1t (k ) +e q,其中e 是一个常量。因此当q>2时,t(q)>2t (q- 1 ) +e,所以t (q)= W ( 2q)。函数Traceback 的复杂性为(q)。 程序15-6 递归计算c (i, j) 和kay (i, j) int C(int i, int j) { / /返回c(i,j) 且计算k(i,j) = kay[i][j] if (i==j) return 0; //一个矩阵的情形 if (i == j-1) { //两个矩阵的情形 kay[i][i+1] = i; return r[i]*r[i+1]*r[r+2];} / /多于两个矩阵的情形 / /设u为k = i 时的最小值 int u = C(i,i) + C(i+1,j) + r[i]*r[i+1]*r[j+1]; kay[i][j] = i; / /计算其余的最小值并更新u for (int k = i+1; k < j; k++) { int t = C(i,k) + C(k+1,j) + r[i]*r[k+1]*r[j+1]; if (r < u) {//小于最小值的情形 u = t; kay[i][j] = k; } return u; } void Traceback (int i, int j ,int **kay) { / /输出计算Mi j 的最优方法 if ( i == j) return; Traceback(i, kay[i][j], kay); Traceback(kay[i][j]+1, j, kay); cout << "Multiply M" << i << ", "<< kay[i][j]; cout << " and M " << (kay[i][j]+1) << ", " << j << end1; } 2. 无重复计算的递归方法 若避免再次计算前面已经计算过的c(及相应的k a y),可将复杂性降低到(q3)。而为了避免重复计算,需用一个全局数组c[ ][ ]存储c(i, j) 值,该数组初始值为0。函数C的新代码见程序1 5 - 7: 程序15-7 无重复计算的c (i, j) 计算方法 int C(int i,int j) { / /返回c(i,j) 并计算k a y ( i , j ) = k a y [ I ] [ j ] / /避免重复计算 / /检查是否已计算过 if (c[i][j] >) return c[i][j]; / /若未计算,则进行计算 if(i==j) return 0; //一个矩阵的情形 i f ( i = = j - 1 ) { / /两个矩阵的情形 kay[i][i+1]=i; c [ i ] [ j ] = r [ i ] * r [ i + 1 ] * r [ i + 2 ] ; return c[i][j];} / /多于两个矩阵的情形 / /设u为k = i 时的最小值 int u=C(i,i)+C(i+1,j)+r[i]*r[i+1]*r[j+1]; k a y [ i ] [ j ] = i ; / /计算其余的最小值并更新u for (int k==i+1; k,则c(i, j, 0) =边 的长度;若i= j ,则c(i,j, 0)=0;如果G中不包含边,则c (i, j, 0)= +∞。c(i, j, n) 则是从i 到j 的最短路径的长度。 例3-16 考察图1 5 - 4。若k=0, 1, 2, 3,则c (1, 3, k)= ∞;c (1, 3, 4)= 2 8;若k = 5, 6, 7,则c (1, 3,k) = 1 0;若k=8, 9, 10,则c (1, 3, k) = 9。因此1到3的最短路径长度为9。对于任意k>0,如何确定c (i, j, k) 呢?中间顶点不超过k 的i 到j 的最短路径有两种可能:该路径含或不含中间顶点k。若不含,则该路径长度应为c(i, j, k- 1 ),否则长度为c(i, k, k- 1) +c (k, j, k- 1 )。c(i, j, k) 可取两者中的最小值。因此可得到如下递归式: c( i, j, k)= m i n {c(i, j, k-1), c (i, k, k- 1) +c (k, j, k- 1 ) },k>0 以上的递归公式将一个k 级运算转化为多个k-1 级运算,而多个k-1 级运算应比一个k 级运算简单。如果用递归方法求解上式,则计算最终结果的复杂性将无法估量。令t (k) 为递归求解c (i, j, k) 的时间。根据递归式可以看出t(k) = 2t(k- 1 ) +c。利用替代方法可得t(n) = ( 2n )。因此得到所有c (i, j, n) 的时间为(n2 2n )。 当注意到某些c (i, j, k-1) 值可能被使用多次时,可以更高效地求解c (i, j, n)。利用避免重复计算c(i, j, k) 的方法,可将计算c 值的时间减少到(n3 )。这可通过递归方式(见程序1 5 - 7矩阵链问题)或迭代方式来实现。出迭代算法的伪代码如图1 5 - 5所示。 / /寻找最短路径的长度 / /初始化c(i,j,1) for (int i=1; i < = n ; i + +) for (int j=1; j<=n; j+ + ) c ( i ,j, 0 ) = a ( i ,j); // a 是长度邻接矩阵 / /计算c ( i ,j, k ) ( 0 < k < = n ) for(int k=1;k<=n;k++) for (int i=1;i<=n;i++) for (int j= 1 ;j< = n ;j+ + ) if (c(i,k,k-1)+c(k,j, k - 1 ) < c ( i ,j, k - 1 ) ) c ( i ,j, k ) = c ( i , k , k - 1 ) + c ( k ,j, k - 1 ) ; else c(i,j, k ) = c ( i ,j, k - 1 ) ; 图15-5 最短路径算法的伪代码 注意到对于任意i,c(i,k,k) =c(i,k,k- 1 )且c(k,i,k) =c(k,i,k- 1 ),因而,若用c(i,j)代替图1 5 - 5的c(i,j,k),最后所得的c(i,j) 之值将等于c(i,j,n) 值。此时图1 5 - 5可改写成程序1 5 - 9的C + +代码。程序1 5 - 9中还利用了程序1 2 - 1中定义的AdjacencyWDigraph 类。函数AllPairs 在c 中返回最短路径的长度。若i 到j 无通路,则c [i] [j]被赋值为N o E d g e。函数AllPairs 同时计算了k a y [ i ] [ j ],其中kay[i][j] 表示从i 到j 的最短路径中最大的k 值。在后面将看到如何根据kay 值来推断出从一个顶点到另一顶点的最短路径(见程序1 5 - 1 0中的函数O u t p u t P a t h)。 程序1 5 - 9的时间复杂性为(n3 ),其中输出一条最短路径的实际时间为O (n)。 程序15-9 c 和kay 的计算 template void AdjacencyWDigraph::Allpairs(T **c, int **kay) { / /所有点对的最短路径 / /对于所有i和j,计算c [ i ] [ j ]和k a y [ i ] [ j ] / /初始化c [ i ] [ j ] = c(i,j,0) for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { c[i][j] = a[i][j]; kay[i][j] = 0; } for (i = 1; i <= n; i++) c[i][i] = 0; // 计算c[i][j] = c(i,j,k) for (int k = 1; k <= n; k++) for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { T t1 = c[i][k]; T t2 = c[k][j]; T t3 = c[i][j]; if (t1 != NoEdge && t2 != NoEdge && (t3 == NoEdge || t1 + t2 < t3)) { c[i][j] = t1 + t2; kay[i][j] = k;} } } 程序15-10 输出最短路径 void outputPath(int **kay, int i, int j) {// 输出i 到j 的路径的实际代码 if (i == j) return; if (kay[i][j] == 0) cout << j << ' '; else {outputPath(kay, i, kay[i][j]); o u t p u t P a t h ( k a y, kay[i][j], j);} } template void OutputPath(T **c, int **kay, T NoEdge, int i, int j) {// 输出从i 到j的最短路径 if (c[i][j] == NoEdge) { cout << "There is no path from " << i << " to " << j << endl; r e t u r n ; } cout << "The path is" << endl; cout << i << ' '; o u t p u t P a t h ( k a y, i , j ) ; cout << endl; } 例3-17 图15-6a 给出某图的长度矩阵a,15-6b 给出由程序1 5 - 9所计算出的c 矩阵,15-6c 为对应的k a y值。根据15-6c 中的kay 值,可知从1到5的最短路径是从1到k a y [ 1 ] [ 5 ] = 4的最短路径再加上从4到5的最短路径,因为k a y [ 4 ] [ 5 ] = 0,所以从4到5的最短路径无中间顶点。从1到4的最短路径经过k a y [ 1 ] [ 4 ] = 3。重复以上过程,最后可得1到5的最短路径为:1,2,3,4,5。 3.2.5 网络的无交叉子集 在11 . 5 . 3节的交叉分布问题中,给定一个每边带n 个针脚的布线通道和一个排列C。顶部的针脚i 与底部的针脚Ci 相连,其中1≤i≤n,数对(i, Ci ) 称为网组。总共有n 个网组需连接或连通。假设有两个或更多的布线层,其中有一个为优先层,在优先层中可以使用更细的连线,其电阻也可能比其他层要小得多。布线时应尽可能在优先层中布设更多的网组。而剩下的其他网组将布设在其他层。当且仅当两个网组之间不交叉时,它们可布设在同一层。我们的任务是寻找一个最大无交叉子集(Maximum Noncrossing Su b s e t,M N S )。在该集中,任意两个网组都不交叉。因(i, Ci ) 完全由i 决定,因此可用i 来指定(i, Ci )。 例3-18 考察图1 5 - 7(对应于图1 0 - 1 7)。( 1 , 8 )和( 2 , 7 )(也即1号网组和2号网组)交叉,因而不能布设在同一层中。而( 1 , 8 ),(7,9) 和(9,10) 未交叉,因此可布设在同一层。但这3个网组并不能构成一个M N S,因为还有更大的不交叉子集。图1 0 - 1 7中给出的例子中,集合{ ( 4 , 2 ) ,( 5 , 5 ) , ( 7 , 9 ) , ( 9 , 1 0 )}是一个含4个网组的M N S。设M N S(i, j) 代表一个M N S,其中所有的(u, Cu ) 满足u≤i,Cu≤j。令s i z e(i,j) 表示M N S(i,j)的大小(即网组的数目)。显然M N S(n,n)是对应于给定输入的M N S,而s i z e(n,n)是它的大小。 例3-19 对于图1 0 - 1 7中的例子,M N S( 1 0 , 1 0 )是我们要找的最终结果。如例3 - 1 8中所指出的,s i z e( 1 0 , 1 0 ) = 4,因为( 1 , 8 ),( 2 , 7 ),( 7 , 9 ),( 8 , 3 ),( 9 , 1 0 )和( 1 0 , 6 )中要么顶部针脚编号比7大,要么底部针脚编号比6大,因此它们都不属于M N S( 7 , 6 )。因此只需考察剩下的4个网组是否属于M N S( 7 , 6 ),如图1 5 - 8所示。子集{( 3 , 4 ) , ( 5 , 5 )}是大小为2的无交叉子集。没有大小为3的无交叉子集,因此s i z e( 7 , 6) = 2。当i= 1时,( 1 ,C1) 是M N S( 1 ,j) 的唯一候选。仅当j≥C1 时,这个网组才会是M N S( 1 ,j) 的一个成员. 下一步,考虑i>1时的情况。若j<Ci,则(i,Ci ) 不可能是M N S( i,j) 的成员,所有属于M N S(i,j) 的(u, Cu ) 都需满足u<i且Cu<j,因此:s i z e(i,j) =s i z e(i- 1 ,j), j 1; i--) // i 号n e t在M N S中? if (size[i][j] != size[i-1][j]){// 在M N S中 Net[m++] = i; j = C[i] - 1;} // 1号网组在M N S中? if (j >= C[1]) Net[m++] = 1; // 在M N S中 } 3.2.6 元件折叠 在设计电路的过程中,工程师们会采取多种不同的设计风格。其中的两种为位-片设计(bit-slice design)和标准单元设计(standard-cell design)。在前一种方法中,电路首先被设计为一个元件栈(如图15-10a 所示)。每个元件Ci 宽为wi ,高为hi ,而元件宽度用片数来表示。图15-10a 给出了一个四片的设计。线路是按片来连接各元件的,即连线可能连接元件Ci 的第j片到元件Ci+1 的第j 片。如果某些元件的宽度不足j 片,则这些元件之间不存在片j 的连线。当图1 5 -10a 的位-片设计作为某一大系统的一部分时,则在V L SI ( Very Large Scale Integrated) 芯片上为它分配一定数量的空间单元。分配是按空间宽度或高度的限制来完成的。现在的问题便是如何将元件栈折叠到分配空间中去,以便尽量减小未受限制的尺度(如,若高度限制为H时,必须折叠栈以尽量减小宽度W)。由于其他尺度不变,因此缩小一个尺度(如W)等价于缩小面积。可用折线方式来折叠元件栈,在每一折叠点,元件旋转1 8 0°。在图15-10b 的例子中,一个1 2元件的栈折叠成四个垂直栈,折叠点为C6 , C9 和C1 0。折叠栈的宽度是宽度最大的元件所需的片数。在图15-10b 中,栈宽各为4,3,2和4。折叠栈的高度等于各栈所有元件高度之和的最大值。在图15-10b 中栈1的元件高度之和最大,该栈的高度决定了包围所有栈的矩形高度。 实际上,在元件折叠问题中,还需考虑连接两个栈的线路所需的附加空间。如,在图1 5 -10b 中C5 和C6 间的线路因C6 为折叠点而弯曲。这些线路要求在C5 和C6 之下留有垂直空间,以便能从栈1连到栈2。令ri 为Ci 是折叠点时所需的高度。栈1所需的高度为5 ?i =1hi +r6,栈2所需高度为8 ?i=6hi +r6+r9。 在标准单元设计中,电路首先被设计成为具有相同高度的符合线性顺序的元件排列。假设此线性顺序中的元件为C1,.,Cn,下一步元件被折叠成如图1 5 - 11所示的相同宽度的行。在此图中, 1 2个标准单元折叠成四个等宽行。折叠点是C4,C6 和C11。在相邻标准单元行之间,使用布线通道来连接不同的行。折叠点决定了所需布线通道的高度。设li 表示当Ci 为折叠点时所需的通道高度。在图1 5 - 11的例子中,布线通道1的高度为l4,通道2的高度为l6,通道3的高度为l11。位-片栈折叠和标准单元折叠都会引出一系列的问题,这些问题可用动态规划方法来解决。 1. 等宽位-片元件折叠 定义r1 = rn+1 =0。由元件Ci 至Cj 构成的栈的高度要求为j ?k= ilk+ ri+ rj + 1。设一个位-片设计中所有元件有相同宽度W。首先考察在折叠矩形的高度H给定的情况下,如何缩小其宽度。设Wi为将元件Ci 到Cn 折叠到高为H的矩形时的最小宽度。若折叠不能实现(如当ri +hi>H时),取Wi =∞。注意到W1 可能是所有n 个元件的最佳折叠宽度。当折叠Ci 到Cn 时,需要确定折叠点。现假定折叠点是按栈左到栈右的顺序来取定的。若第一点定为Ck+ 1,则Ci 到Ck 在第一个栈中。为了得到最小宽度,从Ck+1 到Cn 的折叠必须用最优化方法,因此又将用到最优原理,可用动态规划方法来解决此问题。当第一个折叠点k+ 1已知时,可得到以下公式: Wi =w+ Wk + 1 (1 5 - 9) 由于不知道第一个折叠点,因此需要尝试所有可行的折叠点,并选择满足( 1 5 - 9)式的折叠点。令h s u m(i,k)=k ?j = ihj。因k+ 1是一个可行的折叠点,因此h s u m(i, k) +ri +rk+1 一定不会超过H。 根据上述分析,可得到以下动态规划递归式: 这里Wn+1 =0,且在无最优折叠点k+ 1时Wi 为∞。利用递归式(1 5 - 1 0),可通过递归计算Wn , Wn- 1., W2 , W1 来计算Wi。Wi 的计算需要至多检查n-i+ 1个Wk+ 1,耗时为O (n-k)。因此计算所有Wi 的时间为O (n2 )。通过保留式(1 5 - 1 0)每次所得的k 值,可回溯地计算出各个最优的折叠点,其时间耗费为O (n)。 现在来考察另外一个有关等宽元件的折叠问题:折叠后矩形的宽度W已知,需要尽量减小其高度。因每个折叠矩形宽为w,因此折叠后栈的最大数量为s=W / w。令Hi, j 为Ci , ., Cn 折叠成一宽度为jw 的矩形后的最小高度, H1, s 则是所有元件折叠后的最小高度。当j= 1时,不允许任何折叠,因此:Hi,1 =h s u m(i,n) +ri , 1≤i≤n另外,当i=n 时,仅有一个元件,也不可能折叠,因此:Hn ,j=hn+rn , 1≤j≤s在其他情况下,都可以进行元件折叠。如果第一个折叠点为k+ 1,则第一个栈的高度为h s u m(i,k) +ri +rk+ 1。其他元件必须以至多(j- 1 ) *w 的宽度折叠。为保证该折叠的最优性,其他元件也需以最小高度进行折叠.因为第一个折叠点未知,因此必须尝试所有可能的折叠点,然后从中找出一个使式(1 5 - 11)的右侧取最小值的点,该点成为第一个折叠点。可用迭代法来求解Hi, j ( 1≤i≤n, 1≤j≤s),求解的顺序为:先计算j=2 时的H i, j,再算j= 3,.,以此类推。对应每个j 的Hi, j 的计算时间为O (n2 ),所以计算所有H i, j 的时间为O(s n2 )。通过保存由( 1 5 - 1 2)式计算出的每个k 值,可以采用复杂性为O (n) 的回溯过程来确定各个最优的折叠点。 2. 变宽位-片元件的折叠 首先考察折叠矩形的高度H已定,欲求最小的折叠宽度的情况。令Wi 如式(1 5 - 1 0)所示,按照与(1 5 - 1 0)式相同的推导过程,可得: Wi = m i n {w m i n(i, k) +Wk+1 | h s u m(i,k)+ ri +rk+ 1≤H, i≤k≤n} (1 5 - 1 3) 其中Wn+1=0且w m i n(i,k)= m ini≤j≤k{wj }。可用与(1 5 - 1 0)式一样的方法求解(1 5 - 1 3)式,所需时间为O(n2 )。当折叠宽度W给定时,最小高度折叠可用折半搜索方法对超过O(n2 )个可能值进行搜索来实现,可能的高度值为h(i,j)+ri +rj + 1。在检测每个高度时,也可用( 1 5 - 1 3)式来确定该折叠的宽度是否小于等于W。这种情况下总的时间消耗为O (n2 l o gn)。 3. 标准单元折叠 用wi 定义单元Ci 的宽度。每个单元的高度为h。当标准单元行的宽度W 固定不变时,通过减少折叠高度,可以相应地减少折叠面积。考察Ci 到Cn 的最小高度折叠。设第一个折叠点是Cs+ 1。从元件Cs+1 到Cn 的折叠必须使用最小高度,否则,可使用更小的高度来折叠Cs+1 到Cn,从而得到更小的折叠高度。所以这里仍可使用最优原理和动态规划方法。令Hi , s 为Ci 到Cn 折叠成宽为W的矩形时的最小高度,其中第一个折叠点为Cs+ 1。令w s u m(i, s)=s ?j = iwj。可假定没有宽度超过W的元件,否则不可能进行折叠。对于Hn,n 因为只有一个元件,不存在连线问题,因此Hn, n =h。对于H i, s(1≤i<s≤n)注意到如果w s u m(i, s )>W,不可能实现折叠。若w s u m(i,s)≤W,元件Ci 和C j + 1 在相同的标准单元行中,该行下方布线通道的高度为ls+ 1(定义ln+1 = 0)。因而:Hi, s = Hi+1, k (1 5 - 1 4)当i=s<n 时,第一个标准单元行只包含Ci 。该行的高度为h 且该行下方布线通道的高度为li+ 1。因Ci+ 1 到Cn 单元的折叠是最优的.为了寻找最小高度折叠,首先使用式( 1 5 - 1 4)和(1 5 - 1 5)来确定Hi, s (1≤i≤s≤n)。最小高度折叠的高度为m in{H1 , s}。可以使用回溯过程来确定最小高度折叠中的折叠点。 第 4 章 回溯 寻找问题的解的一种可靠的方法是首先列出所有候选解,然后依次检查每一个,在检查完所有或部分候选解后,即可找到所需要的解。理论上,当候选解数量有限并且通过检查所有或部分候选解能够得到所需解时,上述方法是可行的。不过,在实际应用中,很少使用这种方法,因为候选解的数量通常都非常大(比如指数级,甚至是大数阶乘),即便采用最快的计算机也只能解决规模很小的问题。对候选解进行系统检查的方法有多种,其中回溯和分枝定界法是比较常用的两种方法。按照这两种方法对候选解进行系统检查通常会使问题的求解时间大大减少(无论对于最坏情形还是对于一般情形)。事实上,这些方法可以使我们避免对很大的候选解集合进行检查,同时能够保证算法运行结束时可以找到所需要的解。因此,这些方法通常能够用来求解规模很大的问题。 本章集中阐述回溯方法,这种方法被用来设计货箱装船、背包、最大完备子图、旅行商和电路板排列问题的求解算法。 4.1 算法思想 回溯(b a c k t r a c k i n g)是一种系统地搜索问题解答的方法。为了实现回溯,首先需要为问题定义一个解空间( solution space),这个空间必须至少包含问题的一个解(可能是最优的)。在迷宫老鼠问题中,我们可以定义一个包含从入口到出口的所有路径的解空间;在具有n 个对象的0 / 1背包问题中(见1 . 4节和2 . 2节),解空间的一个合理选择是2n 个长度为n 的0 / 1向量的集合,这个集合表示了将0或1分配给x的所有可能方法。当n= 3时,解空间为{ ( 0 , 0 , 0 ),( 0 , 1 , 0 ),( 0 , 0 , 1 ),( 1 , 0 , 0 ),( 0 , 1 , 1 ),( 1 , 0 , 1 ),( 1 , 1 , 0 ),( 1 , 1 , 1 ) }。下一步是组织解空间以便它能被容易地搜索。典型的组织方法是图或树。图1 6 - 1用图的形式给出了一个3 ×3迷宫的解空间。从( 1 , 1 )点到( 3 , 3 )点的每一条路径都定义了3×3迷宫解空间中的一个元素,但由于障碍的设置,有些路径是不可行的。图1 6 - 2用树形结构给出了含三个对象的0 / 1背包问题的解空间。从i 层节点到i+ 1层节点的一条边上的数字给出了向量x 中第i个分量的值xi ,从根节点到叶节点的每一条路径定义了解空间中的一个元素。从根节点A到叶节点H的路径定义了解x= [ 1 , 1 , 1 ]。根据w 和c 的值,从根到叶的路径中的一些解或全部解可能是不可行的。一旦定义了解空间的组织方法,这个空间即可按深度优先的方法从开始节点进行搜索。在迷宫老鼠问题中,开始节点为入口节点( 1 , 1 );在0 / 1背包问题中,开始节点为根节点A。开始节点既是一个活节点又是一个E-节点(expansion node)。从E-节点可移动到一个新节点。如果能从当前的E-节点移动到一个新节点,那么这个新节点将变成一个活节点和新的E-节点,旧的E-节点仍是一个活节点。如果不能移到一个新节点,当前的E-节点就“死”了(即不再是一个活节点),那么便只能返回到最近被考察的活节点(回溯),这个活节点变成了新的E-节点。当我们已经找到了答案或者回溯尽了所有的活节点时,搜索过程结束。 例4-1 [迷宫老鼠] 考察图16-3a 的矩阵中给出的3×3的“迷宫老鼠”问题。我们将利用图1 6 -1给出的解空间图来搜索迷宫。从迷宫的入口到出口的每一条路径都与图1 6 - 1中从( 1 , 1 )到( 3 , 3 )的一条路径相对应。然而,图1 6 - 1中有些从( 1 , 1 )到( 3 , 3 )的路径却不是迷宫中从入口到出口的路径。搜索从点( 1 , 1 )开始,该点是目前唯一的活节点,它也是一个E-节点。为避免再次走过这个位置,置m a z e( 1 , 1 )为1。从这个位置,能移动到( 1 , 2 )或( 2 , 1 )两个位置。对于本例,两种移动都是可行的,因为在每一个位置都有一个值0。假定选择移动到( 1 , 2 ),m a z e( 1 , 2 )被置为1以避免再次经过该点。迷宫当前状态如图16-3b 所示。这时有两个活节点(1,1) (1,2)。( 1 , 2 )成为E-节点。在图1 6 - 1中从当前E-节点开始有3个可能的移动,其中两个是不可行的,因为迷宫在这些位置上的值为1。唯一可行的移动是( 1 , 3 )。移动到这个位置,并置m a z e( 1 , 3 )为1以避免再次经过该点,此时迷宫状态为1 6 - 3 c。图1 6 - 1中,从( 1 , 3 )出发有两个可能的移动,但没有一个是可行的。所以E-节点( 1 , 3 )死亡,回溯到最近被检查的活节点( 1 , 2 )。在这个位置也没有可行的移动,故这个节点也死亡了。唯一留下的活节点是( 1 , 1 )。这个节点再次变为E-节点,它可移动到( 2 , 1 )。现在活节点为( 1 , 1 ),( 2 , 1 )。继续下去,能到达点( 3 , 3 )。此时,活节点表为( 1 , 1 ),( 2 , 1 ),( 3 , 1 ),( 3 , 2 ),( 3 , 3 ),这即是到达出口的路径。 程序5 - 1 3是一个在迷宫中寻找路径的回溯算法。 例4-2 [0/1背包问题] 考察如下背包问题:n= 3,w= [ 2 0 , 1 5 , 1 5 ],p= [ 4 0 , 2 5 , 2 5 ]且c= 3 0。从根节点开始搜索图1 6 - 2中的树。根节点是当前唯一的活节点,也是E-节点,从这里能够移动到B或C点。假设移动到B,则活节点为A和B。B是当前E-节点。在节点B,剩下的容量r 为1 0,而收益c p 为4 0。从B点,能移动到D或E。移到D是不可行的,因为移到D所需的容量w2 为1 5。到E的移动是可行的,因为在这个移动中没有占用任何容量。E变成新的E-节点。这时活节点为A , B , E。在节点E,r= 1 0,c p= 4 0。从E,有两种可能移动(到J 和K),到J 的移动是不可行的,而到K的移动是可行的。节点K变成了新的E-节点。因为K是一个叶子,所以得到一个可行的解。这个解的收益为c p= 4 0。x 的值由从根到K的路径来决定。这个路径( A , B , E , K)也是此时的活节点序列。既然不能进一步扩充K,K节点死亡,回溯到E,而E也不能进一步扩充,它也死亡了。接着,回溯到B,它也死亡了,A再次变为E-节点。它可被进一步扩充,到达节点C。此时r= 3 0,c p= 0。从C点能够移动到F或G。假定移动到F。F变为新的E-节点并且活节点为A, C,F。在F,r= 1 5,c p= 2 5。从F点,能移动到L或M。假定移动到L。此时r= 0,c p= 5 0。既然L是一个叶节点,它表示了一个比目前找到的最优解(即节点K)更好的可行解,我们把这个解作为最优解。节点L死亡,回溯到节点F。继续下去,搜索整棵树。在搜索期间发现的最优解即为最后的解。 例4-3 [旅行商问题] 在这个问题中,给出一个n 顶点网络(有向或无向),要求找出一个包含所有n 个顶点的具有最小耗费的环路。任何一个包含网络中所有n 个顶点的环路被称作一个旅行(t o u r)。在旅行商问题中,要设法找到一条最小耗费的旅行。 图1 6 - 4给出了一个四顶点网络。在这个网络中,一些旅行如下: 1 , 2 , 4 , 3 , 1;1 , 3 , 2 , 4 , 1和1 , 4 , 3 , 2 , 1。旅行2 , 4 , 3 , 1 , 2;4 , 3 , 1 , 2 , 4和3 , 1 , 2 , 4 , 3和旅行1 , 2 , 4 , 3 , 1一样。而旅行1 , 3 , 4 , 2 , 1是旅行1 , 2 , 4 , 3 , 1的 “逆”。旅行1 , 2 , 4 , 3 , 1的耗费为6 6;而1 , 3 , 2 , 4 , 1的耗费为2 5;1 , 4 , 3 , 2 , 1为5 9。故1 , 3 , 2 , 4 , 1是该网络中最小耗费的旅行。顾名思义,旅行商问题可被用来模拟现实生活中旅行商所要旅行的地区问题。顶点表示旅行商所要旅行的城市(包括起点)。边的耗费给出了在两个城市旅行所需的时间(或花费)。旅行表示当旅行商游览了所有城市再回到出发点时所走的路线。旅行商问题还可用来模拟其他问题。假定要在一个金属薄片或印刷电路板上钻许多孔。孔的位置已知。这些孔由一个机器钻头来钻,它从起始位置开始,移动到每一个钻孔位置钻孔,然后回到起始位置。总共花的时间是钻所有孔的时间与钻头移动的时间。钻所有孔所需的时间独立于钻孔顺序。然而,钻头移动时间是钻头移动距离的函数。因此,希望找到最短的移动路径。 另有一个例子,考察一个批量生产的环境,其中有一个特殊的机器可用来生产n 个不同的产品。利用一个生产循环不断地生产这些产品。在一个循环中,所有n 个产品被顺序生产出来,然后再开始下一个循环。在下一个循环中,又采用了同样的生产顺序。例如,如果这台机器被用来顺序为小汽车喷红、白、蓝漆,那么在为蓝色小汽车喷漆之后,我们又开始了新一轮循环,为红色小汽车喷漆,然后是白色小汽车、蓝色小汽车、红色小汽车,..,如此下去。一个循环的花费包括生产一个循环中的产品所需的花费以及循环中从一个产品转变到另一个产品的花费。虽然生产产品的花费独立于产品生产顺序,但循环中从生产一个产品转变到生产另一个产品的花费却与顺序有关。为了使耗费最小化,可以定义一个有向图,图中的顶点表示产品,边<(i , j)>上的耗费值为生产过程中从产品i 转变到产品j 所需的耗费。一个最小耗费的旅行定义了一个最小耗费的生产循环。既然旅行是包含所有顶点的一个循环,故可以把任意一个点作为起点(因此也是终点)。针对图1 6 - 4,任意选取点1作为起点和终点,则每一个旅行可用顶点序列1, v2 ,., vn , 1来描述,v2 , ., vn 是(2, 3, ., n) 的一个排列。可能的旅行可用一个树来描述,其中每一个从根到叶的路径定义了一个旅行。图1 6 - 5给出了一棵表示四顶点网络的树。从根到叶的路径中各边的标号定义了一个旅行(还要附加1作为终点)。例如,到节点L的路径表示了旅行1 , 2 , 3 , 4 , 1,而到节点O的路径表示了旅行1 , 3 , 4 , 2 , 1。网络中的每一个旅行都由树中的一条从根到叶的确定路径来表示。因此,树中叶的数目为(n- 1 )!。 回溯算法将用深度优先方式从根节点开始,通过搜索解空间树发现一个最小耗费的旅行。对图1 6 - 4的网络,利用图1 6 - 5的解空间树,一个可能的搜索为A B C F L。在L点,旅行1 , 2 , 3 , 4 , 1作为当前最好的旅行被记录下来。它的耗费是5 9。从L点回溯到活节点F。由于F没有未被检查的孩子,所以它成为死节点,回溯到C点。C变为E-节点,向前移动到G,然后是M。这样构造出了旅行1 , 2 , 4 , 3 , 1,它的耗费是6 6。既然它不比当前的最佳旅行好,抛弃它并回溯到G,然后是C , B。从B点,搜索向前移动到D,然后是H , N。这个旅行1 , 3 , 2 , 4 , 1的耗费是2 5,比当前的最佳旅行好,把它作为当前的最好旅行。从N点,搜索回溯到H,然后是D。在D点,再次向前移动,到达O点。如此继续下去,可搜索完整个树,得出1 , 3 , 2 , 4 , 1是最少耗费的旅行。当要求解的问题需要根据n 个元素的一个子集来优化某些函数时,解空间树被称作子集树(subset tree)。所以对有n 个对象的0 / 1背包问题来说,它的解空间树就是一个子集树。这样一棵树有2n 个叶节点,全部节点有2n+ 1-1个。因此,每一个对树中所有节点进行遍历的算法都必须耗时W ( 2n )。当要求解的问题需要根据一个n 元素的排列来优化某些函数时,解空间树被称作排列树(permutation tree)。这样的树有n! 个叶节点,所以每一个遍历树中所有节点的算法都必须耗时W (n! )。图1 6 - 5中的树是顶点{ 2 , 3 , 4 }的最佳排列的解空间树,顶点1是旅行的起点和终点。 通过确定一个新近到达的节点能否导致一个比当前最优解还要好的解,可加速对最优解的搜索。如果不能,则移动到该节点的任何一个子树都是无意义的,这个节点可被立即杀死,用来杀死活节点的策略称为限界函数( bounding function)。在例1 6 - 2中,可使用如下限界函数:杀死代表不可行解决方案的节点;对于旅行商问题,可使用如下限界函数:如果目前建立的部分旅行的耗费不少于当前最佳路径的耗费,则杀死当前节点。如果在图1 6 - 4的例子中使用该限界函数,那么当到达节点I时,已经找到了具有耗费2 5的1 , 3 , 2 , 4 , 1的旅行。在节点I,部分旅行1 , 3 , 4的耗费为2 6,若旅行通过该节点,那么不能找到一个耗费小于2 5的旅行,故搜索以I为根节点的子树毫无意义。 小结 回溯方法的步骤如下: 1) 定义一个解空间,它包含问题的解。 2) 用适于搜索的方式组织该空间。 3) 用深度优先法搜索该空间,利用限界函数避免移动到不可能产生解的子空间。 回溯算法的一个有趣的特性是在搜索执行的同时产生解空间。在搜索期间的任何时刻,仅保留从开始节点到当前E-节点的路径。因此,回溯算法的空间需求为O(从开始节点起最长路径的长度)。这个特性非常重要,因为解空间的大小通常是最长路径长度的指数或阶乘。所以如果要存储全部解空间的话,再多的空间也不够用。 练习 1. 考察如下0 / 1背包问题:n= 4,w= [ 2 0 , 2 5 , 1 5 , 3 5 ],p= [ 4 0 , 4 9 , 2 5 , 6 0 ],c= 6 2。 1) 画出该0 / 1背包问题的解空间树。 2) 对该树运用回溯算法(利用给出的p s , w s , c值),依回溯算法遍历节点的顺序标记节点。确定回溯算法未遍历的节点。 2. 1) 当n= 5时,画出旅行商问题的解空间树。 2) 在该树上,运用回溯算法(使用图1 6 - 6的例子)。依回溯算法遍历节点的顺序标记节点。确定未被遍历的节点。 3. 每周六, Mary 和Joe 都在一起打乒乓球。她们每人都有一个装有1 2 0个球的篮子。这样一直打下去,直到两个篮子为空。然后她们需要从球桌周围拾起2 4 0个球,放入各自的篮子。Mary 只拾她这边的球,而Joe 拾剩下的球。描述如何用旅行商问题帮助Mary 和Joe 决定她们拾球的顺序以便她们能走最少的路径。 4.2 应用 4.2.1 货箱装船 1. 问题 在1 . 3节中,考察了用最大数量的货箱装船的问题。现在对该问题做一些改动。在新问题中,有两艘船, n 个货箱。第一艘船的载重量是c1,第二艘船的载重量是c2,wi 是货箱i 的重量且n ?i = 1wi≤c1+c2。我们希望确定是否有一种可将所有n 个货箱全部装船的方法。若有的话,找出该方法。例4-4 当n= 3,c1 =c2 = 5 0,w=[10,40,40] 时,可将货箱1 , 2装到第一艘船上,货箱3装到第二艘船上。如果w= [ 2 0 , 4 0 , 4 0 ],则无法将货箱全部装船。当n ?i = 1wi=c1+c2 时,两艘船的装载问题等价于子集之和( s u m - o f - s u b s e t)问题,即有n 个数字,要求找到一个子集(如果存在的话)使它的和为c1。当c1=c2 且n ?i = 1wi=2c1 时,两艘船的装载问题等价于分割问题( partition problem),即有n个数字ai , ( 1≤i≤n),要求找到一个子集(若存在的话),使得子集之和为( n ?i = 1ai)/ 2。分割问题和子集之和问题都是N P-复杂问题。而且即使问题被限制为整型数字,它们仍是N P-复杂问题。所以不能期望在多项式时间内解决两艘船的装载问题。当存在一种方法能够装载所有n 个货箱时,可以验证以下的装船策略可以获得成功: 1) 尽可能地将第一艘船装至它的重量极限; 2) 将剩余货箱装到第二艘船。为了尽可能地将第一艘船装满,需要选择一个货箱的子集,它们的总重量尽可能接近c1。这个选择可通过求解0 / 1背包问题来实现,即寻找m ax (n ?i = 1wi xi ),其中n ?i = 1wi xi≤c1,xi ?{ 0 , 1 },1≤i≤n。当重量是整数时,可用1 5 . 2节的动态规划方法确定第一艘船的最佳装载。用元组方法所需时间为O ( m i n {c1 , 2 n })。可以使用回溯方法设计一个复杂性为O ( 2n ) 的算法,在有些实例中,该方法比动态规划算法要好。 2. 第一种回溯算法 既然想要找到一个重量的子集,使子集之和尽量接近c1,那么可以使用一个子集空间,并将其组织成如图1 6 - 2那样的二叉树。可用深度优先的方法搜索该解空间以求得最优解。使用限界函数去阻止不可能获得解答的节点的扩张。如果Z是树的j+ 1层的一个节点,那么从根到O的路径定义了xi(1 ≤i≤j)的值。使用这些值,定义c w(当前重量)为n ?i = 1wi xi 。若c w>c1,则以O为根的子树不能产生一个可行的解答。可将这个测试作为限界函数。当且仅当一个节点的c w值大于c1 时,定义它是不可行的。 例4-5 假定n= 4,w= [ 8 , 6 , 2 , 3 ],c1 = 1 2。解空间树为图1 6 - 2的树再加上一层节点。搜索从根A开始且c w= 0。若移动到左孩子B则c w= 8,c w≤c1 = 1 2。以B为根的子树包含一个可行的节点,故移动到节点B。从节点B不能移动到节点D,因为c w+w2 >c1。移动到节点E,这个移动未改变c w。下一步为节点J,c w= 1 0。J的左孩子的c w值为1 3,超出了c1,故搜索不能移动到J的左孩子。可移动到J的右孩子,它是一个叶节点。至此,已找到了一个子集,它的c w= 1 0。xi 的值由从A到J的右孩子的路径获得,其值为[ 1 , 0 , 1 , 0 ]。回溯算法接着回溯到J,然后是E。从E,再次沿着树向下移动到节点K,此时c w= 8。移动到它的左子树,有c w= 11。既然已到达了一个叶节点,就看是否c w的值大于当前的最优c w 值。结果确实大于最优值,所以这个叶节点表示了一个比[ 1 , 0 , 1 , 0 ]更好的解决方案。到该节点的路径决定了x 的值[ 1 , 0 , 0 , 1 ]。从该叶节点,回溯到节点K,现在移动到K的右孩子,一个具有c w= 8的叶节点。这个叶节点中没有比当前最优cw 值还好的cw 值,所以回溯到K , E , B直到A。从根节点开始,沿树继续向下移动。算法将移动到C并搜索它的子树。当使用前述的限界函数时,便产生了程序1 6 - 1所示的回溯算法。函数M a x L o a d i n g返回≤c的最大子集之和,但它不能找到产生该和的子集。后面将改进代码以便找到这个子集。M a x L o a d i n g调用了一个递归函数m a x L o a d i n g,它是类L o a d i n g的一个成员,定义L o a d i n g类是为了减少M a x L o a d i n g中的参数个数。maxLoading(1) 实际执行解空间的搜索。MaxLoading(i) 搜索以i层节点(该节点已被隐式确定)为根的子树。从根到该节点的路径定义的子解答有一个重量值c w,目前最优解答的重量为b e s t w,这些变量以及与类L o a d i n g的一个成员相关联的其他变量,均由M a x L o a d i n g初始化。 程序16-1 第一种回溯算法 template class Loading { friend MaxLoading(T [], T, int); p r i v a t e : void maxLoading(int i); int n; // 货箱数目 T *w, // 货箱重量数组 c, // 第一艘船的容量 c w, // 当前装载的重量 bestw; // 目前最优装载的重量 } ; template void Loading::maxLoading(int i) {// 从第i 层节点搜索 if (i > n) {// 位于叶节点 if (cw > bestw) bestw = cw; r e t u r n ; } // 检查子树 if (cw + w[i] <= c) {// 尝试x[i] = 1 cw += w[i]; m a x L o a d i n g ( i + 1 ) ; cw -= w[i];} maxLoading(i+1);// 尝试x[i] = 0 } template T MaxLoading(T w[], T c, int n) {// 返回最优装载的重量 Loading X; // 初始化X X.w = w; X.c = c; X.n = n; X.bestw = 0; X.cw = 0; // 计算最优装载的重量 X . m a x L o a d i n g ( 1 ) ; return X.bestw; } 如果i>n,则到达了叶节点。被叶节点定义的解答有重量c w,它一定≤c,因为搜索不会移动到不可行的节点。若c w > b e s t w,则目前最优解答的值被更新。当i≤n 时,我们处在有两个孩子的节点Z上。左孩子表示x [ i ] = 1的情况,只有c w + w [ i ]≤c 时,才能移到这里。当移动到左孩子时, cw 被置为c w + w [ i ],且到达一个i + 1层的节点。以该节点为根的子树被递归搜索。当搜索完成时,回到节点Z。为了得到Z的cw 值,需用当前的cw 值减去w [ i ],Z的右子树还未搜索。既然这个子树表示x [ i ] = 0的情况,所以无需进行可行性检查就可移动到该子树,因为一个可行节点的右孩子总是可行的。 注意:解空间树未被m a x L o a d i n g显示构造。函数m a x L o a d i n g在它到达的每一个节点上花费( 1 )时间。到达的节点数量为O ( 2n ),所以复杂性为O ( 2n )。这个函数使用的递归栈空间为(n)。 3. 第二种回溯方法 通过不移动到不可能包含比当前最优解还要好的解的右子树,能提高函数m a x L o a d i n g的性能。令b e s t w为目前最优解的重量, Z为解空间树的第i 层的一个节点, c w的定义如前。以Z为根的子树中没有叶节点的重量会超过c w + r,其中r=n ?j = i + 1w[ j ] 为剩余货箱的重量。因此,当c w + r≤b e s t w时,没有必要去搜索Z的右子树。 例4-6 令n, w, c1 的值与例4 - 5中相同。用新的限界函数,搜索将像原来那样向前进行直至到达第一个叶节点J(它是J的右孩子)。bestw 被置为1 0。回溯到E,然后向下移动到K的左孩子,此时b e s t w被更新为11。我们没有移动到K的右孩子,因为在右孩子节点c w = 8,r = 0,c w + r≤b e s t w。回溯到节点A。同样,不必移动到右孩子C,因为在C点c w = 0,r = 11且c w + r≤b e s t w。加强了条件的限界函数避免了对A的右子树及K的右子树的搜索。当使用加强了条件的限界函数时,可得到程序1 6 - 2的代码。这个代码将类型为T的私有变量r 加到了类L o a d i n g的定义中。新的代码不必检查是否一个到达的叶节点有比当前最优解还优的重量值。这样的检查是不必要的,因为加强的限界函数不允许移动到不能产生较好解的节点。因此,每到达一个新的叶节点就意味着找到了比当前最优解还优的解。虽然新代码的复杂性仍是O ( 2n ),但它可比程序1 6 - 1少搜索一些节点。 程序16-2 程序1 6 - 1的优化 template void Loading::maxLoading(int i) {// // 从第i 层节点搜索 if (i > n) {// 在叶节点上 bestw = cw; r e t u r n ; } // 检查子树 r -= w[i]; if (cw + w[i] <= c) {//尝试x[i] = 1 cw += w[i]; m a x L o a d i n g ( i + 1 ) ; cw -= w[i];} if (cw + r > bestw) //尝试x[i] = 0 m a x L o a d i n g ( i + 1 ) ; r += w[i]; } template T MaxLoading(T w[], T c, int n) {// 返回最优装载的重量 Loading X; // 初始化X X.w = w; X.c = c; X.n = n; X.bestw = 0; X.cw = 0; // r的初始值为所有重量之和 X.r = 0; for (int i = 1; i <= n; i++) X.r += w[i]; // 计算最优装载的重量 X . m a x L o a d i n g ( 1 ) ; return X.bestw; } 4. 寻找最优子集 为了确定具有最接近c 的重量的货箱子集,有必要增加一些代码来记录当前找到的最优子集。为了记录这个子集,将参数bestx 添加到Maxloading 中。bestx 是一个整数数组,其中元素可为0或1,当且仅当b e s t x [ i ] = 1时,货箱i 在最优子集中。新的代码见程序1 6 - 3。 程序16-3 给出最优装载的代码 template void Loading::maxLoading(int i) { / /从第i 层节点搜索 if (i > n) {// 在叶节点上 for (int j = 1; j <= n; j++) bestx[j] = x[j]; bestw = cw; return;} // 检查子树 r -= w[i]; if (cw + w[i] <= c) {//尝试x[i] = 1 x[i] = 1; cw += w[i]; m a x L o a d i n g ( i + 1 ) ; cw -= w[i];} if (cw + r > bestw) {//尝试x[i] = 0 x[i] = 0; m a x L o a d i n g ( i + 1 ) ; } r += w[i]; } template T MaxLoading(T w[], T c, int n, int bestx[]) {// 返回最优装载及其值 Loading X; // 初始化X X.x = new int [n+1]; X.w = w; X.c = c; X.n = n; X.bestx = bestx; X.bestw = 0; X.cw = 0; // r的初始值为所有重量之和 X.r = 0; for (int i = 1; i <= n; i++) X.r += w[i]; X . m a x L o a d i n g ( 1 ) ; delete [] X.x; return X.bestw; } 这段代码在L o a d i n g中增加了两个私有数据成员: x 和b e s t x。这两个私有数据成员都是整型的一维数组。数组x 用来记录从搜索树的根到当前节点的路径(即它保留了路径上的xi 值),b e s t x记录当前最优解。无论何时到达了一个具有较优解的叶节点, bestx 被更新以表示从根到叶的路径。为1的xi 值确定了要被装载的货箱。数据x 的空间由MaxLoading 分配。因为bestx 可以被更新O ( 2n )次,故maxLoading 的复杂性为O (n2n )。使用下列方法之一,复杂性可降为O ( 2n ): 1) 首先运行程序1 6 - 2的代码,以决定最优装载重量,令其为W。然后运行程序1 6 - 3的一个修改版本。该版本以b e s t w = W开始运行,当c w + r≥b e s t w时搜索右子树,第一次到达一个叶节点时便终止(即i > n)。 2) 修改程序1 6 - 3的代码以不断保留从根到当前最优叶的路径。尤其当位于i 层节点时,则到最优叶的路径由x [ j ](1≤j T MaxLoading(T w[], T c, int n, int bestx[]) {// 返回最佳装载及其值 // 迭代回溯程序 // 初始化根节点 int i = 1; // 当前节点的层次 // x[1:i-1] 是到达当前节点的路径 int *x = new int [n+1]; T bestw = 0, // 迄今最优装载的重量 cw = 0, // 当前装载的重量 r = 0; // 剩余货箱重量的和 for (int j = 1; j <= n; j++) r += w[j]; // 在树中搜索 while (true) { // 下移,尽可能向左 while (i <= n && cw + w[i] <= c) { // 移向左孩子 r -= w[i]; cw += w[i]; x[i] = 1; i + + ; } if (i > n) {// 到达叶子 for (int j = 1; j <= n; j++) bestx[j] = x[j]; bestw = cw;} else {// 移向右孩子 r -= w[i]; x[i] = 0; i + + ; } // 必要时返回 while (cw + r <= bestw) { // 本子树没有更好的叶子,返回 i - - ; while (i > 0 && !x[i]) { // 从一个右孩子返回 r += w[i]; i - - ; } if (i == 0) {delete [] x; return bestw;} // 移向右子树 x[i] = 0; cw -= w[i]; i + + ; } } } 4.2.2 0/1背包问题 0 / 1背包问题是一个N P-复杂问题,为了解决该问题,在1 . 4节采用了贪婪算法,在3 . 2节又采用了动态规划算法。在本节,将用回溯算法解决该问题。既然想选择一个对象的子集,将它们装入背包,以便获得的收益最大,则解空间应组织成子集树的形状(如图1 6 - 2所示)。该回溯算法与4 . 2节的装载问题很类似。首先形成一个递归算法,去找到可获得的最大收益。然后,对该算法加以改进,形成代码。改进后的代码可找到获得最大收益时包含在背包中的对象的集合。与程序1 6 - 2一样,左孩子表示一个可行的节点,无论何时都要移动到它;当右子树可能含有比当前最优解还优的解时,移动到它。一种决定是否要移动到右子树的简单方法是令r 为还未遍历的对象的收益之和,将r 加到c p(当前节点所获收益)之上,若( r + c p)≤b e s t p(目前最优解的收益),则不需搜索右子树。一种更有效的方法是按收益密度pi / wi 对剩余对象排序,将对象按密度递减的顺序去填充背包的剩余容量,当遇到第一个不能全部放入背包的对象时,就使用它的一部分。 例4-7 考察一个背包例子: n= 4,c= 7,p= [ 9 , 1 0 , 7 , 4 ],w= [ 3 , 5 , 2 , 1 ]。这些对象的收益密度为[ 3 , 2 , 3 . 5 , 4 ]。当背包以密度递减的顺序被填充时,对象4首先被填充,然后是对象3、对象1。在这三个对象被装入背包之后,剩余容量为1。这个容量可容纳对象2的0 . 2倍的重量。将0 . 2倍的该对象装入,产生了收益值2。被构造的解为x= [ 1 , 0 . 2 , 1 , 1 ],相应的收益值为2 2。尽管该解不可行(x2 是0 . 2,而实际上它应为0或1),但它的收益值2 2一定不少于要求的最优解。因此,该0 / 1背包问题没有收益值多于2 2的解。解空间树为图1 6 - 2再加上一层节点。当位于解空间树的节点B时,x1= 1,目前获益为c p= 9。该节点所用容量为c w= 3。要获得最好的附加收益,要以密度递减的顺序填充剩余容量c l e f t=ccw= 4。也就是说,先放对象4,然后是对象3,然后是对象2的0 . 2倍的重量。因此,子树A的最优解的收益值至多为2 2。当位于节点C时,c p=c w= 0,c l e f t= 7。按密度递减顺序填充剩余容量,则对象4和3被装入。然后是对象2的0 . 8倍被装入。这样产生出收益值1 9。在子树C中没有节点可产生出比1 9还大的收益值。在节点E,c p= 9,c w= 3,c l e f t= 4。仅剩对象3和4要被考虑。当对象按密度递减的顺序被考虑时,对象4先被装入,然后是对象3。所以在子树E中无节点有多于c p+ 4 + 7 = 2 0的收益值。如果已经找到了一个具有收益值2 0或更多的解,则无必要去搜索E子树。一种实现限界函数的好方法是首先将对象按密度排序。假定已经做了这样的排序。定义类K n a p(见程序1 6 - 5)来减少限界函数B o u n d(见程序1 6 - 6)及递归函数K n a p s a c k(见程序1 6 - 7)的参数数量,该递归函数用于计算最优解的收益值。参数的减少又可引起递归栈空间的减少以及每一个K n a p s a c k的执行时间的减少。注意函数K n a p s a c k和函数m a x L o a d i n g(见程序1 6 - 2)的相似性。同时注意仅当向右孩子移动时,限界函数才被计算。当向左孩子移动时,左孩子的限界函数的值与其父节点相同。 程序16-5 Knap类 template class Knap { friend Tp Knapsack(Tp *, Tw *, Tw, int); p r i v a t e : Tp Bound(int i); void Knapsack(int i); Tw c; / /背包容量 int n; // 对象数目 Tw *w; // 对象重量的数组 Tp *p; // 对象收益的数组 Tw cw; // 当前背包的重量 Tp cp; // 当前背包的收益 Tp bestp; // 迄今最大的收益 } ; 程序16-6 限界函数 template Tp Knap::Bound(int i) {// 返回子树中最优叶子的上限值Return upper bound on value of // best leaf in subtree. Tw cleft = c - cw; // 剩余容量 Tp b = cp; // 收益的界限 // 按照收益密度的次序装填剩余容量 while (i <= n && w[i] <= cleft) { cleft -= w[i]; b += p[i]; i + + ; } // 取下一个对象的一部分 if (i <= n) b += p[i]/w[i] * cleft; return b; } 程序16-7 0/1背包问题的迭代函数 template void Knap::Knapsack(int i) {// 从第i 层节点搜索 if (i > n) {// 在叶节点上 bestp = cp; r e t u r n ; } // 检查子树 if (cw + w[i] <= c) {//尝试x[i] = 1 cw += w[i]; cp += p[i]; K n a p s a c k ( i + 1 ) ; cw -= w[i]; cp -= p[i];} if (Bound(i+1) > bestp) // 尝试x[i] = 0 K n a p s a c k ( i + 1 ) ; } 在执行程序1 6 - 7的函数Kn a p s a c k之前,需要按密度对对象排序,也要确保对象的重量总和超出背包的容量。为了完成排序,定义了类O b j e c t(见程序1 6 - 8)。注意定义操作符< =是为了使归并排序程序(见程序1 4 - 3)能按密度递减的顺序排序。 程序16-8 Object类 class Object { friend int Knapsack(int *, int *, int, int); p u b l i c : int operator<=(Object a) const {return (d >= a.d);} p r i v a t e : int ID; // 对象号 float d; // 收益密度 } ; 程序1 6 - 9首先验证重量之和超出背包容量,然后排序对象,在执行K n a p : : K n a p s a c k之前完成一些必要的初始化。K n a p : K n a p s a c k的复杂性是O (n2n ),因为限界函数的复杂性为O (n),且该函数在O ( 2n )个右孩子处被计算。 程序16-9 程序1 6 - 7的预处理代码 template Tp Knapsack(Tp p[], Tw w[], Tw c, int n) {// 返回最优装包的值 // 初始化 Tw W = 0; // 记录重量之和 Tp P = 0; // 记录收益之和 // 定义一个按收益密度排序的对象数组 Object *Q = new Object [n]; for (int i = 1; i <= n; i++) { // 收益密度的数组 Q[i-1].ID = i; Q[i-1].d = 1.0*p[i]/w[i]; P += p[i]; W += w[i]; } if (W <= c) return P; // 可容纳所有对象 MergeSort(Q,n); // 按密度排序 // 创建K n a p的成员 K n a p < Tw, Tp> K; K.p = new Tp [n+1]; K.w = new Tw [n+1]; for (i = 1; i <= n; i++) { K.p[i] = p[Q[i-1].ID]; K.w[i] = w[Q[i-1].ID]; } K.cp = 0; K.cw = 0; K.c = c; K.n = n; K.bestp = 0; // 寻找最优收益 K . K n a p s a c k ( 1 ) ; delete [] Q; delete [] K.w; delete [] K.p; return K.bestp; } 4.2.3 最大完备子图 令U为无向图G的顶点的子集,当且仅当对于U中的任意点u 和v,(u , v)是图G的一条边时,U定义了一个完全子图(complete subgraph)。子图的尺寸为图中顶点的数量。当且仅当一个完全子图不被包含在G的一个更大的完全子图中时,它是图G的一个完备子图。最大的完备子图是具有最大尺寸的完备子图。 例4-8 在图16-7a 中,子集{ 1 , 2 }定义了一个尺寸为2的完全子图。这个子图不是一个完备子图,因为它被包含在一个更大的完全子图{ 1 , 2 , 5 }中。{ 1 , 2 , 5 }定义了该图的一个最大的完备子图。点集{ 1 , 4 , 5 }和{ 2 , 3 , 5 }定义了其他的最大的完备子图。当且仅当对于U中任意点u 和v,(u , v) 不是G的一条边时,U定义了一个空子图。当且仅当一个子集不被包含在一个更大的点集中时,该点集是图G的一个独立集(independent set),同时它也定义了图G的空子图。最大独立集是具有最大尺寸的独立集。对于任意图G,它的补图(c o m p l e m e n t) 是有同样点集的图,且当且仅当( u,v)不是G的一条边时,它是的一条边。例4-9 图16-7b 是图16-7a 的补图,反之亦然。{ 2 , 4 }定义了16-7a 的一个空子图,它也是该图的一个最大独立集。虽然{ 1 , 2 }定义了图16-7b 的一个空子图,它不是一个独立集,因为它被包含在空子图{ 1 , 2 , 5 }中。{ 1 , 2 , 5 }是图16-7b 中的一个最大独立集。如果U定义了G的一个完全子图,则它也定义了的一个空子图,反之亦然。所以在G的完备子图与的独立集之间有对应关系。特别的, G的一个最大完备子图定义了的一个最大独立集。最大完备子图问题是指寻找图G的一个最大完备子图。类似地,最大独立集问题是指寻找图G的一个最大独立集。这两个问题都是N P-复杂问题。当用算法解决其中一个问题时,也就解决了另一个问题。例如,如果有一个求解最大完备子图问题的算法,则也能解决最大独立集问题,方法是首先计算所给图的补图,然后寻找补图的最大完备子图。例4-10 假定有一个n 个动物构成的集合。可以定义一个有n 个顶点的相容图(c o m p a t i b i l i t yg r a p h)G。当且仅当动物u 和v 相容时,(u,v)是G的一条边。G的一个最大完备子图定义了相互间相容的动物构成的最大子集。3 . 2节考察了如何找到一个具有最大尺寸的互不交叉的网组的集合问题。可以把这个问题看作是一个最大独立集问题。定义一个图,图中每个顶点表示一个网组。当且仅当两个顶点对应的网组交叉时,它们之间有一条边。所以该图的一个最大独立集对应于非交叉网组的一个最大尺寸的子集。当网组有一个端点在路径顶端,而另一个在底端时,非交叉网组的最大尺寸的子集能在多项式时间(实际上是(n2 ))内用动态规划算法得到。当一个网组的端点可能在平面中的任意地方时,不可能有在多项式时间内找到非交叉网组的最大尺寸子集的算法。最大完备子图问题和最大独立集问题可由回溯算法在O (n2n )时间内解决。两个问题都可使用子集解空间树(如图1 6 - 2所示)。考察最大完备子图问题,该递归回溯算法与程序1 6 - 3非常类似。当试图移动到空间树的i 层节点Z的左孩子时,需要证明从顶点i 到每一个其他的顶点j(xj = 1且j 在从根到Z的路径上)有一条边。当试图移动到Z的右孩子时,需要证明还有足够多的顶点未被搜索,以便在右子树有可能找到一个较大的完备子图。回溯算法可作为类A d j a c e n c y G r a p h(见程序1 2 - 4)的一个成员来实现,为此首先要在该类中加入私有静态成员x(整型数组,用于存储到当前节点的路径),b e s t x(整型数组,保存目前的最优解),b e s t n(b e s t x中点的数量),c n(x 中点的数量)。所以类A d j a c e c y G r a p h的所有实例都能共享这些变量。函数m a x C l i q u e(见程序1 6 - 1 0)是类A d j a c e n c y G r a p h的一个私有成员,而M a x C l i q u e是一个共享成员。函数m a x C l i q u e对解空间树进行搜索,而M a x C i q u e初始化必要的变量。M a x c l i q u e ( v )的执行返回最大完备子图的尺寸,同时它也设置整型数组v,当且仅当顶点i 不是所找到的最大完备子图的一个成员时,v [ i ] = 0。 程序16-10 最大完备子图 void AdjacencyGraph::maxClique(int i) {// 计算最大完备子图的回溯代码 if (i > n) {// 在叶子上 // 找到一个更大的完备子图,更新 for (int j = 1; j <= n; j++) bestx[j] = x[j]; bestn = cn; r e t u r n ; } // 在当前完备子图中检查顶点i是否与其它顶点相连 int OK = 1; for (int j = 1; j < i; j++) if (x[j] && a[i][j] == NoEdge) { // i 不与j 相连 OK = 0; b r e a k ; } if (OK) {// 尝试x[i] = 1 x[i] = 1; // 把i 加入完备子图 c n + + ; m a x C l i q u e ( i + 1 ) ; x[i] = 0; c n - - ; } if (cn + n - i > bestn) {// 尝试x[i] = 0 x[i] = 0; m a x C l i q u e ( i + 1 ) ; } } int AdjacencyGraph::MaxClique(int v[]) {// 返回最大完备子图的大小 // 完备子图的顶点放入v [ 1 : n ] // 初始化 x = new int [n+1]; cn = 0; bestn = 0; bestx = v; // 寻找最大完备子图 m a x C l i q u e ( 1 ) ; delete [] x; return bestn; } 4.2.4 旅行商问题 旅行商问题(例4 . 3)的解空间是一个排列树。这样的树可用函数P e r m (见程序1 - 1 0 )搜索,并可生成元素表的所有排列。如果以x=[1, 2, ., n] 开始,那么通过产生从x2 到xn 的所有排列,可生成n 顶点旅行商问题的解空间。由于P e r m产生具有相同前缀的所有排列,因此可以容易地改造P e r m,使其不能产生具有不可行前缀(即该前缀没有定义路径)或不可能比当前最优旅行还优的前缀的排列。注意在一个排列空间树中,由任意子树中的叶节点定义的排列有相同的前缀(如图1 6 - 5所示)。因此,考察时删除特定的前缀等价于搜索期间不进入相应的子树。旅行商问题的回溯算法可作为类A d j a c e n c y W D i g r a p h(见程序1 2 - 1)的一个成员。在其他例子中,有两个成员函数: t S P和T S P。前者是一个保护或私有成员,后者是一个共享成员。函数G . T S P ( v )返回最少耗费旅行的花费,旅行自身由整型数组v 返回。若网络中无旅行,则返回N o E d g e。t S P在排列空间树中进行递归回溯搜索, T S P是其一个必要的预处理过程。T S P假定x(用来保存到当前节点的路径的整型数组),b e s t x(保存目前发现的最优旅行的整型数组),c c(类型为T的变量,保存当前节点的局部旅行的耗费),b e s t c(类型为T的变量,保存目前最优解的耗费)已被定义为A d j a c e n c y W D i g r a p h中的静态数据成员。T S P见程序1 6 - 11。t S P ( 2 )搜索一棵包含x [ 2 : n ]的所有排列的树。 程序1 6 - 11 旅行商回溯算法的预处理程序 template T AdjacencyWDigraph::TSP(int v[]) {// 用回溯算法解决旅行商问题 // 返回最优旅游路径的耗费,最优路径存入v [ 1 : n ] / /初始化 x = new int [n+1]; // x 是排列 for (int i = 1; i <= n; i++) x[i] = i; bestc = NoEdge; bestx = v; // 使用数组v来存储最优路径 cc = 0; // 搜索x [ 2 : n ]的各种排列 t S P ( 2 ) ; delete [] x; return bestc; } 函数t S P见程序1 6 - 1 2。它的结构与函数P e r m相同。当i=n 时,处在排列树的叶节点的父节点上,并且需要验证从x[n-1] 到x[n] 有一条边,从x[n] 到起点x[1] 也有一条边。若两条边都存在,则发现了一个新旅行。在本例中,需要验证是否该旅行是目前发现的最优旅行。若是,则将旅行和它的耗费分别存入b e s t x与b e s t c中。当i<n 时,检查当前i-1 层节点的孩子节点,并且仅当以下情况出现时,移动到孩子节点之一:1) 有从x[i-1] 到x[i] 的一条边(如果是这样的话, x [ 1 : i ]定义了网络中的一条路径);2 )路径x[1:i] 的耗费小于当前最优解的耗费。变量cc 保存目前所构造的路径的耗费。每次找到一个更好的旅行时,除了更新bestx 的耗费外, tS P需耗时O ( (n- 1 ) ! )。因为需发生O ( (n-1)!) 次更新且每一次更新的耗费为(n) 时间,因此更新所需时间为O (n* (n- 1 ) ! )。通过使用加强的条件(练习1 6),能减少由tS P搜索的树节点的数量。 程序16-12 旅行商问题的迭代回溯算法 void AdjacencyWDigraph::tSP(int i) {// 旅行商问题的回溯算法 if (i == n) {// 位于一个叶子的父节点 // 通过增加两条边来完成旅行 if (a[x[n-1]][x[n]] != NoEdge && a[x[n]][1] != NoEdge && (cc + a[x[n-1]][x[n]] + a[x[n]][1] < bestc || bestc == NoEdge)) {// 找到更优的旅行路径 for (int j = 1; j <= n; j++) bestx[j] = x[j]; bestc = cc + a[x[n-1]][x[n]] + a[x[n]][1];} } else {// 尝试子树 for (int j = i; j <= n; j++) / /能移动到子树x [ j ]吗? if (a[x[i-1]][x[j]] != NoEdge && (cc + a[x[i-1]][x[i]] < bestc || bestc == NoEdge)) {//能 // 搜索该子树 Swap(x[i], x[j]); cc += a[x[i-1]][x[i]]; t S P ( i + 1 ) ; cc -= a[x[i-1]][x[i]]; Swap(x[i], x[j]);} } } 4.2.5 电路板排列 在大规模电子系统的设计中存在着电路板排列问题。这个问题的经典形式为将n个电路板放置到一个机箱的许多插槽中,(如图1 6 - 8所示)。n个电路板的每一种排列定义了一种放置方法。令B= {b1 , ., bn }表示这n个电路板。m个网组集合L= {N1,., Nm }由电路板定义,Ni 是B的子集,子集中的元素需要连接起来。实际中用电线将插有这些电路板的插槽连接起来。 例4 - 11 令n=8, m= 5。集合B和L如下: B= {b1, b2, b3, b4, b5, b6, b7, b8 } L= {N1, N2, N3, N4, N5 } N1 = {b4, b5, b6 } N2 = {b2, b3 } N3 = {b1, b3 } N4 = {b3, b6 } N5 = {b7, b8 } 令x 为电路板的一个排列。电路板xi 被放置到机箱的插槽i 中。d e n s i t y(x) 为机箱中任意一对相邻插槽间所连电线数目中的最大值。对于图1 6 - 9中的排列,d e n s i t y为2。有两根电线连接了插槽2和3,插槽4和5,插槽5和6。插槽6和7之间无电线,余下的相邻插槽都只有一根电线。板式机箱被设计成具有相同的相邻插槽间距,因此这个间距决定了机箱的大小。该间距必须保证足够大以便容纳相邻插槽间的连线。因此这个距离(继而机箱的大小)由d e n s i t y(x)决定。电路板排列问题的目标是找到一种电路板的排列方式,使其有最小的d e n s i t y。既然该问题是一个N P-复杂问题,故它不可能由一个多项式时间的算法来解决,而象回溯这样的搜索方法则是解决该问题的一种较好方法。回溯算法为了找到最优的电路板排列方式,将搜索一个排列空间。用一个n ×m 的整型数组B表示输入,当且仅当Nj 中包含电路板bi 时,B [ i ] [ j ] = 1。令t o t a l [ j ]为Nj 中电路板的数量。对于任意部分的电路板排列x [ 1 : i ],令n o w [ j ]为既在x [ 1 : i ]中又被包含在Nj 中的电路板的数量。当且仅当n o w [ j ] > 0且n o w [ j ]≠t o t a l [ j ]时,Nj 在插槽i 和i + 1之间有连线。插槽i 和i + 1间的线密度可利用该测试方法计算出来。在插槽k和k + 1 ( 1≤k≤i ) 间的线密度的最大值给出了局部排列的密度。为了实现电路板排列问题的回溯算法,使用了类B o a r d(见程序1 6 - 1 3)。程序1 6 - 1 4给出了私有方法B e s t O r d e r,程序1 6 - 1 5给出了函数A r r a n g e B o a r d s。ArrangeBoards 返回最优的电路板排列密度,最优的排列由数组bestx 返回。ArrangeBoards 创建类Board 的一个成员x 并初始化与之相关的变量。尤其是total 被初始化以使total[j] 等于Nj 中电路板的数量。now[1:n] 被置为0,与一个空的局部排列相对应。调用x .BestOrder(1,0) 搜索x[1:n] 的排列树,以从密度为0的空排列中找到一个最优的排列。通常,x.BestOrder(i,cd) 寻找最优的局部排列x [ 1 : i - 1 ],该局部排列密度为c d。函数B e s t O r d e r(见程序1 6 - 1 4)和程序1 6 - 1 2有同样的结构,它也搜索一个排列空间。当i=n 时,表示所有的电路板已被放置且cd 为排列的密度。既然这个算法只寻找那些比当前最优排列还优的排列,所以不必验证cd 是否比beste 要小。当i 0 && total[k] != now[k]) l d + + ; } // 更新ld 为局部排列的总密度 if (cd > ld) ld = cd; // 仅当子树中包含一个更优的排列时,搜索该子树 if (ld < bestd) {// 移动到孩子 Swap(x[i], x[j]); BestOrder(i+1, ld); Swap(x[i], x[j]);} // 重置 for (k = 1; k <= m; k++) now[k] -= B[x[j]][k]; } } 程序16-15 BestOrder(程序1 6 - 1 4)的预处理代码 int ArrangeBoards(int **B, int n, int m, int bestx[ ]) {// 返回最优密度 // 在b e s t x中返回最优排列 Board X; // 初始化X X.x = new int [n+1]; X.total = new int [m+1]; X.now = new int [m+1]; X.B = B; X.n = n; X.m = m; X.bestx = bestx; X.bestd = m + 1; // 初始化t o t a l和n o w for (int i = 1; i <= m; i++) { X.total[i] = 0; X.now[i] = 0; } // 初始化x并计算t o t a l for (i = 1; i <= n; i++) { X.x[i] = i; for (int j = 1; j <= m; j++) X.total[j] += B[i][j]; } // 寻找最优排列 X . B e s t O r d e r ( 1 , 0 ) ; delete [] X.x; delete [] X.total; delete [] X.now; return X.bestd; 第 5 章 分枝定界 任何美好的事情都有结束的时候。现在我们学习的是本书的最后一章。幸运的是,本章用到的大部分概念在前面各章中已作了介绍。类似于回溯法,分枝定界法在搜索解空间时,也经常使用树形结构来组织解空间(常用的树结构是第1 6章所介绍的子集树和排列树)。然而与回溯法不同的是,回溯算法使用深度优先方法搜索树结构,而分枝定界一般用宽度优先或最小耗费方法来搜索这些树。本章与第1 6章所考察的应用完全相同,因此,可以很容易比较回溯法与分枝定界法的异同。相对而言,分枝定界算法的解空间比回溯法大得多,因此当内存容量有限时,回溯法成功的可能性更大。 5.1 算法思想 分枝定界(branch and bound)是另一种系统地搜索解空间的方法,它与回溯法的主要区别在于对E-节点的扩充方式。每个活节点有且仅有一次机会变成E-节点。当一个节点变为E-节点时,则生成从该节点移动一步即可到达的所有新节点。在生成的节点中,抛弃那些不可能导出(最优)可行解的节点,其余节点加入活节点表,然后从表中选择一个节点作为下一个E-节点。从活节点表中取出所选择的节点并进行扩充,直到找到解或活动表为空,扩充过程才结束。 有两种常用的方法可用来选择下一个E-节点(虽然也可能存在其他的方法): 1) 先进先出(F I F O) 即从活节点表中取出节点的顺序与加入节点的顺序相同,因此活节点表的性质与队列相同。 2) 最小耗费或最大收益法在这种模式中,每个节点都有一个对应的耗费或收益。如果查找一个具有最小耗费的解,则活节点表可用最小堆来建立,下一个E-节点就是具有最小耗费的活节点;如果希望搜索一个具有最大收益的解,则可用最大堆来构造活节点表,下一个E-节点是具有最大收益的活节点。 例5-1 [迷宫老鼠] 考察图16-3a 给出的迷宫老鼠例子和图1 6 - 1的解空间结构。使用F I F O分枝定界,初始时取(1,1)作为E-节点且活动队列为空。迷宫的位置( 1 , 1)被置为1,以免再次返回到这个位置。(1,1)被扩充,它的相邻节点( 1,2)和(2,1)加入到队列中(即活节点表)。为避免再次回到这两个位置,将位置( 1,2)和(2,1)置为1。此时迷宫如图1 7 - 1 a所示,E-节点(1,1)被删除。 节点(1,2)从队列中移出并被扩充。检查它的三个相邻节点(见图1 6 - 1的解空间),只有(1,3)是可行的移动(剩余的两个节点是障碍节点),将其加入队列,并把相应的迷宫位置置为1,所得到的迷宫状态如图17-1b 所示。节点(1,2)被删除,而下一个E-节点(2,1)将会被取出,当此节点被展开时,节点(3,1)被加入队列中,节点(3,1)被置为1,节点(2,1)被删除,所得到的迷宫如图17-1c 所示。此时队列中包含(1,3)和(3,1)两个节点。随后节点(1,3)变成下一个E-节点,由于此节点不能到达任何新的节点,所以此节点即被删除,节点(3,1)成为新的E-节点,将队列清空。节点( 3,1)展开,(3,2)被加入队列中,而(3,1)被删除。(3,2)变为新的E-节点,展开此节点后,到达节点(3,3),即迷宫的出口。使用F I F O搜索,总能找出从迷宫入口到出口的最短路径。需要注意的是:利用回溯法找到的路径却不一定是最短路径。有趣的是,程序6 - 11已经给出了利用F I F O分枝定界搜索从迷宫的(1,1)位置到(n,n) 位置的最短路径的代码。 例5-2 [0/1背包问题] 下面比较分别利用F I F O分枝定界和最大收益分枝定界方法来解决如下背包问题:n=3, w=[20,15,15], p=[40,25,25], c= 3 0。F I F O分枝定界利用一个队列来记录活节点,节点将按照F I F O顺序从队列中取出;而最大收益分枝定界使用一个最大堆,其中的E-节点按照每个活节点收益值的降序,或是按照活节点任意子树的叶节点所能获得的收益估计值的降序从队列中取出。本例所使用的背包问题与例1 6 . 2相同,并且有相同的解空间树。使用F I F O分枝定界法搜索,初始时以根节点A作为E-节点,此时活节点队列为空。当节点A展开时,生成了节点B和C,由于这两个节点都是可行的,因此都被加入活节点队列中,节点A被删除。下一个E-节点是B,展开它并产生了节点D和E,D是不可行的,被删除,而E被加入队列中。下一步节点C成为E-节点,它展开后生成节点F和G,两者都是可行节点,加入队列中。下一个E-节点E生成节点J和K,J不可行而被删除,K是一个可行的叶节点,并产生一个到目前为止可行的解,它的收益值为4 0。下一个E-节点是F,它产生两个孩子L、M,L代表一个可行的解且其收益值为5 0,M代表另一个收益值为1 5的可行解。G是最后一个E-节点,它的孩子N和O都是可行的。由于活节点队列变为空,因此搜索过程终止,最佳解的收益值为5 0。可以看到,工作在解空间树上的F I F O分枝定界方法非常象从根节点出发的宽度优先搜索。它们的主要区别是在F I F O分枝定界中不可行的节点不会被搜索。最大收益分枝定界算法以解空间树中的节点A作为初始节点。展开初始节点得到节点B和C,两者都是可行的并被插入堆中,节点B获得的收益值是4 0(设x1 = 1),而节点C得到的收益值为0。A被删除,B成为下一个E-节点,因为它的收益值比C的大。当展开B时得到了节点D和E,D是不可行的而被删除,E加入堆中。由于E具有收益值4 0,而C为0,因为E成为下一个E-节点。展开E时生成节点J和K,J不可行而被删除,K是一个可行的解,因此K为作为目前能找到的最优解而记录下来,然后K被删除。由于只剩下一个活节点C在堆中,因此C作为E-节点被展开,生成F、G两个节点插入堆中。F的收益值为2 5,因此成为下一个E-节点,展开后得到节点L和M,但L、M都被删除,因为它们是叶节点,同时L所对应的解被作为当前最优解记录下来。最终,G成为E-节点,生成的节点为N和O,两者都是叶节点而被删除,两者所对应的解都不比当前的最优解更好,因此最优解保持不变。此时堆变为空,没有下一个E-节点产生,搜索过程终止。终止于J的搜索即为最优解。 犹如在回溯方法中一样,可利用一个定界函数来加速最优解的搜索过程。定界函数为最大收益设置了一个上限,通过展开一个特殊的节点可能获得这个最大收益。如果一个节点的定界函数值不大于目前最优解的收益值,则此节点会被删除而不作展开,更进一步,在最大收益分枝定界方法中,可以使节点按照它们收益的定界函数值的非升序从堆中取出,而不是按照节点的实际收益值来取出。这种策略从可能到达一个好的叶节点的活节点出发,而不是从目前具有较大收益值的节点出发。 例5-3 [旅行商问题] 对于图1 6 - 4的四城市旅行商问题,其对应的解空间为图1 6 - 5所示的排列树。F I F O分枝定界使用节点B作为初始的E-节点,活节点队列初始为空。当B展开时,生成节点C、D和E。由于从顶点1到顶点2,3,4都有边相连,所以C、D、E三个节点都是可行的并加入队列中。当前的E-节点B被删除,新的E-节点是队列中的第一个节点,即节点C。因为在图1 6 - 4中存在从顶点2到顶点3和4的边,因此展开C,生成节点F和G,两者都被加入队列。下一步,D成为E-节点,接着又是E,到目前为止活节点队列中包含节点F到K。下一个E-节点是F,展开它得到了叶节点L。至此找到了一个旅行路径,它的开销是5 9。展开下一个E-节点G,得到叶节点M,它对应于一个开销为6 6的旅行路径。接着H成为E-节点,从而找到叶节点N,对应开销为2 5的旅行路径。下一个E-节点是I,它对应的部分旅行1 - 3 - 4的开销已经为2 6,超过了目前最优的旅行路径,因此, I不会被展开。最后,节点J,K成为E-节点并被展开。经过这些展开过程,队列变为空,算法结束。找到的最优方案是节点N所对应的旅行路径。如果不使用F I F O方法,还可以使用最小耗费方法来搜索解空间树,即用一个最小堆来存储活节点。这种方法同样从节点B开始搜索,并使用一个空的活节点列表。当节点B展开时,生成节点C、D和E并将它们加入最小堆中。在最小堆的节点中, E具有最小耗费(因为1 - 4的局部旅行的耗费是4),因此成为E-节点。展开E生成节点J和K并将它们加入最小堆,这两个节点的耗费分别为1 4和2 4。此时,在所有最小堆的节点中,D具有最小耗费,因而成为E-节点,并生成节点H和I。至此,最小堆中包含节点C、H、I、J和K,H具有最小耗费,因此H成为下一个E-节点。展开节点E,得到一个完整的旅行路径 1 - 3 - 2 - 4 - 1,它的开销是2 5。节点J是下一个E-节点,展开它得到节点P,它对应于一个耗费为2 5的旅行路径节点K和I是下两个E-节点。由于I的开销超过了当前最优的旅行路径,因此搜索结束,而剩下的所有活节点都不能使我们找到更优的解。对于例5 - 2的背包问题,可以使用一个定界函数来减少生成和展开的节点数量。这种函数将确定旅行的最小耗费的下限,这个下限可通过展开某个特定的节点而得到。如果一个节点的定界函数值不能比当前的最优旅行更小,则它将被删除而不被展开。另外,对于最小耗费分枝定界,节点按照它在最小堆中的非降序取出。在以上几个例子中,可以利用定界函数来降低所产生的树型解空间的节点数目。当设计定界函数时,必须记住主要目的是利用最少的时间,在内存允许的范围内去解决问题。而通过产生具有最少节点的树来解决问题并不是根本的目标。因此,我们需要的是一个能够有效地减少计算时间并因此而使产生的节点数目也减少的定界函数。回溯法比分枝定界在占用内存方面具有优势。回溯法占用的内存是O(解空间的最大路径长度),而分枝定界所占用的内存为O(解空间大小)。对于一个子集空间,回溯法需要(n)的内存空间,而分枝定界则需要O ( 2n ) 的空间。对于排列空间,回溯需要(n) 的内存空间,分枝定界需要O (n!) 的空间。虽然最大收益(或最小耗费)分枝定界在直觉上要好于回溯法,并且在许多情况下可能会比回溯法检查更少的节点,但在实际应用中,它可能会在回溯法超出允许的时间限制之前就超出了内存的限制。 练习 1. 假定在一个L I F O分枝定界搜索中,活节点列表的行为与堆栈相同,请使用这种方法来解决例5 - 2的背包问题。L I F O分枝定界与回溯有何区别? 2. 对于如下0 / 1背包问题:n=4, p=[4,3,2,1], w=[1,2,3,4], c =6。 1) 画出有四个对象的背包问题的解空间树。 2) 像例1 7 - 2那样,描述用F I F O分枝定界法解决上述问题的过程。 3) 使用程序1 6 - 6的B o u n d函数来计算子树上任一叶节点可能获得的最大收益值,并根据每一步所能得到的最优解对应的定界函数值来判断是否将节点加入活节点列表中。解空间中哪些节点是使用以上机制的F I F O分枝定界方法产生的? 4) 像例1 7 - 2那样,描述用最大收益分枝定界法解决上述问题的过程。 5) 在最大收益分枝定界中,若使用3)中的定界函数,将产生解空间树中的哪些节点? 5.2 应用 5.2.1 货箱装船 1. FIFO分枝定界 4 . 2 . 1节的货箱装船问题主要是寻找第一条船的最大装载方案。这个问题是一个子集选择问题,它的解空间被组织成一个子集树。对程序1 6 - 1进行改造,即得到程序1 7 - 1中的F I F O分枝定界代码。程序1 7 - 1只是寻找最大装载的重量。 程序17-1 货箱装船问题的F I F O分枝定界算法 template void AddLiveNode(LinkedQueue &Q, T wt, T& bestw, int i, int n) {// 如果不是叶节点,则将节点权值w t加入队列Q if (i == n) {// 叶子 if (wt > bestw) bestw = wt;} else Q.Add(wt); // 不是叶子 } template T MaxLoading(T w[], T c, int n) {// 返回最优装载值 // 使用F I F O分枝定界算法 // 为层次1 初始化 LinkedQueue Q; // 活节点队列 Q.Add(-1); //标记本层的尾部 int i = 1; // E-节点的层 T Ew = 0, // E-节点的权值 bestw = 0; // 目前的最优值 // 搜索子集空间树 while (true) { // 检查E-节点的左孩子 if (Ew + w[i] <= c) // x[i] = 1 AddLiveNode(Q, Ew + w[i], bestw, i, n); // 右孩子总是可行的 AddLiveNode(Q, Ew, bestw, i, n); // x[i] = 0 Q.Delete(Ew); // 取下一个E-节点 if (Ew == -1) { // 到达层的尾部 if (Q.IsEmpty()) return bestw; Q.Add(-1); //添加尾部标记 Q.Delete(Ew); // 取下一个E-节点 i++;} // Ew的层 } } 其中函数MaxLoading 在解空间树中进行分枝定界搜索。链表队列Q用于保存活节点,其中记录着各活节点对应的权值。队列还记录了权值- 1,以标识每一层的活节点的结尾。函数AddLiveNode 用于增加节点(即把节点对应的权值加入活节点队列),该函数首先检验i(当前E-节点在解空间树中的层)是否等于n,如果相等,则已到达了叶节点。叶节点不被加入队列中,因为它们不被展开。搜索中所到达的每个叶节点都对应着一个可行的解,而每个解都会与目前的最优解来比较,以确定最优解。如果i<n,则节点i 就会被加入队列中。M a x L o a d i n g函数首先初始化i = 1(因为当前E-节点是根节点),b e s t w = 0(目前最优解的对应值),此时,活节点队列为空。下一步, A - 1被加入队列以说明正处在第一层的末尾。当前E-节点对应的权值为Ew。在while 循环中,首先检查节点的左孩子是否可行。如果可行,则调用A d d L i v e N o d e,然后将右孩子加入队列(此节点必定是可行的),注意到A d d l i v e N o d e可能会失败,因为可能没有足够的内存来给队列增加节点。A d d L i v e N o d e并没有去捕获Q . A d d中的N o M e m异常,这项工作留给用户完成。如果E-节点的两个孩子都已经被生成,则删除该E-节点。从队列中取出下一个E-节点,此时队列必不为空,因为队列中至少含有本层末尾的标识- 1。如果到达了某一层的结尾,则从下一层寻找活节点,当且仅当队列不为空时这些节点存在。当下一层存在活节点时,向队列中加入下一层的结尾标志并开始处理下一层的活节点。 M a x L o a d i n g函数的时间和空间复杂性都是O ( 2n )。 2. 改进 我们可以尝试使用程序1 6 - 2的优化方法改进上述问题的求解过程。在程序1 6 - 2中,只有当右孩子对应的重量加上剩余货箱的重量超出b e s t w时,才选择右孩子。而在程序1 7 - 1中,在i变为n之前,b e s t w的值一直保持不变,因此在i等于n之前对右孩子的测试总能成功,因为b e s t w = 0且r > 0。当i等于n时,不会再有节点加入队列中,因此这时对右孩子的测试不再有效。如想要使右孩子的测试仍然有效,应当提早改变b e s t w的值。我们知道,最优装载的重量是子集树中可行节点的重量的最大值。由于仅在向左子树移动时这些重量才会增大,因此可以在每次进行这种移动时改变b e s t w的值。根据以上思想,我们设计了程序1 7 - 2。当活节点加入队列时,w t不会超过b e s t w,故b e s t w不用更新。因此用一条直接插入M a x L o a d i n g的简单语句取代了函数A d d L i v e N o d e。 程序17-2 对程序1 7 - 1改进之后 template T MaxLoading(T w[], T c, int n) {// 返回最优装载值 // 使用F I F O分枝定界算法 // 为层1初始化 LinkedQueue Q; // 活节点队列 Q . A d d ( - 1 ) ; / /标记本层的尾部 int i = 1; // E-节点的层 T Ew = 0, // E-节点的重量 bestw = 0; // 目前的最优值 r = 0; // E-节点中余下的重量 for (int j = 2; j <= n; j++) r += w[i]; // 搜索子集空间树 while (true) { // 检查E-节点的左孩子 T wt = Ew + w[i]; // 左孩子的权值 if (wt <= c) { // 可行的左孩子 if (wt > bestw) bestw = wt; // 若不是叶子,则添加到队列中 if (i < n) Q.Add(wt);} // 检查右孩子 if (Ew + r > bestw && i < n) Q.Add(Ew); // 可以有一个更好的叶子 Q.Delete(Ew); // 取下一个E-节点 if (Ew == -1) { // 到达层的尾部 if (Q.IsEmpty()) return bestw; Q.Add(-1); //添加尾部标记 Q.Delete(Ew); // 取下一个E-节点 i++; // E-节点的层 r -= w[i];} // E-节点中余下的重量 } } 3. 寻找最优子集 为了找到最优子集,需要记录从每个活节点到达根的路径,因此在找到最优装载所对应的叶节点之后,就可以利用所记录的路径返回到根节点来设置x的值。活节点队列中元素的类型是Q N o d e (见程序1 7 - 3 )。这里,当且仅当节点是它的父节点的左孩子时, L C h i l d为t r u e。 程序17-3 类Q N o d e template class QNode { p r i v a t e : QNode *parent; // 父节点指针 bool LChild; // 当且仅当是父节点的左孩子时,取值为t r u e T weight; // 由到达本节点的路径所定义的部分解的值 } ; 程序1 7 - 4是新的分枝定界方法的代码。为了避免使用大量的参数来调用A d d L i v e N o d e,可以把该函数定义为一个内部函数。使用内部函数会使空间需求稍有增加。此外,还可以把A d d L i v e N o d e和M a x L o a d i n g定义成类成员函数,这样,它们就可以共享诸如Q , i , n , b e s t w, E , b e s t E和bestw 等类成员。 程序1 7 - 4并未删除类型为Q N o d e的节点。为了删除这些节点,可以保存由A d d L i v e N o d e创建的所有节点的指针,以便在程序结束时删除这些节点。 程序17-4 计算最优子集的分枝定界算法 template void AddLiveNode(LinkedQueue*> &Q, T wt, int i, int n, T bestw, QNode *E, QNode *&bestE, int bestx[], bool ch) {// 如果不是叶节点,则向队列Q中添加一个i 层、重量为w t的活节点 // 新节点是E的一个孩子。当且仅当新节点是左孩子时, c h为t r u e。 // 若是叶子,则c h取值为b e s t x [ n ] if (i == n) {// 叶子 if (wt == bestw) { // 目前的最优解 bestE = E; bestx[n] = ch;} r e t u r n ; } // 不是叶子, 添加到队列中 QNode *b; b = new QNode; b->weight = wt; b->parent = E; b->LChild = ch; Q . A d d ( b ) ; } template T MaxLoading(T w[], T c, int n, int bestx[]) {// 返回最优装载值,并在b e s t x中返回最优装载 // 使用F I F O分枝定界算法 // 初始化层1 LinkedQueue*> Q; // 活节点队列 Q . A d d ( 0 ) ; // 0 代表本层的尾部 int i = 1; // E-节点的层 T Ew = 0, // E-节点的重量 bestw = 0; // 迄今得到的最优值 r = 0; // E-节点中余下的重量 for (int j = 2; j <= n; j++) r += w[i]; QNode *E = 0, // 当前的E-节点 * b e s t E ; // 目前最优的E-节点 // 搜索子集空间树 while (true) { // 检查E-节点的左孩子 T wt = Ew + w[i]; if (wt <= c) {// 可行的左孩子 if (wt > bestw) bestw = wt; AddLiveNode(Q, wt, i, n, bestw, E, bestE, bestx, true);} // 检查右孩子 if (Ew + r > bestw) AddLiveNode(Q, Ew, i, n, bestw, E, bestE, bestx, false); Q . D e l e t e ( E ) ; // 下一个E-节点 if (!E) { // 层的尾部 if (Q.IsEmpty()) break; Q . A d d ( 0 ) ; // 层尾指针 Q . D e l e t e ( E ) ; // 下一个E-节点 i + + ; // E-节点的层次 r -= w[i];} // E-节点中余下的重量 Ew = E-> w e i g h t ; // 新的E-节点的重量 } // 沿着从b e s t E到根的路径构造x[ ] , x [ n ]由A d d L i v e N o d e来设置 for (j = n - 1; j > 0; j--) { bestx[j] = bestE->LChild; // 从b o o l转换为i n t bestE = bestE-> p a r e n t ; } return bestw; } 4. 最大收益分枝定界 在对子集树进行最大收益分枝定界搜索时,活节点列表是一个最大优先级队列,其中每个活节点x都有一个相应的重量上限(最大收益)。这个重量上限是节点x 相应的重量加上剩余货箱的总重量,所有的活节点按其重量上限的递减顺序变为E-节点。需要注意的是,如果节点x的重量上限是x . u w e i g h t,则在子树中不可能存在重量超过x.uweight 的节点。另外,当叶节点对应的重量等于它的重量上限时,可以得出结论:在最大收益分枝定界算法中,当某个叶节点成为E-节点并且其他任何活节点都不会帮助我们找到具有更大重量的叶节点时,最优装载的搜索终止。 上述策略可以用两种方法来实现。在第一种方法中,最大优先级队列中的活节点都是互相独立的,因此每个活节点内部必须记录从子集树的根到此节点的路径。一旦找到了最优装载所对应的叶节点,就利用这些路径信息来计算x 值。在第二种方法中,除了把节点加入最大优先队列之外,节点还必须放在另一个独立的树结构中,这个树结构用来表示所生成的子集树的一部分。当找到最大装载之后,就可以沿着路径从叶节点一步一步返回到根,从而计算出x 值。最大优先队列可用HeapNode 类型的最大堆来表示(见程序1 7 - 5)。uweight 是活节点的重量上限,level 是活节点所在子集树的层, ptr 是指向活节点在子集树中位置的指针。子集树中节点的类型是b b n o d e(见程序1 7 - 5)。节点按u w e i g h t值从最大堆中取出。 程序17-5 bbnode 和HeapNode 类 class bbnode { p r i v a t e : bbnode *parent; // 父节点指针 bool LChild; // 当且仅当是父节点的左孩子时,取值为t r u e } ; template class HeapNode { p u b l i c : operator T () const {return uweight;} p r i v a t e : bbnode *ptr; // 活节点指针 T uweight; // 活节点的重量上限 int level; // 活节点所在层 } ; 程序1 7 - 6中的函数A d d L i v e N o d e用于把b b n o d e类型的活节点加到子树中,并把H e a p N o d e类型的活节点插入最大堆。A d d L i v e N o d e必须被定义为b b n o d e和H e a p N o d e的友元。 程序17-6 template void AddLiveNode(MaxHeap > &H, bbnode *E, T wt, bool ch, int lev) {// 向最大堆H中增添一个层为l e v上限重量为w t的活节点 // 新节点是E的一个孩子 // 当且仅当新节点是左孩子ch 为t r u e bbnode *b = new bbnode; b->parent = E; b->LChild = ch; HeapNode N; N.uweight = wt; N.level = lev; N.ptr = b; H . I n s e r t ( N ) ; } template T MaxLoading(T w[], T c, int n, int bestx[]) {// 返回最优装载值,最优装载方案保存于b e s t x // 使用最大收益分枝定界算法 // 定义一个最多有1 0 0 0个活节点的最大堆 MaxHeap > H(1000); // 第一剩余重量的数组 // r[j] 为w [ j + 1 : n ]的重量之和 T *r = new T [n+1]; r[n] = 0; for (int j = n-1; j > 0; j--) r[j] = r[j+1] + w[j+1]; // 初始化层1 int i = 1; // E-节点的层 bbnode *E = 0; // 当前E-节点 T Ew = 0; // E-节点的重量 // 搜索子集空间树 while (i != n+1) {// 不在叶子上 // 检查E-节点的孩子 if (Ew + w[i] <= c) {// 可行的左孩子 AddLiveNode(H, E, Ew+w[i]+r[i], true, i+1);} // 右孩子 AddLiveNode(H, E, Ew+r[i], false, i+1); // 取下一个E-节点 HeapNode N; H.DeleteMax(N); // 不能为空 i = N.level; E = N.ptr; Ew = N.uweight - r[i-1]; } // 沿着从E-节点E到根的路径构造b e s t x [ ] for (int j = n; j > 0; j--) { bestx[j] = E->LChild; // 从b o o l转换为i n t E = E-> p a r e n t ; } return Ew; } 函数M a x L o a d i n g(见程序1 7 - 6)首先定义了一个容量为1 0 0 0的最大堆,因此,可以用它来解决优先队列中活节点数在任何时候都不超过1 0 0 0的装箱问题。对于更大型的问题,需要一个容量更大的最大堆。接着,函数M a x L o a d i n g初始化剩余重量数组r。第i + 1层的节点(即x [ 1 : i ]的值都已确定)对应的剩余容器总重量可以用如下公式求出: r [i]=n ?j=i + 1w[ j ]。 变量E指向子集树中的当前E-节点,Ew 是该节点对应的重量, i 是它所在的层。初始时,根节点是E-节点,因此取i=1, Ew=0。由于没有明确地存储根节点,因此E的初始值取为0。while 循环用于产生当前E-节点的左、右孩子。如果左孩子是可行的(即它的重量没有超出容量),则将它加入到子集树中并作为一个第i + 1层节点加入最大堆中。一个可行的节点的右孩子也被认为是可行的,它总被加入子树及最大堆中。在完成添加操作后,接着从最大堆中取出下一个E-节点。如果没有下一个E-节点,则不存在可行的解。如果下一个E-节点是叶节点(即是一个层为n + 1的节点),则它代表着一个最优的装载,可以沿着从叶到根的路径来确定装载方案。 5. 说明 1) 使用最大堆来表示活节点的最大优先队列时,需要预测这个队列的最大长度(程序1 7 - 6中是1 0 0 0)。为了避免这种预测,可以使用一个基于指针的最大优先队列来取代基于数组的队列,这种表示方法见9 . 4节的左高树。 2) bestw表示当前所有可行节点的重量的最大值,而优先队列中可能有许多其u w e i g h t不超过b e s t w的活节点,因此这些节点不可能帮助我们找到最优的叶节点,这些节点浪费了珍贵的队列空间,并且它们的插入/删除动作也浪费了时间,所以可以将这些节点删除。有一种策略可以减少这种浪费,即在插入某个节点之前检查是否有u w e i g h t < b e s t w。然而,由于b e s t w在算法执行过程中是不断增大的,所以目前插入的节点在以后并不能保证u w e i g h t < b e s t w。另一种更好的方法是在每次b e s t w增大时,删除队列中所有u w e i g h t < b e s e w的节点。这种策略要求删除具有最小u w e i g h t的节点。因此,队列必须支持如下的操作:插入、删除最大节点、删除最小节点。这种优先队列也被称作双端优先队列( double-ended priority queue)。这种队列的数据结构描述见第9章的参考文献。 5.2.2 0/1背包问题 0 / 1背包问题的最大收益分枝定界算法可以由程序1 6 - 6发展而来。可以使用程序1 6 - 6的B o u n d函数来计算活节点N的收益上限u p ,使得以N为根的子树中的任一节点的收益值都不可能超过u p r o f i t。活节点的最大堆使用u p r o f i t作为关键值域,最大堆的每个入口都以H e a p N o d e作为其类型,H e a p N o d e有如下私有成员:uprofit, profit, weight,l e v e l,p t r,其中l e v e l和p t r的定义与装箱问题(见程序1 7 - 5)中的含义相同。对任一节点N,N . p r o f i t是N的收益值,N uprofit是它的收益上限, N. weight 是它对应的重量。b b n o d e类型如程序1 7 - 5中的定义,各节点按其u p r o f i t值从最大堆中取出。程序1 7 - 7使用了类Knap, 它类似于回溯法中的类K n a p(见程序1 6 - 5)。两个K n a p版本中数据成员之间的区别见程序1 7 - 7:1) bestp 不再是一个成员; 2) bestx 是一个指向int 的新成员。新增成员的作用是:当且仅当物品j 包含在最优解中时, b e s t x [ j ] = 1。函数A d d L i v e N o d e用于将新的b b n o d e类型的活节点插入子集树中,同时将H e a p N o d e类型的活节点插入到最大堆中。这个函数与装箱问题(见程序1 7 - 6)中的对应函数非常类似,因此相应的代码被省略。 程序17-7 0/1背包问题的最大收益分枝定界算法 template Tp Knap::MaxProfitKnapsack() {// 返回背包最优装载的收益 // bestx[i] = 1 当且仅当物品i 属于最优装载 // 使用最大收益分枝定界算法 // 定义一个最多可容纳1 0 0 0个活节点的最大堆 H = new MaxHeap > (1000); // 为b e s t x分配空间 bestx = new int [n+1]; // 初始化层1 int i = 1; E = 0; cw = cp = 0; Tp bestp = 0; // 目前的最优收益 Tp up = Bound(1); // 在根为E的子树中最大可能的收益 // 搜索子集空间树 while (i != n+1) { // 不是叶子 // 检查左孩子 Tw wt = cw + w[i]; if (wt <= c) {// 可行的左孩子 if (cp+p[i] > bestp) bestp = cp+p[i]; AddLiveNode(up, cp+p[i], cw+w[i], true, i+1);} up = Bound(i+1); // 检查右孩子 if (up >= bestp) // 右孩子有希望 AddLiveNode(up, cp, cw, false, i+1); // 取下一个E-节点 HeapNode N; H->DeleteMax(N); // 不能为空 E = N.ptr; cw = N.weight; cp = N.profit; up = N.uprofit; i = N.level; } // 沿着从E-节点E到根的路径构造bestx[] for (int j = n; j > 0; j--) { bestx[j] = E-> L C h i l d ; E = E-> p a r e n t ; } return cp; } 函数M a x P r o f i t K n a p s a c k在子集树中执行最大收益分枝定界搜索。函数假定所有的物品都是按收益密度值的顺序排列,可以使用类似于程序1 6 - 9中回溯算法所使用的预处理代码来完成这种排序。函数M a x P r o f i t K n a p s a c k首先初始化活节点的最大堆,并使用一个数组b e s t x来记录最优解。由于需要不断地利用收益密度来排序,物品的索引值会随之变化,因此必须将M a x P r o f i t K n a p s a c k所生成的结果映射回初始时的物品索引。可以用Q的I D域来实现上述映射(见程序1 6 - 9)。在函数M a x P r o f i t K n a p S a c k中,E是当前E-节点,c w是节点对应的重量, c p是收益值,u p是以E为根的子树中任一节点的收益值上限。w h i l e循环一直执行到一个叶节点成为E-节点为止。由于最大堆中的任何剩余节点都不可能具有超过当前叶节点的收益值,因此当前叶即对应了一个最优解。可以从叶返回到根来确定这个最优解。M a x P r o f i t K n a p s a c k中w h i l e循环的结构很类似于程序1 7 - 6的w h i l e循环。首先,检验E-节点左孩子的可行性,如它是可行的,则将它加入子集树及活节点队列(即最大堆);仅当节点右孩子的B o u n d值指明有可能找到一个最优解时才将右孩子加入子集树和队列中。 5.2.3 最大完备子图 4 . 2 . 3节完备子图问题的解空间树也是一个子集树,故可以使用与装箱问题、背包问题相同的最大收益分枝定界方法来求解这种问题。解空间树中的节点类型为b b n o d e,而最大优先队列中元素的类型则是C l i q u e N o d e。C l i q u e N o d e有如下域:c n(该节点对应的完备子图中的顶点数目),u n(该节点的子树中任意叶节点所对应的完备子图的最大尺寸),l e v e l(节点在解空间树中的层),c n(当且仅当该节点是其父节点的左孩子时, c n为1),p t r(指向节点在解空间树中的位置)。u n的值等于c n + n - l e v e + 1。因为根据un 和c n(或l e v e l)可以求出l e v e l(或c n),所以可以去掉c n或l e v e l域。当从最大优先队列中选取元素时,选取的是具有最大u n值的元素。在程序1 7 - 8中,C l i q u e N o d e包含了所有的三个域:c n,un 和l e v e l,这样便于尝试为u n赋予不同的含义。函数A d d C l i q u e N o d e用于向生成的子树和最大堆中加入节点,由于其代码非常类似于装箱和背包问题中的对应函数,故将它略去。函数B B M a x C l i q u e在解空间树中执行最大收益分枝定界搜索,树的根作为初始的E-节点,该节点并没有在所构造的树中明确存储。对于这个节点来说,其cn 值(E-节点对应的完备子图的大小)为0,因为还没有任何顶点被加入完备子图中。E-节点的层由变量i 指示,它的初值为1,对应于树的根节点。当前所找到的最大完备子图的大小保存在b e s t n中。在while 循环中,不断展开E-节点直到一个叶节点变成E-节点。对于叶节点,u n=c n。由于所有其他节点的un 值都小于等于当前叶节点对应的un 值,所以它们不可能产生更大的完备子图,因此最大完备子图已经找到。沿着生成的树中从叶节点到根的路径,即可构造出这个最大完备子图。为了展开一个非叶E-节点,应首先检查它的左孩子,如果左孩子对应的顶点i与当前E-节点所包含的所有顶点之间都有一条边,则i 被加入当前的完备子图之中。为了检查左孩子的可行性,可以沿着从E-节点到根的路径,判断哪些顶点包含在E-节点之中,同时检查这些顶点中每个顶点是否都存在一条到i 的边。如果左孩子是可行的,则把它加入到最大优先队列和正在构造的树中。下一步,如果右孩子的子树中包含最大完备子图对应的叶节点,则把右孩子也加入。 由于每个图都有一个最大完备子图,因此从堆中删除节点时,不需要检验堆是否为空。仅当到达一个可行的叶节点时,w h i l e循环终止。 程序17-8 最大完备子图问题的分枝定界算法 int AdjacencyGraph::BBMaxClique(int bestx[]) {// 寻找一个最大完备子图的最大收益分枝定界程序 // 定义一个最多可容纳1 0 0 0个活节点的最大堆 MaxHeap H(1000); // 初始化层1 bbnode *E = 0; // 当前的E-节点为根 int i = 1, // E-节点的层 cn = 0, // 完备子图的大小 bestn = 0; // 目前最大完备子图的大小 // 搜索子集空间树 while (i != n+1) {// 不是叶子 // 在当前完备子图中检查顶点i 是否与其它顶点相连 bool OK = true; bbnode *B = E; for (int j = i - 1; j > 0; B = B->parent, j--) if (B->LChild && a[i][j] == NoEdge) { OK = false; b r e a k ; } if (OK) {// 左孩子可行 if (cn + 1 > bestn) bestn = cn + 1; AddCliqueNode(H, cn+1, cn+n-i+1, i+1, E, true);} if (cn + n - i >= bestn) // 右孩子有希望 AddCliqueNode(H, cn, cn+n-i, i+1, E, false); // 取下一个E-节点 CliqueNode N; H.DeleteMax(N); // 不能为空 E = N.ptr; cn = N.cn; i = N.level; } // 沿着从E到根的路径构造bestx[] for (int j = n; j > 0; j--) { bestx[j] = E-> L C h i l d ; E = E-> p a r e n t ; } return bestn; } 5.2.4 旅行商问题 旅行商问题的介绍见4 . 2 . 4节,它的解空间是一个排列树。与在子集树中进行最大收益和最小耗费分枝定界搜索类似,该问题有两种实现的方法。第一种是只使用一个优先队列,队列中的每个元素中都包含到达根的路径。另一种是保留一个部分解空间树和一个优先队列,优先队列中的元素并不包含到达根的路径。本节只实现前一种方法。 由于我们要寻找的是最小耗费的旅行路径,因此可以使用最小耗费分枝定界法。在实现过程中,使用一个最小优先队列来记录活节点,队列中每个节点的类型为M i n H e a p N o d e。每个节点包括如下区域: x(从1到n的整数排列,其中x [ 0 ] = 1 ),s(一个整数,使得从排列树的根节点到当前节点的路径定义了旅行路径的前缀x[0:s], 而剩余待访问的节点是x [ s + 1 : n - 1 ]),c c(旅行路径前缀,即解空间树中从根节点到当前节点的耗费),l c o s t(该节点子树中任意叶节点中的最小耗费), r c o s t(从顶点x [ s : n - 1 ]出发的所有边的最小耗费之和)。当类型为M i n H e a p N o d e ( T )的数据被转换成为类型T时,其结果即为l c o s t的值。分枝定界算法的代码见程序1 7 - 9。程序1 7 - 9首先生成一个容量为1 0 0 0的最小堆,用来表示活节点的最小优先队列。活节点按其l c o s t值从最小堆中取出。接下来,计算有向图中从每个顶点出发的边中耗费最小的边所具有的耗费M i n O u t。如果某些顶点没有出边,则有向图中没有旅行路径,搜索终止。如果所有的顶点都有出边,则可以启动最小耗费分枝定界搜索。根的孩子(图1 6 - 5的节点B)作为第一个E-节点,在此节点上,所生成的旅行路径前缀只有一个顶点1,因此s=0, x[0]=1, x[1:n-1]是剩余的顶点(即顶点2 , 3 ,., n )。旅行路径前缀1 的开销为0 ,即c c = 0 ,并且,r c o st=n ?i=1M i n O u t [i]。在程序中,bestc 给出了当前能找到的最少的耗费值。初始时,由于没有找到任何旅行路径,因此b e s t c的值被设为N o E d g e。 程序17-9 旅行商问题的最小耗费分枝定界算法 template T AdjacencyWDigraph::BBTSP(int v[]) {// 旅行商问题的最小耗费分枝定界算法 // 定义一个最多可容纳1 0 0 0个活节点的最小堆 MinHeap > H(1000); T *MinOut = new T [n+1]; // 计算MinOut[i] = 离开顶点i的最小耗费边的耗费 T MinSum = 0; // 离开顶点i的最小耗费边的数目 for (int i = 1; i <= n; i++) { T Min = NoEdge; for (int j = 1; j <= n; j++) if (a[i][j] != NoEdge && (a[i][j] < Min || Min == NoEdge)) Min = a[i][j]; if (Min == NoEdge) return NoEdge; // 此路不通 MinOut[i] = Min; MinSum += Min; } // 把E-节点初始化为树根 MinHeapNode E; E.x = new int [n]; for (i = 0; i < n; i++) E.x[i] = i + 1; E.s = 0; // 局部旅行路径为x [ 1 : 0 ] E.cc = 0; // 其耗费为0 E.rcost = MinSum; T bestc = NoEdge; // 目前没有找到旅行路径 // 搜索排列树 while (E.s < n - 1) {// 不是叶子 if (E.s == n - 2) {// 叶子的父节点 // 通过添加两条边来完成旅行 // 检查新的旅行路径是不是更好 if (a[E.x[n-2]][E.x[n-1]] != NoEdge && a[E.x[n-1]][1] != NoEdge && (E.cc + a[E.x[n-2]][E.x[n-1]] + a[E.x[n-1]][1] < bestc || bestc == NoEdge)) { // 找到更优的旅行路径 bestc = E.cc + a[E.x[n-2]][E.x[n-1]] + a[E.x[n-1]][1]; E.cc = bestc; E.lcost = bestc; E . s + + ; H . I n s e r t ( E ) ; } else delete [] E.x;} else {// 产生孩子 for (int i = E.s + 1; i < n; i++) if (a[E.x[E.s]][E.x[i]] != NoEdge) { // 可行的孩子, 限定了路径的耗费 T cc = E.cc + a[E.x[E.s]][E.x[i]]; T rcost = E.rcost - MinOut[E.x[E.s]]; T b = cc + rcost; //下限 if (b < bestc || bestc == NoEdge) { // 子树可能有更好的叶子 // 把根保存到最大堆中 MinHeapNode N; N.x = new int [n]; for (int j = 0; j < n; j++) N.x[j] = E.x[j]; N.x[E.s+1] = E.x[i]; N.x[i] = E.x[E.s+1]; N.cc = cc; N.s = E.s + 1; N.lcost = b; N.rcost = rcost; H . I n s e r t ( N ) ; } } // 结束可行的孩子 delete [] E.x;} // 对本节点的处理结束 try {H.DeleteMin(E);} // 取下一个E-节点 catch (OutOfBounds) {break;} // 没有未处理的节点 } if (bestc == NoEdge) return NoEdge; // 没有旅行路径 // 将最优路径复制到v[1:n] 中 for (i = 0; i < n; i++) v[i+1] = E.x[i]; while (true) {// 释放最小堆中的所有节点 delete [] E.x; try {H.DeleteMin(E);} catch (OutOfBounds) {break;} } return bestc; } while 循环不断地展开E-节点,直到找到一个叶节点。当s = n - 1时即可说明找到了一个叶节点。旅行路径前缀是x [ 0 : n - 1 ],这个前缀中包含了有向图中所有的n个顶点。因此s = n - 1的活节点即为一个叶节点。由于算法本身的性质,在叶节点上lcost 和cc 恰好等于叶节点对应的旅行路径的耗费。由于所有剩余的活节点的lcost 值都大于等于从最小堆中取出的第一个叶节点的lcost 值,所以它们并不能帮助我们找到更好的叶节点,因此,当某个叶节点成为E-节点后,搜索过程即终止。while 循环体被分别按两种情况处理,一种是处理s = n - 2的E-节点,这时,E-节点是某个单独叶节点的父节点。如果这个叶节点对应的是一个可行的旅行路径,并且此旅行路径的耗费小于当前所能找到的最小耗费,则此叶节点被插入最小堆中,否则叶节点被删除,并开始处理下一个E-节点。其余的E-节点都放在while 循环的第二种情况中处理。首先,为每个E-节点生成它的两个子节点,由于每个E-节点代表着一条可行的路径x [ 0 : s ],因此当且仅当< x[s],x[i] > 是有向图的边且x [ i ]是路径x [ s + 1 : n - 1 ]上的顶点时,它的子节点可行。对于每个可行的孩子节点,将边 的耗费加上E.cc 即可得到此孩子节点的路径前缀( x [ 0 : s ],x[i]) 的耗费c c。由于每个包含此前缀的旅行路径都必须包含离开每个剩余顶点的出边,因此任何叶节点对应的耗费都不可能小于cc 加上离开各剩余顶点的出边耗费的最小值之和,因而可以把这个下限值作为E-节点所生成孩子的lcost 值。如果新生成孩子的lcost 值小于目前找到的最优旅行路径的耗费b e s t c,则把新生成的孩子加入活节点队列(即最小堆)中。 如果有向图没有旅行路径,程序1 7 - 9返回N o E d g e;否则,返回最优旅行路径的耗费,而最优旅行路径的顶点序列存储在数组v 中。 5.2.5 电路板排列 电路板排列问题( 1 6 . 2 . 5节)的解空间是一棵排列树,可以在此树中进行最小耗费分枝定界搜索来找到一个最小密度的电路板排列。我们使用一个最小优先队列,其中元素的类型为B o a r d N o d e,代表活节点。B o a r d N o d e类型的对象包含如下域: x(电路板的排列),s(电路板x[1:s]) 依次放置在位置1 到s 上),c d(电路板排列x [ 1 : s ]的密度,其中包括了到达x[s] 右边的连线),n o w(now[j] 是排列x[1:s] 中包含j 的电路板的数目)。当一个BoardNode 类型的对象转换为整型时,其结果即为对象的cd 值。代码见程序1 7 - 1 0。 程序17-10 电路板排列问题的最小耗费分枝定界算法 int BBArrangeBoards(int **B, int n, int m, int* &bestx) {// 最小耗费分枝定界算法, m个插槽, n块板 MinHeap H(1000); // 容纳活节点 // 初始化第一个E节点、t o t a l和b e s t d BoardNode E; E.x = new int [n+1]; E.s = 0; // 局部排列为E . x [ 1 : s ] E.cd = 0; // E.x[1:s]的密度 E.now = new int [m+1]; int *total = new int [m+1]; // now[i] = x[1:s]中含插槽i的板的数目 // total[i] = 含插槽i的板的总数目 for (int i = 1; i <= m; i++) { total[i] = 0; E.now[i] = 0; } for (i = 1; i <= n; i++) { E.x[i] = i; // 排列为1 2 3 4 5 . . . n for (int j = 1; j <= m; j++) total[j] += B[i][j]; // 含插槽j的板 } int bestd = m + 1; / /目前的最优密度 bestx = 0; // 空指针 do {// 扩展E节点 if (E.s == n - 1) {// 仅有一个孩子 int ld = 0; // 最后一块板的局部密度 for (int j = 1; j <= m; j++) ld += B[E.x[n]][j]; if (ld < bestd) {// 更优的排列 delete [] bestx; bestx = E.x; bestd = max(ld, E.cd); } else delete [] E.x; delete [] E.now;} else {// 生成E-节点的孩子 for (int i = E.s + 1; i <= n; i++) { BoardNode N; N.now = new int [m+1]; for (int j = 1; j <= m; j++) // 在新板中对插槽计数 N.now[j] = E.now[j] + B[E.x[i]][j]; int ld = 0; // 新板的局部密度 for (j = 1; j <= m; j++) if (N.now[j] > 0 && total[j] != N.now[j]) ld++; N.cd = max(ld, E.cd); if (N.cd < bestd) {// 可能会引向更好的叶子 N.x = new int [n+1]; N.s = E.s + 1; for (int j = 1; j <= n; j++) N.x[j] = E.x[j]; N.x[N.s] = E.x[i]; N.x[i] = E.x[N.s]; H . I n s e r t ( N ) ; } else delete [] N.now;} delete [] E.x;} // 处理完当前E-节点 try {H.DeleteMin(E);} // 下一个E-节点 catch (OutOfBounds) {return bestd;} //没有E-节点 } while (E.cd < bestd); // 释放最小堆中的所有节点 do {delete [] E.x; delete [] E.now; try {H.DeleteMin(E);} catch (...) {break;} } while (true); return bestd; } 程序1 7 - 1 0首先初始化E-节点为排列树的根,此节点中没有任何电路板,因此有s=0, cd=0,n o w [ i ] = 0(1≤i≤n),x是整数1到n 的任意排列。接着,程序生成一个整型数组t o t a l,其中total[i] 的值为包含i 的电路板的数目。目前能找到的最优的电路板排列记录在数组bestx 中,对应的密度存储在bestd 中。程序中使用一个do-while 循环来检查每一个E-节点,在每次循环的尾部,将从最小堆中选出具有最小cd 值的节点作为下一个E-节点。如果某个E-节点的cd 值大于等于bestd,则任何剩余的活节点都不能使我们找到密度小于bestd的电路板排列,因此算法终止。 d o - w h i l e循环分两种情况处理E-节点,第一种是处理s = n - 1时的情况,此种情况下,有n - 1个电路板被放置好, E-节点即解空间树中的某个叶节点的父节点。节点对应的密度会被计算出来,如果需要,bested 和bestx 将被更新。在第二种情况中,E-节点有两个或更多的孩子。每当一个孩子节点N生成时,它对应的部分排列( x [ 1 : s + 1 ] )的密度N . c d就会被计算出来,如果N . c d < b e s t d ,则N被存放在最小优先队列中;如果N . c d≥b e s t d,则它的子树中的所有叶节点对应的密度都满足d e n s i t y≥b e s t d,这就意味着不会有优于b e s t x的排列。

下载文档到电脑,查找使用更方便

文档的实际排版效果,会与网站的显示效果略有不同!!

需要 20 金币 [ 分享文档获得金币 ] 0 人已下载

下载文档