开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
我们都知道,数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行的更快,如何让代码更节省存储空间。所以执行效率是算法的一个重要的考量指标。那如何来衡量编写的算法代码的执行效率呢?就是通过时间、空间复杂度分析。
为什么需要复杂度分析?
也许会有这样的疑惑,我把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?而且这种分析方法能比我们实实在在跑一遍得到的数据更准确吗?
首先,可以肯定地说,你这种评估算法执行效率是正确的。很多数据结构和算法数据还给这种方法起了一个名字,叫事后统计法。但是这种统计方法有非常大的局限性。
1. 测试结果非常依赖测试环境
测试环境中硬件的不同会测试结果有很大的影响。比如我们拿同样一段代码,分别用Intel Core i3和Apple M1来运行,不用说,M1处理器要比Intel执行的速度快很多。还有,比如原本再这台机器上a代码执行速度比b代码更快,等我们换到另一台机器上的时候,可能结果截然相反。
2. 测试结果受数据规模影响很大
比如对于同一个排序算法,待排序数据的有序度不一样,排序执行的时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何的操作,执行时间就会非常短,除此之外,如果测试数据规模太小,测试结果可能无法真实地反映算法的性能。比如对于小规模的数据排序,插入排序可能反到会比快速排序要快!
所以,我们需要一个不同具体的测试数据来测试,就可以粗略估计算法的执行效率的方法--时间、空间复杂度分析法。
大O复杂度表示法
算法的执行效率,粗略的讲,就是算法代码执行的时间。但是,如何再不运行代码的情况下,一眼就看出代码的执行时间呢?
比如,一段简单的代码,求1,2,3...n的累加和。
#include <stdio.h>
int main()
{
int sun = 0;
int calculate(int n){
for(int i = 1; i < n; i++) {
sun += i;
}
return sun;
}
int total = calculate(100);
printf("值 = %d", sun);
}
从CPU的角度来看,这段代码的每一行执行着类似的操作,读数据-运算-写数据。尽管每行代码对应对应的CUP执行的个数、执行的时间都不一样,但是,我们只能大概估计,可以假设每行代码执行的时间都一样,为unit_time。在这个假设之上,这段代码的执行时间是多少呢? 第4,5行代码需要一个unit_time的执行时间,第6,7行都需要执行n遍,所以需要2n unit_time的时间,所以这段代码总的执行时间就是(2n+2)*unit_time.可以看出,所有代码的执行时间T(n)与每行代码的执行次数成正比。
按照这个思路分析,可以再看一段代码:
int main()
{
int sum = 0;
int calculate(int n){
for(int i = 1; i < n; i++) {
int j= 1;
for(j; j<n;j++) {
sum = sum + i*j;
}
}
return sum;
}
int total = calculate(100);
printf("值 = %d", sum);
}
我们依旧假设每句代码执行的时间是unite_time。那这代码的总执行的时间T(n)是多少呢?
第3,,8,13行代码,每行需要一个unite_time执行时间,第5,6执行n边,需要2n*unit_time的执行时间,第7,8行执行了n²遍,多以需要2n² * unite_time的执行时间,所以总段代码的执行时间大于T(n) = (2n² + 2n +3) *unit_time.
尽管我们无法确定unit_time的具体值,但是通过这两段代码执行时间推导过程,我们可以得到一个非常重要的规律,那就是:所有代码执行时间T(n)与每行代码执行次数f(n)成正比。
由此我们可以把这个规律总结成一个公式,就是大O。
T(n) = O(f(n))
具体解释这个公式。其中T(n)它表示代码执行的时间;n表示数据规模大小;f(n)表示每行代码执行次数的总和。因为这一个公式,所以用f(n)来表示。公式中的O,表示代码执行时间T(n)与f(n)表达式成正比。
所以第一个例子中的T(n) = O(2n+2),第二个例子中的T(n) = O(2n²+2n+3)。这就是大O时间复杂度表示法。大O时间复杂度实际上并不代表代码正在的执行时间,而是表示代码执行时间随着数据规模的增长的变化趋势,所以,也叫渐进时间复杂度(asymptotic time complexity ) 。简称时间复杂度。
当n很大时,你可以想象成万,百万。而公式中的低阶、常量、系数三部分并不左右正常趋势,所以可以忽略。只要记录一个最大量级就可以了,如果用大O表示法表示刚刚说过的那两段代码,就可以记为:T(n) = O(n);T(n) = O(n²)。
时间复杂度分析
刚刚说完了大O时间复杂度的由来和表示方法。现在接着来判断如果分析时间复杂度?有三个比较实用的方法:
1. 只关注循环执行次数最多的一段代码
刚刚有说,大O这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们再分析一个算法,一段代码时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。 这段核心代码执行次数的n的量级,就是整段代码的时间复杂度。
还拿前面这段代码举例:
1. #include <stdio.h>
2. int main()
3. {
4. int sum = 0;
5. int calculate(int n){
6. for(int i = 1; i < n; i++) {
7. sum += i;
8. }
9. return sum;
10. }
11. int total = calculate(100);
12. printf("值 = %d", sum);
}
其中第4、11行代码执行都是常量级的执行时间,与n的大小无关,所以对复杂度并没有影响。循环执行次数最多的是6、7行代码,所以对这块代码要重点分析。前面说过,这两行代码被执行n次,所以这段代码的时间复杂度就是O(n)。
2. 加法法则: 总复杂度等于量级最大的那段代码的复杂度
如下一段代码:
#include <stdio.h>
int main()
{
int sum = 0;
int p = 1;
for (p; p < 100; p++) {
sum = sum + p;
}
printf("值 = %d", sum);
int sum2 = 0;
int q = 1;
for (q; q < n; q++){
sum2 = sum2 + 1;
}
printf("值 = %d", sum2);
int sum3 = 0;
int i = 1;
for (i; i <= n; i++){
int j = 1;
for (j; j<= n; j++){
sum3 = sum3 + i*j;
}
}
printf("值 = %d", sum2);
printf("总和 = %d", (sum + sum2 + sum3));
}
这代码三部分,分别是求sum,sum2,sum3.可以分析每一部分的时间复杂度,然后把他们放到已开你,再取一个量级最大的作为整段代码的复杂度。
第一段代码执行了100次循环,所以是一个常量执行时间,跟n的规模无关。
这里做一个说明,即便这daunt代码循环了1000次,10000次,只要是一个已知的数,跟n无关,照样也是常量级的执行时间。当n无限大的时候,就可以忽略。尽管对代码的执行时间会有很大的影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量执行时间多大,我们都可以忽略。因为它本身对增长趋势没有影响。
那第二段代码和第三段代码时间的复杂度呢?分别是O(n)和O(n²)。
综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度为O(n²)。也就是说:总的时间复杂度就等于最大那段代码的时间复杂度。那么将这个规律抽象成公式就是:
如果 T1(n) = O(f(n)), T2(n) = O(g(n)); 那么T(n) = T1(n) + T2(n) = max(O(f(n)),O(g(n))) = O(max(f(n),g(n)))。
3. 乘法法则: 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
类比上面可以做成这样一个公式:
如果T1(n) = O(f(n)), T2(n) = O(g(n));那么 T(n) = T1(n) * T2(n) = O(f(n))*O(g(n)) = O(f(n) * g(n))。
也就是说,假设T1(n) = O(n),T2(n) = O(n²),则T1(n) * T2(n) = O(n³) .
具体到代码上,我们可以把乘法法则看成嵌套循环,比如:
#include <stdio.h>
int main()
{
int sum = 0;
int p = 1;
for (p; p < n; p++) {
sum = sum + cal(p);
}
int cal(int n) {
printf("值 = %d", sum);
int sum2 = 0;
int q = 1;
for (q; q < n; q++){
sum2 = sum2 + 1;
}
return sum2;
}
}
我们单独看第一个循环,那他的时间复杂度就是T1(n) = O(n).但是循环里还嵌套了一个循环,cal函数的时间复杂度T1(n) = O(n),所以整段代码的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n * n) = O(n²)。
几种常见时间复杂度示例分析
虽然代码千差万别,但是常见的复杂化度量级并不多,可以分为以下几种。
| 复杂度量级(按数量级递增) |
|---|
| 常量阶O(1) |
| 对数阶O(logn) |
| 线性对数阶O(nlogn) |
| 指数阶O(2ⁿ) |
| 阶乘阶O(n!) |
| 平方阶(n²),立方阶O(n³).....a次方阶 O(nª) |
这些罗列的复杂度量级,可以粗略的分为两类,多项式量级和非多项式量级。其中非多项式量级只有两个:指数阶O(2ⁿ)和阶乘阶O(n!)。
我们把时间复杂度为非多项式量级的算法叫做NP(Non-Deterministic Polynomial 非确定多项式)问题。
当数据规模n越来越大时候,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限加长。所以非多项式时间复杂度的算法其实是非常低效的算法。
1. O(1)
首先必须明确一个概念,O(1)只是常量级时间复杂度的一种表示方法,并不是只执行了一行代码,几遍有10行,它的时间复杂度也是O(1),而不是O(10).
int i = 8;
int j = 6;
int sum = i + j;
可以这样说,只要代码执行时间不随着n的增大而增大,这样代码时间复杂度我们都可以记作O(1).或者说,一般情况下,只要算法中不存在循环语句,递归语句,即使有成千上万行的代码,其时间复杂度也是O(1) 。
2. O(logn)、O(nlogn)
对数阶时间复杂度也比较常见,同时也是最难分析的一种时间复杂度,如下:
i = 1;
while (i <= n) {
i = i * 2;
}
根据上面所诉,第三行代码是循环执行次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中可以看出,变量i的值从1开始读取,每循环一次就乘以2,当大于n时,循环结束。类似中学时期的等比数列,实际上变量i的取值就是一个等比数列。如果我们把他一个个列出来,应该是这个样子:
2˚、 2¹、 2²、2³...2ª = n
所以我们只要知道a值是多少,就知道这行代码执行的次数了。通过 = n 求解,得到 x= ,所以这段代码的复杂度就是O()。
那么如果变成下面的代码,那么时间复杂度是多少呢?
i = 1;
whilie (i <= n) {
i = i *3;
}
由此可得出这段代码的时间复杂度为O( log_3 n ).
实际上,不管是以2为底,还是以3为底,还是其他数值,我们可以把所有对数阶的时间复杂度统一标记为O(logn)。 为什么呢?
在数学关系里,对数之间是可以互相转换的,就等于 * (对数换底公式),所以O()= O(C* ),其中C= 是一个常量。基于我们上面的一个理论,采用大O标记复杂度的时候,可以忽略系数,即O(Cf(n)) = O(f(n))。所以,O()就等于O().因此,在数阶时间复杂度的表示方法里,我们忽略底数的底,统一表示为O(logn)。
如果了解了这部分,那O(nlogn)(线性对数阶)就容易理解了。前面的乘法原则,如果一段代码的时间复杂度是O(logn),我们执行循环了n遍,时间复杂度就是O(nlogn)了。而且O(nlogn)也是一种常见的时间复杂度。比如归并排序、快速排序的时间复杂度都是O(nlogn) 。
3. O(m+n)、O(m*n)
再来看一种跟前面不一样的时间复杂度,代码的复杂度由两个数据的规模来决定,如下:
int calulate(int m, int n) {
int sum = 0;
int i = 1;
for (i;i < m ; i++){
sum = sum +i;
}
int sum2 = 0;
int j = 1;
for (j;j < n; j++){
sum2 = sum2 + j;
}
return sum + sum2;
}
从代码可以看出,m和n是两个数据规模,我们无法评估m和n谁的量级更大,所以我们再表示复杂度的时候,就不能简单地利用加法法则,忽略掉其中一个。所以上面代码的时间复杂度就是O(m+n)。
针对这种情况,原来的加法法则就不正确了,我们需要讲加法法则改为:T1(m) + T2(n) = O((fm) + g(n)).但是乘法法则继续有效: T1(m)*T2(n) = O(f(m) * f(n))。
空间复杂度分析
上面我们花了很长的篇幅介绍时间复杂度分析,理解了上面的内容,空间复杂度分析起来就非常简单了。
上面说过,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度的全称就是渐进空间复杂度(asymptotic space complexity, 表示算法的存储空间与数据规模之间的的增长关系。
比如我们那一段不太正确的代码来做解释:
1. void print(int) {
2. int i = 0;
3. int [] a = new int[n];
4. for (i; i< n; i ++){
5. a[i] = i*i;
6. }
7. for (i = n-1; i >= 0; i--) {
8. print out a[i];
9. }
10.}
跟时间复杂度分析一样,我们可以看到地2行代码中,我们申请了一个空间存储变量i,但是它是常量阶的,跟数据规模n没有关系,所以我们可以忽略。第3行申请了一个大小为n的int类型数组,除此之外,还剩下的代码都没有在占用更多的空间,所以整段代码的空间复杂度就是O(n) 。
我们常见的空间复杂度就是O(1)、O(n)、O(n²),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。
内容总结
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行的效率与数据规模之间的增长关系,可以粗略的概括,越是高阶复杂度的算法,执行效率越低。常见的复杂度并不多,从低阶到高阶有:O(1)、O(longn)、O(n)、O(nlogn)、O(n²) 。