数据结构与算法(二)算法概念

222 阅读12分钟

本文内容参考自大话数据结构,转载请注明出处,未表明出处,发现必追究

一、算法的定义及特性

1. 算法的定义

算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且,每条指令表示一个或多个操作。

算法就是为了解决某个或者某类问题,需要把指令表示成一定的操作序列,操作序列包括一组操作,每一个操作都完成特定的功能,这就是算法。

2. 算法的特性

一个算法必须满足以下五个基本特性

  • 输入 : 一个算法可以有0个或者多个输入。当用函数描述算法时,输入往往是通过形参表示的,当它们被调用时,从主调函数获得输入值。
  • 输出 : 一个算法有一个或多个输出。输出是算法经过信息加工过后得到的结果。无输出的算法没有任何的意义。当用函数描述算法时,输出通常是返回值或者引用类型的形参。
  • 有穷性 : 算法一定要在有限的步骤后结束,且每一步都必须在有限的时间内完成。
  • 确定性 : 算法的每一步都必须是一个明确的含义,必须精准定义无歧义,不会有二义性。
  • 可行性 : 算法的每一步都必须是可行的,也就是说每一步都能通过执行有限次数完成。

3. 算法设计的要求

  • 正确性 : 在合理的输入下,在有限的时间下,能正确反映问题的需求性,并且得到正确的答案。正确性一般具有以下四种层次。

    (1). 语法正确.

    (2). 输入合法的数据,可以反馈出满足要求的结果。

    (3). 输入非法的数据,可以得出满足规格说明的输出。

    (4). 对于艰难的测试数据,也可以得到满足要求的结果。 因为层次(4)非常的难办到,所以一般一个算法满足到层次(3),我们就说它是一个合格的算法。

  • 可读性 : 一个好的算法,首先就是要让人容易去阅读、理解和交流,其次才是计算机的可执行性。可读性低的算法一般很难懂,容易隐藏错误,难于调试和修改。

  • 健壮性 : 当算法输入非法的数据时,可以做出合适的处理,而不是产生异常或者莫名其妙的结果。

  • 高效性 : 所谓高效性,包括时间和空间两个方面。

    时间上 : 时间高效,执行时间短,执行效率高。可用时间复杂度来衡量。

    空间上 : 空间高效,占用存储量合理。可以用空间复杂度衡量。 时间复杂度空间复杂度是衡量算法好坏的重要指标。

4. 算法效率的度量方法

在对算法的设计要求上,有高效性的要求,那什么是高效性?这里的高效大都会指算法执行时间的长短。

度量一个算法的执行时间,一般我们会从两个方向去做参考 : 事后统计事前分析

1. 事后统计

事后统计一般都是通过已经设计好的测试程序和数据,利用计算机的计时器,对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。

缺点 :

(1). 必须根据算法,提前就要编制好程序,这一般都要花费大量的时间和精力。只有在测试过后才知道合不合适。

(2). 容易受到计算机软件或硬件的影响,而这些外在的影响,有可能会掩盖算法本身的优劣。

(3). 算法的测试数据设计起来很困难,并且就算根据算法编制好了程序,程序的效率也会和测试数据的规模有很大的关系。规模太小体现不出来算法的好坏,规模太大就会耗费很多的时间去测试。

因为有以上三点缺点,所以我们一般不会考虑用事后统计法去度量算法效率的高低。

2. 事前分析

在计算机程序编制前,依据统计方法对算法进行估算。

一个用高级语言编写的算法程序在计算机上运行时所消耗的时间,一般会由以下因素决定 :

(1). 算法采用的策略和方法。

(2). 编译产生的代码质量。

(3). 问题的输入规模。

(4). 机器执行指令的速度。

其中,(1)是评判算法好坏的根本,(2)则是由软件来支持,(4)是看硬件的性能。也就是说,如果抛开(2)(4)这些外在的因素,一个程序的运行时间或者说算法程序的好坏,决定于算法本身的策略和输入数据的规模。

举例 :

下面是一个最简单的1~100求和算法

第1种 :

        int i = 0,sum = 0,n = 100;      //执行了1次
        for (i = 1; i <= n; i++) {      //执行了n+1次,因为不符合才跳出
            sum = sum + i;              //执行了n次
        }
        printf("%d",sum);               //执行了1次

第2种 :

        int sum = 0,n = 100;        //执行了1次
        sum = (1 + n) * n/2;        //执行了1次
        printf("%d",sum);           //执行了1次

对比两个求和算法 :

第1种 : 计算机要执行 1 + (n+1) + n + 1 次,也就是2n + 3次。

第2种 : 计算机要执行 1 + 1 + 1 次,也就是3次。

那么就算我们把第1种中的for循环的判断和内容看作一个整体,只算n次,最后一个printf打印和最开始的变量定义都不算次数,这两个算法的差距也是n次和1次的差距,算法的好坏显而易见。

如果我们扩展一下,用如下的代码 :

        int i,j,x = 0,sum = 0,n = 100;      //执行了1次
        for (i = 1; i <= n; i++) {
            
            for (j = 1; j <= n; j++) {
                
                x++;                        //执行了n * n次
                sum = sum + x;
                
            }
        }
        printf("%d",sum);                   //执行了1次

显然,虽然n同样是100,但是执行的次数却是n * n,这个算法的执行时间也要多于前两个算法,所以,测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的次数。

运行时间和基本操作的执行次数成正比

5. 函数的渐进增长

给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么我们就说f(n)的渐进增长快于g(n)。

表1 :

次数(n=)算法A : 2n + 3算法A' : 2n算法B : 3n + 1算法B' : 3n
15243
27476
396109
1023203130
100203200301300

从表1可以看出,算法A在 n = 1 的时候,效率还不如算法B,n = 2的时候效率一样,但是 n > 2 以后,效率逐渐的比算法B要高,而且n越大,效率差距越大。

从表1我们还可以发现,算法A的 +3和算法B的 +1,对算法效率造成的影响,是不如n的倍数大的,无论加多少,n到达一定的数值后,n的倍数对算法的影响都会显得更大。所以,我们可以忽略这些加法常数。

表2 :

次数(n=)算法C : 4n + 8算法C' : n算法D : 2n2+12n^2 + 1算法D' : n2n^2
112131
216294
3203199
104810201100
1004081002000110000
10004008100020000011000000

再看表2,和表1一样,我们还是那么对比,你会发现n的指数开始变得更重要了,对算法效率的影响上,指数比倍数的影响还要大很多。

于是,我们可以得到这样的一个结论 : 判断一个算法的效率时,函数中的常数和其他次项往往可以忽略,更应该关注主项(最高阶项)的阶数。

对比表1和表2,可以看出,判断一个算法的好坏,只通过少量的数据是不能做出准确判断的,因为某个算法,随着n的不断增大,它会越来越优于另一算法,或者越来越差于另一算法。

这就是事前估算方法的理论依据,通过算法时间复杂度来估算算法时间效率。

二、算法时间复杂度

1. 算法时间复杂度定义

在进行算法分析时,语句的总执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况确定T(n)的数量级。

算法的时间复杂度,也就是算法的时间量度,计作T(n) = O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。

其中f(n)是问题规模n的某个函数。

这样用O()来体现算法时间复杂度的记法,我们称之大O记法。

一半情况下,随着n的不断增大,T(n)增长最慢的算法为最优算法。

那么对比上面的举例中的三种求和算法,它们的时间复杂度分别是O(n)O(n),O(1)O(1),O(n2)O(n^2)。我们分别为它们取了非官方的名字 : O(1)O(1)叫常数阶,O(n)O(n)叫线性阶,O(n2)O(n^2)叫平方阶。

2. 推导大O阶方法

其实推导大O阶的方法就是总结前面我们举的例子。

推导大O阶

  1. 用常数1取代运行时间中的所有加法常数。

  2. 在修改后的运行次数函数中,只保留最高阶项。

  3. 如果最高阶项存在且不是1,则去除与这个项相乘的常数。

得到的结果就是大O阶。

1. 常数阶

先看下面的算法(上面的第二种求和算法,高斯算法) :

        int sum = 0,n = 100;        //执行了1次
        sum = (1 + n) * n/2;        //执行了1次
        printf("%d",sum);           //执行了1次

这个算法的运行次数函数f(n)=3f(n) = 3。根据我们上面推导的大O阶的方法 :

用常数1取代运行时间中的加法常数,所以要把3换成1。然后在修改后的运行次数函数f(n)=1f(n) = 1中,只保留最高阶项,发现这没有最高阶项,所以这个算法的时间复杂度是O(1)O(1)

就算把上面的这个算法,连续写上无数次,它的时间复杂度也不是O(n)O(n),这种与问题的大小无关,执行时间恒定的算法,我们称之为具有O(1)O(1)的时间复杂度,又叫常数阶。

注意 : 不管这个常数是多少,我们都记作O(1)O(1),而不能是O(3)O(3)O(10)O(10)等等其他的数字。

对于单纯的分支结构(不包含在循环结构中)而言,无论是真是假,执行的次数是恒定的,不会随着n的变大而变化,其时间复杂度也是O(1)O(1)

2. 线性阶

线性阶的循环结构往往会复杂很多,要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。

例如上面的第1种求和算法 :

        int i;      //执行了1次
        for (i = 0; i < n; i++) {      //执行了n次
            *时间复杂度为O(1)的程序步骤序列*            //执行了n次
        }

这里去分析算法的复杂度,就是线性的,重要的是for循环里,时间复杂度为O(1)的程序步骤序列,它执行了多少次,用了多少时间,才是这个算法的重点。

3. 对数阶

        int count = 1,n = 100;
        while (count < n) {
            count = count * 2;
        }

由于count乘以2以后,就会离n更近一点,所以如果要跳出循环,满足的条件就是count = n,也就是说,要看count要乘多少个2才可以等于n,假设要乘x个2,那么也就是2x=n2^x = n,所以x=log2nx = \log_2n,所以这个循环的时间复杂度就是O(logn)O(logn)。也就是对数阶。

4. 平方阶

        int i,j,n;
        for (i = 0; i < n; i++) {
            for (j = 0; j < n; j++) {
                /*时间复杂度为O(1)的程序步骤序列*/
            }
        }

对于外层的for循环,不过是对内部的这个时间复杂度为O(n)的语句再进行n次循环,所以这段代码的时间复杂度为O(n2)O(n^2)

如果外层的for循环的次数从n改成了m,那么这段代码的时间复杂度就是O(n*m)。

再看如下代码 :

        int i,j,n;
        for (i = 0; i < n; i++) {
            for (j = i; j < n; j++) {               //注意这里是j = i
                /*时间复杂度为O(1)的程序步骤序列*/
            }
        }

当i = 0的时候,内部for循环的程序序列步骤会执行n次,当i = 1的时候,内部会执行n - 1次,当i = n-1的时候,内部会执行1次,都是因为j = i。于是我们可以把所有的执行次数加起来,得到 :

n+(n1)+(n2)++1=n(n+1)2=n22+n2n + (n - 1) + (n - 2) + …… + 1 = \frac{n * (n + 1)}{2} = \frac{n^2}{2} + \frac{n}{2}

那根据我们的大O阶推导法,第一步把加法常数都用1取代,结果还是这个。第二步,保留最高阶项,则变成n22\frac{n^2}{2}。最后最高阶项不为1,那就去掉它的乘数也就是12\frac{1}{2},得到的结果就是n2n^2,所以这个代码的时间复杂度就是O(n2)O(n^2)

再看如下代码,分析方法调用的时间复杂度 :


       int i,j,n = 10;
       for (i = 0; i < n; i++) {
           function(i);
       }
     
     
     
void function(int count)
{
   printf("%d",count);
}

这个很好理解,因为function就是一个print,所以它的时间复杂度就是O(1),而for循环是n次,所以整个的时间复杂度就是O(n),这个和上面的线性阶是一样的。那如果假如function变成了下面的代码 :

        int i,n = 10;
        for (i = 0; i < n; i++) {
            function(i,n);
        }
        
        
        void function(int count, int n)
{
    int j;
    for (j = count; j < n; j++) {
        /*时间复杂度为O(1)的程序步骤序列*/
    }
}

这就和我们上面的

图片.png

是一样的了,只不过把for循环放到一个函数里面。所以它的时间复杂度也是O(n2)O(n^2)

再看下面相对复杂一点的代码 :

        int i,j,n = 10;  /**执行1次*/
        
        n++;           /**执行1次*/

        function(n);   /**执行n次*/
        
        for (i = 0; i < n; i++) {       /**执行了 (1 + n) * n/2 次*/
            function(i);
        }
        
        for (i = 0; i < n; i++) {       /**执行了 (1 + n) * n/2 次*/
            for (j = i; j < n; j++) {
                printf("%d",j);
            }
        }
        
        
        void function(int count)
{
    int a;
    for (a = 0; a < count; a++) {
        printf("%d",a);
    }
}

因为n++后,我们依然可以把n看成一个新n,它还是n,没有造成影响。它的总执行次数是 : f(n)=1+1+n+(1+n)n/2+(1+n)n/2f(n) = 1 + 1 + n + (1 + n) * n/2 + (1 + n) * n/2,最终的结果就是f(n)=n2+2n+2f(n) = n^2 + 2n + 2。根据大O阶推导法,最终这段代码的时间复杂度也是O(n2)O(n^2)

5. 常见的时间复杂度

执行次数函数非正式术语
12O(1)O(1)常数阶
2n + 3O(n)O(n)线性阶
3n2+2n+13n^2 + 2n + 1O(n2)O(n^2)平方阶
5log2n+205\log_2 n + 20O(logn)O(logn)对数阶
2n+3nlog2n+192n + 3n\log_2 n + 19O(nlogn)O(nlogn)nlogn阶
6n3+2n2+3n+46n^3 + 2n^2 + 3n + 4O(n3)O(n^3)立方阶
2n2^nO(2n)O(2^n)指数阶

常用的时间复杂度所消耗的时间从小到大依次是 :

O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

对于其中的立方阶O(n3)O(n^3),指数阶O(2n)O(2^n)和阶乘阶O(n!)O(n!),n小的时候还好说,但是如果n变得更大了,很多都脱离现实了,所以我们不怎么讨论这种。

3. 最坏情况与平均情况

最坏情况 :

最坏情况运行时间是一种保证,最坏就代表运行时间不会再比这个时间更长了。在应用中这是一种最重要的需求,通常,除非特别指定,否则我们所指的运行时间都是最坏情况的运行时间。

平均情况 :

平均情况的运行时间是所有情况中最有意义的,因为它是期望的运行时间。

例如,我们查找一个有n个元素的数字数组中的某个数字,最好的情况就是这个数字就在数组的第一位上,那么这个算法的时间复杂度就是O(1),但是如果这个数字是在数组的最后一位上的话,那这个算法的时间复杂度就变成了O(n)。

对算法的分析,一种是计算所有情况的平均值,这种时间复杂度的计算方法称为平均时间复杂度。

另一种是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般在没有特殊说明的情况下,都是指最坏时间复杂度。

三、算法空间复杂度

算法的空间复杂度,通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作 : S(n) = O(f(n)),其中n为问题的规模,f(n)为语句关于n所占存储空间的函数。

一般情况下,一个程序在机器上执行时,出了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的数据单元。若输入数据所占空间仅取决于问题本身而和算法无关的话,这样只分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。

举例 :

数组逆序,将一个数组a中的n个元素,逆序的放回原数组a中。

算法1 :

        int n = 10;
        int a[] = {1,2,3,4,5,6,7,8,9,10};
        for (int i = 0; i < n/2; i++) {
            int b = a[i];
            a[i] = a[n - 1 - i];
            a[n - 1 - i] = b;
        }

算法2 :

        int n = 10;
        int a[] = {1,2,3,4,5,6,7,8,9,10};
                int b[10] = {};
        for (int i = 0; i < n; i++) {
            b[i] = a[n - 1 - i];
        }
        for (int j = 0; j < n; j++) {
            a[j] = b[j];
        }

来对比两个算法 :

算法1 : 借助了另外一个变量b,而这个变量b和问题规模n的大小无关啊,所以其空间复杂度为O(1)。

算法2 : 需要去借助另外一个大小为n的辅助数组b,所以其空间复杂度为O(n)。

通常呢,我们都以时间复杂度来指运行时间的需求,空间复杂度指空间的需求。当不用限定词的复杂度时,通常都是指时间复杂度,而鉴于现在的运算空间也比较充足,所以往往以算法的时间复杂度作为算法优劣的衡量指标。

四、总结

  1. 算法的定义 : 解决特定问题求解步骤的描述,在计算机中为指令的有限序列,并且每条指令表示一个或多个操作。

  2. 算法的特性 : 输入、输出、可行、确定、有穷

  3. 算法设计的要求 : 正确、可读、健壮、高效、低存储量要求

  4. 算法的度量方法 : (1)事后统计(不科学、不准确)。(2)事前分析

  5. 有关事前分析,要知道函数的渐进增长,什么是函数的渐进增长?给定两个函数f(n)和g(n),如果存在一个整数N,对于所有的n > N,f(n)总是比g(n)大,那么,我们说f(n)的渐进增长快于g(n)。 于是我们得出一个结论,判断一个算法好不好,我们只通过少量数据是不能做出准确判断的,如果我们可以对比算法的关键之行次数函数的渐进增长性,基本就可以分析出某个算法,随着问题规模n的增大,它会越来越优于另一个算法,或者越来越差于另一个算法。

  6. 算法时间复杂度的定义 : 算法的时间复杂度,也就是算法的时间量度,记作T(n) = O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度。

  7. 推导大O阶的步骤 : (1).用常数1取代运行时间中的所有加法常数。(2).在修改后的运行次数函数中,只保留最高阶项。(3).如果最高阶项存在且不是1,则去掉与最高阶项相乘的常数。得到的结果就是大O阶。

  8. 常见的时间复杂度所消耗时间的大小排列 : O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

  9. 空间复杂度的概念 : 通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作S(n) = O(f(n))。其中n为问题规模,f(n)为语句关于问题规模n所占存储空间的函数。