数据结构与算法学习(九)

九 算法设计技巧

  当给定一个算法时,实际的数据结构无需指定。为使运行时间尽可能短,需要由编程人员来选择适当的数据结构。本章把注意里从算法的实现转向算法的设计。本章集中讨论用于求解问题的5种常见类型的算法。对于许多问题,很可能这些方法中至少一种方法是可以解决问题的。对于每种类型的算法,我们将:

  1. 了解一般的处理方法。

  2. 考察几个例子。

  3. 在适当的地方概括地讨论时间和空间复杂度。

  9.1 贪心算法

  我们将考察的第一类算法是贪心算法(greedy algrithm)。前面我们看到的贪心算法:Dijktra算法、Prim算法和Kruskal算法。贪心算法分阶段工作,在每个阶段,可以认为所做的决定是号的,而不考虑将来的后果。一般来说,这意味着选择的是某个局部最优。这种“眼下能够拿到的就拿”的策略即是这类算法名称的来源。当算法终止时,我们希望局部最优就是全局最优。如果这样的话,那么算法就是正确的;否则,算法得到的是一个次最优解。如果不要求绝对的最佳解,那么有时用简单的贪心算法生成近似解,而不是使用一般来说产生准确解所需要的复杂算法。

 

   9.1.1 一个简单的调度问题

  现有作业j1,j2,…,jN,已知对应的运行时间分别为t1,t2,…,tN,而处理器只有一个。为了把作业平局完成的时间最小化,调度这些作业最好的方式是什么?这里将假设非抢占调度(nonpreemptive scheduling):一旦开始一个作业,就必须把该作业运行完。

  作为一个例子,设四个作业和相关的运行时间如图9-1所示。一个可能调度在图9-2中给出,平均时间是25。一个更好的调度如图9-3,它产生的平均完成时间为17.75。

152300_fMoN_2537915.jpg

图9-1 作业和时间

152351_YFXo_2537915.jpg

图9-2 1号调度

152432_10SW_2537915.jpg

图9-3 2号调度(最优)

  可以看到调度的总值C是:

153118_ztWf_2537915.jpg

  可以看出影响到总值的是第二项。只有当最小运行时间最先安排的调度才是最优的。这个结果指出了操作系统程序一般把优先权赋予那些更短的作业的原因。

   1.多处理器的情形

  对于多处理器的情况。设P=3,而作业则如图9-4所示。

153629_znrR_2537915.jpg

图9-4 作业和时间

  图9-5显示了一个最优的安排,它把平均时间优化到最小。总的时间是165,平均时间是18.33。

153924_6D16_2537915.jpg

图9-5 多处理器情形的一个最优解

  解决多处理器情形的算法是按顺序开始作业的,处理器之间轮转分配作业。图9-6显示了第二个最优解。

154250_Ghle_2537915.jpg

图9-6 多处理器情形的第二个最优解

 

   2.将最后完成时间最小化

  如果我们只考虑作业最后完成的时间,图9-7指出了最小完成时间是34,这个结果不能再改进了,因为每个处理器都在一直忙着。

154918_qONZ_2537915.jpg

图9-7 将最后完成时间最小化

  这个调度方法对于一个用户拥有所有这些作业是可行的。但这是一个NP完全问题,因此,将最后完成时间最小化要比把平均完成时间最小化困难得多。

 

   9.1.2 赫夫曼编码

  本章考虑第二个贪心算法的应用,称为文件压缩(file compression)。

  标准ASCII字符集大约由100个“可打印”字符组成。为了把这些字符区分开来,需要7个位。

  设有一个文件,它只包含字符a、e、i、s、t,加上一些空格和换行(newline)。如图9-8所示。

160415_jcw8_2537915.jpg

图9-8 使用一个标准编码方案

  在现实中,许多文件可能相当大。因此想要一种更好的编码降低所需的总比特数。代表字母的二进制代码可以用二叉树来表示,如图9-9所示。

160814_jSxB_2537915.jpg

图9-9 树中原始编码的表示

  每个字符通过从根节点开始,用0表示左分支,用1表示右分支来记录路径的方法表示出来。这种数据结构有时称为检索树(tire)。

  可以利用换行唯一是一个儿子而得到一种比图9-9给出的编码更好的编码,如图9-10所示。

161343_n1yh_2537915.jpg

图9-10 稍微好一些的树

  9-10的树是一棵满树(full tree):所有的节点要么是树叶,要么有两个儿子。最优的编码将总具有这个性质。

  只要没有任何字符编码是别的字符编码的前缀。这样的编码统称为前缀码(prefix code)。综上所述,基本问题是在于找到总值最小的满二叉树,其中所有的字符都位于树叶上。9-11中的树显示了本例的最优树,9-12可以看出,这种编码只用了146个位。

161911_f6Mr_2537915.jpg

图9-11 最优前缀码的树

161951_i5sC_2537915.jpg

图9-12 最优前缀码

  赫夫曼给出了一个算法,因此,这种编码系统统称为赫夫曼编码(Huffman code)。

 

   赫夫曼算法

  假设字符个数为C,赫夫曼算法(Huffman algorithm)可以描述如下:维护一个由树组成的森林。一棵树的权等于它的叶子的频率和。任意选取最小权的两棵树T1和T2,并任意形成以T1和T2为子树的新树,将这样的过程进行C-1次。在算法开始,存在C棵单节点树——每个字符一棵。在算法结束时得到一棵树,这棵树就是最优赫夫曼编码树。

  下面通过一个例子来说明算法的操作。图9-13表示的是初始的森林。

163322_KHqv_2537915.jpg

图9-13 赫夫曼算法的初始状态

  图9-14令s是左儿子,令其为左右儿子是任意的。

 

163722_eU9h_2537915.jpg

图9-14 第一次合并后的赫夫曼算法

  现在有六棵树,再选两棵最小的树。这两棵树是T1和t,然后将它们合并成一棵新树。如图9-15。

164013_yg05_2537915.jpg

图9-15 第二次合并后的赫夫曼算法

  第三步将T2和a合并建立T3。如图9-16所示。

164157_t5UD_2537915.jpg

图9-16 第三次合并的赫夫曼算法

  在第三次合并完成后。最低的两棵树是代表i和空格的两个单节点树。图9-17指出这两棵树如何合并成树T4的。

164509_s3zv_2537915.jpg

图9-17 第四次合并后的赫夫曼算法

  第五步合并根为e和T3,结果如图9-18所示。

164639_bhVp_2537915.jpg

图9-18 第五次合并后的赫夫曼算法

  最后,将两棵剩下的树合并,得到图9-11所示的最优树。图9-19显示了这棵最优树,其根为T6。

164838_iHMV_2537915.jpg

图9-19 最后一次合并后的赫夫曼算法

  该算法是贪心算法的原因在于,在每一个阶段都进行一次合并并没有进行全局的考虑,只是选择两棵最小的树。如果依权排序将这些树保存在一个优先队列中,那么,对元素个数不会超过C的优先队列将进行一次buildHeap、2C-2次deleteMin和C-2次insert。因此运行时间为Ο(ClogC)。若使用一个简单链表实现,时间复杂度将是Ο(C^2)。

 

   9.1.3 近似装箱问题

  本节将考虑某些解决装箱问题(bin packing problem)的算法。这些算法很快,但没有产生最优解。

  设给定N项物品,大小为s1,s2,…,sN,所有的大小都满足0<si≤1。问题是把这些物品装到最少数量的箱子中去,已知每个箱子的容量是1个单位。下图是一个例子。

165945_K9wo_2537915.jpg

图9-20 对0.2,0.5,0.4,0.7,0.1,0.3,0.8的最优装箱

  有两个版本的装箱问题。第一种是联机装箱(on-line packing)问题。这种问题中,每一件物品必须放入一个箱子之后才处理下一件物品。第二种是脱机装箱(off-line packing)问题。在脱机装箱算法中,做任何事需要等到所有的输入数据全部被读入之后才进行。

 

   1.联机算法

  要考虑的第一个问题是,联机算法是否实际上总能给出最优的解,即使在允许无限计算的情况下。

  定理9.1 存在一些输入使得任意联机装箱算法至少使用最优箱子数的4/3。

 

   2.下项适配

  大概最简单的算法就是下项适配(next fit)算法了。当处理任何一项物品时,我们都要检查看它还能装进刚刚装进物品的那个箱子去。如果能装进,那么就把它放进该箱中;否则,就开辟一个新的箱子。这个算法实现非常简单,而且以线性时间运行。图9-21显示了与图9-20相同输入所得到的装箱过程。

172620_61ZJ_2537915.jpg

图10-21 对0.2,0.5,0.4,0.7,0.1,0.3,0.8的下项适配算法

  定理9.2 令M是将一批物品I装箱所需的最优装箱数,则下项适配算法所用的箱子数决不查过2M个。存在一些序列使下项适配算法用箱2M-2个。

 

   3.首次适配

  下项适配在实践中差,因为不需要开辟新箱子的时候它却开辟了新箱子。首次适配(first fit)算法的策略是依序扫描这些箱子并把一项新的物品放入足能盛下它的第一个箱子中。因此,只有前面放置物品的箱子已经容不下当前物品的时候,才开辟一个新的箱子。图9-22显示了结果。

173705_VJ38_2537915.jpg

图9-22 对0.2,0.5,0.4,0.7,0.1,0.3,0.8的首次适配算法

  首次适配算法一个简单的实现是通过顺序扫描箱子序列处理每一项物品,这将花费Ο(N^2)的时间。

  定理9.3 令M是将一批I个物品装箱所需要的最优箱子树,则首次适配算法使用的箱子数决不多于[(17/10)M]。存在使得首次适配算法使用[(17/10)(M-1)]个箱子的序列。

 

   4.最佳适配

  第三种联机策略是最佳适配(best fit)算法。该算法不是把一项新物品放入所发现的第一个能够容纳它的箱子,而是放到所有箱子中能够容纳它最满的箱子中。典型的方法如图9-23所示。

174353_UCYw_2537915.jpg

图9-23 对0.2,0.5,0.4,0.7,0.1,0.3,0.8的最佳适配算法

  最佳适配算法绝不会超过最优算法的大约1.7倍。特别是需要Ο(NlogN)算法时。

 

   5.脱机算法

  如果能够观察全部物品以后再算出结果,那么应该会做得更好。所有联机算法的主要问题在于,将大项物品装箱困难,特别是输入后期出现。自然的解决方法是把最大项物品放在最先。此时可以应用首次适配算法或者最佳适配算法,分别得到首次适配递减(first fit decreasing)算法和最佳适配递减(best fit decreasing)算法。图9-24指出了我们这个例子的最优解。

175100_TueZ_2537915.jpg

图9-24 对0.2,0.5,0.4,0.7,0.1,0.3,0.8的首次适配算法

  引理9.1 令N项物品的输入大小(以递减顺序排序)分别为s1,s2,…,sN,并设最优装箱方法使用M个箱子。那么,首次适配递减算法放到M个箱子之外的其余箱子中的所有物品的大小最多为1/3。

  引理9.2 放入其余箱子中的物品个数最多是M-1。

  定理9.4 令M是将物品集I装箱所需的最优箱子数,则首次适配递减算法所用的箱子树决不超过(4M+1)/3。

  定理9.5 令M是将物品集I装箱所需的最优箱子数,则首次适配递减算法所用的箱子数决不超过(11/9)M+4。此外,存在使得首次适配递减算法用到(11/9)M个下该子的序列。

 

  9.2 分治算法

  用于设计算法的另一种常用技巧为分治(divide and coquer)。分治算法由两部分组成:

  • 分(divide):递归解决较小的问题(当然,除基本情形外)。

  • 治(conquer):然后,从子问题的解构建原问题的解。

  习惯上,在正文中至少含有两个递归调用的方法叫做分治算法,而正文中含有一个递归调用的方法不是分治算法。我们一般认为子问题是不相交的(即基本上不重叠的)。

  我们已经看到几个分治算法。最大子序列和的一个Ο(NlogN)解。线性时间的树的遍历方法。归并排序和快速排序,它们分别Ο(NlogN)的最坏情形。

  还看到过很多可能不算是分治的递归算法例子,而只是化简到一个更简单的情况。如,使用递归有效的取幂运算。二叉树的一些简单搜索方法。用于合并左式堆的简单递归。花线性时间解决选择问题的算法。递归地写出不相交集的find操作。Dijktra算法重新找出最短路径的一些方法以及对图进行深度优化搜索的其他过程。这些算法时间上都不是分治算法,因为只进行了一次递归调用。

 

   9.2.1 分治算法的运行时间

  我们将看到的所有有效的分治算法都是把问题分成一些子问题,每个子问题都是原问题的一部分,然后进行某些附加的工作以算出最后的结果。作为一个例子,我们已经看到归并排序对两个问题进行运算,每个问题均为原问题大小的一半,然后用到Ο(N)附加工作。由此得到运行时间方程(带有适当的初始条件):

T(N)=2T(N/2)+Ο(N)

该方程的解为Ο(NlogN)。下面的定理可以用来确定大部分分治算法的运行时间。

  定理9.6 方程T(N)=aT(N/b)+Θ(N^K)的解为:

224054_5iId_2537915.jpg

  其中a≥1,b>1。 

  定理9.7 方程T(N)=aT(N/b)+Θ((N^K)(logN)^p)的解为:

224436_v4WA_2537915.jpg

  其中a≥1,b>1且p≥0。

  定理9.8 如果224632_LSrB_2537915.jpg,则方程225307_HpkP_2537915.jpg的解为T(N)=Ο(N)。

 

   9.2.2 最近点问题

  我们的第一个问题的输入是平面上的点列P。如果p1=(x1,y1)和p2=(x2,y2),那么p1和p2间的欧几里得距离为[(x1-x2)^2+(y1-y2)^2]^(1/2)。我们要找出一对最近的点。有可能两个点位于同一个位置:在这种情形下这两个点就是最近的,它们的距离为零。

  如果存在N个点,那么就存在N(N-1)/2对点间距离。可以用一个Ο(N^2)的很短的穷尽搜索算法。但我们期望更好一些的算法。

  假设平面上这些点已经按照x的坐标排过序,这将在最终时间界上增加Ο(NlogN)。图9-25画出了一个小的样本点集P。图9-26显示了把这些点集分为两半:PL和PR。则最近的一对点或者都在PL上,或者都在PR上,或者一个在PL而另一个在PR上。把这三个距离分别叫做dL、dR和dC

 

090328_D88q_2537915.jpg

图9-25 一个小规模的点集

090412_Cuoa_2537915.jpg

图9-26 被分成PL和PR的点集P:显示了最短的距离

  令δ=min(dL,dR)。第一个观察的结论是,如果dC对δ有所改进,那么只需计算dC。如果dC是这样的距离,则决定dC的两个点必然在分割线的δ距离之内:把这样的区域叫做带(strip)。如图9-27所示,这个观察限定了所需要考虑的点的个数(此例中的δ=dR)。

091317_iTNY_2537915.jpg

图9-27 双道带区域,包含对于dC带所考虑的全部点

  两种策略可以用来计算dC,对于均匀分布的大型点集,预计位于该带中的点的个数是非常少的。平均只有

Ο(N^(1/2))个点在带中。因此,可以以Ο(N)时间对这些点进行蛮力计算。下面是该策略的伪代码。

// Points are all in the strip

for( i = 0; i < numPointsInStrip; i++ )
  for( j = i + 1; j < numPointsInStrip; j++ )
    if( dist( pi, pj ) < δ )
      δ = dist( pi, pj );

  在最坏情形下,所有点都在这条带状区域,因此这种方法不总能以线性时间进行。下面是改进:确定dC的两个点的y坐标相差最多为δ。否则,dC>δ。代码如下:

// Points are all in the strip and sorted by y-coordinate

for( i = 0; i < numPointsInStrip; i++ )
  for( j = i + 1; j < numPointsInStrip; j++ )
    if( pi and pj's y-coordinates differ by more than δ )
      break; // Go to next pi.
    else
    if( dist( pi, pj ) < δ )
      δ = dist( pi, pj );

  这个附加测试对运行时间有显著影响。图9-28所示。

093453_upKN_2537915.jpg

图9-28 在第2个for循环中只考虑p4和p3

  最坏的情形的状况见图9-29所示。

093743_yZmD_2537915.jpg

图9-29 最多有8个点在该矩形中,其中有两个坐标由两个点共享

 

   9.2.3 选择问题

  选择问题(selection problem)要求找出只含N个元素的集合S中的第k个最小元素。

  基本的算法是简单的递归策略。设N大于截至点(cutoff point),元素将从截至点开始进行简单的排序,v是选出的一个元素,叫做枢纽元(pivot)。这个算法和快速排序之间的主要区别在于,这里要求解的只有一个子问题而不是两个子问题。为了得到一个好的最坏情形,关键想法是再用一个间接层。我们不是从随机元素的样本中找出中项,而是从中项的样本中找出中项。

  基本的枢纽元选择算法如下:

  1. 把N个元素分成[N/5]组,5个元素一组,忽略(最多4个)多于元素。

  2. 找出每组的中项,得到个[N/5]中项的表M。

  3. 求出M的中项,将其作为枢纽元v返回。

  我们将用术语五分化中项的中项(median-ofmedian-of-five partitioning)描述使用上面给出的枢纽元选择的快速选择算法。图9-30指出当N=45时如何选出枢纽元。

100415_Kbns_2537915.jpg

图9-30 枢纽元的选择

  在图9-30中,v代表该算法选出作为枢纽元的元素。

  定理9.9 使用“五分化中项的中项”的快速选择算法的运行时间是Ο(N)

  分治算法还可以用来降低选择算法所需要的期望的比较次数。

 

   9.2.4 一些算术问题的理论改进

  本节描述一个分治算法,该算法是将两个N位数相差。对于大的数,乘法并不是以常数时间完成的。我们还要介绍经典的分治算法,它以亚立方时间将两个NxN矩阵相乘。

    1.整数相乘

  为了得到一个亚二次的算法,必须使用少于4次的递归调用。关键的观察结果是

102029_KQhW_2537915.jpg

于是,不用两次乘法来计算10^4的系数,而可以用一次乘法再加上已经完成的两次乘法的结果。图9-31演示了如何只求解3次递推子问题。

102342_MedQ_2537915.jpg

图9-31 分治算法的执行情况

  容易看出现在的递推方程满足:

T(N)=3T(N/2)+Ο(N)

  从而得到T(N)=Ο(N^1.59)。为完成这个算法,必须要有一个基准情形,该情形可以无需递归而解决。

  当两个数都使一位数字时,可以通过查表进行乘法;若有一个乘数为0,则返回0;

 

    2.矩阵乘法

  一个基本的数值问题是两个矩阵的乘法。下面给出了一个简单的Ο(N^3)算法计算C=AB,其中A、B和C均为NxN矩阵。该算法来自直接的矩阵乘法定义。

/** 
 * Standard matrix multiplication
 * Arrays start at 0.
 * Assumes a and b are square.
 */
 matrix<int> operator* ( const matrix<int> & a, const matrix<int> & b )
 {
   int n = a.numrows();
   matrix<int> c( n, n );
   
   int i;
   for( i = n; i < n; i++ ) // Initialization
     for( int j = 0; j < n; j++ )
       c[ i ][ j ] = 0;
       
   for( int i = 0; i < n; i++ )
     for( int j = 0; j < n; j++ )
       for( int k = 0; k < n; k++ )
         c[ i ][ j ] += a[ i ][ k ] * b[ k ][ j ];
         
   return c;
 }

  Strassen算法的基本想法是把每一个矩阵都分成4块,如图9-32所示。

105220_HBhJ_2537915.jpg

图9-32 把AB=C分解成4块乘法

  此时,容易证明:

105328_sCSw_2537915.jpg

  作为一个例子,为了进行乘法AB:

105509_NFhh_2537915.jpg

  我们定义下列8个N/2×N/2阶矩阵:

105606_9KO5_2537915.jpg

  此时,可以进行8个N/2×N/2阶矩阵的乘法和4个N/2×N/2阶矩阵的加法。这些加法花费Ο(N^2)时间。运行时间满足:

T(N)=8T(N/2)+Ο(N^2)

  从定理9.6可以看到T(N)=Ο(N^3)。这没对算法作出改进。必须把子问题化简到8个以下。Strassen使用了类似于整数乘法分治算法的策略,并指出如何仔细安排计算只使用7次递归调用。这7个乘法是

110217_MBAF_2537915.jpg

110236_aAVf_2537915.jpg

  一旦执行这些乘法,则最后结果可以通过下列8次加法得到:

110354_BOrN_2537915.jpg

  现在运行时间满足递推关系:

T(N)=7T(N/2)+Ο(N^2)

  这个递推关系的解为T(N)=Ο(N^2.81)。Strassen算法在N不够大时不如矩阵直接乘法,它也不能推广到矩阵是稀疏矩阵的情况。

 

  9.3 动态规划

  在前一节,我们看到一个可以被数学上递归表示的问题也可以表示成递归算法,在许多情形下对朴素的穷举搜索得到显著的性能改进。

  任何数学递推公式都可以直接翻译成递归算法,但是基本实现是编译器常常不能正确地对待递归算法,结果产生低效的程序。当怀疑可能是这种情况时,必须再给编译器提供一些帮助,将递归算法重写成非递归算法,让后者把这些子问题的解系统地记录在一个表(table)内。利用这种方法的一种技巧称为动态规划(dynamic programming)。

 

   9.3.1 用表代替递归

  前面介绍过下面计算斐波那契数数的自然递归程序是非常低效的,运行时间T(N)满足T(N)≥T(n-1)+T(N-2)。T(N)呈指数级增长。

/**
 * Compute Fibonacci numbers 
 */
 int fib( int i )
 {
   if( n <= 1 )
     renturn 1;
   else 
     return fib( n - 1 ) + fib( n - 2 );
 }

  另一方面,由于计算FN所需的只是计算FN-1和FN-2的调用。因此只需要记录最近算出的两个斐波拉契数。然而,由于FN-1递归地对FN-2和FN-3进行调用,因此存在两个单独的计算FN-2的调用。如图9-33所示,冗余计算的增长是爆炸性的。

161513_EYx0_2537915.jpg

图9-33 跟踪斐波拉契数的递归计算

  如果编译器的递归模拟算法能够保留一个预先算出的值的表而对已经解过的子问题不再进行递归调用,那么这种指数式的爆炸增长就可以避免。这就是下面程序如此有效的原因。

/**
 * Compute Fibonacci numbers
 */
 int fibonacci( int n )
 {
   if( n <= 1 )
     return 1;
     
   int last = 1;
   int nextToLast = 1;
   int answer = 1;
   for( int i = 2; i <= n; i++ )
   {
     answer = last + nextToLast;
     nextTolast = last;
     last = answer;
   }
   return answer;
 }

  作为第二个例子,来看递推关系162504_LzJu_2537915.jpg,其中C(0)=1。假设我们想要检查所得到的解是否在数值上是正确的,此时可以编写下面的简单程序来计算这个递归问题。

double eval( int a )
{
  if( n == 0 )
    return 1.0;
  else
  {
    double sum = 0.0;
    for( int i = 0; i < n; i++ )
      sum += eval( i );
    return 2.0 * sum / n + n;
  }
}

  这里递归又做了重复的工作。下面用表来进行改进。

double eval( int n )
{
  vector<double> c( n + 1 );
  
  c[ 0 ] = 1.0;
  for( int i = 1; i <= n; i++ )
  {
    double sum = 0.0;
    for( int j = 0; j < i; j++ )
      sum += c[ j ];
    c[ i ] = 2.0 * sum / i + 1;
  }
  renturn c[ n ];
}


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

转载于:https://my.oschina.net/u/2537915/blog/648128

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值