[大话数据结构 - 第2章] 算法

在《数据结构》课程中,谈到算法是为了帮助理解好数据结构,并不会详细谈及算法的方方面面。

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

1 两种算法的比较

现在要求你写一个求 1+2+3+……+100 结果的程序,你应该怎么写呢?

大多数人会马上写出下面的 C 语言代码(或者其他语言的代码):

int i, sum = 0, n=100; 
for (i = 1; i<=n; i++) 
	sum = sum + i; 
printf("%d\n",sum) ; 

这是最简单的计算机程序之一,它就是一种算法,但是这样是不是真的很好?是不是最高效?


并不是,看看传说中数学家高斯的算法:

/*
sum = 1 + 2 + 3 + ... + 99 + 100
sum = 100 + 99 + 98 + ... + 2 + 1
2*sum = 101 + 101 + 101 +... + 101 + 101
*/

int i, sum = 0, n = 100;
sum = (1 + n) * n / 2;
printf("%d\n", sum);

这种方法相当于另一种求等差数列的算法,只需要执行一行代码即可实现,效率相比前者大大提高。


2 算法的特性

算法具有五个基本特性: 输入、输出、 有穷性、确定性和可行性。

  • 输入:算法具有零个或多个输入,尽管对于绝大多数算法来说,输入参数都是必要的,但对于个别情况,如打印 "hello world! " 这样的代码,不需要任何输入参数。
  • 输出:算法至少有一个或多个输出, 算法是一定需要输出的,不需要输出,你用这个算法干吗?输出的形式可以是打印输出,也可以是返回一个或多个值等.
  • 有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。现实中经常会写出死循环的代码,这就是不满足有穷性。
  • 确定性:算法的每一步骤都具有确定的含义, 不会出现二义性。 算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。算法的每个步骤被精确定义而无歧义。
  • 可行性:算法的每一步都必须是可行的, 也就是说,每一步都能够通过执行有限次数完成。 可行性意味着算法可以转换为程序上机运行,并得到正确的结果。

3 算法设计的要求

算法不是唯一的,但面对同一个问题,依然有相对好的和不好的算法,如何判断一个算法是不是好的算法?


1. 正确性

算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求、能够得到问题的正确答案。大体分为以下四个层次:

  • 算法程序没有语法错误;
  • 算法程序对于合法的输入能够产生满足要求的输出结果;
  • 算法程序对于非法的输入能够得出满足规格说明的结果;
  • 算法程序对于精心选择的、甚至***难的测试数据都有满足要求的输出结果。

通常以第三个层次作为一个算法是否正确的标准。


2. 可读性

算法设计的另一目的是为了便于阅读、理解和交流。可读性高有助于人们理解算法,晦涩难懂的算法往往隐含错误,不易被发现,并且难于调试和修改。


3. 健壮性

当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。


4. 时间效率高和存储量低

  • 时间效率指的是算法的执行时间,对同一个问题,若存在多个算法,则执行时间短的算法效率高,执行时间长的算法效率低。
  • 存储量需求指算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。

综上,好的算法,应该具有正确性、 可读性、健壮性、 高效率和低存储量的特征。


4 算法效率的度量方法

4.1 事后统计方法

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

然而这种方法存在诸多缺陷:

  • 必须依据算法事先编好程序,花费大量时间和精力。如果验证发现算法很差,则前期投入均被浪费;
  • 时间的比较依赖于计算机硬件和软件等因素,有时会掩盖算法本身的优劣。不同的计算机硬件运算速度迥异;所用的操作系统、编译器、运行框架等软件差异也会影响结果;即使是相同的软硬件条件,CPU 使用率和内存占用不同,也会造成细微差异;
  • 算法的测试数据设计困难,且程序运行时间往往与测试数据的规模有关,效率高的算法在较小的测试数据下得不到体现。

基于以上考虑,我们不使用这种方法。


4.2 事前分析估算方法

我们的计算机前辈们,为了对算法的评判更科学,研究出了一种叫做事前分析估算的方法:在计算机程序编制前,依据统计方法对算法进行估算。

经过分析,我们发现,一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:

  1. 算法采用的策略、方法。
  2. 编译产生的代码质量。
  3. 问题的输入规模。
  4. 机器执行指令的速度。

第1条当然是算法好坏的根本,第2条要由软件来支持, 第4条要看硬件性能。也就是说, 抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指输入量的多少。


第一种算法:

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

共执行了 2n+3 次;


第二种算法:

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

共执行了 3 次;


第三种算法:

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

共执行了 n^2+2 次。

由此可见,测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数,运行时间与之成正比。(基本操作的执行次数,也可被称为操作数量)

这样,不计那些循环索引的递增和循环终止条件、变量声朋、打印结果等操作,最终,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。


为了全面分析一个算法的运行时间,必须把基本操作的执行次数与输入规模关联起来,即基本操作的执行次数应表示成输入规模的函数。如前面三种算法中,第一种的操作执行次数 f(n)=n;第二种 f(n)=1;第三种 f(n)=n^2。


从上图可以看出,随着 n 值的越来越大,这三种算法在时间效率上的差异也就越来越大。


5 函数的渐近增长

假设两个算法的输入规模都是 n,算法A要做 2n+3 次操作,算法B要做 3n+1 次操作,哪种算法运行更快?答案是不一定。如下图所示:


n=1 时,算法A的效率不如算法B;n=2 时,两者效率相同;n>2 时,算法A就开始优于算法B了,且随着 n 的增加,算法A比算法B越来越好了。因此算法A总体上要好于算法B。

此时我们给出这样的定义,输入规模 n 在没有限制的情况下,只要超过一个数值 N,这个函数就总是大于另一个函数,我们称函数是渐近增长的。

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

根据类似的例子可以得出以下结论:

  • 加法常数可以忽略;
  • 与最高次项相乘的常数可以忽略;
  • 最高次项的指数大的,函数随着n的增长,结果也会变得增长特别快。

综合来说:判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。


6 算法时间复杂度

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(1), O(n^2)。我们分别给它们取了非官方的名称,O(1) 叫常数阶、O(n) 叫线性阶、O(n^2) 叫平方阶,当然,还有其他的一些阶,我们之后会介绍。


2. 推导大O阶方法

那么如何分析一个算法的时间复杂度呢?即如何推导大 O 阶呢?我们给出了下面的推导方法:

  • 用常数 1 取代运行时间中的所有加法常数;
  • 在修改后的运行次数函数中,只保留最高阶项;
  • 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大 O 阶。

哈,仿佛是得到了游戏攻略一样,我们好像已经得到了一个推导算法时间复杂度的万能公式。可事实上,分析一个算法的时间复杂度,没有这么简单,我们还需要多看几个例子。


3. 常数阶

首先顺序结构的时间复杂度。下面这个算法,也就是刚才的第二种算法(高斯算法),为什么时间复杂度不是 O(3),而是 O(1)。

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

这个算法的运行次数函数是 f(n) =3。根据我们推导大 O 阶的方法,第一步就是把常数项 3 改为 1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为 O(1)。

注意: 不管这个常数是多少,我们都记作 O(1),而不能是 O(3)、O(12) 等其他任何数字,这是初学者常常犯的错误。对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着 n 的变大而发生变化,所以单纯的分支结构(不包含在循环结构中),其时间复杂度也是 O(1)。


4. 线性阶

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

下面这段代码,其循环的时间复杂度为 O(n),因为循环体中的代码需要执行 n 次。

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

5. 对数阶

下面的这段代码,时间复杂度又是多少呢?

int count = 1;
while (count < n)
{
    count = count * 2;
    /* 时间复杂度为O(1)的程序步骤序列 */
}

由于每次 count 乘以 2 之后,就距离 n 更近了一分。也就是说,有多少个 2 相乘后大于 n,则会退出循环。由 2^x=n 得到 x=log2^n。所以这个算法的时间复杂度为 O(logn)。


6. 平方阶

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

这段代码的内循环已经分析过,时间复杂度为 O(n)。而外循环其实就是内部这个时间复杂度为 O(n) 的语句再循环 n 次。所以这段代码的时间复杂度为 O(n^2)。

如果外循环的循环次数改为了 m,时间复杂度就变为 O(m∗n)。


所以可以总结出,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。

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

当 i=0 时,内循环执行了 n 次,当 n=1 时,执行了 n-1 次,······当 i=n-1 时,执行了 1 次。故总的执行次数为:

n+(n−1)+(n−2)+⋅⋅⋅+1=n(n+1)/2 = n^2/2 + n/2

根据推导大 O 阶的方法,第一条,没有加法常数不予考虑;第二条,只保留最高阶项,因此保留 n^2/2;第三条,去除这个项相乘的常数,也就是除去 1/2,最终这段代码的时间复杂度为 O(n^2)。

从这个例子,我们也可以得到一个经验,其实理解大 O 推导不算难,难的是对数列的一些相关运算,这更多的是考察你的数学知识和能力。


我们继续看例子,对于方法调用的时间复杂度又如何分析。

void function(int count)
{
    printf(count);
}

int i,j;
for(i=0;i<n;i++)
{
    function();
}

函数体是打印这个参数。其实这很好理解,function 函数的时间复杂度是 O(1),所以整体的时间复杂度为 O(n)。


下面这段相对复杂的语句:

n++; 						/* 执行次数为1 */ 
function(n);				/* 执行次数为1 */  
int i,j; 
for(i = 0; i < n; i++)		/* 执行次数为n^2 */  
{
	function(i); 
}
for(i = 0; i < n; i++) 		/* 执行次数为n(n+1)/2 */ 
{
	for(j =  i;j < n; j++) 
	{
        /*时间复杂皮为。{川的程序步骤序1') */ 
	}
}	

它的执行次数为:

f(n)=1+n+n^2+n*(n+1)/2 = (3/2)*n^2+(3/2)*n+1

根据推导大 O 阶的方法,最终这段代码的时间复杂度也是 O(n^2)。


7. 常见的时间复杂度

常见的时间复杂度如下图:


它们所耗费的时间从小到大依次是:


我们前面已经谈到了。O(1) 常数阶、O(logn) 对数阶、O(n) 线性阶、O(n^2) 平方阶等,至于 O(nlogn) 以后会介绍,而像 O(n^3),过大的 n 都会使得结果变得不现实。同样指数阶 O(2^n) 和阶乘阶 O(n!) 等除非是很小的n值,否则哪怕 n 只是 100,都是噩梦般的运行时间。所以这种不切实际的算法时间复杂度,一般我们都不去讨论。


8 最坏情况与平均情况

算法和生活中一样存在最坏情况和最好情况,以及平均情况。


1. 最坏情况

查找一个有 n 个随机数字数组中的某个数字,最好的情况是第一个数字就是,那么时间复杂度为 O(1);最坏的情况是这个数字位于最后一位,那么算法的时间复杂度就是 O(n)。

最坏情况运行时间是一种保证,那就是运行时间不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。


2. 平均情况

而平均运行时间就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为 n/2 次后发现这个目标元素。

平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。也就是说,我们运行一段程序时,是希望看到平均运行时间的。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的实验数据后估算出来的。


3. 算法分析

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


9 算法的空间复杂度

我们在写代码时,完全可以用空间来换取时间,比如说,要判断某某年是不是闰年,你可能会花一点心思写了一个算法,每次给一个年份,都是要通过计算得到是否是闰年的结果。 还有另一个办法就是,事先建立一个有 2050 个元素的数组(年数略比现实多一点),然后把所有的年份按下标的数字对应,如果是闰年,此数组项的值就是 1,如果不是值为 0。

这样,所谓的判断某一年是否是闰年,就变成了查找这个数组的某一项的值是多少的问题。此时,我们的运算是最小化了,但是硬盘上或者内存中需要存储这 2050 个 0 和 1。

这是通过一笔空间上的开销来换取计算时间的小技巧。到底哪一个好,其实要看你用在什么地方。

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

通常, 我们都使用 "时间复杂度" 来指运行时间的需求,使用 "空间复杂度" 指空间需求。当不用限定词地使用 "复杂度' 时,通常都是指时间复杂度。


10 总结回顾

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

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

算法的设计的要求:正确性、可读性、健壮性、高效率和低存储量需求

算法的度量方法:事后统计方法(不科学准确)、事前分析估算方法

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

推导大 O 阶的步骤:

  • 用常数 1 取代运行时间中的所有加法常数;
  • 在修改后的运行次数函数中,只保留最高阶项;
  • 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大 O 阶。

常见的时间复杂度所耗时间的大小:


最后,我们给出了关于算法最坏情况和平均情况的概念,以及空间复杂度的概念。


参考:

《大话数据结构 - 第2章》算法


posted @ 2019-01-21 23:58  fengMisaka  阅读(616)  评论(0编辑  收藏  举报