算法和算法分析
在数据结构中谈一个算法的时候,着眼于数据量(数据规模)不是很小且在内存中处理的问题。
我们处理的数据量(数据规模)不会太小,因为数据量(数据规模)太小,不容易明显的区分和分析那种算法的性能更好。数据量(数据规模)太小,算法的性能不会相差太大。
尽管数据量(数据规模)不是很小,但是这些数据在内存当中足以放得下。因为在内存中能够处理的问题,我们就可以集中考虑算法时间和空间的代价。否则,内存放不下,数据量过大,涉及到内存和外存之间数据的调入调出,都会对于算法的评估有一定的影响。
所以我们重申:
1.数据结构着眼于数据量(数据规模)不是很小,要有足够的数据量(数据规模)。
2.足够量的数据一定在内存中足够放得下。
在这两个前提下去研究算法以及算法分析问题。
算法的基本概念
算法—是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。
这里要注意什么是特定问题?
特定问题是对于某一种抽象数据类型的操作,操作就是要解决某一具体问题。
例如:线性表这种抽象数据类型,它的操作就有查找,插入,删除,修改等。每一种操作都是一个特定的问题,要解决这个问题就会涉及到一系列步骤的描述,也就是算法。
要解决特定问题,算法并不是只有唯一的一种。所以在写算法的时候,前提是能解决特定问题的基础之上然后去考虑这个算法是不是最优的。所谓最优,我们会从很多不同的角度去衡量。
算法的五个特征
有穷性:直接来说就是不能死循环,要求算法必须在合理的时间之内能够结束。
确定性:指令具备确切的含义,具有唯一确定的执行路径。给定相同的输入能够得到相同的输出。不能出现多次输入相同的值,输出的结果不一样的情况。
可行性:描述的操作一定是可以实现的。
0个或多个输入:算法有0个或多个输入。
1个或多个输出:算法要解决特定的问题,执行完之后没有结果,那算法就没有存在的必要性了。
算法的设计要求
正确性:设计的算法没有语法错误,经得起测试,合理的输入一定要能够得到合法的结果。
可读性:尤其大型软件开发,团队合作,每个人会负责不同的模块,你的代码自己能读懂,别人也要方便能读懂。这样做有便于后期的维护,升级和修改。例如:详加注释。
健壮性:足够的强壮,如果用户给了非法的数据,算法要能够做出适当的反应或者报错,不能出现给一个非法测试,程序直接没有任何反应了,这是不行的。
高效率:高效率是指时间方面的性能,也就是算法执行要快。执行快是有前提的,在解决相同的问题时,两个算法一个执行快,一个执行慢,体现出来了算法的效率差距。解决不同的问题时,算法没有可比性。
低存储:算法的执行必然借助于存储空间,我们就需要考虑对于存储空间的消耗。
解决相同的问题,需要的存储量越小对于我们来说肯定是越好。
算法和程序
算法的含义与程序十分相似,但二者是有区别的。
1、 一个程序不一定满足有穷性(如一个操作系统在用户未使用前一直处于“等待” 的循环中, 直到出现新的用户事件为止。这样的系统可以无休止地运行,直到系统停工。);
2、 程序中的指令必须是机器可执行的,而算法中的指令则无此限制。算法若用计算机语言来书写,则它就可以是程序。
算法的描述
一个算法可以用自然语言、数学语言或约定符号来描述,也可以用流程图、计算机高级程序语言(如 C 语言)或伪代码等来描述。
算法的表现形式:伪代码表示
冒泡排序算法:
void bubble_sort(int a[] , int n)
{
//将a中整数序列按从小到大的顺序排序
for(i = n-1;i>=1; i--)
{
for(j = 0; j<i;j++)
if(a[j] > a[j+1])
{
a[j]<--->a[j+1];
}
}
冒泡排序的优化算法:
void bubble_sort(int a[] , int n)
{ //将a中整数序列按从小到大的顺序排序
for(i = n-1, change = TURE;i>=1 && change; i--)
{
change = FALSE;//交换标识
for(j = 0; j<i;j++)
if(a[j] > a[j+1])
{
a[j]<--->a[j+1];
change = TRUE;
}
}
详细的冒泡排序说明在点击下面进入:
👇👇👇
详细的冒泡排序说明
算法效率的度量
事后统计法
先运行,最终看运行结果。有一个弊端就是运行结果会依赖计算机的软件和硬件系统。不同的计算机会有差异,同一台计算机不同的时间段运行也会有差异。
事前分析法
事前根据算法的策略,问题的规模,实现的语言以及编译程序所产生的机器代码的质量和机器执行指令的速度等方面进行分析。
在前面很多条件里面我们会发现:
算法的策略和人脑的思维有关,例如,冒泡排序算法不同的人也会写出不同的算法。
问题规模大小不等。
实现语言的不同,C语言实现和使用其它语言实现也会有一定的差异。
不管是实现语言还是编译程序所产生的机器代码的质量和机器执行指令的速度,都和软件
硬件相关,就会受很多因素的影响,这些都会影响到算法的效率。
但是我们总是要对于算法的效率进行度量,所以这个时候就想出来了一个办法:
只考虑问题规模
考虑问题规模,只关注基本操作。那么就会计算出一个值,这个值的大小最终能够对于算法进行一个评定,并不是绝对的评定,但是是一个非常有效的办法。
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数f(n),
记作:T(n) = O(f(n)),T(n)称为渐进时间复杂度,它表示随着问题规模n的增大,算法执行时间的增长率和f(n)函数的增长率相同,从数学意义上来讲是一个同数量级的函数。所以说要分析一个算法的时间性能,我们首先要确定基本操作是什么,然后计算出来基本操作重复执行的次数。是不是有点绕口,我们引入下面概念:
频度:基本操作的执行次数。
时间复杂度:一般情况下只考虑问题的规模,算法中基本操作重复执行的次数是问题规模n的某个函数,那么这个时候某个函数就放在O(f(n))的形式当中,这就是时间复杂度。
时间复杂度表示基本操作执行的次数随着问题规模n的增大,趋近于一个函数f(n),那么这里的关键就是基本操作执行次数的计算上,我们换一种方式进行说明:
数学意义上的描述:
所以计算渐进时间复杂度T(n)会用到频度,计算频度会用到基本操作执行的过程。那么接下来我们通过实际操作进行计算:
例:
{++x; s=0;}
基本操作的语句{++x; s=0;} 的执行次数与数据规模无关。
所以
P(n) = 1 f(n) = 1 T(n) = O(1)
包含 “x 增 1” 基本操作的语句的频度为1,即时间复杂度为O(1)。O(1) 表示算法的运行时间为常量。即:常量阶。
如果P(n) = 10 和n无关只考虑问题规模,所以是一个常数项T(n) = O(1)。
例:
for(i =1; i <=n; ++i)
{++x; s += x;}
i = 1 基本操作的语句 {++x; s += x;} 执行1次
i = 2 基本操作的语句 {++x; s += x;} 执行1次
i = 3 基本操作的语句 {++x; s += x;} 执行1次
…………
i = n 基本操作的语句 {++x; s += x;} 执行1次
所以P(n) = n f(n) = n T(n) = O(n)
包含 “x 增 1” 基本操作的语句的频度为:n,其时间复杂度为:O(n),即:线性阶。
例:
for(i =1; i <= n; ++i )
for(j =1; j <= n; ++j )
{++x; s += x;}
i = 1 基本操作的语句 {++x; s += x;} 执行n次
i = 2 基本操作的语句 {++x; s += x;} 执行n次
i = 1 基本操作的语句 {++x; s += x;} 执行n次
i = 1 基本操作的语句 {++x; s += x;} 执行n次
i = 1 基本操作的语句 {++x; s += x;} 执行n次
所以P(n) = n^2 f(n) = n^2 T(n) = O(n^2)
包含 “x 增 1” 基本操作的语句的频度为:n2,其时间复杂度为:O(n2),即:平方阶。
那么是不是很简单呢,常量阶,线性阶,平方阶。都是这样吗?是不是有什么规律呢?二重循环就是O(n^2),一重循环就是O(n)。
那么我们分析下面代码的时间复杂度:
例:
for( i =2; i <= n; ++i )
for( j =2; j <= i - 1; ++j )
{++x; a[ i, j]=x;}
i=2 j=2 2<=1 不满足 基本操作的语句 {++x; a[ i, j]=x;} 不执行。
i=3 j=2 2<=2 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
i=4 j=2 2<=3 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
i=4 j=3 3<=3 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
所以 i = 4 基本操作的语句 {++x; a[ i, j]=x;} 执行2次。
i=5 j=2 2<=4 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
i=5 j=3 3<=4 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
i=5 j=4 4<=4 满足 基本操作的语句 {++x; a[ i, j]=x;} 执行1次。
所以 i = 5 基本操作的语句 {++x; a[ i, j]=x;} 执行3次。
i=n j=2 ~n - 1 2 ~n - 1<=n - 1 基本操作的语句 {++x; a[ i, j]=x;} 执行n - 2次。
包含 “x 增 1” 基本操作的语句的频度为:
1+2+3+…+n-2 = (1+n-2)×(n-2)/2 = (n-1)(n-2)/2 是一个等差数列
等差数列运行结果为:
基本操作的 {++x; a[ i, j]=x;} 语句的频度为:n2,其时间复杂度为:O(n2),即:平方阶。
f(n)的求法
f(n)一般用频度表达式中增长最快的项表示,并将其常数去掉。
例如: 假设某元操作的频度:100 * 2^n + 8 * n * 2
则 T(n) = O(2^n)
时间复杂度会根据不同的算法来算出不同的值。所以同一种算法时间性能好不好,可以通过时间复杂度很形象的对比。
随着问题规模的增加,算法的时间复杂度常见的有:
常数阶 O(1),对数阶 O(log n),线性阶 O(n),
线性对数阶 O(nlog n),平方阶 O(n2),立方阶 O(n3),…,
k 次方阶O(nk),指数阶 O(2n),阶乘阶 O(n!)。
常见的算法的时间 复杂度之间的关系为:
O(1)<O(log n)<O(n)<O(nlog n)<O(n2)<O(2n)<O(n!)<O(nn)
当 n 很大时,指数阶算法和多项式阶算法在所需时间上非常悬殊。因此,只要有人能将现有指数阶算法中的任何一个算法化简为多项式阶算法,那就取得了一个伟大的成就。
常见函数的增长率
当n比较小的时候,它们之间并没有必然的规律。但是当问题规模n比较大的时候,上面函数增长率的曲线在同一个n值的情况下,它们T(n)会有很大的区别,并函数的增长线不会有交叉,都是绝对的。这也就是为什么数据结构研究算法,分析算法的时候数据规模不能太小。数据规模太小,算法的好坏不容易衡量。
时间复杂度的三种具体情况
void bubble-sort(int a[],int n)
{ //将 a 中整数序列重新排列成自小至大有序的整数序列。
for(i = n-1, change = TURE; i >= 1 && change; --i)
change = false;
for ( j = 0; j < i; ++j)
if (a[ j] > a[ j +1]) {a[ j]←→a[ j +1]; change = TURE}
}// bubble-sort
时间复杂度分析:
如果原本是有序的,那么时间复杂度就是最好的情况:0次
时间复杂度最坏的情况:1+2+3+…+n-1=n(n-1)/2
平均时间复杂度为:O(n2)
我们在后面讨论的所有时间复杂度,均指最坏的时间复杂度。因为我算出来最坏情况的时间复杂度,所有情况下,只能和它一样或者比它更好,不能比它更坏,这是一个底线。
空间复杂度
程序代码本身所占空间对不同算法通常不会有数量级之差别,因此在比较算法时可以不加考虑;算法的输入数据量和问题规模有关,若输入数据所占空间只取决于问题本身,和算法
无关,则在比较算法时也可以不加考虑;由此只需要分析除输入和程序之外的额外空间。
那么算法的空间复杂度及就是算法在运行过程中临时占用的存储空间 。
记作 S(n) = O(f(n))
其中 n 为问题的规模。
若所需临时空间不随问题规模的大小而改变,也就是与问题规模无关,则称此算法为原地工作,记作O(1)。
若所需存储量依赖于数据的规模,则通常按最坏情况考虑。
分析空间复杂度:
float abc ( float a, float b, float c )
{
return a + b + b * c;
}
上面运行的代码只需要占用固定的临时空间用来存放return的数据,与问题规模n无关,所以S(n) = O(1) 也就是原地工作。
float sum ( float list [ ], int n )
{
float tempsum = 0;
for( int i = 0; i < n; i++ ) tempsum += list[ i ];
return tempsum;
}
上面运行的代码只需要占用固定的临时空间用来存放tempsum的累加结果,与问题规模n无关,所以S(n) = O(1) 也就是原地工作。
float rsum ( float list [ ], int n )
{
if(n == 0) return 0;
return rsum( list, n-1 ) + list[ n-1 ];
}
上面提供的是一个自己调用自己的递归问题。
如果要解决问题规模为n 必须解决 n - 1,函数调用,所以继续调用临时空间来保存现场。
如果要解决问题规模为n-1 必须解决 n - 2函数调用,所以继续调用临时空间来保存现场。
如果要解决问题规模为n-2 必须解决 n - 3函数调用,所以继续调用临时空间来保存现场。
…………
直到解决问题规模 n 为 0。
问题规模为n,需要多次调用临时空间来保存现场,所以空间复杂度S(n) = O(n)。
小结
绪论是为以后其他数据结构的内容作基本知识的准备。