一、数据结构与算法的关系
上一次我大致说了数据结构的一些基本概念,应该还蛮通俗易懂的吧(大概吧!!!)。数据结构与算法这两个概念其实是可以单独拿出来讲的,毕竟我们大学有数据结构课,有算法课,单独拿出来讲好像没什么问题,但是数据结构就那么一些(数组、队列、树、图等结构),单独拿出来很快就说完了,但是说完之后,一脸懵逼,两脸茫然,感觉数据结构没什么用啊,但是,注意了啊,但是引入算法,变成程序之后你就会发觉某些特别困难的问题,原来可以用程序这么简单的解决。
所以在我们用程序解决问题看来,程序 = 数据结构 + 算法,数据结构和算法两个概念间的逻辑关系贯穿了整个程序世界,二者不可分割。数据结构是底层,算法高层。数据结构为算法提供服务。算法围绕数据结构操作。
但是我们这个毕竟是数据结构系列嘛,所以算法涉及就比较少,好了,我们接下来进入正题。
二、算法的定义
算法 (Algorithm) 这个单词最早出现在波斯数学家阿勒·花刺子密在公元 825 年(相当于我们中国的唐朝时期)所写的 《印度数字算术》中。 如今普遍认可的对算法的定义是:算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
三、算法的特征
算法具有五个基本特性: 输入、输出、 有穷性、确定性和可行性。
1、输入:算法可以有零个或者多个输入。
2、输出:算法至少有一个或者多个输出,算法必须要输出的,输入你可以没有,输出都没有,那算法就没有意义了。
3、有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每个步骤在可接受的时间内完成。可接受的时间就是比如你计算100内的加减法,然后需要个1年时间,这个就肯定无法接受了,虽然有穷了,但是算法的意义就没有了。
4、确定性:算法的每一步骤都具有确定的含义, 不会出现二义性。
5、可行性:算法的每一步都必须是可行的, 也就是说,每一步都能够通过执行有限次数完成。
四、算法设计的要求
好的算法的设计应该要满足以下几个要求:
1、正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、 能正确反映问题的需求、能够得到问题的正确答案。
2、可读性:算法设计的另一目的是为了便于阅读、 理解和交流。
3、健壮性:当输入数据不合法时,算法也能做出相关处理, 而不是产生异常或莫名其妙的结果。
4、时间效率高:对于同一个问题,如果有多个算法能够解决, 执行时间短的算法效率高,执行时间长的效率低,好的算法时间效率高。
5、存储量底:存储量指的是算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间,好的算法占用的存储空间尽量低。
五、算法效率的度量方法
对算法效率的度量一般有两种方法,事后统计法与事前分析估算法。
1、事后统计法;这种方法主要是通过设计好的测试程序和数据,利用计算机计时 器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
事后统计法有很大的缺陷:先设计算法,然后实施,最后测量,软硬件不同,或许结果不同,同时测试结果测算也不好统计,最重要的是统计出来结果不理想,又花费了那么的多的人力物力,吃力不讨好,所以我们一般不会选这种方法。
2、事前分析估算法:在计算机程序编制前,依据统计方法对算法进行估算。
事前分析估算法较好,那我们怎么估算呢,一般依靠下面两点:时间复杂度与空间复杂度。
六、算法的时间复杂度
先说时间复杂度的定义:在进行算法分析时, 语句总的执行次数 T ( n )是关于问题规模 n 的函数,进而分析 T ( n )随 n 的变化情况并确定T(n)的数量级。 算法的时间复杂度,也就是算法的时间量度,记作: T ( n ) = O(f(n))。 它表示随问题规模 n 的增大,算法执行时间的增长率和 f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。 其中 f ( n) 是问题规模 n 的某个函数。这样用大写 O()来体现算法时间复杂度的记法,我们称之为大 O 记法。
是不是没看懂,没关系,其实很简单的,九个字概括:估算算法运行的次数。也就是说我们将一个算法程序看作一个整体,然后估算程序运行过程中执行的次数,然后以数量级(比如常数阶1;线性阶n;平方阶n^2等)来表示,并用O()这种形式来记录。还不懂,那么接下来看例子就应该懂了:
还说一点,那么大O阶的表示规则是怎么样的呢(这个是重点):
1、用常数 1 取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是 1 ,则去除与这个项相乘的常数。
得到的结果就是大O阶。
1、常数阶
首先顺序结构的时间复杂度。看个程序:
int sum = 0; /*执行一次*/ int n = 100; /*执行一次*/ sum = (1+n)*n/2;/*执行一次*/ system.out.print("sum:"+sum);/*执行一次*/
这个算法的运行次数函数是 f(n)= 4,根据我们大O阶的方法,第一步就是把常数项4改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)。
另外,我们试想一下,如果这个算法当中的语句 sum= ( 1+n)*n/2 有 10 句,
即:
int sum = 0; /*执行一次*/ int n = 100; /*执行一次*/ sum = (1+n)*n/2;/*执行第一次*/ sum = (1+n)*n/2;/*执行第二次*/ sum = (1+n)*n/2;/*执行第三次*/ sum = (1+n)*n/2;/*执行第四次*/ sum = (1+n)*n/2;/*执行第五次*/ sum = (1+n)*n/2;/*执行第六次*/ sum = (1+n)*n/2;/*执行第七次*/ sum = (1+n)*n/2;/*执行第八次*/ sum = (1+n)*n/2;/*执行第九次*/ sum = (1+n)*n/2;/*执行第十次*/ system.out.print("sum:"+sum);/*执行一次*/
事实上无论 n 为多少,上面的两段代码就是 4次和 13 次执行的差异。这种与问题的大小无关 (n 的多少) ,执行时间恒定的算法,我们称之为具有 O(1)的时间复杂度,又叫常数阶。
注意: 不管这个常数是多少,我们都记作O(1),而不能是O(4)、O(13)等其他任何数字,这是我们这种初学者常常犯的错误。
对于分支结构(可理解为非循环结构)而言,无论是真,还是假,执行的次数都是恒定的,不会随着 n 的变大而发生变化,所以单纯的分支结构(不包含在循环结构中) ,其时间复杂度都是O(1)。
2、线性阶:
线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。
下面这段代码,它的循环的时间复杂度为 O(n) , 因为循环体中的代码须要执行 n次。
int i; for( i = 0;i < n;i++){/*总执行次数是n*/ /*时间复杂度O(1)的程序步骤序列 */ }
3、对数阶:
下面的这段代码,时间复杂度又是多少呢?
int count = 1; while(count < n){ count = count*2; /*时间复杂度O(1)的程序步骤序列 */ }
由于每次 count 乘以 2 之后,就距离 n 更近了一分。 也就是说,有多少个2 相乘后大于 n ,则会退出循环。 由 2^x=n 得到 x=log2^n。 所以这个循环的时间复杂度为 O(log^n)。
4、平方阶:
下面例子是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为 O(n)。
int i, j; for(i = 0; i < n; i++){/*总执行次数是n*/ for(j = 0; j < n; j++){/*总执行次数是n*/ /*时间复杂度O(1)的程序步骤序列 */ } }
而对于外层的循环,不过是内部这个时间复杂度为 O(n)的语句,再循环 n 次。 所以这段代码的时间复杂度为 O(n^2).
如果外循环的循环次数改为了m时间复杂度就变为 O(m*n)。
int i, j; for(i = 0; i < m; i++){/*总执行次数是m*/ for(j = 0; j < n; j++){/*总执行次数是m*/ /*时间复杂度O(1)的程序步骤序列 */ } }
所以我们可以总结得出,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
那么下面这个循环嵌套,它的时间复杂度是多少呢?
int i, j; for(i = 0; i < m; i++){/*总执行次数是m*/ for(j = i; j < n; j++){/*注意这里是i,不是0*/ /*时间复杂度O(1)的程序步骤序列 */ } }
由于当 i= 0 时,内循环执行了 n 次,当 i = 1 时,执行了 n-1 次,……当 i=n —1 时,执行了 1 次。所以总的执行次数为:
用我们推导大 0 阶的方法,第一条:没有加法常数不予考虑; 第二条:只保留最高阶项,因此保留时n^2/2;第三条:去除这个项相乘的常数,也就是去除 1/2 ,最终这段代码的时间复杂度为O(n^2)。
从这个例子,我们也可以得到一个经验,其实理解大O阶推导不算难,难的是对数列的一些相关运算,这更多的是考察你的数学知识和能力。
5、常见的时间复杂度:
执行次数函数 | 阶 | 非正式术语 |
13 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n^2+2n+1 | O(n^2) | 平方阶 |
2log2^n+3 | O(log^n) | 对数阶 |
2n+3nlog2^n+4 | O(nlog^n) | nlong^n阶 |
4n^3+2n+1 | O(n^3) | 立方阶 |
2^n | O(2^n) | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
七、算法的空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作: S(n)= O(f(n)),其中,O 为问题的规模, f(n)为语句关于 n 所占存储空间的函数。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、 变量和输入数据外,还需要存储对数据操作的存储单元,若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为 0(1)。
通常, 我们都使用"时间复杂度"来指运行时间的需求,使用"空间复杂度"指空间需求。当不用限定词地使用"复杂度'时,通常都是指时间复杂度。
一般来说我们最关心的是时间复杂度。
八、总结回顾
算法的定义:算法是解决特定问题求解步骤的描述,在计算机中为指令的有限序列,并且每条指令表示一个或多个操作。
算法的特性: 有穷性、确定性、可行性、输入、输出。
算法的设计的要求: 正确性、可读性、健壮性、 高效率和低存储量需求。
算法特性与算法设计容易混,需要对比记忆。
算法的度量方法: 事后统计方法(不科学、不准确)、 事前分析估算方法。
推导大 O 阶:
1、用常数 1 取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是 1 ,则去除与这个项相乘的常数。**
得到的结果就是大 O阶。
通过这个步骤,我们可以在得到算法的运行次数表达式后,很快得到算法的时间复杂度,即大O阶。同时我也提醒了大家,其实推导大 O 阶很容易,但如何得到运行次数的表达式却是需要数学功底的。
接着我们给出了常见的时间复杂度所耗时间的大小排列:
最后,我们给出了算法空间复杂度的概念。