目录
2.1数学基础




2.2模型
为了在正式的构架中分析算法,我们需要一个计算模型。我们的模型基本上是一台标准的计算机,在机器中指令被顺序地执行。该模型有一个标准的简单指令系统,如加法、乘法、比较和赋值等。但不同于实际计算机情况的是,模型机做任一件简单的工作都恰好花费一个时间单位。为了合理起见,我们将假设模型像一台现代计算机那样有固定大小(比如32位)的整数并且不存在如矩阵求逆或排序这种想象的操作,它们显然不能在一个时间单位内完成。我们还假设模型机有无限的内存。
显然,这个模型有些缺点。很明显,在现实生活中不是所有的运算都恰好花费相同的时间。特别在我们的模型中,一次磁盘读入按一次加法计时,虽然加法一般要快几个数量级。还有,由于假设有无限的内存,我们再不用担心缺页中断,而它可能是个实际问题,特别是对一些高效的算法。
2.3要分析的问题

偶尔也分析一个算法最好情形的性能。不过,通常这没有什么重要意义,因为它不代表典型的行为。平均情形性能常常反映典型的行为,而最坏情形的性能则代表对任何可能输入的性能的一种保证。还要注意,虽然在这一章我们分析的是Java程序,但所得到的界实际上是算法的界而不是程序的界。程序是算法以一种特殊编程语言的实现,程序设计语言的细节几乎总是不影响大О的答案。如果一个程序比算法分析提出的速度慢得多,那么可能存在低效率的实现。这在类似C++的语言中很普遍,比如,数组可能当作整体而被漫不经心地拷贝,而不是由引用来传递。不管怎么说,这在Java中也可能出现;在12.7节的最后两段有一个极其巧妙的例子来说明这个问题。因此,在后面各章我们将分析算法而不是分析程序。
一般说来,若无相反的指定,则所需要的量是最坏情况的运行时间。其原因之一是它对所有的输入提供了一个界限,包括特别坏的输入,而平均情况分析不提供这样的界。另一个原因是平均情况的界计算起来通常要困难得多。在某些情况下,“平均”的定义可能影响分析的结果。(例如,什么是下述问题的平均输入?)
作为一个例子,我们将在下一节考虑下述问题:最大子序列和问题
![]()


2.4运行时间计算
有几种方法估计一个程序的运行时间。前面的表是凭经验得到的。如果认为两个程序花费大致相同的时间,要确定哪个程序更快的最好方法很可能就是将它们编码并运行!
一般地,存在几种算法思想,而我们总愿意尽早除去那些不好的算法思想,因此,通常需要分析算法。不仅如此,进行分析的能力常常提供对于设计有效算法的洞察能力。一般说来,分析还能准确地确定瓶颈,这些地方值得仔细编码。
为了简化分析,我们将采纳如下的约定:不存在特定的时间单位。因此,我们抛弃一些前导的常数。我们还将抛弃低阶项,从而要做的就是计算大О运行时间。由于大О是一个上界,因此我们必须仔细,绝不要低估程序的运行时间。实际上,分析的结果为程序在一定的时间范围内能够终止运行提供了保障。程序可能提前结束,但绝不可能错后。
2.4.1一个简单的例子

对这个程序段的分析是简单的。所有的声明均不计时间。第1行和第4行各占一个时间单元。第3行每执行一次占用4个时间单元(两次乘法,一次加法和一次赋值),而执行N次共占用4N个时间单元。第2行在初始化i、测试i≤N和对i的自增运算隐含着开销。所有这些的总开销是初始化1个单元时间,所有的测试为N+1个单元时间,而所有的自增运算为N个单元时间,共2N+2个时间单元。我们忽略调用方法和返回值的开销,得到总量是6N+4个时间单元。因此,我们说该方法是O(N)。
如果每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的负担。幸运的是,由于我们有了大О的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。例如,第3行(每次执行时)显然是0(1)语句,因此精确计算它究竟是2、3还是4个时间单元是愚蠢的;这无关紧要。第1行与for循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使我们得到若干一般法则。
2.4.2一般法则

for(i=0;i<n;i++)
for(j=0;j<n;j++)
k++;

for( i = 0; i < n; i++ )
a[ i ] = 0;
for( i - 0; i < n; i++ )
for( j = 0; j < n; j++ )
a[ i ] += a[ j ]+ i + j;
法则4——if/else语句
对于程序片段
if( condition )
S1
else
S2
一个if/else语句的运行时间从不超过判断的运行时间再加上S1和S2中运行时间长者的总的运行时间。
显然在某些情形下这么估计有些过头,但决不会估计过低。
其他的法则都是显然的,但是,分析的基本策略是从内部(或最深层部分)向外展开工作的。如果有方法调用,那么要首先分析这些调用。如果有递归过程,那么存在几种选择。若递归实际上只是被薄面纱遮住的for循环,则分析通常是很简单的。例如,下面的方法实际上就是一个简单的循环从而其运行时间为O(N):
public static 1ong factorial( int n ){
if( n <= 1 )
return 1;
else
return n * factorial ( n - 1 );
}
实际上这个例子对递归的使用并不好。当递归被正常使用时,将其转换成一个循环结构是相当困难的。在这种情况下,分析将涉及求解一个递推关系。为了观察到这种可能发生的情形,考虑下列程序,实际上它对递归使用的效率低得令人惊诧。

初看起来,该程序似乎对递归的使用非常聪明。可是,如果将程序编码并在N值为40左右时运行,那么这个程序让人感到效率低得吓人。分析是十分简单的。令T(N)为调用函数fib(n)的运行时间。如果N=0或N=1,则运行时间是某个常数值,即第1行上做判断以及返回所用的时间。因为常数并不重要,所以我们可以说T(0)=T(1)=1。对于N的其他值的运行时间则相对于基准情形的运行时间来度量。若N>2,则执行该方法的时间是第1行上的常数工作加上第3行上的工作。第3行由一次加法和两次方法调用组成。由于方法调用不是简单的运算,因此必须用它们自己来分析它们。第一次方法调用是fib(n -1),从而按照T的定义它需要T(N-1)个时间单元。类似的论证指出,第二次方法调用需要T(N-2)个时间单元。此时总的时间需求为T(N-1)+T(N-2)+2,其中2指的是第1行上的工作加上第3行上的加法。于是对于N≥2,有下列关于fib(n)的运行时间公式:
T(N)= T(N - 1 )+ T(N -2)+2
但是fib(N) = fib(N-1 ) + fib(N-2),因此由归纳法容易证明T(N)≥fib(N)。在1.2.5节我们证明过fib(N)<(5/3)^N",类似的计算可以证明(对于N>4)fib(N)≥(3/2)^N",从而这个程序的运行时间以指数的速度增长。这大致是最坏的情况。通过保留一个简单的数组并使用一个for循环,运行时间可以显著降低。
这个程序之所以运行缓慢,是因为存在大量多余的工作要做,违反了在1.3节中叙述的递归的第四条主要法则(合成效益法则)。注意,在第3行上的第一次调用即fib(n -1)实际上在某处计算fib(n -2)。这个信息被抛弃而在第3行上的第二次调用时又重新计算了一遍。抛弃的信息量递归地合成起来并导致巨大的运行时间。这或许是格言“计算任何事情不要超过一次”的最好的实例,但它不应使你被吓得远离递归而不敢使用。本书中将随处看到递归的杰出使用。
2.4.3最大子序列和问题的求解
现在我们将要叙述四个算法来求解早先提出的最大子序列和问题。第一个算法如图2-5所示,它只是穷举式地尝试所有的可能。for循环中的循环变量反映了Java中数组从0开始而不是从1开始这样一个事实。还有,本算法并不计算实际的子序列;实际的计算还要添加一些额外的代码。

该算法肯定会正确运行(这用不着花太多的时间去证明)。运行时间为O(N^3),这完全取决于第13行和第14行,它们由一个含于三重嵌套for循环中的0(1)语句组成。第8行上的循环大小为N。
第2个循环大小为N-i,它可能要小,但也可能是N。我们必须假设最坏的情况,而这可能会使得最终的界有些大。第3个循环的大小为j-i+1我们也要假设它的大小为N。因此总数为O(1·N·N·N) = O(N^3)。第6行总共的开销只是O(1),而语句16和17也只不过总共开销O(N^2),因为它们只是两层循环内部的简单表达式。


对这个问题有一个递归和相对复杂的O(N logN)解法,我们现在就来描述它。要是真的没出现O(N)(线性的)解法,这个算法就会是体现递归威力的极好的范例了。该方法采用一种“分治( divide-and-conquer)”策略。其想法是把问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”的部分。“治”阶段将两个子问题的解修补到一起并可能再做些少量的附加工作,最后得到整个问题的解。
在我们的例子中,最大子序列和可能在三处出现。或者整个出现在输入数据的左半部,或者整个出现在右半部,或者跨越输入数据的中部从而位于左右两半部分之中。前两种情况可以递归求解。第三种情况的最大和可以通过求出前半部分(包含前半部分最后一个元素)的最大和以及后半部分(包含后半部分第一个元素)的最大和而得到。此时将这两个和相加。作为一个例子,考虑下列输入:
我们看到,在形成本例中的最大和子序列的三种方式中,最好的方式是包含两部分的元素。于是,答案为11。图2-7提出了这种策略的一种实现手段。
有必要对算法3的程序进行一些说明。递归过程调用的一般形式是传递输入的数组以及左边界和右边界,它们界定了数组要被处理的部分。单行驱动程序通过传递数组以及边界0和N -1而将该过程启动。
第8行至第12行处理基准情况。如果left == right,那么只有一个元素,并且当该元素非负时它就是最大子序列。left > right的情况是不可能出现的,除非N是负数(不过,程序中小的扰动有可能致使这种混乱产生)。第15行和第16行执行两个递归调用。我们可以看到,递归调用总是对小于原问题的问题进行,不过程序中的小扰动有可能破坏这个特性。第18行至第24行以及第26行至第32行计算达到中间分界处的两个最大和的和数。这两个值的和为扩展到左右两部分的最大和。例程max3(未给出)返回这三个可能的最大和中的最大者。
显然,算法3需要比前面两种算法更多的编程努力。然而,程序短并不总意味着程序好。正如我们在前面显示算法运行时间的表中已经看到的,除最小的输入量外,该算法比前两个算法明显要快。
对运行时间的分析方法与在分析计算斐波那契数程序时的方法类似。令T(N)是求解大小为N的最大子序列和问题所花费的时间。如果N=1,则算法3执行程序第8行到第12行花费某个常数时间量,我们称之为一个时间单位。于是,T(1)=1。否则,程序必须运行两个递归调用,即在第19行和第32行之间的两个for循环,以及某个小的簿记量,如第14行和第18行。这两个for循环总共接触到从A。到Ay.,的每一个元素,而在循环内部的工作量是常量,因此,在第19到32行花费的时间为O(N)。在第8行到第14行,第18、26和34行上的程序的工作量都是常量,从而与O(N)相比可以忽略。其余就是第15、16行上运行的工作。这两行求解大小为N/2的子序列问题(假设N是偶数)。因此,这两行每行花费T(N/2)个时间单元,共花费2T(N/2)个时间单元。算法3花费的总的时间为2T(N/2)+0(N)。我们得到方程组
T(1) = 1
T(N) =2T(N/2)+ O(N)

为了简化计算,我们可以用N代替上面方程中的0(N)项;由于T(N)最终还是要用大0来表示,因此这么做并不影响答案。在第7章,我们将会看到如何严格地求解这个方程。至于现在,如果T(N)=2T(N/2)+N,且T(1)=1,那么T(2)=4=2*2,T(4)= 12=4*3,T(8 ) =32=8*4,以及T(16)=80 = 16 *5。其形式是显然的并且可以得到,即若N=2^k,则T(N)=N*(h +1) =N log N +N = o(N log N)。
这个分析假设N是偶数,否则N/2就不确定了。通过该分析的递归性质可知,实际上只有当N是2的幂时结果才是合理的,否则我们最终要得到大小不是偶数的子问题,方程就是无效的了。当N不是2的幂时,我们多少需要更加复杂一些的分析,但是大О的结果是不变的。
在后面的章节中,我们将看到递归的几个漂亮的应用。这里,我们还是介绍求解最大子序列和的第4种方法,该算法实现起来要比递归算法简单而且更为有效。它在图2-8中给出。

不难理解为什么时间的界是正确的,但是要明白为什么算法是正确可行的却需要多加思考。为了分析原因,注意,像算法1和算法2一样,j代表当前序列的终点,而i代表当前序列的起点。碰巧的是,如果我们不需要知道具体最佳的子序列在哪里,那么i的使用可以从程序上被优化,因此在设计算法的时候假设i是需要的,而且我们想要改进算法2。一个结论是,如果a[ i]是负的,那么它不可能代表最优序列的起点,因为任何包含a[i]的作为起点的子序列都可以通过用a[ i+1]作起点而得到改进。类似地,任何负的子序列不可能是最优子序列的前缀(原理相同)。如果在内循环中检测到从a[i]到a[i]的子序列是负的,那么可以推进i。关键的结论是,我们不仅能够把i推进到i +1,而且实际上还可以把它一直推进到j+1。为了看清楚这一点,令p为i+1和j之间的任一下标。开始于下标p的任意子序列都不大于在下标i开始并包含从a[i]到a[p-1]的子序列的对应的子序列,因为后面这个子序列不是负的(j是使得从下标i开始其值成为负值的序列的第一个下标)。因此,把i推进到j+1是没有风险的:我们一个最优解也不会错过。
这个算法是许多聪明算法的典型:运行时间是明显的,但正确性则不那么容易看出来。对于这些算法,正式的正确性证明(比上面的分析更正式)几乎总是需要的;然而,即使到那时,许多人仍然还是不信服。此外,许多这类算法需要更有技巧的编程,这导致更长的开发过程。不过当这些算法正常工作时,它们运行得很快,而我们将它们和一个低效(但容易实现)的蛮力算法通过小规模的输人进行比较可以测试到大部分的程序原理。
该算法的一个附带的优点是,它只对数据进行一次扫描,一旦a[i]被读入并被处理,它就不再需要被记忆。因此,如果数组在磁盘上或通过互联网传送,那么它就可以被按顺序读人,在主存中不必存储数组的任何部分。不仅如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案(其他算法不具有这个特性)。具有这种特性的算法叫作联机算法。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。
2.4.4运行时间中的对数
分析算法最混乱的方面大概集中在对数上面。我们已经看到,某些分治算法将以O(N log N)时间运行。此外,对数最常出现的规律可概括为下列一般法则:如果一个算法用常数时间(O(1))将问题的大小削减为其一部分(通常是1/2),那么该算法就是O( log N)。另一方面,如果使用常数时间只是把问题减少一个常数的数量(如将问题减少1 ),那么这种算法就是O(N)的。
显然,只有一些特殊种类的问题才能够呈O( log N)型。例如,若输入N个数,则算法只要把这些数读人就必须耗费Π(N)的时间量。因此,当我们谈到这类问题的O(log N)算法时,通常都是假设输人数据已经提前读入。下面,我们提供具有对数特点的三个例子。

显然,每次迭代在循环内的所有工作花费O(1),因此分析需要确定循环的次数。循环从high -low =N-1开始,并保持high -low ≥-1。每次循环后high - low的值至少将该次循环前的值折半;于是,循环的次数最多为 log(N- 1)1+2。(例如,若high - low = 128,则在各次迭代后high - low的最大值是64,32,16,8,4,2,1,0,-1。)因此,运行时间是0( log N)。与此等价,我们也可以写出运行时间的递推公式,不过,当我们理解实际在做什么以及为什么的原理时,这种强行写公式的做法通常没有必要。
折半查找可以看作是我们的第一个数据结构实现方法,它提供了在O( log N)时间内的contains操作,但是所有其他操作(特别是insert 操作)均需要O(N)时间。在数据是稳定(即不允许插入操作和删除操作)的应用中,这种操作可能是非常有用的。此时输入数据需要一次排序,但是此后的访问会很快。有个例子是一个程序,它需要保留(产生于化学和物理领域的)元素周期表的信息。这个表是相对稳定的,因为很少会加进新的元素。元素名可以始终是排序的。由于只有大约110种元素,因此找出一个元素最多需要访问8次。要是执行顺序查找就会需要多得多的访问次数。
欧几里得算法
第二个例子是计算最大公因数的欧几里得算法。两个整数的最大公因数( gcd)是同时整除二者的最大整数。于是,gcd(50,15)=5。图2-10所示的算法计算gcd(M,N),假设M≥N(如果N >M,则循环的第一次迭代将它们互相交换)。
算法连续计算余数直到余数是0为止,最后的非零余数就是最大公因数。因此,如果M = 1989和N= 1590,则余数序列是399,393,6,3,0。从而,gcd( 1989,1590)=3。正如例子所表明的,这是一个快速算法。

如前所述,估计算法的整个运行时间依赖于确定余数序列究竟有多长。虽然log N看似像理想中的答案,但是根本看不出余数的值按照常数因子递减的必然性,因为我们看到,例中的余数从399仅仅降到393。事实上,在一次迭代中余数并不按照一个常数因子递减。然而,我们可以证明,在两次迭代以后,余数最多是原始值的一半。这就证明了,迭代次数至多是2 log N =0(log N)从而得到运行时间。这个证明并不难,因此我们将它放在这里,可从下列定理直接推出它。


2.4.5分析结果的准确性
根据经验,有时分析会估计过大。如果这种情况发生,那么或者需要进一步细化分析(一般通过机敏的观察),或者可能是平均运行时间显著小于最坏情形的运行时间,不可能对所得的界再加以改进。对于许多复杂的算法,最坏的界通过某个坏的输人是可以达到的,但在实践中它通常是估计过大的。遗憾的是,对于大多数这类问题,平均情形的分析是极其复杂的(在许多情形下仍然悬而未决),而最坏情形的界尽管过分地悲观,但却是最好的已知解析结果。

894

被折叠的 条评论
为什么被折叠?



