略
略
现在要求写一个求 1+2+3+…+100 结果的程序,应该怎么写呢?
大多数人会这样写:
#include
int main() {
int i, sum = 0, n = 100;
for (i = 1; i <= n; i++) {
sum += i;
}
printf(" %d ", sum);
}
再看看高斯的算法:
#include
int mian() {
int sum = 0, n = 100;
sum = (1 + n) * n / 2;
printf(" %d ", sum);
}
显然第一种算法要循环执行 100 次操作,而第二种算法执行 1 次操作就行了
算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法具有五个基本特性:输入,输出,有穷性,确定性和可行性
输入:算法具有零个或多个输入
输出:算法至少有一个或多个输出
有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成
确定性:算法的每一步骤都具有明确的含义,不会出现二义性
可行性:算法的每一步都必须是可行的,也就是说,每一步都能够通过执行有限次数完成
正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反应问题的需求、能够的到问题的正确答案
可读性:算法设计的另一目的是为了便于阅读、理解和交流
健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的报错
时间效率:时间效率指的是算法的执行时间
存储量:存储量需求指的是算法在执行过程中需要的最大存储空间
设计算法应该尽量满足时间效率高和存储量低的需求
我们上面提到设计算法要时间效率高,这里的时间效率指的是算法的执行时间,那么如何度量一个算法的执行时间呢?
事后统计方法:这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
基于事后统计方法有很多缺陷,我们考虑不予采纳
事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
经过分析,我们发现,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。
我们再来看看之前两种求和的算法:
第一种:
#include
int main() {
int i, sum = 0, n = 100; /* 执行 1 次 */
for (i = 1; i <= n; i++) { /* 执行 n + 1 次 */
sum += i; /* 执行 n 次 */
}
printf(" %d ", sum); /* 执行 1 次 */
}
第二种:
#include
int mian() {
int sum = 0, n = 100; /* 执行 1 次 */
sum = (1 + n) * n / 2; /* 执行 1 次 */
printf(" %d ", sum); /* 执行 1 次 */
}
显然,第一种算法执行了 2n + 3 次,而第二种算法执行了 3 次。事实上两个算法第一条语句和最后一条语句是一样的,我们关注的代码其实是中间的那部分,那么这两个算法其实就是 n 次和 1 次的差距。算法的好坏显而易见。
再来延伸一下上面的这个例子:
#include
int main() {
int i, j, x = 0, sum = 0, n = 100; /* 执行 1 次 */
for (i = 1; i <= n; i++) {
for (j = 1; j <= n; j++) {
x++; /* 执行 n*n 次 */
sum += x;
}
}
printf(" %d ", sum); /* 执行 1 次 */
}
显然,对于同样输入规模 n = 100,这个算法需要执行 n*n 次,要多于前面两种算法。
此时可以发现,测试运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数,运行时间和这个成正比
我们在分析一个算法的运行时间时,重要的是把基本操作的数量与输入规模关联起来,即基本操作的数量必须表示成输入规模的函数,如下图:
我们可以这样认为,随着 n 值的越来越大,它们在时间效率上的差异也就越来越大。
次数 | 算法 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 |
算法 A 和 B 哪个更好呢?n = 1 时,算法 A 效率不如算法 B;n = 2 时,两者效率相同;n > 2 时,算法 A 就开始优于算法 B 了。并且随着 n 的增加,算法 A 比算法 B 越来越好了。于是我们的出结论:算法 A 总体上要好过算法 B
此时我们给出这样的定义,输入规模 n 在没有限制的情况下,只要超过一个数值 N,这个函数总是大于另一个函数,我们称函数是渐近增长的。
并且我们发现,随着 n 的增大,后面的 +3 还是 +1 其实是不影响最终的算法变化的,例如算法 A’ 和算法 B’,所以我们可以忽略这些加法常数。
再来看一个例子:
次数 | 算法 C( 4n+8 ) | 算法 C’( n ) | 算法 D( 2n^2+1 ) | 算法 B’( n^2 ) |
---|---|---|---|---|
n = 1 | 12 | 1 | 3 | 1 |
n = 2 | 16 | 2 | 9 | 4 |
n = 3 | 20 | 3 | 19 | 9 |
n = 10 | 48 | 10 | 201 | 100 |
n = 100 | 408 | 100 | 20001 | 10000 |
n = 1000 | 4008 | 1000 | 2000001 | 1000000 |
我们发现,随着 n 的增大,哪怕去掉与 n 相乘的常数结果也没有发生改变。也就是说,与最高次相乘的常数并不重要。
再来看一个例子:
次数 | 算法 E( 2n^2+3n+1 ) | 算法 E’( n^2 ) | 算法 F( 2n^3+3n+1 ) | 算法 F’( n^3 ) |
---|---|---|---|---|
n = 1 | 6 | 1 | 6 | 1 |
n = 2 | 15 | 4 | 23 | 8 |
n = 3 | 28 | 9 | 64 | 27 |
n = 10 | 231 | 100 | 2031 | 1000 |
n = 100 | 20301 | 10000 | 2000301 | 1000000 |
通过观察发现,最高次项的指数大的,随着函数 n 的增长,结果也会变得增长特别快。
于是我们可以得出这样一个结论:判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
在进行算法分析时,语句总的执行次数 T(n) 是关于问题规模 n 的函数,进而分析 T(n) 随 n 的变化情况并确定 T(n) 的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模的增大,算法执行时间的增长率和 f(n) 的增长率相同,称为算法的渐进时间复杂度,简称为时间复杂度。其中 f(n) 是问题规模 n 的某个函数。
一般情况下,随着 n 的增大,T(n) 增长最慢的算法为最优算法。
那么如何分析一个算法的时间复杂度呢? 即如何推导大 O 阶呢?
我们给出了以下方法:
顺序结构的时间复杂度:
int sum = 0, n = 100; /* 执行 1 次 */
sum = (1 + n) * n / 2; /* 执行 1 次 */
printf(" %d ", sum); /* 执行 1 次 */
这个算法的运行次数函数是 f (n) = 3。根据我们推导大 O 阶的方法,第一步就是把常数项 3 改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为 O(1)。
这种与问题的大小无关(n 的多少),执行时间恒定的算法,我们称之为具有 O(1) 的时间复杂度,又称常数阶。
分支结构的时间复杂度:
对于分支结构而言,无论真还是假,执行的次数是恒定的,不会随着 n 的变大而发生变化,所以其时间复杂度为 O(1)
下面这段代码,它的循环的时间复杂度为 O(n),因为循环体中的代码需要执行 n 次
int i;
for (i = 0; i < n; i++) {
/* 时间复杂度为 O(1) 的程序步骤序列 */
}
下面的这段代码,时间复杂度又是多少呢?
int count = 1;
while(count < n) {
count = count * 2;
/* 时间复杂度为 O(1) 的程序步骤序列 */
}
由于每次 count 乘以 2 之后,就距离 n 更近了一分。也就是说,有多少个 2 相乘后大于 n,则会退出循环。由 2^x=n 得到 x=log2n。所以这个循环的时间复杂度为 O(logn)。
下面例子是一个循环嵌套:
int i, j;
for(i = 0; i < n; i++){
for(j = 0; j < n; j++){
/*时间复杂度为O(1)的程序步骤序列*/
}
}
它的内循环刚才我们已经分析过,时间复杂度为 O(n)。而对于外层的循环,不过是内部这个时间复杂度为 O(n) 的语句,再循环 n 次。所以这段代码的时间复杂度为 O(n^2)。
如果外循环的循环次数改为了 m,时间复杂度就变为 O(m*n)。
所以我们可以总结得出,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
那么下面这个循环嵌套,它的时间复杂度是多少呢 ?
int i, j;
for(i = 0; i < n; i++){
for(j = i; j < n; j++){ /* 注意 j = i而不是 0 */
/*时间复杂度为O(1)的程序步骤序列*/
}
}
由于当 i=0 时,内循环执行了 n 次,当 i=1 时,执行了 n-1 次,……当 i=n-1 时,执行了 1 次。所以总的执行次数为:n + (n-1) + (n-2) + … + 1 = n(n+1)/2 = (n^2)/2 + n/2
用我们推导大 O 阶的方法,第一条,没有加法常数不予考虑;第二条,只保留最高阶项,因此保留时 (n^2)/2; 第三条,去除这个项相乘的常数,也就是去除 1/2,最终这段代码的时间复杂度为 O(n^2)。
我们继续看例子,对于方法调用的时间复杂度又如何分析?
int i,j;
for ( i = 0; i < n; i++ )
{
function (i);
}
void function (int count)
{
print ( count );
}
函数体是打印这个参数。其实这很好理解,function 函数的时间复杂度是 O(1)。所以整体的时间复杂度为 O(n)
假如 function 是下面这样的:
void function ( int count )
{
int j ;
for (j = count; j < n; j++)
{
/*时间复杂度为O(1)的程序步骤序列*/
}
}
事实上,这和刚才举的例子是一样的,只不过把嵌套内循环放到了函数中,所以最终的时间复杂度为 O(n^2)
下面这段相对复杂的语句:
n++; /*执行次数为 1 */
function ( n ); /*执行次数为 n */
int i,j;
for ( i = 0; i < n; i++) /*执行次数为 n^2 */
{
function ( i);
}
for (i = 0; i < n; i++) /*执行次数为 n ( n + 1 ) /2 */
{
for (j = i;j < n; j++)
{
/*时间复杂度为 O(1) 的程序步骤序列*/
}
}
它的执行次数 f(n) = 1 + n + n^2 + n(n+1)/2 = 3/2(n^2) + 3/2(n) +1,根据推导大 O 阶的方法,最终这段代码的时间复杂度也是 O(n^2)
常见的时间复杂度如表所示:
常用的时间复杂度所耗费的时间从小到大的依次是:
O(1)
我们前面已经谈到了 O(1) 常数阶,O(logn) 对数阶,O(n) 线性阶,O(n^2) 平方阶等,至于 O(nlogn) 我们将会在今后的课程中介绍,而像 O(n^3),过大的 n 都会使得结果变得不现实。同样指数阶 O(2^n) 和阶乘阶 O(n!) 等除非是很小的 n 值,否则哪怕 n 只是 100,都是噩梦般的运行时间。所以这种不切实际的算法时间复杂度,一般我们都不去讨论它。
最坏情况运行时间是一种保证,那就是运行时间将不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
平均时间是所有情况中最有意义的,因为他是期望的运行时间。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的试验数据后估算出来的。
一般在没有特殊说明的情况下,都是指最坏时间复杂度。
算法的空间复杂度通过计算算法所需的存储空间实现,算法控件复杂度的计算公式记作:S(n) = O(f(n)),其中,n 为问题的规模,f(n) 为语句相关于 n 所占存储空间的函数。
略
略