数据结构与算法 第二章 算法

第二章 算法

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

1.1 两种算法比较

要求写一个求1+2+3+…+100结果的程序

简单方法(C语言):

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

这是最简单的计算机程序之一

那么高斯数学家也有不同的办法:

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

1.2 算法定义

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

1.3 算法的特性

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

1.3.1 输入输出

算法具有零个或多个输出,如打印"Hello world!"这样的代码,不需要任何输入参数,因此算法的输入可以是零个。

算法至少有一个或多个输出,算法一定要输出的,没有输出的算法是没有用的。输出的形式可以是打印输出,也可以是返回一个或多个值等

1.3.2 有穷性

有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。

1.3.3 确定性

**确定性:**算法的每一步骤都具有确定的含义,不会出现二义性。

1.3.4 可行性

**可行性:**算法的每一步都必须是可行的,也就是说,每一步都能够通过执行有限次数完成。

1.4 算法设计要求

1.4.1 正确性

**正确性:**算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性,能正确反映问题的需求,能够得到问题的正确答案。

1.4.2 可读性

**可读性:**算法设计的另一目的是为了便于阅读、理解和交流。

可读性是算法(也包括实现它的代码)好坏的重要标志

1.4.3 健壮性

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

1.4.4 时间效率高和存储量低

==存储量需求==指的是算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。

设计算法应该尽量满足时间效率高和存储量低的需求

1.5 算法效率的度量方法

1.5.1 事后统计方法

**事后统计方法:**这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。

缺陷

  • 必须提前设计好程序,设计时间长,检测后与预期效果偏差太大。
  • 时间的比较,依赖计算机硬件和软件的因素,有时会掩盖算法本身的优劣;所有的操作系统、编译器、运行框架等软件的不同,也可以影响它们的结果;就算是同一台机器,CPU使用率和内存占用情况不一样,也会有细微的差异。
1.5.2 事前分析估算方法

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

一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:

在这里插入图片描述

第一条是算法好坏的根本,第二条主要由软件来支持,第四条要看硬件功能。

抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。

所谓问题输入规模是指输入量的多少

通过上面举得两个例子,两种求和算法:

第一种算法:

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

一共执行了1+(n+1)+n+1=2n+3次

第二种算法:

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

一共执行了1+1+1=3次

事实上两个算法的第一条和最后一条语句是一样的,我们主要关注的代码是中间的那一部分,把循环看作一个整体,其实就是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++;
        sum = sum + x;
    }
}
printf("%d",sum);

在上面的例子中,i 从 1 循环到 100 ,每次都要让 j 循环100 次,而当中的 x++ 和 sum = sum + x;其实就是1+2+3+4+5+…+10000,也就是==1002==次,所以这个算法当中循环部分的代码要执行n2(忽略循环体头尾的开销)次。

测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。运行时间与这个计数成正比。

在分析程序的运行时间时,最重要的是把程序看成是对于程序设计语言的算法或一系列步骤。

1.5.2.1 对于大O记法中的f(n)解释

在以上的问题中,同样问题的输入规模是n,求和算法的第一种,求 1+2+3+…+n需要一段代码运行n次。那么这个问题的输入规模是的操作数量是f(n)=n,显然,运行100次的同一段代码规模是运算10次的10倍

第二种无论n为多少,运行次数都为1,即f(n)=1;

第三种,运算100次是运算10次的100倍,因为他是f(n)=n2

我们在分析一个算法的运行时间的时候,最重要的是把基本操作的数量和输入规模关联起来,即基本操作的数量必须表示成输入规模的函数(如下图所示)

在这里插入图片描述

1.6 函数的渐进增长

现在,要判断A和B哪个算法好更好。讲这两个问题的输入规模都是n

算法A:要做2n+3次操作,你可以理解为先有一个n次的循环,执行完成后,再有一个n次循环,最后又三次赋值或运算,共2n+3次操作。

算法B:要做3n+1次操作。

他们谁更快还真不一定(如下表所示):

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

当n>2时,算法A就开始优于算法B了,随着n的增加,算法A比算法B越来越好,(执行的次数比B要少)。于是我们可以得出结论,算法A总体上要好过算法B

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

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

随着n的增大,后面的+3或+1其实并不影响最终的算法变化的。

例如算法A‘和算法B’,所以,我们可以忽略这些加法常数

下面的例子更好的让我们了解到常数被忽略的意义会更加明显:

算法C是4n+8,算法D是2n2+1。

次数算法C (4n+8)算法C‘ (n)算法D (2n2+1)算法D’ (n2)
n = 112131
n = 216294
n = 3203199
n = 104810201100
n = 1004081002000110000
n = 1 0004008100020000011000000

当n≤3的时候,算法C要差于算法D(因为算法C次数比较多),但n>3后,算法C就要优于算法D了,去掉相加的常数,再去掉相乘的系数,都可以发现算法C的运行次数都要算法D的运行次数。

也就是说,与最高次项相乘的常数并不重要

再来看第三个例子:算法E是2n2+3n+1,算法F是2n3+3n+1。

次数算法E (2n2+3n+1)算法E‘ (n2)算法F (2n3+3n+1)算法F’ (n3)
n = 16161
n = 2154238
n = 32896427
n = 1023110020311000
n = 100203011000020003011000000

当n=1的时候,算法E与算法F结果相同,但当n>1后,算法E的优势就要开始使用算法F,随着n的增大,差异越来越明显。

最高次项的指数大的,函数随着n的增长,结果也会增长更快

来看最后一个例子。算法G是2n2,算法H是3n+1,算法I是2n2+3n+1。

次数算法G (2n2)算法H (3n+1)算法I (2n2+3n+1)
n = 1246
n = 28715
n = 5501666
n = 1020031231
n = 10020 00030120 301
n = 10002 000 0003 0012 003 001
n = 10 000200 000 00030 001200 030 001
n = 100 00020 000 000 000300 00120 000 300 001
n = 1 000 0002 000 000 000 0003 000 0012 000 003 000 001

当n值越来越大时,3n+1已经没法和2n2的结果相比较,最终几乎可以忽略不计。也就是说,随着n值变得非常打以后,算法G其实很趋近于算法I。

我们可以得出结论,判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数

通过以上的几个样例,可以发现,如果我们可以对比这几个算法的关键执行次数函数的渐进增长性,基本就可以分析出:某个算法,随着n的增大,他会越来越优于另一算法,或者越来越差于另一算法。 这其实就是事前估算方法的理论依据,通过算法实现复杂度来估算算法时间效率。

1.7算法时间复杂度

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

1.7.2 推导大O阶方法

推导大O阶:

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

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

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

1.7.3 常数阶

首先介绍顺序结构的时间复杂度

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

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

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

​ 另外,我们试想一下,如果这个算法当中的语句sum=(1+n)*n/2有10句,即:

int sum = 0,n = 100;		//执行1次
sum = (1+n) *n/2;			//执行第1次
sum = (1+n) *n/2;			//执行第2次
sum = (1+n) *n/2;			//执行第3次
sum = (1+n) *n/2;			//执行第4次
sum = (1+n) *n/2;			//执行第5次
sum = (1+n) *n/2;			//执行第6次
sum = (1+n) *n/2;			//执行第7次
sum = (1+n) *n/2;			//执行第8次
sum = (1+n) *n/2;			//执行第9次
sum = (1+n) *n/2;			//执行第10次
printf ("%d",sum) ;			//执行1次

事实上无论n为多少,上面的两段代码就是3次和12次执行的差异。这种与问题的大小(n的大小)无关,执行时间恒定的算法,我们称之为具有O(1)的时间复杂度,又叫常数阶。

注意:不管这个常数是多少,我们都记作O(1),而不能是O(3)、O(12)等其他任何数字,这是初学者常常犯的错误

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

1.7.4 线性阶

​ 线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。

因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。

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

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

​ 我们来看一下下面的这段代码,时间复杂度又是多少?

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

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

1.7.6 平方阶

​ 下面例子是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为O(n)。

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

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

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

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

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

​ n+(n-1)+(n-2)+···+1=n(n+1)/2=n2/2 + n/2

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

​ 其实,理解大O阶推导不算难,难的是对数列的一些相关运算,这更多的是考察数学知识和能力。

​ 我们继续往下看,对于方法调用的时间复杂度又如何分析:

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

上面这段代码调用一个函数function()。

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

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

​ 假如function()是下面的这样

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

​ 事实上,者和刚才举的例子是一样的,只是因为把嵌套内循环放到了函数中,所以最终的时间复杂度为O(n2)。

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

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

它的执行次数f(n)=1+n+n2+n(n+1)/2=3n2/2+3n/2+1,根据推导大O阶的方法,最终这段代码的时间复杂度也是O(n2)。

1.8 常见的时间复杂度

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

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

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

1.9 最坏情况与平均情况

​ 我们查找一个有n个随机数字数组中的某个数字,最好的情况是第一个数字就是,那么算法的时间复杂度为O(1),但也有可能这个数字就在最后一个位置上待着,那么算法的时间复杂度就是O(n),这是最坏的一种情况了。

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

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

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

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

​ 另一种方法就是计算最坏情况下的时间复杂度,这种方法成为最坏时间复杂度。

一般在没有特殊说明的情况下,都是指最坏时间复杂度

1.10 算法空间复杂度

​ 我们在写代码时,完全可以用空间来换取时间,比如说,要判断某某年是不是闰年,可能会花一点心思写了一个算法,而且由于是一个算法,也就意味着,每次给一个年份,都是要通过计算得到是否是闰年的结果。

​ 还有另一个办法就是,实现建立一个有2050个元素的数组(年数略比现实多一点),然后把所有的年份按下标的数字对应,如果是闰年,此数组项的值就是1,如果不是则值为0。这样,所谓的判断某一年是否是闰年,就变成了查找这个数组的某一项的值是多少的问题。此时,我们的运算时最小化了,但是硬盘上或者内存中需要存储2050个0或1的数字。

这是以存储空间来换取计算时间的小技巧。到底哪一个好,其实要看你用在什么地方。

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

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

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

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 84
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值