聊聊算法中的时间复杂度
1.什么是时间复杂度
时间复杂度是算法复杂度的一个类别(另一个是空间复杂度),时间复杂度是指执行算法所需要的计算工作量,空间复杂度指执行这个算法所需要的内存空间
时间复杂度
一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度,记为$T(n)$
一般情况下,算法中基本操作重复执行的次数是问题规模 $n$ 的某个函数,用 $T(n)$ 表示,若有某个辅助函数 $f(n)$ ,使得当 $n$ 趋近于无穷大时,$T(n)/f(n)$ 的极限值为不等于零的常数,则称 $f(n)$ 是 $T(n)$ 的同数量级函数。记作 $T(n)=O(f(n))$,称 $O(f(n))$ 为算法的渐进时间复杂度,简称时间复杂度
时间复杂度分类
算法完成工作最少需要多少基本操作叫做最优时间复杂度,是一种最乐观最理想的状态
算法完成工作最多需要多少基本操作叫做最坏时间复杂度,是算法的一个保障
算法完成工作平均需要多少基本操作叫做平均时间复杂度,它可以均匀全面的评价一个算法的好坏
2.时间复杂度计算
假设计算机运行一行基础代码需要执行一次运算,那么运行下边这个方法就需要执行 2 次运算
int aFunc(void) {
printf("Hello, World!\n"); // 需要执行 1 次
return 0; // 需要执行 1 次
}
再看另外一个例子
int aFunc(int n) {
for(int i = 0; i<n; i++) { // 需要执行 (n + 1) 次
printf("Hello, World!\n"); // 需要执行 n 次
}
return 0; // 需要执行 1 次
}
则需要执行 n+1+n+1=2n+2 次运算
我们把算法需要执行的运算次数用输入大小n
的函数表示,即 T(n)
拿到算法的执行次数函数 T(n) 之后如何得到算法的时间复杂度呢?
(1)我们知道常数项对函数的增长速度影响并不大,所以当 T(n) = c,c 为一个常数的时候,我们说这个算法的时间复杂度为 O(1);如果 T(n) 不等于一个常数项时,直接将常数项省略
- 第一个 Hello World 的例子中 $T(n) = 2$,所以我们说那个函数(算法)的时间复杂度为 $O(1)$
- $T(n) = n + 29$,此时时间复杂度为 $O(n)$
(2)我们知道高次项对于函数的增长速度的影响是最大的。n^3 的增长速度是远超 n^2 的,同时 n^2 的增长速度是远超 n 的。 同时因为要求的精度不高,所以我们直接忽略低次项
- $T(n) = n^3 + n^2 + 29$,此时时间复杂度为 $O(n^3)$
(3)因为函数的阶数对函数的增长速度的影响是最显著的,所以我们忽略与最高阶相乘的常数
- T(n) = 3n^3,此时时间复杂度为 O(n^3)
综上:如果一个算法的执行次数是 T(n),那么只保留最高次项,同时忽略最高项的系数后得到函数 f(n),此时算法的时间复杂度就是 O(f(n))。为了方便描述,下文称此为大O推导法
由此可见,由执行次数 T(n) 得到时间复杂度并不困难,很多时候困难的是从算法通过分析和数学运算得到 T(n)。对此,提供下列四个便利的法则,这些法则都是可以简单推导出来的,总结出来以便提高效率
单个循环体推导法则
对于一个循环,假设循环体的时间复杂度为 $O(n)$,循环次数为 $m$,则这个循环的时间复杂度为 $O(n×m)$
void aFunc(int n) {
for(int i = 0; i < n; i++) { // 循环次数为 n
printf("Hello, World!\n"); // 循环体时间复杂度为 O(1)
}
}
此时时间复杂度为 $O(n × 1)$,即 $O(n)$
多重循环体推导法则
对于多个循环,假设循环体的时间复杂度为 O(n),各个循环的循环次数分别是a, b, c…,则这个循环的时间复杂度为O(n×a×b×c…)。分析的时候应该由里向外分析这些循环
void aFunc(int n) {
for(int i = 0; i < n; i++) { // 循环次数为 n
for(int j = 0; j < n; j++) { // 循环次数为 n
printf("Hello, World!\n"); // 循环体时间复杂度为 O(1)
}
}
}
此时时间复杂度为 $O(n × n × 1)$,即 $O(n^2)$
多个时间负责度推导法则
对于顺序执行的语句或者算法,总的时间复杂度等于其中最大的时间复杂度
void aFunc(int n) {
// 第一部分时间复杂度为 O(n^2)
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
printf("Hello, World!\n");
}
}
// 第二部分时间复杂度为 O(n)
for(int j = 0; j < n; j++) {
printf("Hello, World!\n");
}
}
此时时间复杂度为$max(O(n^2),O(n))$,即 $O(n^2)$
条件语句的推导法则
对于条件判断语句,总的时间复杂度等于其中时间复杂度最大的路径的时间复杂度
void aFunc(int n) {
if (n >= 0) {
// 第一条路径时间复杂度为 O(n^2)
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
printf("输入数据大于等于零\n");
}
}
} else {
// 第二条路径时间复杂度为 O(n)
for(int j = 0; j < n; j++) {
printf("输入数据小于零\n");
}
}
}
此时时间复杂度为 $max(O(n^2)$, O(n)),即 $O(n^2)$
时间复杂度分析的基本策略是:从内向外分析,从最深层开始分析。如果遇到函数调用,要深入函数进行分析
3.练习
(1)基础题
void aFunc(int n) {
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
printf("Hello World\n");
}
}
}
程序执行次数为 $T(n) = n + (n - 1) + (n - 2)……+ 1 = n(n + 1) / 2 = n^2 / 2 + n / 2$,则时间复杂度为 $O(n^2)$
(2)进阶题
void aFunc(int n) {
for (int i = 2; i < n; i++) {
i *= 2;
printf("%i\n", i);
}
}
假设循环次数为 t,则循环条件满足 $2^t < n$
执行次数 $T = log(2)(n)$,即 $T(n) = log(2)(n)$,可见时间复杂度为 $O(log(2)(n))$,即 $O(log n)$
(3)真进阶题
long aFunc(int n) {
if (n <= 1) {
return 1;
} else {
return aFunc(n - 1) + aFunc(n - 2);
}
}
$T(0) = T(1) = 1, T_2 = T(n - 1) + T(n - 2) + 1$
$T(n) = T(n - 1) + T(n - 2) $是一个斐波那契数列,通过归纳证明法可以证明,当 $n >= 1$ 时 $T(n) < (5/3)^n$,同时当 $n > 4$ 时 $T(n) >= (3/2)^n$
所以该方法的时间复杂度可以表示为 $O((5/3)^n)$,简化后为 $O(2^n)$