数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省空间。所以,执行效率是算法一个非常重要的考量指标。衡量算法的执行效率最常用的就是时间和空间复杂度分析。
一、为什么需要复杂度分析?
把代码跑一遍,通过统计、监控来得到算法执行的时间和占用的内存大小,这种做法叫做事后统计法。事后统计法有非常大的局限性:
1、测试结果非常依赖测试环境。
测试环境中硬件的不同会对测试结果有很大的影响。
2、测试结果受收据规模的影响很大。
对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。此外,如果测试数据规模过小,测试结果可能无法真实地反映算法的性能。
所以,需要一个不用具体的测试数据来测试,就可以粗略估算算法执行效率的方法。这就是时间、空间复杂度分析方法。
二、大O复杂度表示法
如下面的代码,我们怎么来估算一下其执行时间呢?
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
假设每行代码执行的时间都一样,为unit_time,那么这段代码的总执行时间为(2n+2)*unit_time(第2、3行分别需要1个unit_time的执行时间,第4、5行都运行n遍,需要2n*unit_time的执行时间)。
由此看出,所有代码的执行时间T(n)与每行代码的执行次数成正比。
再看下面这边代码:
int cal(int n) {
int sum = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum = sum + i * j;
}
}
}
在这段代码中,第2、3、4行都需要1个unit_time的执行时间,第5、6行代码循环执行了n遍,需要2n*unit_time的执行时间,第7、8行代码循环了遍,需要2*unite_time的执行时间。所以,整段代码总的执行时间为T(n)=(2+2n+3)*unit_time。
由此得到:所有代码的执行时间T(n)与每行代码的执行次数n成正比。用公式表示:
T(n) = O(f(n))
其中T(n)代表执行时间,n表示数据规模的大小,f(n)表示每行代码执行的次数总和。
第一个例子T(n)=O(2n+2),第二个例子T(n)=O(2+2n+3);这就是大O时间复杂度表示法。它表示代码执行时间随数据规模增长的变化趋势,也叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当n很大时,公式中的低阶、常量、系数三部分不左右增长趋势所以可以忽略,只需要记录一个最大量级就可以。前面两段代码的时间复杂度可以记为:T(n)=O(n);T(n)=O()。
三、时间复杂度分析
三个实用分析代码的时间复杂度的方法:
1、只关心循环执行次数最多的一段代码
2、加法法则:总复杂度等于量级最大的那段代码的复杂度
int cal(int n) {
int sum_1 = 0;
int p = 1;
for(; p < 100; ++p) { -----------时间复杂度:O(1)
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for(; q < n; ++q) { -----------时间复杂度:O(n)
sum_2 = sum_2 + q;
}
int sum_3 = 0;
int i = 1;
int j = 1;
for(; i < n; ++i) {
j = 1;
for(; j < n; ++j) { -----------时间复杂度:O(n^2)
sum_3 = sum_3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
上面这段代码分三个部分,我们可以分别分析每一部分的时间复杂度,然后把它们放在一块儿,取一个量级最大的作为整段代码的复杂度。所以上面这段代码的时间复杂度为O()。
抽象为公式:
如果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、乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
int cal(int n) {
int ret = 0;
int p = 1;
for(; i < n; ++i) { -----------时间复杂度:O(n)
ret = ret + f(i);
}
}
int f(int n) {
int sum = 0;
int i = 1;
for(; i < n; ++i) { -----------时间复杂度:O(n)
sum = sum + i;
}
return sum;
}
假设f()只是一个普通的操作,那么4~6行的时间复杂度T1(n)=O(n)。但f()函数本身不是一个简单的操作,它的时间复杂度T2(n)=O(n),所以整个cal()函数的时间复杂度T(n)=T1(n)*T2(n)=O(n)*O(n)=O()。
四、几种常见时间复杂度实例分析
按数量级递增:
常数阶 O(1)
对数阶 O(logn)
线性阶 O(n)
线性对数阶 O(nlogn)
平方阶 O()、立方阶 O()...k次方阶 O()
指数阶 O()
阶乘阶 O(n!)
上述复杂度量级可分为两类:多项式量级和非多项式量级。其中非多项式量级只有两个:指数阶和阶乘阶。
当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。
1、O(1)
O(1)只是常量级时间复杂度的一种表示方法。只要代码的执行使劲不随n的增大而增长,这样代码的时间复杂度都记作O(1)。一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行,其时间复杂度也是O(1)。
2、O(logn)、O(nlogn)
i = 1;
while(i <= n) {
i = i * 2;
}
在这段代码中i的值分别为、、、......,当 > n时, 循环结束。通过=n求解,x=。所以这段代码的时间复杂度为。
i = 1;
while(i <= n) {
i = i * 3;
}
同理,在这段代码中i的值分别为、、、......,当 > n时, 循环结束。通过=n求解,x=。所以这段代码的时间复杂度为。
实际上,不管是以2为底,还是以3为底,我们可以把所有对数阶的时间复杂度都记为O(logn)。这是因为对数之间是可以相互转换的(),而在采用大O表示复杂度的时候,可以忽略系数,即O(Cf(n))=O(f(n))。
如果一段代码的时间复杂度为O(logn),循环执行n遍,那么总体的时间复杂度就是O(nlogn)。
3、O(m+n)、O(m*n)
int cal(int m, int n) {
int sum_1 = 0;
int p = 1;
for(; p < m; ++p) { -----------时间复杂度:O(m)
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for(; q < n; ++q) { -----------时间复杂度:O(n)
sum_2 = sum_2 + q;
}
return sum_1 + sum_2;
}
在上面代码中,m和n表示两个数据规模,我们无法事先评估m和n谁的量级大,所以,在表示复杂度时,不能简单地利用加法规则,省略掉其中一个。由此,上面代码的时间复杂度为O(m+n)。
针对这种情况,加法规则需要变更为T1(m)+T2(n)=O(f(m)+g(n))。但乘法法则继续有效:T1(m)*T2(n)=O(f(m)*g(n))。
五、空间复杂度分析
空间复杂度全称渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
void print(int n) {
int i = 0;
int[] a = new int[n];
for (; i < n; i++) {
a[i] = i * i;
}
for (i = n-1; i >= 0; --i) {
print out a[i];
}
}
上面代码中。第2行申请了一个存储变量i,但它是常量阶的,可以忽略。第3行申请了一个大小为n的数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度为O(n)。
常见的空间复杂度为O(1)、O(n)、O(n^2)。
极客时间版权所有: https://time.geekbang.org/column/article/40011