|
|
|
|
移动端

2.4.4 运行时间中的对数

《数据结构与算法分析:Java语言描述(原书第3版)》第2章算法分析,本章对如何分析程序的复杂性给出一些提示。遗憾的是, 它并不是完善的分析指南。简单的程序通常给出简单的分析, 但是情况也并不总是如此。本节为大家介绍运行时间中的对数。

作者:冯舜玺/陈越 译来源:机械工业出版社|2016-04-13 11:41

技术沙龙 | 邀您于8月25日与国美/AWS/转转三位专家共同探讨小程序电商实战

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)算法时, 通常都是假设输入数据已经提前读入。下面, 我们提供具有对数特点的三个例子。

折半查找

第一个例子通常叫作折半查找(binary search)。

折半查找:给定一个整数X和整数A0,A1,…,AN-1, 后者已经预先排序并在内存中, 求下标i使得Ai=X, 如果X不在数据中, 则返回i=-1。

明显的解法是从左到右扫描数据, 其运行花费线性时间。然而, 这个算法没有用到该表已经排序的事实, 这就使得算法很可能不是最好的。一个好的策略是验证X是否是居中的元素。如果是, 则答案就找到了。如果X小于居中元素, 那么我们可以应用同样的策略于居中元素左边已排序的子序列; 同理, 如果X大于居中元素, 那么我们检查数据的右半部分。(同样, 也存在可能会终止的情况。)图2-9列出了折半查找的程序(其答案为mid)。图中的程序同样也反映了Java语言数组下标从0开始的惯例。

显然, 每次迭代在循环内的所有工作花费O(1), 因此分析需要确定循环的次数。循环从high-low=N-1开始,并保持high-low ≥-1。每次循环后high-low的值至少将该次循环前的值折半; 于是, 45循环的次数最多为log(N-1)+2。(例如, 若high-low=128,  则在各次迭代后high-low的最大值是64,32,16,8,4,2,1,0,-1。)因此, 运行时间是O(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, 则循环的第一次迭代将它们互相交换)。46

算法连续计算余数直到余数是0为止, 最后的非零余数就是最大公因数。因此, 如果M=1989和N=1590, 则余数序列是399,393,6,3,0。从而, gcd(1989,1590)=3。正如例子所表明的, 这是一个快速算法。

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

定理2.1 如果M>N, 则M mod N<M/2。

证明:

存在两种情形。如果N≤M/2, 则由于余数小于N, 故定理在这种情形下成立。另一种情形是N>M/2。但是此时M仅含有一个N从而余数为M-N<M/2, 定理得证。

从上面的例子来看, 2 log N大约为20, 而我们仅进行了7次运算, 因此有人会怀疑这是不是可能的最好的界。事实上, 这个常数在最坏的情况下还可以稍微改进成1.44log N(如M和N是两个相邻的斐波那契数时就是这种情况)。欧几里得算法在平均情况下的性能需要大量篇幅的高度复杂的数学分析, 其迭代的平均次数约为(12 ln2 lnN)/π2+1.47。

幂运算

我们在本节的最后一个例子是处理一个整数的幂(它还是一个整数)。由取幂运算得到的数一般都是相当大的, 47因此, 我们只能在假设有一台机器能够存储这样一些大整数(或有一个编译程序能够模拟它)的情况下进行我们的分析。我们将用乘法的次数作为运行时间的度量。

计算XN的明显的算法是使用N-1次乘法自乘。有一种递归算法效果更好。N≤1是这种递归的基准情形。否则, 若N是偶数, 我们有XN=XN/2·XN/2, 如果N是奇数, 则XN=X(N-1)/2·X(N-1)/2·X。

例如, 为了计算X62, 算法将如下进行, 它只用到9次乘法:

显然, 所需要的乘法次数最多是2logN, 因为把问题分半最多需要两次乘法(如果N是奇数)。这里, 我们又可写出一个递推公式并将其解出。简单的直觉避免了盲目的强行处理。

图2-11中的代码实现了这个想法有时候看一看程序能够进行多大的调整而不影响其正确性倒是很有意思的。在图2-11中, 第5行到第6行实际上不是必需的, 因为如果N是1, 那么第10行将做同样的事情。第10行还可以写成:

而不影响程序的正确性。事实上, 程序仍将以O(log n)运行, 因为乘法的序列同以前一样。不过, 下面所有对第8行的修改都是不可取的, 虽然它们看起来似乎都正确:

8a和8b两行都是不正确的, 因为当N是2时递归调用pow中有一个是以2作为第2个参数。这样, 程序产生一个无限循环, 将不能往下进行(最终导致程序非正常终止)。

使用8c行会影响程序的效率, 因为此时有两个大小为N/2的递归调用而不是一个。分析指出, 其运行时间不再是O(log N)。我们把它作为练习留给读者去确定这个新的运行时间。

喜欢的朋友可以添加我们的微信账号:

51CTO读书频道二维码


51CTO读书频道活动讨论群:342347198

【责任编辑:book TEL:(010)68476606】

回书目   上一节   下一节
点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

嬴在用户:Web人物角色创建和应用实践指南

您如何保证您的网站确实给予用户他们所需要的,并对您产生商业成果?您需要了解谁是您的用户,您的用户的目标、行为和观点是什么,还要把他...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊