这篇文章目的是分析算法的复杂度问题,关于算法的定义、特性等等问题在这里不作讲解。
如何度量算法效率
我们知道,算法是解决复杂问题的思路,条条大路通罗马,对于一个复杂的问题,能够解决的算法也有很多种,对于有多种解决方案的情况,我们当然是想选择一种快速、有效的算法了。那么我们该如何知晓一个算法的效率呢?
1、事后统计法
该方法通过设计好的测试程序和数据,然后在计算机中运行,接着对运行时间进行比较,耗时少的效率高。
但很显然,这种方式有很大缺陷,首先,算法的测试数据需要花时间设计,因为不同的测试数据往往会直接影响运行时间,然后是计算机的硬件也会影响运行时间。这就造成了度量结果的不稳定。
2、事前分析法
由此,事前分析法诞生了,该方法无需运行程序,就能够分析出一个算法的效率。
经过大量分析,前辈们总结出一个算法在计算机上运行时所消耗的时间取决于以下因素:
- 算法采用的策略、方法
- 编译产生的代码质量
- 问题的输入规模
- 机器执行指定的速度
我们抛开第四点,因为第四点是由计算机硬件决定的,我们无法左右。对于一个算法的效率,它的决定因素就是算法的好坏和问题的输入规模。
通过例子来感受一下:
void main(){
int i,sum = 0,n = 100;
for(i = 1;i <= n;i++){
sum += i;
}
printf("%d",sum);
}
这段代码相信大家都不陌生,这是一个求1到100之间数字和的程序,我们可以来分析一下程序执行的步骤。
首先,程序执行第一行代码,int i,sum = 0,n = 100;
只执行了一次,然后是for循环,循环条件i = 1;i <= n;i++
和循环体sum += i;
分别执行了n+1次和n次,最后是输出语句printf("%d",sum);
,只执行一次。
执行次数分析出来之后,我们将每句代码的执行次数相加,即:1 + (n + 1) + n + 1 = 2n + 3
,这段程序所有代码的执行次数为2n+3次,我们继续看一段程序:
void main(){
int sum = 0,n = 100;
sum = (1 + n) * n / 2;
printf("%d",sum);
}
这是刚才求和程序的改进版本,首项加尾项的和乘以项数除以2,即可求出1到100的和,我们来算算这个程序的执行次数。会发现,这段程序中总共三行代码,都只执行了一次,那么总共的执行次数就为3。
对于事前分析法,我们无需关心实现的语言种类,运行的计算机硬件,忽略循环索引的递增、循环终止条件、变量声明和输出语句等等操作,即可得到一个比较客观的算法效率。
两个程序对于同一个问题的输入规模是n(输入规模指的是输入量的多少),对于第一个程序,忽略这些因素之后,它的效率为n,第二个程序,它的效率为1。通过比较执行效率不难发现,两个算法的效率相差的不是一点。
函数的渐进增长
当然了,算法的效率度量远没有这么简单,我们通过分析来总结一下该如何去计算一个算法的效率。
首先,我们假设有两个算法,这两个算法的输入规模都为n,而算法1要做2n+3次操作,算法2要做3n+1次操作,你能从操作次数上就判断出哪个算法更高效吗?这其实是办不到的,我们分析一下:
次数 | 2n+3 | 2n | 3n+1 | 3n |
---|---|---|---|---|
n =1 | 5 | 2 | 4 | 3 |
n = 2 | 7 | 4 | 7 | 6 |
n = 10 | 23 | 20 | 31 | 30 |
n = 100 | 203 | 200 | 301 | 300 |
通过分析发现,当n = 1时,算法1不如算法2,而当n = 2时,算法1和算法2效率相同,按照这个规律,随着n逐渐增大,算法1的效率会逐渐高于算法2。
此时给出这样的定义,当输入规模n在无限制的情况下,只要超过了一个数值N,这个函数就总是大于另一个函数,我们称函数是渐进增长的。从表格数据中,我们还可以发现,对于常数项,比如2n+3和2n在n的变化下,其函数值并不受影响,所以在计算算法效率的时候可以忽略常数项。
我们再假设有两个输入规模都为n的算法,算法1要做4n + 8次操作,算法2要做2n2+1次操作,继续分析:
次数 | 4n+8 | n | 2n2+1 | n2 |
---|---|---|---|---|
n = 1 | 12 | 1 | 3 | 1 |
n = 2 | 16 | 2 | 9 | 4 |
n = 10 | 48 | 10 | 201 | 100 |
n = 1000 | 4008 | 1000 | 2000001 | 1000000 |
当n = 1时,算法1不如算法2,而当n = 10时,算法1的效率高于算法2,此后,随着n的逐渐增大,算法1的优势越来越明显,我们还能发现,这里除了去掉常数项外,还去掉了与n相乘的常熟,比较n与n2,结论相同。由此可以得出结论,算法效率与最高次项的常数也没有关系。
我们继续假设有三个输入规模都为n的算法,算法1要做2n2次操作,而算法2要做3n+1次操作,算法3要做2n2+3n+1次操作,继续分析:
次数 | 2n2 | 3n+1 | 2n2+3n+1 |
---|---|---|---|
n = 1 | 2 | 4 | 6 |
n = 2 | 8 | 7 | 15 |
n = 10 | 200 | 31 | 231 |
n = 100 | 20000 | 301 | 20301 |
n = 1000 | 2000000 | 3001 | 2003001 |
分析发现,随着n的逐渐增大,算法1和算法2的差距逐渐拉大,当n无穷大时,算法1和算法2的效率基本相同,所以得出结论,算法效率与函数中的常数项和其它次要项没有关系,我们只需关注最高次项。
时间复杂度
扯了这么多,就是为了引出算法的时间复杂度,在前面的分析中我们得出结论,一个算法,随着输入规模n的增大,它会越来越优于(或者差于)另一算法,这其实就是事前分析法的依据,通过算法时间复杂度来估算算法时间效。那么什么是算法的时间复杂度呢?
定义:
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级,算法的时间复杂度,也就是算法的时间度量,记作:T(n) = O(f(n))。它表示随着问题规模n的增大, 算法执行时间的增长率与f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度。其中f(n)是问题规模的某个函数。
这样用大写字母O来体现时间复杂度的方法,我们称之为大O记法。
如何分析算法的时间复杂度
如何分析出一个算法的时间复杂度,也叫推导大O阶,其实可以根据上面分析得出的结论:
- 用常数1取代程序中的所有加法常数
- 只保留最高阶项
- 如果最高阶存在且不为1,则去掉与最高阶相乘的常数
由此得到的结果即为算法的时间复杂度,下面讲解一下比较常见的大O阶:
1、常数阶
看下面的程序:
void main(){
int sum = 0,n = 100;
sum = (1 + n) * n / 2;
printf("%d",sum);
}
执行次数为3,此时根据结论,用常数1代替所有加法常数,没有最高阶项,所以该算法的时间复杂度为O(1)。
2、线性阶
看下面的程序:
void main(){
int i,sum = 0,n = 100;
for(i = 1;i <= n;i++){
sum += i;
}
printf("%d",sum);
}
执行次数为1 + (n + 1) + n + 1 = 2n + 3
,根据结论,用常数1代替加法常数,3替换为1;保留最高阶项2n,去除与最高阶项相乘的常数,所以该算法的时间复杂度为O(n)。
其实在计算的时候,我们无需这样算出每一句代码的执行次数,对于赋值、循环条件、输出语句,我们可以直接不考虑,所以可以直接得出该算法的时间复杂度为O(n)。
3、对数阶
看下面的程序:
void main(){
int count = 1;
while(count < n){
count *= 2;
}
}
该程序中我们只需得出循环次数即可求出时间复杂度,由于每次count乘以2之后,就距离n更近了一分,也就是说,有多少个2相乘后大于n,才会退出循环。由2x = n得出x = log2n,所以该算法的时间复杂度为O( logn)。
4、平方阶
看下面的程序:
void main(){
int i,j,n = 100;
for(i = 0;i < n;i++){
for(j = 0;j < n;j++){
printf("%d\t",n);
}
}
}
我们知道,对于内层循环,其时间复杂度为O(n),而外层循环不过是执行n次内层循环,所以该算法的时间复杂度为O(n2)。
那么下面这个程序的时间复杂度为多少呢?
void main(){
int i,j,n = 100;
for(i = 0;i < n;i++){
for(j = i;j < n;j++){
printf("%d\t",n);
}
}
}
这里修改了一下内层循环,让j = i。分析一下,当i = 0时,内层循环执行n次;当i = 1时,内层循环执行n - 1次;当i = n - 1时,内层循环执行1次。所以总的执行次数为n + (n - 1) + (n - 2) + …… + 1 = (1/2)n^2 + (1/2)n
,
根据推导大O阶的结论,保留最高阶项n2/2,去掉相乘的常数1/2,最终算法的时间复杂度为O(n2)。
5、其它大O阶
还有一些常见的大O阶在这里就不做详细讲解了,就简单地提一下:
nlogn阶 | O(nlogn) |
---|---|
立方阶 | O(n3) |
指数阶 | O(2n) |
常用的时间复杂度所耗费的时间从小到大依次是:
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
算法分析的最坏情况
在一个算法中,很有可能会出现一些不确定的情况,例如通过循环查找一个数组中的元素,当这个值就是数组的第一个元素时,算法的时间复杂度为O(1),而如果它是最后一个元素,那么时间复杂度为O(n),这也是这个程序中的最坏情况。
对于所有情况,平均运行时间是最有意义的,然后,一段程序的平均运行时间我们很难通过分析得到,所以一般在没有特殊说明的情况下,求一个算法的时间复杂度都是指求它在最坏情况下的时间复杂度。
空间复杂度
随着互联网科技的发展,早先比较贵的存储在如今都较为便宜,所以在某些特定的场景下,可以考虑使用空间来换取时间。
例如在一个求指定年份是否为闰年的算法中,我们可以事先定义一个有限大的数组,数组下标表示年份,哪一年是闰年,对应的下标元素就为1,此时,如果想判断输入的年份是否为闰年,只需得到该下标的元素值,若为1,则是闰年;若为0,则不是闰年。这样虽然高效,但会加大存储的开销。
比如排序算法中的基数排序是用空间换取时间的经典算法。
算法的空间复杂度通过计算算法所需的存储空间实现,空间复杂度的计算公式为:S(n) = O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
通常情况下,我们更注重算法的时间复杂度,所以,空间复杂度只作为一个了解,不深入讨论。