002-数据结构之算法的时间复杂度和空间复杂度
一、概述
对于同一个问题来说,可以有多种解决问题的算法。尽管算法不是唯一的,但是对于问题本身来说相对好的算法还是存在的,这里可能有人会问区分好坏的标准是什么?这个要从「时效」和「存储」两方面来看。
好的算法应该具备时效高和存储低的特点。这里的「时效」是指时间效率,也就是算法的执行时间,对于同一个问题的多种不同解决算法,执行时间越短的算法效率越高,越长的效率越低;「存储」是指算法在执行的时候需要的存储空间,主要是指算法程序运行的时候所占用的内存空间。
1.1、不同写法影响的算法
实现:1+2+…+99+100
算法一、
int i, sum = 0, n = 100; // 执行1次 for( i=1; i <= n; i++ ) // 执行了n+1次 { sum = sum + i; // 执行n次 }
算法二、
int sum = 0, n = 100; // 执行1次 sum = (1+n)*n/2; // 执行1次
第一种算法:执行了1+(n+1)+n=2n+2次。
第二种算法:是1+1=2次
如果我们把循环看做一个整体,忽略头尾判断的开销,那么这两个算法其实就是n和1的差距。
二、时间复杂度
2.1、算法效率的度量方法
2.1.1、事后统计方法
这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
2.1.2、事前分析估算方法【推荐】
在计算机程序编写前,依据统计方法对算法进行估算。
经过总结,我们发现一个高级语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 算法采用的策略,方案
- 编译产生的代码质量
- 问题的输入规模
- 机器执行指令的速度
2.2、大O表示法
「数量级」函数用来描述当规模 n 增加时,T(n) 函数中增长最快的部分,这个数量级函数我们一般用「大 O」表示,记做 O(f(n))。它提供了计算过程中实际步数的近似值,函数 f(n) 是原始函数 T(n) 中主导部分的简化表示。
在上面的求和函数的那个例子中,T(n) = n + 1,当 n 增大时,常数 1 对于最后的结果来说越来不越没存在感,如果我们需要 T(n) 的近似值的话,我们要做的就是把 1 给忽略掉,直接认为 T(n) 的运行时间就是 O(n)。这里你一定要搞明白,这里不是说 1 对 T(n) 不重要,而是当 n 增到很大时,丢掉 1 所得到的近似值同样很精确。
再举个例子,比如有一个算法的 T(n) = 2n^2+ 2n + 1000,当 n 为 10 或者 20 的时候,常数 1000 看起来对 T(n) 起着决定性的作用。但是当 n 为 1000 或者 10000 或者更大呢?n^2 起到了主要的作用。实际上,当 n 非常大时,后面两项对于最终的结果来说已经是无足轻重了。与上面求和函数的例子很相似,当 n 越来越大的时候,我们就可以忽略其它项,只关注用 2n^2 来代表 T(n) 的近似值。同样的是,系数 2 的作用也会随着 n 的增大,作用变得越来越小,从而也可以忽略。我们这时候就会说 T(n) 的数量级 f(n) = n^2,即 O(n^2)。
2.3、最好情况、最坏情况和平均情况
某个特定的数据集能让算法的执行情况极好,这就是最「最好情况」,而另一个不同的数据会让算法的执行情况变得极差,这就是「最坏情况」。不过在大多数情况下,算法的执行情况都介于这两种极端情况之间,也就是「平均情况」。因此一定要理解好不同情况之间的差别,不要被极端情况给带了节奏。
对于「最优情况」,没有什么大的价值,因为它没有提供什么有用信息,反应的只是最乐观最理想的情况,没有参考价值。「平均情况」是对算法的一个全面评价,因为它完整全面的反映了这个算法的性质,但从另一方面来说,这种衡量并没有什么保证,并不是每个运算都能在这种情况内完成。而对于「最坏情况」,它提供了一种保证,这个保证运行时间将不会再坏了,**所以一般我们所算的时间复杂度是最坏情况下的时间复杂度**,这和我们平时做事要考虑到最坏的情况是一个道理。
2.4、各种数量级函数
对比表格
序号 | 函数名 | f(n) | 阶 | 说明 | 举例 | 典型代码 |
1 | 常数函数 | 1 | O(1) | 普通语句 |
将两个数相加 大多数Java操作所需的时间为常数 |
a=b+c; |
2 | 对数函数 | 2log2n+2 | O(logn) | 二分策略 |
二分查找 运行时间和问题规模成对数关系的程序的经典例子就是二分查找。 对数的底数和增长的数量级无关 |
|
3 | 线性函数 | 2n+1 | O(n) | 循环 |
找出最大元素 例如单个for循环,增长数量级是线性的 它的运行时间和N成正比 |
double max=a[0]; |
4 | nlogn函数 | 2nlog2n+2n+2 | O(nlogn) | 分治 |
归并排序 |
|
5 | 二次函数 | 2n2+2n+2 | O(n2) | 双层循环 |
检查所有元素 双层for循环嵌套,例如Selection.sort() 和Insertion.sort() |
for (int i = 0; i < N; i++) |
6 | 三元函数 | 2n3+2n+2 | O(n3) | 三层循环 |
检查所有三元组 三层for循环嵌套 |
for (int i = 0; i < N; i++) |
7 | 指数函数 | 2n | O(2n) | 穷举查找 | 检查所有子集 |
对比图
在上图中,我们可以看到当 n 很小时,函数之间不易区分,但是当 n 增大时,能看到很明显的区别
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n)<O(n!)
注:2=log2(4)=log3(9)=log5(25)=lg100
说明:log2(4) 其实是22=4以2为底4的对数是2 ,lg 是指以10为底即log10,ln是以自然对数为底即loge(XX),其中e是一个无限不循环小数,其值约等于2.71828182845
n!标识n的阶乘,n!=1*2*3…(n-2)*(n-1)*n,如0!=1,3!=1*2*3=6
Ο(1)表示基本语句的执行次数是一个常数,一般来说,只要算法中不存在循环语句,其时间复杂度就是Ο(1)。其中Ο(log2n)、Ο(n)、 Ο(nlog2n)、Ο(n2)和Ο(n3)称为多项式时间,而Ο(2n)和Ο(n!)称为指数时间。计算机科学家普遍认为前者(即多项式时间复杂度的算法)是有效算法,把这类问题称为P(Polynomial,多项式)类问题,而把后者(即指数时间复杂度的算法)称为NP(Non-Deterministic Polynomial, 非确定多项式)问题。
一般情况下,对一个问题(或一类算法)只需选择一种基本操作来讨论算法的时间复杂度即可,有时也需要同时考虑几种基本操作,甚至可以对不同的操作赋予不同的权值,以反映执行不同操作所需的相对时间,这种做法便于综合比较解决同一问题的两种完全不同的算法。
2.5、求解算法的时间复杂度
⑴ 找出算法中的基本语句;
算法中执行次数最多的那条语句就是基本语句,通常是最内层循环的循环体。
⑵ 计算基本语句的执行次数的数量级;
只需计算基本语句执行次数的数量级,这就意味着只要保证基本语句执行次数的函数中的最高次幂正确即可,可以忽略所有低次幂和最高次幂的系数。这样能够简化算法分析,并且使注意力集中在最重要的一点上:增长率。
⑶ 用大Ο记号表示算法的时间性能。
将基本语句执行次数的数量级放入大Ο记号中。
如果算法中包含嵌套的循环,则基本语句通常是最内层的循环体,如果算法中包含并列的循环,则将并列循环的时间复杂度相加。例如:
for (i=1; i<=n; i++) x++; for (i=1; i<=n; i++) for (j=1; j<=n; j++) x++;
第一个for循环的时间复杂度为Ο(n),第二个for循环的时间复杂度为Ο(n2),则整个算法的时间复杂度为Ο(n+n2)=Ο(n2)。
2.6、在计算算法时间复杂度时有以下几个简单的程序分析法则
(1).对于一些简单的输入输出语句或赋值语句,近似认为需要O(1)时间
(2).对于顺序结构,需要依次执行一系列语句所用的时间可采用大O下"求和法则"
求和法则:是指若算法的2个部分时间复杂度分别为 T1(n)=O(f(n))和 T2(n)=O(g(n)),则 T1(n)+T2(n)=O(max(f(n), g(n)))
特别地,若T1(m)=O(f(m)), T2(n)=O(g(n)),则 T1(m)+T2(n)=O(f(m) + g(n))
(3).对于选择结构,如if语句,它的主要时间耗费是在执行then字句或else字句所用的时间,需注意的是检验条件也需要O(1)时间
(4).对于循环结构,循环语句的运行时间主要体现在多次迭代中执行循环体以及检验循环条件的时间耗费,一般可用大O下"乘法法则"
乘法法则: 是指若算法的2个部分时间复杂度分别为 T1(n)=O(f(n))和 T2(n)=O(g(n)),则 T1*T2=O(f(n)*g(n))
(5).对于复杂的算法,可以将它分成几个容易估算的部分,然后利用求和法则和乘法法则技术整个算法的时间复杂度
另外还有以下2个运算法则:(1) 若g(n)=O(f(n)),则O(f(n))+ O(g(n))= O(f(n));(2) O(Cf(n)) = O(f(n)),其中C是一个正常数
2.7、常见复杂度示例
(1)、O(1)
Temp=i; i=j; j=temp;
以上三条单个语句的频度均为1,该程序段的执行时间是一个与问题规模n无关的常数。算法的时间复杂度为常数阶,记作T(n)=O(1)。注意:如果算法的执行时间不随着问题规模n的增加而增长,即使算法中有上千条语句,其执行时间也不过是一个较大的常数。此类算法的时间复杂度是O(1)。
(2)、O(n2)
a、 交换i和j的内容
sum=0; (一次) for(i=1;i<=n;i++) (n+1次) for(j=1;j<=n;j++) (n2次) sum++; (n2次)
解:因为Θ(2n2+n+1)=n2(Θ即:去低阶项,去掉常数项,去掉高阶项的常参得到),所以T(n)= =O(n2);
b、
for (i=1;i<n;i++) { y=y+1; ① for (j=0;j<=(2*n);j++) x++; ② }
解: 语句1的频度是n-1
语句2的频度是(n-1)*(2n+1)=2n2-n-1
f(n)=2n2-n-1+(n-1)=2n2-2;
又Θ(2n2-2)=n2
该程序的时间复杂度T(n)=O(n2).
一般情况下,对步进循环语句只需考虑循环体中语句的执行次数,忽略该语句中步长加1、终值判别、控制转移等成分,当有若干个循环语句时,算法的时间复杂度是由嵌套层数最多的循环语句中最内层语句的频度f(n)决定的。
(3)、O(n)
a=0; b=1; ① for (i=1;i<=n;i++) ② { s=a+b; ③ b=a; ④ a=s; ⑤ }
解: 语句1的频度:2,
语句2的频度: n,
语句3的频度: n-1,
语句4的频度:n-1,
语句5的频度:n-1,
T(n)=2+n+3(n-1)=4n-1=O(n).
(4)、O(log2n)
i=1; ① hile (i<=n) i=i*2; ②
解: 语句1的频度是1,
设语句2的频度是f(n), 则:2^f(n)<=n;f(n)<=log2n
取最大值f(n)=log2n,
T(n)=O(log2n )
(5)、O(n3)
for(i=0;i<n;i++) { for(j=0;j<i;j++) { for(k=0;k<j;k++) x=x+2; } }
解:当i=m, j=k的时候,内层循环的次数为k当i=m时, j 可以取 0,1,...,m-1 , 所以这里最内循环共进行了0+1+...+m-1=(m-1)m/2次所以,i从0取到n, 则循环共进行了: 0+(1-1)*1/2+...+(n-1)n/2=n(n+1)(n-1)/6所以时间复杂度为O(n3).
2.8、 常用的算法的时间复杂度和空间复杂度
常用的排序算法的时间复杂度和空间复杂度
排序法 |
最差时间分析 |
平均时间复杂度 |
稳定度 |
空间复杂度 |
备注 |
说明 |
冒泡排序 |
O(n2) |
O(n2) |
稳定 |
O(1) |
n小时较好 |
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。
所以,如果两个元素相等,无操作;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,
这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。
|
插入排序 |
O(n2) |
O(n2) |
稳定 |
O(1) |
大部分已经排序时较好 |
插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。 比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面, 否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。 所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。 |
选择排序 |
O(n2) |
O(n2) |
不稳定 |
O(1) |
n小时较好 |
选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推, 直到第n - 1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小, 而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。 举个例子,序列5 8 5 2 9,我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了, 所以选择排序不是一个稳定的排序算法。 |
二叉树排序 |
O(n2) |
O(n*log2n) |
不一定 |
O(n) |
||
快速排序 |
O(n2) |
O(nlogn) |
不稳定 |
O(log2n)~O(n) |
n大时较好 |
快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标, 一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i > j。 交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候, 很有可能把前面的元素的稳定性打乱. 比如序列为5 3 3 4 3 8 9 10 11,现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱, 所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j] 交换的时刻。 |
堆排序 |
O(nlogn) |
O(nlogn) |
不稳定 |
O(1) |
n大时较好 |
我们知道堆的结构是节点i的孩子为2 * i和2 * i + 1节点,大顶堆要求父节点大于等于其2个子节点, 小顶堆要求父节点小于等于其2个子节点。在一个长为n 的序列,堆排序的过程是从第n / 2开始和其子节点共3个值选择最大(大顶堆) 或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n / 2 - 1, n / 2 - 2, ... 1这些个父节点选择元素时, 就会破坏稳定性。有可能第n / 2个父节点交换把后面一个元素交换过去了,而第n / 2 - 1个父节点把后面一个相同的元素没 有交换, 那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。 |
希尔排序 |
O(ns)1<s<2) |
O(nlogn) |
不稳定 |
O(1) |
s是选分组 |
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快; 当元素基本有序了,步长很小, 插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比O(n^2)好一些。 由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中, 相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。 |
归并排序 |
O(n*log2n) |
O(n*log2n) |
稳定 |
O(1) |
n大时较好 |
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换), 然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时, 1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中, 稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面, 这样就保证了稳定性。所以,归并排序也是稳定的排序算法。 |
基数排序 |
O(logRB) | O(logRB) |
稳定 |
O(n) |
B 是真数(0-9) R是基数(个十百) |
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的, 先按低优先级排序,再按高优先级排序,最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。 基数排序基于分别排序,分别收集,所以其是稳定的排序算法。 |
查找算法时间复杂度
查找 |
平均时间复杂度 |
查找条件 |
算法描述 |
顺序查找 |
O(n) |
无序或有序队列 |
按顺序比较每个元素,直到找到关键字为止 |
二分查找(折半查找) |
O(logn) |
有序数组 |
查找过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜素过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。 如果在某一步骤数组为空,则代表找不到。 |
二叉排序树查找 |
O(logn) |
二叉排序树 |
在二叉查找树b中查找x的过程为: 1. 若b是空树,则搜索失败 2. 若x等于b的根节点的数据域之值,则查找成功; 3. 若x小于b的根节点的数据域之值,则搜索左子树 4. 查找右子树。 |
哈希表法(散列表) |
O(1) |
先创建哈希表(散列表) |
根据键值方式(Key value)进行查找,通过散列函数,定位数据元素。 |
分块查找 |
O(logn) |
无序或有序队列 |
将n个数据元素"按块有序"划分为m块(m ≤ n)。 每一块中的结点不必有序,但块与块之间必须"按块有序";即第1块中任一元素的关键字都必须小于第2块中任一元素的关键字;而第2块中任一元素又都必须小于第3块中的任一元素,……。然后使用二分查找及顺序查找。 |
是
三、空间复杂度
当今硬件的存储量级比较大,一般不会为了稍微减少一点儿空间复杂度而大动干戈,更多的是去想怎么优化算法的时间复杂度。所以我们在日常写代码的时候就衍生出了用「空间换时间」的做法,并且成为常态。比如我们在求解斐波那契数列数列的时候我们可以直接用公式去递归求,用哪个求哪个,同样也可以先把很多结果都算出来保存起来,然后用到哪个直接调用,这就是典型的用空间换时间的做法
举个例子说,要判断某年是不是闰年,你可能会花一点心思来写一个算法,每给一个年份,就可以通过这个算法计算得到是否闰年的结果。
另外一种方法是,事先建立一个有2050个元素的数组,然后把所有的年份按下标的数字对应,如果是闰年,则此数组元素的值是1,如果不是元素的值则为0。这样,所谓的判断某一年是否为闰年就变成了查找这个数组某一个元素的值的问题。
第一种方法相比起第二种来说很明显非常节省空间,但每一次查询都需要经过一系列的计算才能知道是否为闰年。第二种方法虽然需要在内存里存储2050个元素的数组,但是每次查询只需要一次索引判断即可。