浅入浅出数据结构(2)——简要讨论算法的时间复杂度

       所谓算法的“时间复杂度”,你可以将其理解为算法“要花费的时间量”。比如说,让你用抹布将家里完完全全打扫一遍(看成算法吧……)大概要5个小时,那么你用抹布打扫家里的“时间复杂度”就是5个小时。

       但是,在对算法进行分析时,并没有那么简单。大部分情况下我们不能一眼看出算法执行完需要耗费多少时间,一方面是因为我们很难考虑执行算法的具体机器在各种操作上花费的时间,因为不同机器的运算速度不同,同一机器执行不同操作的所用时间也不一样。另一方面是我们很难统计算法到底执行了多少个“操作”,比如不起眼的a+=1其实算两个操作。所以我们对算法进行时间上的分析时,往往需要使用到“大概”这个概念。但即使是推算算法耗费的“大概”时间也是需要一些基本原则的,接下来我们就来看看如何推算算法的时间复杂度。(完整、严谨的算法分析比较复杂,本文只是写一些“入门”的概念与分析方法)

 

 

       即使在现实生活中,我们也会遇到类似于分析算法时间一样的问题,比如有人问你多久能看完某本书,你可能会说“一个月之内”而不是具体的“19天”,又比如有人问你最快多久能完成某项任务,你可能会说“至少3天”而不是“70小时”。而我们对算法进行时间分析时也会用到类似的“技巧”,即不追求具体的时间耗费,而是追求“上界”或“下界”。

 

        为了找出“上界”与“下界”,我们先要使用两个定义:

        1.如果存在正常数c和n0,使得当N>=n0时T(N)<=c·f(N),则记为T(N)=O(f(N))

        2.如果存在正常数c和n0,使得当N>=n0时T(N)>=c·f(N),则记为T(N)=Ω(f(N))

       第一个定义的意思就是:当N超过某个值后,c·f(N)总是至少比T(N)要大。忽略常数因子,即f(N)至少与T(N)一样大。

  类似的,第二个定义意思就是:当N超过某个值后,c·f(N)总是最多和T(N)一样大。

 

  其实这两个定义就是为了比较两个函数之间的“相对增长率”,比如1000x和x2,虽然x<1000时1000x>x2,但是x2以更快的速度增长,因此x2最终会更大。

       当我们说T(N)=O(f(N))时,其实就是说“T(N)是在以不快于f(N)的速度增长”,类似的T(N)=Ω(f(N))即“T(N)是在以不慢于f(N)的速度增长”。不难发现,O(f(N))就是T(N)的“上界”,Ω(f(N))就是T(N)的“下界”。

      举例来说,N3比N2增长更快,因此N2=O(N3)与N3=Ω(N2)都是对的;2*N2与N2有着相同的相对增长率,因此N2=O(2*N2)与N2=Ω(2*N2)都是正确的。由于对算法进行时间分析时往往考虑“最坏情况”,所以我们通常计算的是O(f(N)),即“上界”,俗称“大O阶”。

 

       正如文章开头说的,相同的算法在不同的机器上也会有不同的运行时间,同一台机器的不同操作也会有不同的时间开销。因此,我们假设我们的“计算机”所有运算如加减乘除、比较、赋值等都是耗费相同时间的,并且不考虑内存问题,从而后面讨论算法时间复杂度时,我们不再带单位,只关心“数值”。

 

 

       接下来,让我们带着现有的概念与知识,来计算一个简单的函数可能花费的时间(也可以说时间复杂度,或者大O阶)

void  func ( unsigned int N )

{

        for ( int  i = 0; i < N ; ++i )

        {

                i = i ;

        }

}

 

       显然这个函数并没有什么意义,我们也只是拿来练练手算算时间开销罢了。那么接下来就让我们一步一步看看它要花费多少时间。

 

       根据我们之前所说,所有运算耗费相同时间且不带单位,那么,初始化i花费1时间,每次循环需要执行一次比较,一次赋值,一次自增总共3时间,N次循环即3N时间,加上定义i的1时间,算法花费的总时间是3N+1。再回顾之前所说,对于算法,我们一般都是计算大O阶(即使这里我们算出了3N+1这样“比较准确”的时间花费),因此接下来我们要对3N+1计算大O阶。

 

       但是3N+1的大O阶有很多很多,比如O(N2)、O(N3)等等(因为N2和N3的相对增长率肯定比3N+1大),究竟哪一个才是我们需要的?直觉告诉我们应该是“最接近的”,即O(N)(根据定义一,显然存在c=1000,n0=1这样的情况使得N成为3N+1的大O阶)。但是选择这个“最接近”的大O阶时有没有什么原则呢?原则当然还是有的,接下来我们就来说一说计算算法时间复杂度O()时的一些原则(和捷径)。

 

 

        首先,我们要确定三个关于大O阶的法则:

        1.如果T(N)=O(f(N)),G(N)=O(h(N)),那么T(N)+G(N)=max(O(f(N)) , O(h(N)))。T(N)*G(N)=O(f(N)*h(N))。

        2.忽略时间花费中的常数项,比如3*N^2+3,直接简化为N^2

        通过法则1中的加法规律(和法则2的简化办法),我们发现N2=O(N2),N=O(N),那么N2+N=max(O(N2) , O(N)) = O(N2)。因此,我们有了法则3:

        3.如果T(N)是一个k次多项式,那么T(N)=O(N^k)。

 

        法则2与法则3是我们常用的,因为算法的时间复杂度往往是一个多项式,而法则2和法则3告诉了我们如何大大简化该多项式来获得大O阶。假设一个算法花费时间3*N3+N2+3,那么根据法则2与法则3,我们可以直接得出其大O阶为O(N3)。

 

 

         那么接下来的问题就只剩下如何得到那个原始的时间开销了,比如我们知道了时间花费是3*N2+3,那么我们可以得出大O阶为O(N2),但是问题在于3*N2+3该如何得到。其实这也是不难的。回顾之前我们分析了的那个无意义的函数,我们就会发现,时间复杂度中最重要的就是“不确定次数的循环”,因为顺序执行时不论有1000个还是10000个赋值、比较、算术运算,最后计算大O阶时都会变为常数项从而被忽略掉。至于为什么说是“不确定次数的”循环,原因就是如果次数确定,那么该循环也会变成一个常数项:       

for ( int i = 0 ; i< 10 ;++i );

        不难发现这个循环的时间花费其实是固定的1+10+9=20,是一个常数,而常数项是会被忽略的。

 

        那么对于次数不定的循环(假定循环次数都由算法的输入参数N决定),那么我们有几个很简单的基本原则:

        1.对于循环,运行时间最多为其内部语句的运行时间(比如4次运算)乘以循环次数(N)。

  比如

for ( int i = 0; i < N ;++i );

  的运行时间最多为1*N,即O(N)

 

        2.对于嵌套循环,根据原则1,不难发现就是内部循环的运行时间乘以外部循环次数(N)。

  比如

for ( int i = 0; i < N ; ++i )

     for ( int j = 0; j < N ; ++j );

  的运行时间就是N*N,即O(N2)

 

        3.对于顺序结构,只需要将各“部分”运行时间相加即可。(对于IF/ELSE结构,我们将整个IF/ELSE的运行时间假定为其中最大的一种情况,这样也许会比平均运行时间要大,但是保证了“上界”的要求)

  比如

for ( int i = 0; i < N ;++i );

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

  的运行时间就是N+N*N=N^2+N,大O阶为O(N^2)

 

        4.对于递归,如果其只是“遮了面纱”的循环,比如

 int  func ( int  N )

 {

        if ( N<=1 )    return  1;

        return   N*func ( N - 1 );

 }

   那么其运行时间就以其循环形式计算,得出N。但实际情况中遇到的递归往往是难以化简为循环的,这时对递归的时间分析将比较复杂,本文不予讨论。

 

 

         最后总结,由于诸多现实原因,对于算法的时间分析我们往往只计算个大概,而计算这个大概时我们最在乎的是代表着最坏情况的“上界”,也即大O阶。要想计算一个算法的大O阶,我们首先要计算其大致的时间花费,比如一个循环N次的循环体中有不确定的常数c次运算,此时我们不计较c的具体大小,直接将该循环体时间花费记为N,然后根据计算大O阶的简化原则将其简化,得出算法的大O阶。

 

         虽然算法千千万,但是算法的时间复杂度,大O阶还是有一些规律的。什么规律呢?就是我们常见的大O阶是可以列举出来的。常见的大O阶按照从好到坏,也就是增长率从低到高,列举出来的话有:

         常数级C

         对数级logN

         对数平方根级logN2

         线性级N

         N*logN

         平方级N2

         立方级N3

         指数级2N

 

         稍加分析就会发现其实它们的顺序就是函数增长率的顺序,有了这个顺序,我们就可以对一些算法的时间复杂度进行比较了。比如完成同一件事,一个算法是O(NlogN),另一个算法是O(N^2),那么显然当N很大时,前者比后者会快很多(观察函数图像也可以很明显的发现这一点)。

 

 

         但是,对数级logN的复杂度是什么情况出现的呢?一般来说,如果一个算法用常数时间O(1)将问题的大小削减为其一部分,那么该算法就是O(logN)的。

 

         虽然很多时候,一个算法的数据输入就不得不耗费Ω(N)的时间,因而整个算法最终的时间复杂度不会是O(logN),但为了说明O(logN)的情况,我们假设算法的数据已经输入到了内存中,那么作为O(logN)的典例就是二分查找(本例中假设数组已按从小到大排好顺序,我们要找出某个数在数组中的位置):

int  BinarySearch ( const  int  A[] , int   X,   int  N )       //  X为要找的元素,N为数组大小

{          int  Low=0,High=N-1,Mid;

           while ( Low <= High )

           {

                 Mid= ( Low + High ) / 2;

                 if ( A[ Mid ] < X )

                       Low = Mid + 1;

                 else  if  ( A[ Mid ] > X )

                       High = Mid - 1;

                 else   return  Mid;

             }

}

 

         显然,循环体内部的运行时间为O(1),接下来分析循环的次数,循环从High-Low=N-1开始,到High-Low=-1结束,每次循环后High-Low的值都会“折半”,符合我们之前说的判断是否为logN级的条件,因而二分查找是O(logN)的。(即使不是削减为二分之一,而是三分之一、四分之一等,我们也记作logN级别)

 

 

         文章写到这,相信读者对于基本的算法分析已经有了概念。但是算法分析并不只是这些东西,比如我们一直没有提到的类似于O()和Ω()的θ(),还有算法的空间复杂度(比如同一个算法用循环实现和递归实现的空间占用就会明显不同)等,并且在复杂的算法计算中还会用到高等数学的极限思想与计算方法。有相关兴趣的读者可以自行搜索关于算法分析的其它内容来了解。另外,对于不同的场景,算法的分析会有不同的要求,比如我们说忽略常数项,但如果这个常数项真的足够大而机器又足够慢,那么即使是常数项也不是随便忽略的。

         

原文地址:https://www.cnblogs.com/mm93/p/6563780.html