如何从理论上评估算法的时间复杂度

参考  如何从理论上评估算法的时间复杂度 - 云+社区 - 腾讯云

一、时间复杂度的极限理论基础

定义1:

如果存在正常数cn_0使得当N \geqslant n_0T(N) \leqslant cf(N),则记为T(N)=O(f(N))

定义2:

如果存在正常数cn_0使得当N \geqslant n_0T(N) \geqslant cg(N),则记为T(N)=\Omega (f(N))

定义3:

T(N)= \Theta (h(N))当且仅当T(N)=O(h(N))T(N)=\Omega (h(N))

定义4:

如果T(N)=O(p(N))T(N) \neq \Theta (p(N)),则T(N)=o(p(N))

这四个定义的含义为:

这四个定义的目的是要在函数间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上的一个函数的值小于另一个函数的值,因此,像f(N)<g(N)这样的声明是没有什么意义的。于是,比较相对增长率(relative rate of growth)。虽然N较小时,1000N要比N^2大,但N^2以更快的的速度增长,因此N^2最终将更大。在这种情况下,N=1000是转折点。第一个定义是说,最后总存在某个点n_0,从它以后c \cdot f(N)总是至少与T(N)一样大,从而若忽略常数因子,则f(N)至少与T(N)一样大。在以上例子中,T(N) = 1000N,f(N)=N^2n_0=1000而c=1。我们也可以让n_0=10c=100。因此,我们可以说1000N=O(N^2)(N平方级)。这种记法称为大O记法。人们常常不说“......级的”,而说是“大O......”。如果我们用传统的不等式来计算增长率,那么第一个定义是说T(N)的增长率小于等于f(N)的增长率。第二个定义T(N) =\Omega (g(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)。与此同时,f(N)=\Omega (g(N))都是正确的。当两个函数以相同的速度增长时,是否需要使用“\Theta ()”表示可能依赖于具体的上下文。直观的来说,如果g(N)=2N^2,那么g(N)=O(N^4)

g(N)=O(N^3)g(N)=O(N^2)从技术上看都是成立的,但最后一个选择是最好的答案。写法g(N)=\Theta(N^2)不仅表示g(N)=O(N^2)而且还表示结果会尽可能地好。

法则1:

如果T_1(N)=O(f(N))T_2=O(g(N)),那么

(a)T_1+T_2=max(O(f(N)),O(g(N)))

(b)T_1(N)*T_2(N)=O(f(N)*g(N))

法则2:

如果T(N)是一个k次多项式,则T(N)=\Theta(N^k)

法则3:
对任意常数k,log^kN=O(N)。它告诉我们对数增长得非常缓慢。

这三个法则足以按照增长率对大部分常见的函数进行分类。将低阶项放进大O是非常坏的习惯。不要写成T(N)=O(2N^2)T(N)=O(2N^2)。这就是说,在需要大O表示的任何分析中,各种简化都是可能发生的。低阶项一般可以忽略,而常数也可以丢弃。此时要求的精度是很低的。

通过极限\lim\limits_{N\to+\infty} \frac{f(N)}{g(N)},这也符合实际的物理意义,评估算法的性能是在大量输入数据上,必要的时候可以使用洛必达法则:

  1. 极限是0:这意味着f(N)=o(g(N)),f(N)的时间复杂度小于g(N)。
  2. 极限是不为零的常数:这意味着f(N)=\Theta(g(N)),f(N)和g(N)的时间复杂度相等。
  3. 极限是无穷大:这意味着g(N)=o(f(N)),f(N)的时间复杂度大于g(N)。
  4. 极限摆动:二者大小关系不确定,这种情况在计算机中算法中不存在。

应用这几种方法几乎总能够算出相对增长率。通常,两个函数f(N)和g(N)间的关系可以用简单的代数方法得到。例如,如果f(N)=NlogNg(N)=N^{1.5},那么确定f(N)和g(N)哪个增长的更快,实际上就是确定logNN^{0.5}哪个增长更快。这与确定log^2NN哪个增长得快是一样的,而后者是一个简单的问题,因为我们已经知道,N的增长率快于logN的任意次幂。因此,g(N)的增长快于f(N)的增长。另外,在风格上还应注意:不要说成f(N)<O(g(N)),因为定义已经隐含有不等式了。写成f(N)\geqslant O(g(N))是错误的,她没有意义。

二、一般意义上的模型和分析的问题

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

要分析的最重要的资源一般来说就是运行时间。有几个因素影响着程序的运行时间。有些因素如所使用的编译器和计算机显然超出了任何理论模型的范畴,因此,它们虽然是重要的,但是我们在这里还不能处理它们。剩下的主要因素则是使用的算法以及对该算法的输入。典型的情形时,输入的大小是主要的考虑方面。定义两个函数T_{avg}(N)T_{worst}(N),分别为输入为N时,算法所花费的平均运行时间和最坏运行时间。显然,T_{avg}(N)\leqslant T_{worst}(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行在吃刷花i、测试i\leqslant N和对i的自增运算中隐含着开销。所有这些的总开销是初始化1个时间单元,所有的测试N+1个时间单元,以及所有的自增运算N个时间单元,共2N+2。我么忽略调用函数和返回值的开销,得到总量是6N+4.因此,我们说该函数是O(N)

如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大O的结果,因此就存在寻多可以采取的捷径并且不影响最后的结果。例如,第8行(每次执行)显然是O(1)语句,因此精确计算它究竟是二、三还是四个时间单位是愚蠢的;这无关紧要。第6行与for循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我么得到了若干一般法则。

  • 法则1---FOR循环:
  • 一次for循环的运行时间至多是该for循环内语句(包括测试)的运行时间乘以迭代的次数。

  • 法则2---嵌套的FOR循环:
  • 从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的for循环的大小的乘积。

作为一个例子,下列程序片段为O(N^3):

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

  • 法则3---顺序语句:
  • 将各个语句的运行时间求和即可(这意味着,其中的最大值就是所得的运行时间)。

作为一个例子,下面的程序片段先用去O(N),再花费O(N^2),总的开销也是O(N^2).

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/else语句的运行时间从不超过判断再加上S1和S2中运行时间长着的总的运行时间。
if( Condition)
   S1
else
   S2

  • 法则5---递归:
  • 一般转换求解递推公式的范围。

其他法则是显然的,但是,分析的基本策略是从内部(或最深层部分)向外展开的。如果只有函数调用,那么这些调用首先要分析。如果有递归过程,那么存在几种选择。若递归实际上只是被薄棉纱遮住的for循环,则分析通常是很简单的。例如,下面的函数实际上就是一个简单的for循环,从而其他运行时间为O(N)

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的值并运行,那么这个程序让人感到效率低得吓人。分析十分简单,令T(N)为函数Fib(N)的运行时间。如果N=0N=1,则运行时间是某个常数值,即第4行上做做判断以及返回所用时间。因为常数不重要,所以我们可以说T(0)=T(1)=1。对于N的其他值的运行时间则相对基准情形的运行时间来度量。若N>2,则就执行该函数的时间是第4行上的常数工作加上第7行上的工作。第7行由于一次加法和两次函数调用是Fib(N-1),从而按照T的定义,它需要T(N-1)个时间单元。类似的论证指出,第二次函数调用需要T(N-2)个时间单元。此时总的时间需求为T(N-1)+T(N-2)+2,其中“2”指的是第4行上的工作加上第7行上的加法。于是对于N\geqslant 2有下列关于Fib(N)的运行时间公式:

                                                              T(N)=T(N-1)+T(N-2)+2

由于Fib(N)=Fib(N-1)+Fib(N-2),因此由归纳算法容易证明T(N)\geqslant Fib(N)。利用已知结论,对于斐波那契数列有结论,对于N\geqslant 4Fib(N)\geqslant (3/2)^N,可见,这个程序的运行时间以指数的速度增长。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wanderer001

ROIAlign原理

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值