算法效率
计算机系统中最重要的资源之一就是CPU时间。完成具体任务的算法效率是决定程序执行速度的一个主要因素。我们也可以分析算法使用的内存量,但通常更关心CPU时间。
用一个日常的例子“洗盘子”来引入算法效率的概念。如果洗一个盘子用30秒,烘干盘子再用30秒,则洗净并烘干n个盘子要用时n分钟,可用下式表示这个计算:
洗n个盘子所用的时间 = n*(30秒洗的时间 + 30秒烘干时间) = 60n 秒
即 f(n) = 30n+30n = 60n
现在把这个例子用在一种极端的情况下:每洗一个盘子,就烘干刚洗的这个盘子和之前洗过的所有盘子。这种情况下每洗一个盘子仍用时30秒,而烘干这之前所有的盘子的用时将越来越多。洗第一个盘子用时30秒,烘干第一个盘子用时30秒;洗第二个盘子用时30秒,烘干第一、二个盘子用时2x30秒;……;洗第n个盘子用时30秒,烘干第一到n个所有的盘子用时nx30秒。可以用下式表示这个计算:
洗n个盘子所用的时间 = n*(30秒洗的时间) + n(n+1)/2 *(30秒烘干时间)
得到 f(n) = 15n2 + 45n
如果要洗30个盘子,第一种方法要用30分钟,而第二种极端方法要用247.5分钟。并且清洗的盘子越多这种差异就越大,例如要洗300个盘子,第一种方法用时5个小时,第二种方法用时却达到了约15000小时。这时候有人会问了,谁会用这么没效率的方法一遍又一遍地重复烘干之前已经烘干过的盘子?但其实在我们初学程序设计时,往往就是会用到这么没效率的编程方式,大大增加了程序运行的时间。
增长函数
对于要分析的每个算法,需要定义问题的大小。对于洗盘子这个例子来说,问题的大小就是要洗净并烘干的盘子的个数。我们还必须确定表示时间或空间效率的评估标准。就时间来说,常常要让相关的处理步骤减到最少,例如我们的目标是使洗净并烘干下一个盘子的时间达到最少。花在任务上的全部时间与该任务的执行次数直接相关。所以一个算法的效率可以用问题的大小和处理步骤来定义。
如果考虑将一组数进行升序排序的算法,我们很自然地用待排序数据的个数来表示问题的大小,而希望优化的处理步骤可以表示为在排序算法中必须进行的数据比较次数。比较次数越多,使用的CPU时间也就越多。
增长函数(grow function)表明问题大小(n)与希望优化的值之间的关系。该函数表示算法的时间复杂度(time complexity)或空间复杂度(space complexity)。一般来说时间复杂度更重要。
增长函数显示了与问题大小相关的时间或空间利用率。
我们知道第二个洗盘子算法的增长函数是:
t(n) = 15n2 + 45n
对于算法而言,并不一定非要知道准确的增长函数,我们感兴趣的主要是算法的渐进复杂度(asymptotic complexity),即关注当n增大时函数的一般特性。
增长函数变化的特性依表达式的主项而定——当n增大时,主项的变化最快。当n变得非常大时,因为n2项比n项的变化快得多,所以洗盘子算法的增长函数由n2决定。当n增大时,本例中的常数15和45及第二项45n变得不太重要,也就是说n2的值决定了表达式值的增长。
当然,当n的值特别小时,45n会比15n^2更大,因此主项并不是在n为任何值时都比其他项更大。
渐进复杂度称为算法的阶(order)。第二种洗盘子算法有n2阶时间复杂度,记为O(n2)。效率更高的第一个洗盘子算法的阶为n,记为O(n)。表示阶的这个记号称为大O符号。执行时间为常数、不受问题大小影响的算法的增长函数有O(1)阶。
程序设计中的赋值语句和if语句均不受问题大小的影响且仅执行一次,所以它们的复杂度都是O(1),并且无论语句序列中有多少条,都仍是O(1)。循环语句和方法调用可能会有更高阶的增长函数,因为它们可能会根据问题的大小让一条语句执行很多遍。
一般情况下,我们只关心程序或算法中决定增长函数及效率的可执行语句,但是要注意,有些声明可能包含了初始化过程,而有些可能复杂到足以影响算法的效率。因为函数的阶是关键因素,所以通常不考虑其他的项和常数。一般情况下,认为具有某个特定阶的所有算法从效率上来讲都是等价的。
也许有人会设想,当处理器的速度提升并且有更多可用的便宜内存时,算法分析就不再需要了,然而事实并非如此。处理器速度和内存不能弥补算法效率的差异。因为之前在讨论算法的阶时已经忽略了相关的常数,而处理器速度的提升只是给增长函数增加一个常数。因此在更多的时候,寻找一个更有效率的算法要好于寻找一个更快的处理器。
更快的处理器不能弥补当问题的大小增大时算法的低效率。当n很小时,算法之间的差别也很小,即如果可以确保问题的大小非常小(5个或更小),使用哪一种算法没有太大区别。但当n变大时,增长函数之间的差异很快就会变得非常明显。