作者 | Lee
在研究排序算法的时候,我们经常会听到时间复杂度的概念。很多时候只是习惯性的记下来,但是怎么来的,却很少能够研究明白。于是在笔试题中,经常要求时间复杂度为O(n),就很懵。
解决同样的问题,不同的代码消耗的时间会不一样。衡量一段代码的好坏通常有两个标准:
运行的时间;
占用的空间;
占用的空间不在本文讨论范围内。
运行的时间往往会随着输入规模以及机器性能的不同而不同,所以想要精准的算出代码的执行时间基本上不可能的。但是我们可以估算出代码的执行次数。
代码的执行次数
代码执行的次数记为 T(n)。
案例一:
void func(){ printf("helloworld\n");}
函数只有一条语句,代码执行了 1 次。T(n) = 1。
案例二:
void func(){ int i; for (i = 0; i 5; i++) printf("helloworld\n");}
输出语句执行了 5 次,循环变量执行 5 次,故T(n) = 10。(循环变量的定义和初始化也可以算作执行次数,只不过对最终的时间复杂度没有影响。为了方便计算,以下所有案例都没有算入循环变量,只计算循环体执行次数。)
案例一和案例二都可以称作常数阶。
案例三:
void func(int n){ int i; for (i = 0; i < n; i++) printf("helloworld\n");}
输出语句执行次数 n 次,T(n) = n。
像下面这种的,属于同一个案例:
void func(int n){ int i; for (i = 0; i < n; i++) { printf("helloworld\n"); printf("helloworld\n"); printf("helloworld\n"); }}
输出语句的执行次数 T(n) = 3n。
案例三属于线性阶。
案例四:
int func(int n){ int i; for (i = 1; i <= n; i *= 2) { printf("helloworld\n"); }}
这个应该怎么计算,假设执行的次数为x,则应该满足2 ^ x = n,所以输出语句执行的次数为 log2n,T(n) = log2n。
案例四属于对数阶。
案例五:
int func(int n){ int i, j; for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { printf("helloworld\n"); } }}
第一个循环执行了 n 次,第二个循环同样执行了 n 次,所以输出语句一共执行了 n^2次。T(n) = n^2。
下面的案例类似:
int func(int n){ int i, j; for (i = 0; i < n; i++) { for (j = 0; j { printf("helloworld\n"); } } printf("12345\n");}
这个计算起来有点麻烦。字符串 “12345” 输出了 n 遍;字符串 “helloworld” 输出了 1 + 2 + 3 + 4 + ... + n 遍。两个输出语句一共执行了 0.5n^2 + 1.5n。T(n) = 0.5n^2 + 1.5n。
案例五属于平方阶。
渐近时间复杂度
代码的执行次数 T(n) 还不能体现出代码的效率,主要是因为表达式中含有系数。比如算法 A 的执行次数:
T(n) = 5n;
算法 B 的执行次数:
T(n) = 2n^2。
如果 n = 1 很显然,算法 B 的效率更高;但是如果 n = 10 结果又变成了算法 A 的效率更高。所以代码的执行次数无法比较两种算法的好坏。
所以就有了渐近时间复杂度的概念。
定义如下:
若存在函数 f(n) 使得当 n 趋于无穷大时,T(n) / f(n) 的极限值为不等于 0 的常数,则 f(n) 是 T(n) 的同数量级函数。记作 T(n) = O(f(n)),其中 O(f(n)) 称为该算法的渐近时间复杂度,简称时间复杂度。
听起来很难懂。记住时间复杂度的几个原则:
如果执行的次数是常数量级,则用常数 1 来表示;
只保留 T(n) 中最高的阶项;
省略最高项前面的系数。
看下上面的案例。
案例一的代码执行次数:
T(n) = 1
案例二的代码执行次数:
T(n) = 5
案例一和案例二的执行次数都是常量,跟 n 的值无关,所以时间复杂度记为 O(1)。
案例三的代码执行次数:
T(n) = n
T(n) = 3n
保留最高阶并且省去他们的系数,得到时间复杂度为 O(n)。
案例四的代码执行次数:
T(n) = log2n
于是得到时间复杂度就是:O(log2n)。
案例五的代码执行次数:
T(n) = n^2
T(n) = 0.5n^2 + 1.5n
保留最高阶并且去掉系数,得到时间复杂度 O(n^2)。
有了时间复杂度,我们就能通过时间复杂度来判断代码的好坏,很显然:
O(1)
冒泡排序的时间复杂度分析
先来看下冒泡的代码:
#include int main(){ int a[10] = {0}; printf("请输入10个数字:\n"); int i, j; for (i = 0; i < sizeof(a) / sizeof(a[0]); i++) { scanf("%d", &a[i]); } for (i = 0; i < sizeof(a) / sizeof(a[0]) - 1; i++) { for (j = 0; j < sizeof(a) / sizeof(a[0]) - i - 1; j++) { if (a[j] < a[j + 1]) { int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; } } } for (i = 0; i < sizeof(a) / sizeof(a[0]); i++) { printf("%d ", a[i]); } printf("\n"); return 0;}
这里我们只关注算法本身的执行次数:
假设一共有 n 个数据,则一共循环 n + (n - 1) + ... + 2 + 1 = 0.5n^2 + 0.5n次。
因为循环体有个判断条件,所以循环体中的三条语句并不是每次都要执行。最好的情况是,判断条件每次都不成立(数据本身就是有序的),则代码执行次数就是循环变量的执行次数:
T(n) = 0.5n^2 + 0.5n
最坏的情况是,判断条件每次都成立(数据本身正好逆序),则
T(n) = 4 * (0.5n^2 + 0.5n)
综上,冒泡排序的时间复杂度为 O(n^2)。
注:有些时候冒泡排序的最好情况时间复杂度为 O(n),因为修改了代码,需要添加一个标志,如果一趟比较完发现没有需要交换的数据,则退出循环。