一、 算法设计的要求:
为什么要学算法?
/* 输出Hello word! */ #include "stdio.h" void main() { printf("Hello word!\n"); }
在此程序中,要综合运用数据结构和算法。数据结构是加工对象,语言是工具,变成需要合适的方法,但没有一个合格的算法,我们称不上合格的开发程序。所以,算法是程序设计的灵魂和核心。
- 1. 正确性
正确性:算法应当满足具体问题的需求。
“正确”一词的含义在通常的用法中有很大的差别,大体分为以下四个层次:
程序不含语法错误;
例如,程序片段如下
int a;
float b;
a=3;b=4.5;
printf("%f%d\n",a,b);
编译时不给出出错信息,但运行结果将与原意不符,输出为
0.000000 16402
常见的语法错误,输入输出语句要求变量及格式说明一定要类型不一致。
程序对于合法的输入数据能够产生满足要求的输出结果;
程序能够正常的输出数据。
程序对于非法的输入数据能够得出满足规格说明的结果;
程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果;
显然,达到层次4是最困难的,我们几乎不可能之一验证所有的输入都得到正确的结果。一般情况下,通常以第3层意义的正确性作为衡量一个程序是否合格的标准。
- 2. 可读性
可读性:算法设计的另一目的是为了便于阅读、理解和交流。
可读性好有助于人们对算法的理解;晦涩难懂的算法往往隐含错误,不易被发现,并难于调试和修改。可读性是算法好坏很重要的标志。
例:a=a+b; b=a-b; a=a-b; |
此片段表达的意思是什么?难道是a,b互换?
- 3. 健壮性
一个好的算法应该能对输入数据不合法的情况做合适的处理。
健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
如下程序片断:
if ((fp=fopen(filename,”w”))==NULL) { printf(“cannot open file\n”); exit(0); }
- 4. 时间效率高和存储量低
时间效率指的是算法的执行时间,对于同一个问题如果有多个算法可以解决,执行时间短的算法效率高,执行时间长的效率低。
存储量需求指算法执行过程中所需要的最大存储空间。
效率和低存储量需求这两者都与问题的规模有关。
如:求100个人的平均分与求1000个人的平均分所花的执行时间或运行空间显然有一定的差别。
二、 算法效率的度量方法
算法执行时间需通过依据该算法编制的程序在计算机上运行时所消耗的时间来度量。而度量一个程序的执行时间通常有两种方法:事后统计方法和事前分析估算方法。
- 1. 事后统计方法
这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序运行时间进行比较,从而确定算法效率的高低。
但这种方法显然有很大的缺陷:
(1)必须先运行依据算法编制的程序,通常需要花费大量的时间和精力;
(2)所得时间的统计量依赖于计算机的硬件和软件等环境因素,有时容易掩饰算法本身的优劣;
基于这样的缺陷,我们常常采用另一种事前分析估算的方法。
- 2. 事前分析估算的方法
事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
经分析,我们发现,一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
算法采用的策略、方法。
编译产生的代码质量。
问题的输入规模。
机器执行指令的速度
第1条是算法好坏的根本,第2条要有软件来支持,第4条要看硬件性能。就是说,抛开这些与计算机硬件、软件有关的因素,可以认为一个特定算法的“运行工作量”的大小,只依赖于问题的规模(通常用整数量n表示),或者说,它是问题规模的函数。
三、 函数的渐近增长
给定两个算法A和B,假设两个算法的输入规模都是n,算法A要做2n+3次操作,你可以理解为现有一个n次循环,执行完成后,再有一个n次循环,最后有三次赋值或运算,共2n+3次操作。算法B要做3n+1次操作。你觉得它们谁更快呢?
答案是不一定的。
次数 | 算法A(2n+3) | 算法A'(2n) | 算法B(3n+1) | 算法B'(3n) |
n=1 | 5 | 2 | 4 | 3 |
n=2 | 7 | 4 | 7 | 6 |
n=3 | 9 | 6 | 10 | 9 |
n=10 | 23 | 20 | 31 | 30 |
n=100 | 203 | 200 | 301 | 300 |
当n=1时,算法A效率不如算法B(次数比算法B要多一次)。而当n=2时,两者效率相同;当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)。
四、 算法的时间复杂度
- 1. 算法时间复杂度定义
在进行算法分析时,语句中的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
一般情况下,随着n的增大,T(n)增长最慢的算法为最优算法。(量增加,时间增长慢)
显然,由此算法时间复杂度的定义可知,我们的三个求和算法的时间复杂度分别为O(n),O(1),O(n2)。我们分别给它们取了非官方的名称,O(1)叫做常数阶、O(n)叫线性阶、O(n2)叫平方阶。
如:
for(i=1;i<=n;++i) for(j=1;j<=n;++j){ c[i][j]=0; for(k=1;k<=n;++k) c[i][j]+=a[i][k]*b[k][j]; }
此程序的时间复杂度是O(n3)。
- 2. 常数阶
下面这个算法,即高斯算法,为什么时间复杂度不是O(3),而是O(1)。
int sum = 0,n = 100; /* 执行一次 */
sum = (1+n) *n/2; /* 执行一次 */
printf("%d",sum); /* 执行一次 */
这个算法的运行次数函数是f(n)=3。把常数项3改为1,在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)。
另外,我们试想一下,如果这个算法当中的语句sum = (1+n) *n/2有10句,即:
事实上无论n为多少,上面的两段代码就是3次和12次执行的差异。这种与问题的大小无关(n的多少),执行时间恒定的算法,我们称之为具有O(1)的时间复杂度,又叫常数阶。
注意:不管这个常数是多少,我们都记作O(1),而不是O(3),O(12)等其他任何数字,这是初学者常常犯的错误。
对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着n的变大而发生变化,所以单纯的分支结构(不包含在循环结构中),其时间复杂度也是O(1)。
- 3. 线性阶
线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。
下面这段代码,它的循环的时间复杂度为O(n),因为循环体中的代码需要执行n次。
int i; for(i = 0;i < n;i++) { /* 时间复杂度为O(1)的程序步骤序列 */ }
- 4. 对数阶
我们以一段代码为例,说明对数阶:
int count = 1; while (count < n) { count = count *2 /* 时间复杂度为O(1)的程序步骤序列 */ }
由于每次count乘以2之后,就距离n更近了一分。也就是说,有多少个2相乘后大于n,则会退出循环。由2x=n得到x=log2n。所以这个循环的时间复杂度为O(log2n)。
- 5. 平方阶
下面例子是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为O(n2)。
int i,j;
for(i = 0;i < n; i++)
{
for (j = 0;j < n;j++)
{
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
对于外层的循环,不过是内部这个时间复杂度为O(n)的语句,再循环n次。所以这段代码的时间复杂度为O(n2)。
常见的时间复杂度
常见的时间复杂度如下所示。
执行次数函数 | 阶 | 非正式用语 |
12 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n2+2n+1 | O(n2) | 平方阶 |
5log2n+20 | O(log2n) | 对数阶 |
2n+3nlog2n+19 | O(nlog2n) | nlog2n阶 |
6n3+2n2+3n+4 | O(n3) | 立方阶 |
2n | O(2n) | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
1 3 10 30 100 1000 1024
O(1)< O(log2n)< O(n)< O(nlog2n)< O(n2)< O(n3)< O(2n)< O(n!)<O(nn)
最坏情况与平均情况
我们查找一个有n个随机数字数组中的某个数字,最好的情况是第一个数字就是,那么算法的时间复杂度为O(1),最坏的情况是这个数字在最后一个位置上,那么算法的时间复杂度就是O(n)。
最坏情况运行时间是一种保证,那就是运行时间将不会再坏了(在应用中,这是一种最重要的需求,通常除非特别指定,我们提到的运行时间都是最坏情况的运行时间)。
平均运行时间是从概率的角度看,这个数字在每个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个目标元素。
平均运行时间是期望的运行时间。也就是说,我们运行一段代码时,是希望看到平均运行时间的。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的实验数据后估算出来的。
对算法的分析,一种方法是计算所有情况的平均值,这种时间复杂度的计算方法称为平均时间复杂度。另一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般在没有特殊说明的情况下,都是指最坏时间复杂度。
例如:冒泡排序法
void bubble_sort(int a[],int n)
{
chang=false;
for(i=n-1;change=TURE;i>1&&change;-i)
for (j=0;j<I;++j)
if(a[j]>a[j+1])
{
a[j]←→a[j+1];change=TURE;}
}
最好情况:0次
最坏情况:1+2+3……+n-1=n(n-1)/2
平均时间复杂度为:O(n2)
4.7算法的空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n)=O(f(n)),表示随着问题规模n的增大,算法运行所需存储量的增长率与f(n)的增长率相同。
算法的存储量包括:
输入数据所占空间;
程序本身所占空间;
辅助变量所占空间。
若输入数据所占空间只取决于问题本身,和算法无关,则只需要分析除输入和程序之外的辅助变量所占额外空间。
若所需额外空间相对于输入数据量来说是常数,则称此算法为原地工作。
若所需存储量依赖于特定的输入,则通常按最坏情况考虑。
希尔排序代码
/* 希尔排序 缩小增量排序----->通俗的讲就是改进后的直接插入排序 增加了k 增量序列 分组的组数 k=MAX/2 增量k的值是越来越小 先分小组,分别对每个组内进行直接插入排序 然后在k=k/2 分组 直到组数为1截止 进行最终的一趟直接插入排序结束 */ #include "stdio.h" #define MAX 11 void main() { int a[MAX]={6,3,8,1,7,4,9,12,52,54,2}; int i;//控制循环趟数 以及 待排序元素的下标 int j;//控制有序数组的下标 int temp;//存放 待排序元素 temp数据类型 与数组类型一致 int k;//增量 k代表把元素分为几组 //希尔排序开始 for(k=MAX/2;k>=1;k=k/2) // 缩小增量排序 继续分组 继续进行直接插入排序 { //直接插入排序开始 for(i=k;i<MAX;i++) { temp=a[i];//待排序元素 if(temp<a[i-k]) { for(j=i-k;a[j]>temp&&j>=0;j=j-k)//i-k有序数组最后一个元素的下标 { a[j+k]=a[j]; } //当我们结束第二层for循环时候,结束时j=j-k a[j+k]=temp; } } //直接插入排序结束 } //希尔排序结束 printf("希尔排序结果:\n"); for(i=0;i<MAX;i++) { printf("%d\t",a[i]); } }
折半法代码
/* 折半查找 前提 顺序存储 记录有顺序 折半查找 low 头下标 high 尾巴下标 mid 中间位置下标=(low+high)/2 拿要查找的值key 和 中间值 比较 key大于 中间值 去右边找 右边有尾巴没有头 按个头 low=mid+1 key小于 中间值 去左边找 左边有头没有尾巴 high=mid-1 key == 中间值 找到了 输出下标 break;终止查找 */ #include "stdio.h" #define MAX 10 int a[MAX]={11,13,14,15,16,18,34,66,99,100}; //折半查找函数 int zheban(int key) //传递待查找的关键字 { int low=0; int high=MAX-1; //数组最后一个元素下标 int mid; while(low<=high) { mid=(low+high)/2; //求出中间值得下标 if(key>a[mid]) { //去右边找 low=mid+1; }else if(key<a[mid]) { //去左边找 high=mid-1; }else{ printf("查找成功\n"); return mid; } } //循环结束后 /*if(low>high) { printf("查找失败\n"); } */ return -1; } void main() { int key;//存放待查找的关键件 printf("请输入您要查找的数:"); scanf("%d",&key); printf("@%d@\n",zheban(key)); }