我们知道,编写代码的时候,使用合适的数据结构和算法,特别是在处理体量非常庞大的数据的时候,可以极大地提高计算效率。那么,数据结构和算法效率如何去衡量,如何选用合适的数据结构和算法呢?需要引入一个衡量的标准(metric)—时间复杂度和空间复杂度。
学习数据结构和算法的基石,就是要学会复杂度分析。知道怎么去分析复杂度,才能作出正确的判断,在特定的场景下选用合适的正确的算法。而不是盲目的死记烂背,机械操作。
复杂度分析是评估所采用的算法和数据结构效率的一种方式,又可以叫做“事前分析估算方法”,指的是在程序运行之前,在程序员编写程序时就进行代码时间复杂度和空间复杂度的估算,进而提高代码运行效率的一种手段。
与事前分析估算方法对应的,还有事后分析估算方法,通过设计好的测试程序和数据,来统计和监控,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
复杂度又分为时间复杂度和空间复杂度
时间复杂度的全称是渐进时间复杂度(asymptotic time complexity),表示算法的执行时间与数据规模之间的增长关系。
空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
所以我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。
假设有所有代码的执行时间T(n)与每行代码的执行次数成正比,那么大O复杂度表示为:
大O复杂度表示法并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。所以也叫渐进时间复杂度。
当n很大时,公式中的低阶、常量、系数三部分并不改变增长的趋势,所以只需要记录最大的量级就好了。
O(1)
一般情况下,只要算法中不存在循环语句,递归语句,即使有成千上万行的代码,其时间复杂度也是O(1)。
O(n)
算法中有n次循环语句,且其他语句复杂度都为1,这个算法的时间复杂度为O(n)。
O(logn)、O(nlogn)
i=1;
while(i<=n){
i=i*3;
}
诸如此类,时间复杂度为O(logn),如果此循环外再套一个复杂度为O(n)的循环,则总体的时间复杂度就为O(nlogn)。
O(m+n)、O(m*n)
int cal(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
当存在多个数据规模,且无法判断哪个数据规模大的时候,无法直接套用加法法则,需要保留多个数据规模。时间复杂度为O(m+n)。
当循环嵌套时,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积,复杂度为O(m*n)。
我们常见的空间复杂度就是 O(1) O(n) O(n2 ),像 O(logn) O(nlogn) 这样的对数阶复杂度平时很少用到。
多项式量级和非多项式量级
其中,非多项式量级NP(Non-Deterministic Polynomial), 只有O(2^n)和O(n!),是非常低效的算法,一般不会使用,不做过多讨论。
最好情况时间复杂度:在最理想的情况下,执行这段代码的时间复杂度。
最坏情况时间复杂度:在最糟糕的情况下,执行这段代码的时间复杂度。
比如遍历长度为n的数组去找一个特定元素,如果数组第一个元素就是该元素,则时间复杂度为O(1),为最好情况时间复杂度,若最后一个元素是该元素或该元素根本不存在于该数组中,则为最坏情况时间复杂度,为O(n)。
依然在长度为n的队列中查找变量x,而我们这一次查找到x之后就直接退出循环。
// n表示数组array的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}
这种情况下,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。所以总概率为:
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。
注意:只有同一块代码在不同的情况下,时间复杂度有量级的差距时!!我们才会使用这三种复杂度表示法来区分。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
如下面这段代码,向一个长度为n的空数组中不断填入数组,填满了就求和,再把和放在数组第一个位置,再将其他数不断填入数组,以此循环。那么当数组被填满时,遍历求和时间复杂度为O(n),但大部分情况下,该算法时间复杂度都为O(1)。所以我们可以吧O(n)多花的时间均摊到O(1)上,从数量级上看,这段代码的时间复杂度就是O(1)。
// array表示一个长度为n的数组
// 代码中的array.length就等于n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
我们说过,空间复杂度表示算法的存储空间与数据规模之间的增长关系。举个例子。
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i <n; ++i) {
a[i] = i * i;
}
for (i = n-1; i >= 0; --i) {
print out a[i]
}
}
跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
我们常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。空间复杂度分析比时间复杂度分析要简单很多。
渐进时间,空间复杂度分析为我们提供了一个很好的理论分析的方向,并且它是宿主平台无关的,能够让我们对我们的程序或算法有一个大致的认识,让我们知道,比如在最坏的情况下程序的执行效率如何,同时也为我们交流提供了一个不错的桥梁,我们可以说,算法1的时间复杂度是O(n),算法2的时间复杂度是O(logN),这样我们立刻就对不同的算法有了一个“效率”上的感性认识。
当然,渐进式时间,空间复杂度分析只是一个理论模型,只能提供给粗略的估计分析,我们不能直接断定就觉得O(logN)的算法一定优于O(n), 针对不同的宿主环境,不同的数据集,不同的数据量的大小,在实际应用上面可能真正的性能会不同,个人觉得,针对不同的实际情况,进而进行一定的性能基准测试是很有必要的,比如在统一一批手机上(同样的硬件,系统等等)进行横向基准测试,进而选择适合特定应用场景下的最有算法。
综上所述,渐进式时间,空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,但是一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时间复杂度程序,所以在实际编程中,时刻关心理论时间,空间度模型是有助于产出效率高的程序的,同时,因为渐进式时间,空间复杂度分析只是提供一个粗略的分析模型,因此也不会浪费太多时间,重点在于在编程时,要具有这种复杂度分析的思维。