趣学数据结构
上QQ阅读APP看书,第一时间看更新

1.2 算法复杂度

首先看一道某跨国公司的招聘试题。

写一个算法,求下面序列之和:

-1,1, -1,1, …, (-1)n

当你看到这个题目,你会怎么想?使用for语句或while循环?

先看算法1-1。

    //算法1-1
    sum=0;
    for(i=1; i<=n; i++)
      sum=sum+pow(-1, n); //即(-1)^n

这段代码可以实现求和运算,但是为什么不这样算呢?

再看算法1-2。

    //算法1-2
    if(n%2==0)  //判断n是不是偶数,%表示求余数
      sum=0;
    else
      sum=-1;

有的读者看到算法1-2后恍然大悟,原来可以这样啊!这不就是高斯那种将两个数结合成对的算法吗?

一共50对数,每对之和均为101,那么总和为:

(1+100)×50=5050

1787年,小高斯10岁,用了几分钟的时间算出了结果,而其他孩子却要算很长时间。

可以看出,算法1-1需要运行n次加法,如果n=10000,就要运行10000次,而算法1-2只需要运行1次!是不是有很大差别?

问:高斯的方法我也知道,但遇到类似的题还是……我用的笨办法也是算法吗?

答:是算法。

算法是指对特定问题求解步骤的一种描述。

算法只是对问题求解方法的一种描述,它不依赖于任何一种语言,可以用自然语言、C、C++、Java、Python等描述,也可以用流程图、框图来表示。为了更清楚地说明算法的本质,我们一般去除了计算机语言的语法规则和细节,采用“伪代码”来描述算法。“伪代码”介于自然语言和程序设计语言之间,它更符合人们的表达方式,容易理解,但不是严格的程序设计语言,如果要上机调试,则需要转换成标准的计算机程序设计语言才能运行。

算法具有以下特性。

(1)有穷性:算法是由若干条指令组成的有穷序列,总是在执行若干次后结束,不可能永不停止。

(2)确定性:每条语句有确定的含义,无歧义。

(3)可行性:算法在当前环境条件下可以通过有限次运算实现。

(4)输入和输出:有零个或多个输入,一个或多个输出。

问:嗯,第二种方法的确算得挺快的,但我写了一个算法,怎么知道它好不好?

“好”算法的标准如下。

(1)正确性:指算法能够满足具体问题的需求,程序运行正常,无语法错误,并能够通过典型的软件测试,达到预期需求规格。

(2)易读性:算法遵循标识符命名规则,简洁、易懂,注释语句恰当、适量,方便自己和他人阅读,并便于后期调试和修改。

(3)健壮性:算法对非法数据及操作有较好的反应和处理。例如,在学生信息管理系统中,登记学生年龄时,21岁误输入为210岁,系统应该提示出错。

(4)高效性:指算法运行效率高,即算法运行所消耗的时间短。算法时间复杂度就是算法运行需要的时间。现代计算机一秒能计算数亿次,因此不能用秒来具体计算算法消耗的时间。由于相同配置的计算机进行一次基本运算的时间是一定的,我们可以用算法基本运算的执行次数来衡量算法的效率。因此将算法基本运算的执行次数作为时间复杂度的度量标准。

(5)低存储性:指算法所需要的存储空间低。尤其是像手机、平板电脑这样的嵌入式设备,算法如果占用空间过大,则无法运行。算法占用的空间大小称为空间复杂度

除了前3条基本标准外,我们对好的算法的评判标准就是高效性、低存储性

问:前3条都好办,但时间复杂度怎么算呢?

时间复杂度:算法运行需要的时间,一般将算法基本运算的执行次数作为时间复杂度的度量标准。

看算法1-3,并分析这一算法的时间复杂度。

    //算法1-3
    sum=0;                        //运行1次
    total=0;                      //运行1次
    for(i=1; i<=n; i++)            //运行n+1次,最后依次判断条件不成立,结束
    {
      sum=sum+i;                  //运行n次
      for(j=1; j<=n; j++)          //运行n*(n+1)次
        total=total+i*j;         //运行n*n次
    }

把算法所有语句的运行次数加起来,即1+1+n+1+n+n×(n+1)+n×n,可以用一个函数T(n)表达:

T(n)=2n2+3n+3

n足够大时,如n=105时,T(n)=2×1010+3×105+3,我们可以看到算法运行时间主要取决于第一项,后面的基本可以忽略不计。

如果用极限来表示,则为:

如果用时间复杂度渐进上界表示,如图1-14所示。

图1-14 时间复杂度渐进上界

从图1-15可以看出,当nn0,T(n)≤cf (n),当n足够大时,T(n)和f (n)近似相等,因此我们用O(f (n))来表示时间复杂度渐近上界,通常用这种表示法衡量算法时间复杂度。算法1-3的时间复杂度渐进上界为O(f (n))=O(n2),如果用极限来表示,则为:

图1-15 时间复杂度渐进下界

还有渐近下界符号(T(n)≥cf (n)),如图1-15所示。

从图1-16中可以看出,当nn0,T(n)≥cf (n),当n足够大时,T(n)和f (n)近似相等,因此用(f (n))来表示时间复杂度渐近下界。

图1-16 时间复杂度渐进精确界

渐近精确界符号Θ(c1f (n)≤T(n)≤c2f (n)),如图1-16所示。

从图1-3中可以看出,当nn0c1f (n)≤T(n)≤c2f (n),当n足够大时,T(n)和f (n)近似相等,这种两边逼近的方式,更加精确近似,时间复杂度渐近精确界用Θ(f (n))来表示。

我们通常使用时间复杂度渐近上界O(f (n))来表示时间复杂度。

看算法1-4,并分析算法的时间复杂度。

    //算法1-4
    i=1;                 //运行1次
    while(i<=n)         //可假设运行x+1次
    {
      i=i*2;             //可假设运行x次
    }

算法1-4乍一看无法确定while及i = i*2运行了多少次,这时可假设运行了x次,每次运算后i值为2,22,23, …,2x,当i=n时结束,即2x=n时结束,则x=log2n,那么算法1-4的运算次数为1+2log2n,时间复杂度渐进上界为O(f (n))=O(log2n)。

问题规模:即问题的大小,是指问题输入量的多少。一般来讲,算法的复杂度和问题规模有关,规模越大,复杂度越高。复杂度一般表示为关于问题规模的函数,如问题规模为n,时间复杂度渐进上界表示为O(f (n))。

语句频度:语句重复执行的次数。

在算法分析中,渐进复杂度是对算法运行次数的粗略估计,大致反映问题规模增长趋势,而不必精确计算算法的运行时间。在计算渐进时间复杂度时,可以只考虑对算法运行时间贡献大的语句,而忽略那些运算次数少的语句,循环语句中处在循环内层的语句往往是运行次数最多的,即语句频度最多的语句,该语句对运行时间贡献最大。比如,在算法1-3中,total=total+i*j是对算法贡献最大的语句,只计算该语句的运行次数即可。

注意:不是每个算法都能直接计算运行次数。

例如算法1-5,在a[n]数组中顺序查找x,返回其下标i,如果没找到,则返回-1。

    //算法1-5
    findx(int x)        //在a[n]数组中顺序查找x
    {
    for(i=0; i<n; i++)
        {
        if(a[i]==x)
            return i;    //返回其下标i
        }
      return -1;
    }

算法1-5很难计算该程序到底执行了多少次,因为执行次数依赖于x在数组中的位置。如果第一个元素就是x,则执行1次(最好情况);如果最后一个元素是x,则执行n次(最坏情况);如果分布概率均等,则平均执行次数为平均情况)。

有些算法(如排序、查找、插入等)可以分为最好情况、最坏情况平均情况分别求算法渐进复杂度,但我们考查一个算法通常考查其最坏情况,而不是最好情况,最坏情况对衡量算法的好坏具有实际的意义。在现实生活中,我们做什么事情,也会考虑最坏会怎样,最好会怎样,但最坏情况对决策有关键作用。

问:我明白了,那空间复杂度应该就是算法占多大存储空间了?

空间复杂度:算法占用的空间大小。一般将算法的辅助空间作为衡量空间复杂度的标准。

空间复杂度的本意是指算法在运行过程中占用了多少存储空间,算法占用的存储空间包括如下。

(1)输入/输出数据所占空间。

(2)算法本身所占空间。

(3)额外需要的辅助空间。

输入/输出数据占用的空间是必需的,算法本身占用的空间可以通过精简算法来缩减,但这个压缩的量是很小的,可以忽略不计。而在运行时使用的辅助变量所占用的空间,即辅助空间是衡量空间复杂度的关键因素。

算法1-6将两个数交换,并分析其空间复杂度。

    //算法1-6
    swap(int x, int y)  //x与y交换
    {
      int temp;
      temp=x; // temp为辅助空间 ①
      x=y;    ②
      y=temp; ③
    }

两数的交换过程如图1-17所示。

图1-17 两数的交换过程

图1-17中的步骤标号与算法1-6中的语句标号一一对应,该算法使用了一个辅助空间temp,空间复杂度为O(1)。

注意:在递归算法中,每一次递推需要一个栈空间来保存调用记录,因此空间复杂度需要计算递归栈的辅助空间。

看算法1-7,计算n的阶乘,并分析其空间复杂度。

    //算法1-7
    fac(int n) //计算n的阶乘
    {
      if(n<0)   //小于零的数无阶乘值
      {
          printf("n<0, data error! ");
          return -1;
      }
      else if(n==0||n==1)
              return 1;
            else
              return n*fac(n-1);
    }

阶乘是典型的递归调用问题,递归包括递推和回归。递推首先将原问题不断分解成子问题,直到达到结束条件,返回最近子问题的解;然后逆向逐一回归,最终到达递推开始的原问题,返回原问题的解。

思考:例如,求5的阶乘,程序将怎样计算呢?

5的阶乘递推和回归过程如图1-18和图1-19所示。

图1-18 5的阶乘递推过程

图1-19 5的阶乘回归过程

图1-18和图1-19的递推和回归过程是我们从逻辑思维上推理,并以图的方式形象表达出来的。计算机内部是怎样处理的呢?计算机使用一种称为“栈”的数据结构,它类似于一个放一摞盘子的容器,每次放进去一个,拿出来的时候只能从顶端拿一个,不允许从中间插入或抽取,因此称为“后进先出”(Last In First Out, LIFO)。

5的阶乘递推(进栈)过程的形象表达如图1-20所示,实际递归中传递的是参数地址。

图1-20 5的阶乘递推(进栈)过程

5的阶乘回归(出栈)过程的形象表达如图1-21所示。

图1-21 5的阶乘回归(出栈)过程

从图1-20和图1-21的进栈和出栈过程中可以很清晰地看到,首先一步步把子问题压进栈,直到得到返回值,再一步步出栈,最终得到递归结果。在运算过程中,使用了n个栈空间作为辅助空间,因此阶乘递归算法的空间复杂度为O(n)。算法1-7中的时间复杂度也为O(n),因为n的阶乘仅比n-1的阶乘多了一次乘法运算,fac(n)=n*fac(n-1),如果用T(n)表示fac(n)的时间复杂度,那么: