定义:
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级,算法的时间复杂度,也就是算法的时间度量,记作:T(n) = O(f(n))。它表示随着问题规模n的增大, 算法执行时间的增长率与f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度。其中f(n)是问题规模的某个函数。
这样用大写字母O来体现时间复杂度的方法,我们称之为大O记法。
算法分析的最坏情况
在一个算法中,很有可能会出现一些不确定的情况,例如通过循环查找一个数组中的元素,当这个值就是数组的第一个元素时,算法的时间复杂度为O(1),而如果它是最后一个元素,那么时间复杂度为O(n),这也是这个程序中的最坏情况。
对于所有情况,最坏情况下的时间复杂度是算法在任何输入实例上运行时间的界限,这就保证了算法的运行时间不会比最坏情况更长。所以一般在没有特殊说明的情况下,求一个算法的时间复杂度都是指求它在最坏情况下的时间复杂度。
执行次数 T(n)
先通过例子来感受一下执行次数T(n):
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);
}
这是刚才求和程序的改进版本,首项加尾项的和乘以项数的一半,即可求出1到100的和,我们来算算这个程序的执行次数。会发现,这段程序中总共三行代码,都只执行了一次,那么总共的执行次数就为3。
算法的效率
当然了,算法的效率度量远没有这么简单,我们通过分析来总结一下该如何去计算一个算法的效率。
现在,我们假设有两个算法,这两个算法的输入规模都为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要做2n^2+1次操作,继续分析:
| 次数 | 4n+8 | n | 2n^2+1 | n^2 |
|---|---|---|---|---|
| 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与n^2,结论相同。由此可以得出结论,算法效率与最高次项的常数也没有关系。
我们继续假设有三个输入规模都为n的算法,算法1要做2n^2次操作,而算法2要做3n+1次操作,算法3要做2n^2+3n+1次操作,继续分析:
| 次数 | 2n^2 | 3n+1 | 2n^2+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和算法3的效率基本相同,所以得出结论,算法效率与函数中的常数项和其它次要项没有关系,我们只需关注最高次项。
综上所述,计算算法的时间复杂度步骤可分为四步:
即弄清楚输入是什么以及n代表什么→用n来表示算法执行操作的次数→仅保留最高次幂的项→去掉所有的常数因子(常数阶用常数1代替常数,实例见下文)
算法的时间复杂度
那么什么是算法的时间复杂度呢?
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);
}
由上文可知该程序执行次数为2n + 3,根据结论,渐进增长的函数常数项忽略,即去除3,保留最高阶项2n,去除与最高阶项相乘的常数,所以该算法的时间复杂度为O(n)。
3 、对数阶
看下面的程序:
void main(){
int count = 1;
while(count < n){
count *= 2;
}
}
该程序中我们只需得出循环次数即可求出时间复杂度,由于每次count乘以2之后,就距离n更近了一分,也就是说,有多少个2相乘后大于n,才会退出循环。由2^x = 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(n^2)。
那么下面这个程序的时间复杂度为多少呢?
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阶的结论,保留最高阶项n^2/2,去掉相乘的常数1/2,最终算法的时间复杂度为O(n^2)。
5 、其它大 O 阶
还有一些常见的大O阶在这里就不做详细讲解了,就简单地提一下:
| nlogn阶 | O(nlogn) |
|---|---|
| 立方阶 | O(n^3) |
| 指数阶 | O(2^n) |
常用的时间复杂度所耗费的时间从小到大依次是:
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2n) < O(n!) < O(n^n)