1 引入
我们知道,一个程序的好坏,应该至少要满足bug少,效率高,所占空间小,可读性强,以及一些好的习惯
而本篇所讲的就关乎于效率问题
不知道是不是真的哈,据说《道德与法治5OL》以前的加载时间巨长,启动的时候需要跑19.8亿次循环的if语句(这里挂上链接 你见过的最烂的代码长什么样子?),其实这就是R星的程序员没有关注这个地方的时间复杂度所造成的问题,可见时间复杂度的学习有多重要。
2 什么是时间复杂度
简单来讲,时间复杂度就是一个函数(数学意义上的函数),这个函数用于表示一个程序或者一串代码运行完所需要的时间量级
我们来看一段代码
void fun(int n)
{
int count = 0;
for(int i = 0; i < n; i++)
{
count++;
}
}
这里count在循环里不断 ++ 了 n 次,记作T(N)
注意:这里其实我们不关注循环里面的内容,因为循环里面的内容不管是什么对于CPU来说都是一瞬间就可以完成的事,所以我们只关注循环次数而非循环里的内容
再比如这个函数
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
count++;
}
}
}
这里count ++ 了 n^2 次,那么这里就是 T(N^2)
再比如这个函数
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
count++;
}
}
for (int p = 0; p < n; p++)
{
count++;
}
这里count ++ 了 N^2 + N 次,那就是T(N^2 + N)
所以简单来说,T(?) 就是用来表示这段代码循环了多少次的
3 大O记法的引入
注意看时间复杂度的定义,我着重标注了“时间量级”,这是因为在绝大多数情况下,其实比较具体次数是没有意义的,我们只能用抽象的时间量级来比较
比方说以下三段代码
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
count++;
}
}
for (int p = 0; p < n; p++)
{
count++;
}
}
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
count++;
}
}
}
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
count++;
}
}
不难看出一个是T(N^2 + N),第二个是P(N^2),第三个是Q(N)
现在我们往N代入数字
N | T(N^2 + N) | P(N^2) | Q(N) |
---|---|---|---|
5 | 30 | 25 | 5 |
10 | 110 | 100 | 10 |
1000 | 1001000 | 1000000 | 1000 |
1000000 | 1000001000000 | 1000000000000 | 1000000 |
不难发现N在不断增大的同时,带了N^2的增速要比不带N^2的快不少,每次增加1,两者相差都会大更多,而两个带N^2的增速就差不多了,每次增加1,两者相差都只大了1而已,如果这三个函数实现的效果相当并且N = 1000000 的话,那么对于计算机来讲,P(N^2) 就要比 T(N^2 + N) 少算1000000 次,但 Q(N) 就要比 T(N^2 + N) 少算 1000000000000 次!对于现代的CPU来说,多算1000000 次问题不大,但多算 1000000000000 那问题就大了!!
所以借此,我们引入了一种新的估算记法,即大O记法
注意:为什么我们不考虑N很小的时候??
因为当N很小时,这三个函数的执行时间都非常非常小,小到可以忽略不计,所以我们一般不讨论N很小时的情况
这里再提一嘴循环里的内容,如果循环里面再加一行例如 num++ 的代码,那么 N 的前面应该多一个系数2,例如 T(N^2) 变成 T(2*N^2),只是这里的系数对整个运算过程的影响依旧太小了(有兴趣可以代数字推导一下)所以我们一般不关注系数
4 大O记法
其实本质上就是时间量级。
所谓“量级”,就是某几个东西的差距大到无论怎么比较,一方总是碾压另一方,比方说自行车的速度再怎么快,你也赶不上火车,火车再怎么快,你也赶不上火箭,火箭再怎么快,你也赶不上光速,光速再怎么快,你也赶不上虫洞(空间跳跃)
前面我们看到的三个函数,你会发现N很大时 T(N^2 + N) 所花的时间和 P(N^2) 差不多,而 T(N^2 + N) 所花的时间要比 Q(N) 多上很多,这就是因为前俩时间量级是相同的,后俩时间量级不同,不难发现前俩能花这么长时间的根本原因就是因为 N 带上了平方,而除了 N^2 外的其他因素就都可以忽略不计了(比方说自行车改装得再怎么好,你也赶不上一列全速前进的火车),所以前俩的时间量级就都是 N^2 ,记作 O(N^2) ,而 最后一个函数的时间量级就是 O(N) ,所以大O记法的最大特征就是不能直接计算“次数”,而是用 “时间量级” 来评判一串代码效率是否高。
所以这里不管如何,O(N^2) 再怎么快,你的效率也追不上 O(N)
那我们称 O(?) 为大 O 阶,而阶也分为很多种,我们接着看
注意:其实大O记法就是取多项式里增速最快的一项,因为不关注系数,所以还要把系数也一并省略掉
5 常见的阶
执行次数举例 | 阶 | 别称 |
---|---|---|
18(任意常数) | O(1) | 常数阶 |
2N + 3 | O(N) | 线性阶 |
3*N^2 + 3 | O(N^2) | 平方阶 |
3log2 N + 6 | O(logN) | 对数阶 |
3N*log2 N | O(NlogN) | nlogn阶 |
N^3 + 1 | O(N^3) | 立方阶 |
2^N + 6N + 1 | O(2^n) | 指数阶 |
事实上,我们常用到的阶并不多,一般也就用用常数阶,线性阶,平方阶,对数阶,剩下的阶几乎很少用,因为效率太低了,会对程序运行速度产生很大影响
6 最坏情况和平均情况
现在我们看看以下代码
char* fun(char* str, char ch)
{
while (str != NULL)
{
if (*str == ch)
{
return str;
}
else
{
str++;
}
}
}
这个代码其实就是用来找一个字符串里面有没有想要找的字符
不难看出其实这里执行次数是根据参数在动态变化的
最好情况 | O(1) |
---|---|
最坏情况 | O(N) |
平均情况 | O(N) (1/2 * N次) |
遇到这种不确定执行次数的情况下,一般我们都保守估计最坏情况来评判代码的效率,用平均情况来说明其期望运行时间,当然哈,写代码的时候肯定要做最坏的打算的,最坏你也得符合预期