第二章 算法
两种算法的比较
例如:要求写出程序,求1+2+3+···+100的结果。
直观的想法:
int i, sum = 0, n = 100;
for(i = 1; i < = n; i++)
{
sum = sum + i;
}
printf(" %d ", sum);
传说中数学家高斯的想法:
int i, sum = 0, n = 100;
sum = (1 + n) * n / 2;
printf(" %d ", sum);
后者不仅可以用于1加到100,同样适用于一千、一万、一亿等;而前者则显然需要循环相应次数才能实现。
算法定义
算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法的特性
算法具有五个基本特性:输入、输出、有穷性、确定性、可行性。
输入输出
- 算法具有零个或多个输入,多数情况下输入均不是零个,但如打印“hello world”这样的代码,就不需要任何输入参数;
- 算法至少有一个或多个输出,输出的形式可以是打印输出,也可以是返回一个或多个值。
有穷性
指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每个步骤在可接受的时间内完成。此处有穷的概念并非纯数学意义的,要考虑实际,如一个算法需要若干年才能完成,则不符合算法的有穷性。
确定性
算法的每一步都具有确定的含义,不会出现二义性。算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。
可行性
算法的每一步都必须是可行的,即,每一步都能够通过执行有限次数完成。可行性意味着算法可以转换为程序上机运行,并得到正确的结果。
算法设计的要求
算法不是唯一的,但面对同一个问题,依然有相对好的和不好的算法,如何判断一个算法是不是好的算法?
正确性
算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求、能够得到问题的正确答案。大体分为以下四个层次:
- 算法程序没有语法错误;
- 算法程序对于合法的输入能够产生满足要求的输出结果;
- 算法程序对于非法的输入能够得出满足规格说明的结果;
- 算法程序对于精心选择的、甚至刁难的测试数据都有满足要求的输出结果。
通常以第三个层次作为一个算法是否正确的标准。
可读性
算法设计的另一目的是为了便于阅读、理解和交流。可读性高有助于人们理解算法,晦涩难懂的算法往往隐含错误,不易被发现,并且难于调试和修改。
健壮性
当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
时间效率高和存储量低
- 时间效率指的是算法的执行时间,对同一个问题,若存在多个算法,则执行时间短的算法效率高,执行时间长的算法效率低。
- 存储量需求指算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。
算法效率的度量方法
事后统计方法
通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
然而这种方法存在诸多缺陷:
- 必须依据算法事先编好程序,花费大量时间和精力。如果验证发现算法很差,则前期投入均被浪费;
- 时间的比较依赖于计算机硬件和软件等因素,有时会掩盖算法本身的优劣。不同的计算机硬件运算速度迥异;所用的操作系统、编译器、运行框架等软件差异也会影响结果;即使是相同的软硬件条件,CPU使用率和内存占用不同,也会造成细微差异;
- 算法的测试数据设计困难,且程序运行时间往往与测试数据的规模有关,效率高的算法在较小的测试数据下得不到体现。
基于以上考虑,我们不使用这种方法。
事前分析估算方法
在计算机程序编制前,依据统计方法对算法进行估算。
因此,抛开与计算机软硬件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模(指输入量的多少)。
举例说明算法效率的估计:求1+2+3+ ······+n
第一种算法:
int i, sum = 0, n = 100; /* 执行1次 */
for(i = 1; i < = n; i++) /* 执行了n+1次 */
{
sum = sum + i; /* 执行n次 */
}
printf(" %d ", sum); /* 执行1次 */
共执行了2n+3次;
第二种算法:
int i, sum = 0, n = 100; /* 执行1次 */
sum = (1 + n) * n / 2; /* 执行1次 */
printf(" %d ", sum); /* 执行1次 */
共执行了3次;
第三种算法:
int i, j, x = 0, sum = 0 , n = 100; /* 执行1次 */
for(i = 1; i < = n; i++)
{
for(j = 1; j < = n; j++)
{
x++; /* 执行n x n次 */
sum = sum + x;
}
}
printf("%d", sum); /* 执行1次 */
共执行了n^2+2次。
由此可见,测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数,运行时间与之成正比。
我们不关心编写程序所用的程序设计语言是什么,也不关心这些程序将跑在什么样的计算机中,我们只关心它所实现的算法。这样,不计那些循环索引的递增和循环终止条件、变量声朋、打印结果等操作,最终,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
为了全面分析一个算法的运行时间,必须把基本操作的数量与输入规模关联起来,即基本操作的数量应表示成输入规模的函数。
如前面三种算法中,第一种的操作数量f(n)=n;第二种f(n)=1;第三种f(n)=n^2。
函数的渐近增长
假设两个算法的输入规模都是n,算法A要做2n+3次操作,算法B要做3n+1次操作,哪种算法运行更快?答案是不一定。如下表:
n=1时,算法A的效率不如算法B;n=2时,两者效率相同;n>2时,算法A就开始优于算法B了,且随着n的增加,算法A比算法B越来越好了。因此算法A总体上要好于算法B。
于是给出渐近增长的定义:
给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。
根据类似的例子可以得出以下结论:
- 加法常数可以忽略;
- 与最高次项相乘的常数可以忽略;
- 最高次项的指数大的,函数随着n的增长,结果也会变得增长特别快。
综合来说:判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
算法的时间复杂度
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
推导大O阶方法
步骤如下:
- 用常数1取代运行时间中的所有加法常数;
- 在修改后的运行次数函数中,只保留最高阶项;
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大O阶。
几个推导大O阶的例子
常数阶
int sum = 0, n = 100; /* 执行1次 */
sum = (1 + n) *n/2; /* 执行1次 */
printf("%d", sum); /* 执行1次 */
算法运行次数是3,按照推导方法,将加法常数3用1替代,此运行时间无最高阶项,故时间复杂度为O(1)。
注意无论运行次数的常数是多少,我们都将其记为O(1)。对于分支结构,由于无论分支是真还是假,执行的次数都是恒定的,与问题规模无关,故单纯的分支结构(不包含在循环结构中),其时间复杂度也是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=log_2n\\)。所以这个算法的时间复杂度为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次,当n=1时,执行了n-1次,······当i=n-1时,执行了1次。故总的执行次数为:
\\[n+(n-1)+(n-2)+\\cdot\\cdot\\cdot+1=\\frac{n(n+1)}{2}=\\frac{n^2}{2}+\\frac{n}{2}\\]
根据推导大O阶的方法,第一条,没有加法常数不予考虑;第二条,只保留最高阶项,因此保留\\(n^2/2\\);第三条,去除这个项相乘的常数,也就是除去1/2,最终这段代码的时间复杂度为O(\\(n^2\\))。
常见的时间复杂度
常见的时间复杂度如下表:
它们所耗费的时间从小到大依次是:
最坏情况与平均情况
算法和生活中一样存在最坏情况和最好情况,以及平均情况。
查找一个有n个随机数字数组中的某个数字,最好的情况是第一个数字就是,那么时间复杂度为O(1);最坏的情况是这个数字位于最后一位,那么算法的时间复杂度就是O(n)。
最坏情况运行时间是一种保证,那就是运行时间不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
而平均运行时间就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个目标元素。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。也就是说,我们运行一段程序时,是希望看到平均运行时间的。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的实验数据后估算出来的。
算法的空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:\\(S(n)=O(f(n))\\),其中n为问题的规模,f(n)为语句关于n所占存储空间的函数。
当不使用限定词地提到“复杂度”时,通常都是指时间复杂度。
总结回顾
算法的定义
算法是解决特定问题求解步骤的描述,在计算机中为指令的有限序列,并且每条指令表示一个或多个操作。
算法的特性
有穷性、确定性、可行性、输入、输出
算法的设计的要求
正确性、可读性、健壮性、高效率和低存储量需求
算法的度量方法
事后统计方法(不科学准确)、事前分析估算方法
函数渐近增长的定义
函数的渐近增长:给定两个函数f(n]和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。于是我们可以得出一个结论,判断一个算法好不好,我们只通过少量的数据是不能做出准确判断的,如果我们可以对比算法的关键执行次数函数的渐近增长性,基本就可以分析出:某个算法,随着n的变大,它会越来越优于另一算法,或者越来越差于另一算法。
推导大O阶的步骤
- 用常数1取代运行时间中的所有加法常数;
- 在修改后的运行次数函数中,只保留最高阶项;
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大O阶。