一、算法的基本概念
1、为什么要引入算法?
数据结构中的算法仅仅是用于评价代码的性能,诸如递归分治、贪心算法、动态规划、回溯法、分支限界等算法在严薇敏数据结构中并不去介绍
比如下面累加求和的两种算法,我们需要评价代码性能,选出更优的算法
//思想:循环累加 int i, sum = 0, n = 100; for (i = 1; i <= n; i++) { sum = sum + i; } ptintf("%d", sum); //需要执行n次循环才能算出结果,效率低
//思想:求和公式 int sum = 0, n = 100; sum = (1 + n) * n / 2; printf("%d", sum); //仅需计算一次就算出结果,效率高
2、算法的定义及描述方法
定义:算法是对特定问题求解步骤的一种描述,它是指令的有限序列,其中一条指令表示一个或多个操作
说明:指令能被人或机器等计算装置执行;可以是计算机指令,也可以是我们平时的语言文字;在算法中,为了解决某个或某类问题,需要把指令表示成一定的操作序列,操作序列包括一组操作,每一个操作都完成特定的功能
例子1:把大象装冰箱 例子2:吃饭 <指令1>打开冰箱门
<指令2>把大象塞进去
<指令3>关上冰箱门
<指令1>拿起筷子
<指令2>端起碗
<指令3>用筷子把食物送到嘴里
描述方法:自然语言、数学语言、流程图、伪语言、程序设计语言······
其中伪语言是最合适的方法(语言:如C/C++,可以使用其中设计好的一些语法去表达算法;伪:不用陷入语言本身的一些细节问题)
3、算法的5个特性
①有穷性
含义:在有穷步骤之后能结束,每一步能在有穷时间内完成
说明:这里有穷的概念并不是纯数学意义,写一个算法,计算机需要算上个二十年,一定会结束,它在数学意义上是有穷了,但是这个算法的意义就不大了
现实:经常会写出死循环的代码,这就是不满足有穷性
例子:运行的QQ、微信软件,它是一个程序,但是它不是算法,它不满足有穷性
说明:程序并不一定需要满足上述的有穷性,例如操作系统,只要整个系统不受破坏,操作系统就无休止地为用户提供服务,永不结束;并且程序应是用机器可执行的某种程序设计语言来书写的,而算法通常并没有这样的限制
②确定性
含义:每一条指令必须有确切的含义;读者对其理解不会产生二义性;相同的输入得到相同的输出
③可行性
含义:算法中的操作都能在有限次执行后结束;意味着算法可以转换为程序上机运行,并得到正确的结果
例子:把大象装冰箱就不是算法,因为他不满足可行性
④输入
含义:有零个或多个输入
例子:一个算法的功能就是打印“我要考清华”,这时候是不需要输入参数的;一个算法是计算n的累加和,n由用户给出,这时候是需要参数的
⑤输出
含义:有一个或多个输出
说明:算法是一定需要输出的,不需要输出,你用这个算法干吗?输出的形式可以是打印输出,也可以是返回一个或多个值等
算法的评价(4个方面)
算法不是唯一的。也就是说,同一个问题,可以有多种解决问题的算法。如何去确定哪个算法更好,这就是算法的评价。
1.正确性
含义:算法能正确地解决问题
正确的层次:
①算法程序没有语法错误
②算法程序对于合法的输入数据能够产生满足要求的输出结果
③算法程序对于非法的输入数据能够得出满足规格说明的结果
④算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果
说明:层次1要求最低,层次4是最困难的,我们几乎不可能逐一验证所有的输入都得到正确的结果;算法的正确性在大部分情况下都不可能用程序来证明,而是用数学方法证明的;证明一个复杂算法在所有层次上都是正确的,代价非常昂贵;一般情况下,我们把层次3作为一个算法是否正确的标准
2.可读性
含义:有助于人们阅读、理解和交流;晦涩难懂的算法往往隐含错误,不易被发现,并且难于调试和修改
写代码的目的:一方面是为了让计算机执行;另一方面是为了便于他人阅读
可读性不好:别人读不懂;时间久了,自己都读不懂自己的写的东西
3.健壮性
含义:对非法输入能进行相应处理
例子:年龄输入是负数,要进行相应处理,不能产生莫名奇妙的错误
4.高效率与低存储量
高效率:指时间,效率越高越好,时间短的算法效率高
低存储量:指空间,存储量越低越好,指算法在执行过程中需要的最大存储空间,运行时所占用的内存或外部硬盘存储空间
二、算法效率的度量
度量标准
时间复杂度、空间复杂度
度量方法
1.事后统计方法
定义:主要是通过设计好的测试程序和数据;利用计算机计时器对不同算法编制的程序的运行时间进行比较;从而确定算法效率的高低
缺陷:
a.必须依据算法事先编制好程序,这通常需要花费大量的时间和精力
b.编制出来发现它根本是很糟糕的算法,不是竹篮打水一场空吗?
c.时间的比较依赖计算机硬件和软件等环境因素,有时会掩盖算法本身的优劣
d.所用的操作系统、编译器、运行框架等软件的不同,也可以影响它们的结果
e.算法的测试数据设计困难
f.程序的运行时间往往还与测试数据的规模有很大关系,效率高的算法在小的测试数据面前往往得不到体现(例子:16个数字的排序,不管用什么算法,差异几乎是零,但是有一百万个随机数字排序,那不同算法的差异就非常大了,到底用多少数据来测试,这是很难判断的问题)
既然事后统计方法有这样那样的缺陷,我们一般考虑不予采纳;在复试上机的时候,会采用事后统计方法
2.事前分析估算方法
定义:在计算机程序编制前,依据统计方法对算法进行估算
一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
(1)算法采用的策略、方法——算法好坏的根本
(2)编译产生的代码质量——由软件支持
(3)问题的输入规模
(4)机器执行指令的速度——看硬件性能
关注点:
(1)抛开这些与计算机硬件、软件有关的因素
(2)一个程序的运行时间,依赖于算法的好坏和问题的输入规模
(3)所谓问题输入规模是指输入量的多少
(4)比如说在累加求和例子中,n的值,就是问题的输入规模
计算的方法:
(1)单位时间——把计算机的一次执行,如一次赋值、一次判断、一次输入、一次输出等均看作是一个时间单位;忽略它们之间在执行时间上的差异,即每条语句执行一次的时间均是单位时间
(2)语句频度(FrequencyCount)——一条语句的重复执行次数称作语句频度
(3)算法的总耗时——语句的执行时间则为该条语句的重复执行次数和执行一次所需时间的乘积;算法的总耗时是该算法中各个语句执行时间之和,而每个语句的执行时间正比于该语句的执行次数;这样就可以独立于机器的软、硬件系统来分析算法的
(4)例子——
int main() { int i, sum = 0, n = 100; /*执行1次*/ for (i = 1; i <= n; i++) /*执行n+1次*/ { sum = sum + i; /*执行n次*/ } printf("%d", sum); /*执行1次*/ return 0; } //执行时间与f(n)成正比,f(n)=1+n+1+n+1
总结:关注在问题输入规模导致问题计算的量级;屏蔽了其他因素的影响
时间复杂度
前言
用所有语句频度之和:可以描述算法的时间消耗
一个问题:n趋于无穷大时,各个f(n)有什么表现
数学基础:
(1)在描述频度之和这里,在有意义的研究范围之内,频度之和函数不会是减函数
(2)n趋于无穷大时,若频度之和函数是增函数,频度之和函数值也尽力增大;若频度之和函数是常数函数,值不变。
(3)研究几个频度之和的函数谁消耗的时间更多,就是在n趋于无穷大时,看谁的函数值更大
(4)一个数学问题
引例1——
与
,当n趋于无穷大时,
更大
引例2——
与
,当n趋于无穷大时,
更大
结论——假设有f(n)与g(n),只要f(n)中增长最快的那一项比g(n)增长最快的那一项更快,对g(n)做如下操作【给g(n)增长最快的那一项乘以一个常数,给g(n)再增加不如g(n)增长最快的那一项的h(n)】,n趋于无穷大的时候,仍然是f(n)更大
(5)三个数学公式(高数)
①f(n)中增长最快的那一项比g(n)增长最快的那一项更慢 如f(n)=5n,g(n)= ②f(n)中增长最快的那一项是g(n)增长最快的那一项的k倍 如f(n)= ,g(n)=
③f(n)中增长最快的那一项是g(n)增长最快的那一项的k倍
f(n)中增长最快的那一项比h(n)增长最快的那一项更慢
g(n)中增长最快的那一项比h(n)增长最快的那一项更慢
如f(n)=5n,g(n)=2n,h(n)= 比较两个算法时间复杂度的优劣:
假设算法1的频度之和为f(n),算法2的频度之和为g(n)
f(n)、g(n)中增长最快的那一项不是倍数关系
只需要比较f(n)、g(n)中增长最快的那一项的大小;
就能确定这两个算法的时间复杂度的优劣;
如数学公式①,则f(n)对应的算法消耗的时间更少;
f(n)、g(n)中增长最快的那一项是倍数关系 因为相差只是一个倍数关系,所以认为它们一样复杂度;
也有去考虑倍数、以及其它增长不快的项的情况(快速排序算法,内部排序再说)
数量级:
f(n)中增长最快的那一项,并且去掉常系数;如f(n)=
,则f(n)的数量级是
常见函数的增长情况:
T(n)
定义:算法中所有语句的频度之和
n:表示该算法问题的规模
说明:T(n)是该算法问题规模n的函数
分析时间复杂度:主要是分析T(n)的数量级
算法复杂度
问题输入规模n:随着n的值越来越大,它们在时间效率上的差异也就越来越大
关注点:我们关注的是量级,也就是最能反映该算法复杂度的那一项;如2n+1我们关注到量级n,如
我们关注到量级
数量级哪里找:
数量级定义——基本操作与f(n)的数量级相同;它是最内层的语句;它可以反应算法的复杂度
例子——
int main() { int i, sum = 0, n = 100; /*执行1次*/ for (i = 1; i <= n; i++) /*执行n+1次*/ { sum = sum + i; /*执行n次*/ } printf("%d", sum); /*执行1次*/ return 0; } //f(n)=1+n+1+n+1 //基本操作为sum = sum + i;————执行了n次,与f(n)的数量级相同
算法时间复杂度
定义:用算法的基本运算的频度f(n)来分析
算法时间复杂度标记:T(n)=O(f(n))
注:
(1)O为T(n)的数量级【T(n)——保留f(n)中的增长速度最快的项;去掉上述保留项的系数,即系数为1】;比如由T(n)=O(f(n)),f(n)=
得到T(n)=O(
)
(2)常见阶的叫法
O(1) 常数阶 O(n) 线性阶 O( )
对数阶 O( )
平方阶 (3)常见T(n)的大小关系
(4)复杂度计算规则
加法规则 T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n)))
乘法规则 T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)) (5)最好、最坏、平均情况
最坏时间复杂度
最坏情况下的时间复杂度(一般考虑最坏情况下的时间复杂度,以保证算法的运行时间不会比它长)
平均时间复杂度 所有输入等概率的情况下,算法期望运行的时间 最好时间复杂度 最好情况下的时间复杂度
在{1,2,3,4, 5, 6,7}数组中顺序查找元素,求查找成功时的复杂度
最好时间复杂度 查找1:一次就找到
最坏时间复杂度 查找7:第7次才查找到
平均时间复杂度 1~7每个数字的查找复杂度依次为1,2,3,4,5,6,7;等概率下时间复杂度为(1+2+3+4+5+6+7)/7
(6)影响时间复杂度的因素——问题规模n和输入数据的性质(输入数据的初始状态)
在{1,2,3,4, 5, 6,7}数组中顺序查找元素,求查找成功时的复杂度 最好情况 输入数据的初始状态 最坏、平均情况 问题规模n
空间复杂度
说明:存储空间也是重要的资源,所以评价一个算法的好坏,还应当考虑该算法所消耗的存储空间
算法的空间复杂度:记为S(n),表示该算法所消耗的空间,它是问题规模n的函数
渐进空间复杂度:简称为空间复杂度,记为S(n)=O(g(n))
分析空间复杂度:只需分析除输入和程序之外的额外空间【存放本身指令、常数、变量、输入数据不考虑在内】,就是看消耗的空间与问题规模n是否有关系
原地工作:意思是指所需辅助空间为常量,即O(1);并不是说算法不占空间
空间消耗两方面:
使用malloc申请空间的函数 申请的规模g(n)对应的O(g(n))就是空间复杂度
计算空间复杂度方法——就和molloc的参数一致
使用了函数的递归调用 递归调用时需要用到系统栈,需要消耗空间
函数每调用一次消耗1个单位的空间(每次调用许多临时变量都需要存储)
递归调用函数的次数g(n)对应的O(g(n))就是空间复杂度
计算方法——和求递归函数的时间复杂度的方法类似(但仍有区别)
例子1:
//累加求和1+2+···+n int accumulation(int n) { int i, sum = 0; for (int i = 1; i <= n; i++) { sum = sum + i; } return sum; }
空间复杂度为O(1),对空间的消耗是常数,与问题规模n没有关系
例子2:
/*对于输入的数组,输出与之逆序的数组 例如,输入 a=[1,2,3,4,5],输出 [5,4,3,2,1] a:数组 n:数组a的大小 */ int* reverseArray(int a[], int n) { int* b = (int*)malloc(sizeof(int) * n); for (int i = 0; i < n; ++i) { b[n - i - 1] = a[i]; } return b; }
空间复杂度为O(n),对空间的消耗在malloc处
例子3:
int fac(int n) { if (n == 0 || n == 1) { return 1; } else { int r = n * fac(n - 1); return r; } }
空间复杂度为O(n),因为fac函数调用的次数为g(n)=n
一般可以通过空间换时间:
比如判断闰年
原地工作
对每一年都按闰年规则进行计算,得出该年是否是闰年
消耗考虑年份个数空间
对于新来的年,直接查表计算,1次完成