终于开始写数据结构和算法的博客了,迈出了2019年的第一步,今年的计划,首先是写完数据结构和算法的博客,然后开始学习Android源码,另外,小程序,flutter,kotlin也开始要学起来,学习的中途,也会做自己的项目,是一个MVP+retrofit+rxjava+dagger2的项目,另外,公司的项目已经截止,接的私活开始做起来,买了扔物线的HencoderPlus,两个月学完,今年是很忙的一年,也是会进步巨大的一年。今年加薪50%跳槽,是一个非常不错的开始,希望下一次跳槽,能有更大的涨幅,加油吧!
要分析数据结构和算法,离不开复杂度的分析,复杂度分析包括:时间复杂度和空间复杂度。数据结构和算法,解决的就是两个问题,第一:如何让代码运行的更快;第二:如何让代码更节省空间。这两个问题对应的就是时间复杂度和空间复杂度。所以学习数据结构和算法,首先要学习的就是时间复杂度和空间复杂度的分析。
一、时间复杂度
1.1 大o复杂度表示法
首先,我们看下面一段代码
public int sum(int n){
int sum = 0;
int i = 1;
for(; i < n;++i){
sum = sum + I;
}
return sum;
}
在这里,我们粗略估计,cpu在运行这段代码时,每一行代码的执行时间都相同为unit_time,(当然,每一行代码的运行时间必然不相同,但这里我们只是粗略的估计,视为每行代码运行时间都相同。)那么估算一下,这段代码的运行时间是多少呢?
我在下面的代码块里标注出每行代码的运行时间:
public int sum(int n){
int sum = 0; // 1 * unit_time
int i = 1;// 1 * unit_time
for(; i < n;++i){ // n * unit_time
sum = sum + i; // n * unit_time
}
return sum; // 1 * unit_time
}
因此,我们可以算出,这段代码总的运行时间为:T(n) = (3+n+n)* unit_time。在这里,代码执行时间T(n)与代码执行次数n成正比。
再看下面这段稍微复杂一点的代码:同样的,我们依然假设CPU执行每一行代码的时间都是相同的为unit_time,在代码块中标注出每一行代码执行的时间:
public int sum(int n){
int sum = 0; // 1*unit_time
int i = 1; // 1*unit_time
int j = 1; // 1* unit_time
for(;i <= n;++i){ // n * unit_time
j = 1; //n* unit_time
for(; j <= n ; ++j){ // n² * unit_time
sum = sum + i*j; // n² * unit_time
}
}
}
如上所示,可以算出,这段代码执行的总时间T(n) = (3 + n + n + n² + n² )* unit_time ,这里,我们也可以得出一个结论,代码执行时间T(n)与执行次数n成正比。也就是我们常说的大O复杂度表示法:
公式中,T(n)表示的是代码执行的总时间,n表示的是数据的规模,f(n)表示的是代码执行次数的总和,公式中的O表示代码的执行时间T(n)与f(n)成正比。
上面第一个例子和第二个例子使用大O时间复杂度表示法就可以表示成:
T(n) = O((3+n+n)* unit_time) 和 T(n) = O( (3 + n + n + n² + n² )* unit_time ) 这就是大O时间表示法。大O时间复杂度实际上并不具体表示代码真正的执行时间,而是代表代码执行时间随数据规模增长的变化趋势,所以,也叫做渐进时间复杂度,简称时间复杂度。
当n很大时,公式中的低阶、常量、系数三部分不能左右增长趋势,都可以忽略,只需要记录一个最大量级就可以了,上述两个例子中,用大O复杂度表示,可以记为T(n) = O(n)和 T(n) = O(n²)
1.2 时间复杂度分析
了解了时间复杂度的概念,怎么分析一段代码的时间复杂度呢?三个方法
1. 只关注循环执行次数最多的一段代码。
2. 总复杂度等于量级最大的那段代码的复杂度。
3. 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。
1.2.1 几种常见的时间复杂度分析
常见的复杂度量级,可以粗略的分为两类,多项式量级和非多项式量级。其中非多项式量级只有两个:O(2的n次方) 和 O(n!)。当数据规模越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长,所以,非多项式量级的算法都是非常低效的算法,应该尽量避免。我们来看几种常见的多项式时间复杂度。
1.常量阶时间复杂度 O(1)
只要代码执行时间不随n的增大而增大,这样的代码的时间复杂度我们都记作O(1) ,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万条代码,其时间复杂度也是O(1)。
2.对数阶时间复杂度 O(logn)、O(nlogn)
对数阶时间复杂度非常常见,看下面这段代码:
I=1;
while (i <= n) {
i = i * 2;
}
对上面这段代码进行分析:变量i的值从1开始取,每循环一次就乘以2。当大于n时,循环结束。当i < n时,i的值分别为 1、2、4、8、16....也就是2的n次方。
所以,我们只要知道x的值,就知道这段代码的执行次数了,x=log2n ,这段代码的时间复杂度就是O(log2n)(2是底数),如果把这段代码修改一下,改为:
I=1;
while (i <= n) {
i = i * 3;
}
那么这段代码的时间复杂度就是O(log3n)(3是底数),我们知道,对数之间是可以相互转换的,log3n 就等于 log32 * log2n,其中log32是一个常数,我们前面分析得出的结论,在分析复杂度时,常数可以忽略,所以O(log2n) = O(log3n),在对数阶复杂度表示方法里,忽略对数的底,统一表示为:O(logn)。
如果一段代码的时间复杂度是O(logn),当它循环执行n次时,它的时间复杂度就是O(nlogn)。
3. 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;
}
从代码中可以看出,m和n是两个数据规模。在不知道m、n的值的情况下,无法事先评估m和n谁的量级大,所以在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个,此时时间复杂度就为:T1(n) + T2(m) = O(f(n) + g(m)) .
1.2.2 最好、最坏、平均、均摊时间复杂度
1 最好、最坏时间复杂度
先来看段代码:
// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
int i = 0; // 1 * unit_time
int pos = -1; // 1 * unit_time
for (; i < n; ++i) { // n * unit_time
if (array[i] == x) pos = I; // n * unit_time
}
return pos; // 1 * unit_time
}
我们来分析下这段代码的时间复杂度,这段代码的目的是查询x在数组中的位置,查找到之后,返回position,如果x不在数组中,返回-1。按照之前的学习的时间复杂度分析,我们可以知道的是,这段代码的时间复杂度是O(n)。如果我们把这段代码改进一下,当在数组中找到x后,中断循环直接返回position。改进后的代码如下:
// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) { // 这句代码运行时间是多少个unit_time呢?
if (array[i] == x) {
pos = I;
break;
}
}
return pos;
}
这个时候,循环代码执行的次数和x在数组中的位置有关,如果x在数组中的第一个位置,那么循环只会执行一次,这个情况就是最好的情况,那么它对应的时间复杂度就是最好情况时间复杂度;如果x在数组最后一个位置,那么循环执行n次,这个情况是最坏的情况,它对应的时间复杂度就是最坏情况时间复杂度。
由此可以引出最好情况、最坏情况时间复杂度的概念。
最好情况时间复杂度:顾名思义,就是在最理想的情况下,执行这段代码的时间复杂度。上个例子中对应的就是x在数组第一个位置时执行这段代码的时间复杂度。
最坏情况时间复杂度:在最坏的情况下,执行这段代码的时间复杂度。上个例子中对应的就是x在数组最后一个位置时执行这段代码的时间复杂度。
2 平均情况时间复杂度
上面的例子,当x在第一个位置或者x在最后一个位置这种极端情况其实并不常见,发生的概率并不大,事实上,x可以在数组中的任何位置,如果我们假设x在数组中位置的概率都一样,都是1/n,另外还有一种可能是x不在数组中,所以,要查找x在数组中的位置,有n+1种情况。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以n+1,就可以得到需要遍历的元素个数的平均值。
但是这样计算实际上是不准确的,因为这n+1种情况,出现的概率并不一样,x在数组中和不在数组中,概率为1/2。另外,要查找的数据出现在0 - n-1 这n个位置的概率也是一样的,为1/n,根据概率乘法法则,要查找的数据出现0 - n-1 中任意位置的概率是1/2n。
如果将各种情况发生的概率都统计进去的话,平均时间复杂度要这么计算:
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称叫做加权平均时间复杂度或者期望时间复杂度。
可以看到,这段代码的加权平均值是(3n+1)/4,用大O表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度是O(n)。
在大多数情况下,我们并不需要区分最好最坏平均情况时间复杂度三种情况,很多时候只需要一个复杂度久够了。除非一段代码在不同情况下,时间复杂度有量级的差距,我们才需要使用这三种复杂度来区分。
1.3 空间复杂度分析
空间复杂度全称是渐进空间复杂度,表示算法存储空间与数据规模之间的增长关系。
看下面这段代码:
void print(int n) {
int i = 0; //申请了一个存储变量空间
int[] a = new int[n]; //申请了n个存储变量空间
for (i; i <n; ++i) {
a[i] = i * I;
}
for (i = n-1; i >= 0; --i) {
print out a[I]
}
}
除了标注的两行代码,其他代码都没有占用多少空间,所以整段代码的空间复杂度就是O(n)。常见的空间复杂度一般就是O(1),O(n),O(n²)。