📄 id5.txt
字号:
现在考虑k = 1时的贪婪启发法。最初的子集为{ 1 } , { 2 } , { 3 } , { 4 }。子集{ 1 } , { 2 }产生与k= 0时相同的结果,考虑子集{ 3 },置x3 为1。此时还剩5个单位的容量,按价值密度非递增顺序来考虑如何利用这5个单位的容量。首先考虑物品1,它适合,因此取x1 为1,这时仅剩下3个单位容量了,且剩余物品没有能够加入背包中的物品。通过子集{ 3 }开始求解得结果为x = [ 1 , 0 , 1 , 0 ],获得的价值为1 8。若从子集{ 4 }开始,产生的解为x = [ 1 , 0 , 0 , 1 ],获得的价值为1 9。考虑子集大小为0和1时获得的最优解为[ 1 , 0 , 0 , 1 ]。这个解是通过k= 1的贪婪启发式算法得到的。
若k= 2,除了考虑k< 2的子集,还必需考虑子集{ 1 , 2 } , { 1 , 3 } , { 1 , 4 } , { 2 , 3 } , { 2 , 4 }和{ 3 , 4 }。首先从最后一个子集开始,它是不可行的,故将其抛弃,剩下的子集经求解分别得到如下结果:[ 1 , 1 , 0 , 0 ] , [ 1 , 0 , 1 , 0 ] , [ 1 , 0 , 0 , 1 ] , [ 0 , 1 , 1 , 0 ]和[ 0 , 1 , 0 , 1 ],这些结果中最后一个价值为2 3,它的值比k= 0和k= 1时获得的解要高,这个答案即为启发式方法产生的结果。
这种修改后的贪婪启发方法称为k阶优化方法(k - o p t i m a l)。也就是,若从答案中取出k 件物品,并放入另外k 件,获得的结果不会比原来的好,而且用这种方式获得的值在最优值的( 1 0 0 / (k + 1 ) ) %以内。当k= 1时,保证最终结果在最佳值的5 0 %以内;当k= 2时,则在3 3 . 3 3 %以内等等,这种启发式方法的执行时间随k 的增大而增加,需要测试的子集数目为O (nk ),每一个子集所需时间为O (n),因此当k >0时总的时间开销为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 |<n, 且没有候选顶点能加入V中,因此至少有一个顶点q1 不在V中,有向图中必包含边( q2 , q1)且q2 不在V中,否则, q1 是可加入V的候选顶点。同样,必有边(q3 , q2)使得q3 不在V中,若q3 = q1 则q1 q2 q3 是有向图中的一个环路;若q3 ≠q1,则必存在q4 使(q4 , q3)是有向图的边且q4 不在V中,否则,q3 便是V的一个候选顶点。若q4 为q1 , q2 , q3 中的任何一个,则又可知有向图含有环,因为有向图具有有限个顶点数n,继续利用上述方法,最后总能找到一个环路。
2. 数据结构的选择
为将图1 - 5用C + +代码来实现,必须考虑序列V的描述方法,以及如何找出可加入V的候选顶点。一种高效的实现方法是将序列V用一维数组v 来描述的,用一个栈来保存可加入V的候选顶点。另有一个一维数组I n D e g r e e,I n D e g r e e[ j ]表示与顶点j相连的节点i 的数目,其中顶点i不是V中的成员,它们之间的有向图的边表示为( i, j)。当I n D e g r e e[ j ]变为0时表示j 成为一个候选节点。序列V初始时为空。I n D e g r e e[ j ]为顶点j 的入度。每次向V中加入一个顶点时,所有与新加入顶点邻接的顶点j,其I n D e g r e e[ j ]减1。对于有向图1 - 4,开始时I n D e g r e e [ 1 : 6 ] = [ 0 , 0 , 1 , 3 , 1 , 3 ]。由于顶点1和2的I n D e g r e e值为0,因此它们是可加入V的候选顶点,由此,顶点1和2首先入栈。每一步,从栈中取出一个顶点将其加入V,同时减去与其邻接的顶点的I n D e g r e e值。若在第一步时从栈中取出顶点2并将其加入V,便得到了v [ 0 ] = 2,和I n D e g r e e [ 1 : 6 ] = [ 0 , 0 , 1 , 2 , 0 , 3 ]。由于I n D e g r e e [ 5 ]刚刚变为0,因此将顶点5入栈。
程序1 3 - 2给出了相应的C + +代码,这个代码被定义为N e t w o r k的一个成员函数。而且,它对于有无加权的有向图均适用。但若用于无向图(不论其有无加权)将会得到错误的结果,因为拓扑排序是针对有向图来定义的。为解决这个问题,利用同样的模板来定义成员函数AdjacencyGraph, AdjacencyWGraph,L i n k e d G r a p h和L i n k e d W G r a p h。这些函数可重载N e t w o r k中的函数并可输出错误信息。如果找到拓扑序列,则Topological 函数返回t r u e;若输入的有向图无拓扑序列则返回f a l s e。当找到拓扑序列时,将其返回到v [ 0 :n- 1 ]中。
3. Network:Topological 的复杂性
第一和第三个f o r循环的时间开销为(n )。若使用(耗费)邻接矩阵,则第二个for 循环所用的时间为(n2 );若使用邻接链表,则所用时间为(n+e)。在两个嵌套的while 循环中,外层循环需执行n次,每次将顶点w 加入到v 中,并初始化内层while 循环。使用邻接矩阵时,内层w h i l e循环对于每个顶点w 需花费(n)的时间;若利用邻接链表,则这个循环需花费dwout 的时间,因此,内层while 循环的时间开销为(n2 )或(n+e)。所以,若利用邻接矩阵,程序1 3 - 2的时间复杂性为(n2 ),若利用邻接链表则为(n+e)。
程序13-2 拓扑排序
bool Network::Topological(int v[])
{// 计算有向图中顶点的拓扑次序
// 如果找到了一个拓扑次序,则返回t r u e,此时,在v [ 0 : n - 1 ]中记录拓扑次序
// 如果不存在拓扑次序,则返回f a l s e
int n = Ve r t i c e s ( ) ;
// 计算入度
int *InDegree = new int [n+1];
InitializePos(); // 图遍历器数组
for (int i = 1; i <= n; i++) // 初始化
InDegree[i] = 0;
for (i = 1; i <= n; i++) {// 从i 出发的边
int u = Begin(i);
while (u) {
I n D e g r e e [ u ] + + ;
u = NextVe r t e x ( i ) ; }
}
// 把入度为0的顶点压入堆栈
LinkedStack<int> 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;
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -