定义1:
如果存在正常数和使得当时,则记为。
定义2:
如果存在正常数和使得当时,则记为。
定义3:
当且仅当且。
定义4:
如果且,则。
这四个定义的含义为:
这四个定义的目的是要在函数间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上的一个函数的值小于另一个函数的值,因此,像这样的声明是没有什么意义的。于是,比较相对增长率(relative rate of growth)。虽然N较小时,1000N要比大,但以更快的的速度增长,因此最终将更大。在这种情况下,N=1000是转折点。第一个定义是说,最后总存在某个点,从它以后总是至少与T(N)一样大,从而若忽略常数因子,则f(N)至少与T(N)一样大。在以上例子中,T(N) = 1000N,,而c=1。我们也可以让而。因此,我们可以说(N平方级)。这种记法称为大O记法。人们常常不说“......级的”,而说是“大O......”。如果我们用传统的不等式来计算增长率,那么第一个定义是说T(N)的增长率小于等于f(N)的增长率。第二个定义是说T(N)的增长率大于等于g(N)的增长率。第三个定义是说T(N)的增长率等于h(N)的增长率。最后一个定义T(N)=o(p(N))说的则是T(N)的增长率小于p(N)的增长率。它不同于大O,因为大O包含增长率相同这种可能性。
一般我们仅使用大O就可以了。为了证明某个函数T(N)=O(f(N)),我们通常不是形式地使用这些定义,而是使用一些已知的结果。一般来说,这就意味着证明(或确定假设不成立)是非常简单的计算并不涉及微积分,除非遇到特殊情况。当我们说T(N)=O(f(N))时,我们是在保证函数T(N)是在以不快于f(N)的速度增长;因此f(N)是T(N)的一个上界(upper bound)。与此同时,都是正确的。当两个函数以相同的速度增长时,是否需要使用“”表示可能依赖于具体的上下文。直观的来说,如果,那么,
和从技术上看都是成立的,但最后一个选择是最好的答案。写法不仅表示而且还表示结果会尽可能地好。
法则1:
如果且,那么
(a)
(b)
法则2:
如果是一个k次多项式,则。
法则3:
对任意常数k,。它告诉我们对数增长得非常缓慢。
这三个法则足以按照增长率对大部分常见的函数进行分类。将低阶项放进大O是非常坏的习惯。不要写成或。这就是说,在需要大O表示的任何分析中,各种简化都是可能发生的。低阶项一般可以忽略,而常数也可以丢弃。此时要求的精度是很低的。
通过极限,这也符合实际的物理意义,评估算法的性能是在大量输入数据上,必要的时候可以使用洛必达法则:
应用这几种方法几乎总能够算出相对增长率。通常,两个函数f(N)和g(N)间的关系可以用简单的代数方法得到。例如,如果和,那么确定f(N)和g(N)哪个增长的更快,实际上就是确定和哪个增长更快。这与确定和哪个增长得快是一样的,而后者是一个简单的问题,因为我们已经知道,N的增长率快于logN的任意次幂。因此,g(N)的增长快于f(N)的增长。另外,在风格上还应注意:不要说成,因为定义已经隐含有不等式了。写成是错误的,她没有意义。
为了在正式的框架中分析算法,需要一个计算机模型。基本上是一台标准计算机,在机器中指令被顺序的执行。该模型有一个标准的简单指令系统,如加法、乘法、比较和赋值等。但不同于实际计算机情况的是,该模型做任一简单的工作都恰好花费一个时间单元。为了合理起见,我们将假设我们的模型像一台现代计算机那样有固定范围的整数(比如32个比特)并且不存在诸如矩阵求逆或排序等运算,它们显然不能再一个时间单位内完成。由于只评估时间复杂度而不评估空间复杂度,还假设模型机有无限的内存。显然这个模型有些缺点。很明显,在现实生活中不是所有的运算都恰好花费相同的时间。特别的,在我们的模型中,一次磁盘读入挤时间一次加法,虽然加法一般要快几个数量级。还有,由于假设有无限的内存,不用担心页面中断,它可能是一个实际的问题,特别是对高效的算法。
要分析的最重要的资源一般来说就是运行时间。有几个因素影响着程序的运行时间。有些因素如所使用的编译器和计算机显然超出了任何理论模型的范畴,因此,它们虽然是重要的,但是我们在这里还不能处理它们。剩下的主要因素则是使用的算法以及对该算法的输入。典型的情形时,输入的大小是主要的考虑方面。定义两个函数和,分别为输入为N时,算法所花费的平均运行时间和最坏运行时间。显然,。如果存在更多的输入,那么这些函数可以有更多的变量。
一般来说,若无相反的指定,则所需的量是最坏情况下的运行时间。其原因之一是它对所有的输入提供了一个界限,包括特别坏的输入,而平均情况分析不提供这样的界。另一个原因是平均情况的界计算起来通常要困难得多。在某些情况下,“平均”的定义可能影响分析的结果。
当然最好的方法是将两个程序都写出来并运行来比较时间,下面介绍在运行之前如何对两个时间复杂度明显不同的程序进行区分。为了简化分析将采用如下约定:不存在特定的时间单位。因此,抛弃一些常数系数。还将抛弃低阶项,从而要做的就是计算大O运行时间。由于大O是一个上界,因此必须仔细,不要低估程序的运行时间。实际上,分析的结果为程序在一定的时间范围内能够终止运行提供了保障。程序可能提前结束,但绝不可能拖后。
下面给出一个简单的C语言程序,计算前N个整数三次方和的程序:
int
Sum( int N)
{
int i ,PartialSum;
PartialSum = 0;
for( i = 1; i <= N; i++)
PartialSum += i*i*i;
return PartialSum;
}
对这个程序的分析很简单。声明不计时间。第6行和第9行各占一个时间单元。第8行每执行一次占用四个时间单元(两次乘法,一次加法和一次赋值),而执行N次共占用4N个时间单元(两次乘法,一次加法和一次赋值),而执行N次共占用4个时间单元。第7行在吃刷花、测试和对的自增运算中隐含着开销。所有这些的总开销是初始化1个时间单元,所有的测试个时间单元,以及所有的自增运算个时间单元,共。我么忽略调用函数和返回值的开销,得到总量是.因此,我们说该函数是。
如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大O的结果,因此就存在寻多可以采取的捷径并且不影响最后的结果。例如,第8行(每次执行)显然是语句,因此精确计算它究竟是二、三还是四个时间单位是愚蠢的;这无关紧要。第6行与for循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我么得到了若干一般法则。
作为一个例子,下列程序片段为:
for(i=0; i
作为一个例子,下面的程序片段先用去,再花费,总的开销也是.
for( i=0; i
if( Condition)
S1
else
S2
其他法则是显然的,但是,分析的基本策略是从内部(或最深层部分)向外展开的。如果只有函数调用,那么这些调用首先要分析。如果有递归过程,那么存在几种选择。若递归实际上只是被薄棉纱遮住的for循环,则分析通常是很简单的。例如,下面的函数实际上就是一个简单的for循环,从而其他运行时间为。
long int
Factorial( int N )
{
if(N == 1)
return 1;
else
return N * Factorial(N-1);
}
这个例子中对递归的使用实际上并不好。当递归被正常使用时,将其转换成一个简单的循环结构是相当困难的。在这种情况下,分析将涉及求求解一个递推关系。为了观察到这种可能发生的情形,考虑下列例子,实际上它对递归使用的效率低得令人惊讶。
long int
Fib( int N )
{
if( N <= 1 )
return 1;
else
return Fib(N-1) + Fib(N-2);
}
初看起来这个程序似乎对递归使用非常聪明。可是,如果将程序编码并且赋予N大约30的值并运行,那么这个程序让人感到效率低得吓人。分析十分简单,令为函数的运行时间。如果或,则运行时间是某个常数值,即第4行上做做判断以及返回所用时间。因为常数不重要,所以我们可以说。对于N的其他值的运行时间则相对基准情形的运行时间来度量。若,则就执行该函数的时间是第4行上的常数工作加上第7行上的工作。第7行由于一次加法和两次函数调用是,从而按照T的定义,它需要个时间单元。类似的论证指出,第二次函数调用需要个时间单元。此时总的时间需求为,其中“2”指的是第4行上的工作加上第7行上的加法。于是对于有下列关于的运行时间公式:
由于,因此由归纳算法容易证明。利用已知结论,对于斐波那契数列有结论,对于有,可见,这个程序的运行时间以指数的速度增长。