算法的时间和空间复杂度
来源:互联网 发布:mac磁盘映像怎么删除 编辑:程序博客网 时间:2024/06/17 16:35
序言
算法效率分析分为两种:第一种是时间效率,第二种是空间效率。时间效率被称为时间复杂度,而空间效率被称作空间复杂度。
时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量一个算法所需要的额外空间,在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度,所以我们如今已经不太关注一个算法的空间复杂度。
一般对于一个给定的算法,我们要做两项分析。第一是从数学上证明算法的正确性,这一步主要用到形式化证明的方法及相关推理模式,如循环不变式、数学归纳法等。而在证明算法是正确的基础上,第二步就是分析算法的时间复杂度。算法的时间复杂度反映了程序执行时间随输入规模增长而增长的量级,在很大程度上能很好反映出算法的优劣与否。
一个算法是由控制结构(顺序+分支+循环)和原操作(指固有数据类型的操作)构成的,则算法时间取决于两者的综合效果。为了便于比较同一个问题的不同算法,通常的做法是,从算法中选取一种对于所研究的问题(或算法类型)来说是基本操作(算法中的基本操作一般指算法中最深层循环内的语句)的原操作,以该基本操作的重复执行的次数作为算法的时间量度。
一. 时间复杂度
一个高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素:
- 算法采用的策略、方案 - 编译产生的代码质量 - 问题的输入规模 - 机器执行指令的速度
如果不考虑计算机硬件、软件有关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模。
算法分析的种类:最坏情况(Worst Case):任意输入规模的最大运行时间。(Usually) 平均情况(Average Case):任意输入规模的期待运行时间。(Sometimes) 最佳情况(Best Case):通常最佳情况不会出现。(Bogus)
时间频度
一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。 一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。 一个算法中语句执行次数称为语句频度或时间频度,记为T(n),n称为问题规模。
在时间频度中,当n不断变化时,时间频度T(n)也会不断变化。
时间复杂度的数学意义
从数学上定义,给定算法A,如果存在函数T(n),当n=k时,T(k)表示算法A在输入规模为k的情况下的运行时间,则称T(n)为算法A的时间复杂度。在大多数情况下我们不需要对T(n)进行精确分析,这一方面由于在较复杂的算法中,进行精确分析是非常复杂的;另外则由于实际中大多数时候我们并不关心T(n)的精确度量,而只是关心其量级。引进辅助函数g(n),T(n) = O(g(n))称O(g(n))为算法的渐进时间复杂度,简称时间复杂度。(1) 渐进时间复杂度的概念
定义一:同阶符号 Θ(g(n))={T(n) | 如果存在正常数c1、c2和正整数n0,使得当n>=n0时, 0<c1g(n)<=T(n)<=c2g(n)恒成立};T(n)与g(n)增长相同定义二:上界符号 Ο(g(n))={T(n) | 如果存在正常数c和正整数n0,使得当n>=n0时,0<=T(n) <=cg(n)恒成立};T(n)比g(n)增长慢定义三:下界符号 Ω(g(n))={T(n) | 如果存在正常数c和正整数n0,使得当n>=n0时,0<=cg(n) <=T(n)恒成立};T(n)比g(n)增长快
三个定义其实都定义了一个函数集合,只不过集合中的函数需要满足的条件不同。有了以上定义,就可以定义渐近时间复杂度了。不过这里还有个问题:T(n)不是确定的,他是在一个范围内变动的,那么我们关心哪个T(n)呢?一般我们在分析算法时,使用最坏情况下的T(n)来评价算法效率,原因有如下两点:
* 如果知道了最坏情况,我们就可以保证算法在任何时候都不能比这个情况更坏了。 * 很多时候,算法运行发生最坏情况的概率还是很大的,如查找问题中待查元素不 存在的情况。且在很多时候,平均情况的渐近时间复杂度和最坏情况的渐近时间复杂度是一个量级 的。
于是给出如下定义:
定义一:设T(n)为算法A在最坏情况下T(n),则如果T(n)属于Θ(g(n)),则说算法A的渐近时间复 杂度为g(n),且g(n)为T(n)的渐近确界。 定义二:设T(n)为算法A在最坏情况下T(n),则如果T(n)属于Ο(g(n)),则说算法A的渐近时间复杂 度上限为g(n),且g(n)为T(n)的渐近上确界。 定义三:设T(n)为算法A在最坏情况下T(n),则如果T(n)属于Ω(g(n)),则说算法A的渐近时间复杂 度下限为g(n),且g(n)为T(n)的渐近下确界。
这里一定要注意,由于我们是以T(n)最坏情况分析的,所以,我们可以100%保证在输入规模超过临界条件n0时,算法的运行时间一定不会高于渐近上确界,但是并不能100%保证算法运行时间不会低于渐近下确界,而只能100%保证算法的最坏运行时间不会低于渐近下确界。
(2) 几个概念解释
- 最坏时间复杂度:最坏情况下的时间复杂度称最坏时间复杂度。一般不特别说明,讨论的时间复杂度均是最坏情况下的时间复杂度。 这样做的原因是:最坏情况下的时间复杂度是算法在任何输入实例上运行时间的上界,这就保证了算法的运行时间不会比任何更长。在最坏情况下的时间复杂度为T(n)=O(n),它表示对于任何输入实例,该算法的运行时间不可能大于O(n)。
- 平均时间复杂度:是指所有可能的输入实例均以等概率出现的情况下,算法的期望运行时间。
- 输入规模:并不是输入了多少数据,数据规模就是多少。非严格的讲,输入规模是指算法A所接受输入的自然独立体的大小。例如,对于排序算法来说,输入规模一般就是待排序元素的个数,而对于求两个同型方阵乘积的算法,输入规模可以看作是单个方阵的维数n,也可以选取n*n。
- 算法的运算时间度量:一种不依赖与任何无关因素的度量标准——基本操作。也被称为算法中最重要的操作,比如:对两种排序算法,我们没有办法计算出程序运行了多少时间,但是我们可以根据计算来得到每个程序的基本操作所出现的次数:加减,赋值等等。基本操作可能会有很多种,但是我们只需要找出来贡献最大的那个就行了。对于数学问题的算法:最消耗时间的是除法,其次是乘法,最后是加法和减法。
二. 空间复杂度
类似于时间复杂度的讨论,一个算法的空间复杂度S(n)定义为该算法所耗费的存储空间,它也是问题规模n的函数。渐近空间复杂度也常常简称为空间复杂度。
定义:空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。一个算法在计算机存储器上所占用的存储空间,包括存储算法本身所占用的存储空间,算法的输入输出数据所占用的存储空间和算法在运行过程中临时占用的存储空间这三个方面。
- 存储算法本身所占用的存储空间(指令空间):存储经过编译之后的程序指令,指令有操作数和操作码构成。
与算法书写的长短成正比,要压缩此方面的存储空间,则必须编写出精短的算法。 - 算法的输入输出数据所占用的存储空间(数据空间):存储所有常量和所有变量值所需的空间。
由解决的问题决定,是通过参数表由调用函数传递而来,不随算法的不同而改变。 - 算法在运行过程中临时占用的存储空间(环境栈空间):保存函数调用返回时恢复运行所需要的信息。
随算法的不同而不同。主要包括动态分配的空间、以及递归栈所需的空间等。
(1) 指令空间:程序所需指令空间的大小取决于如下因素:
- 把程序编译成机器代码的编译器。所使用的编译器不同,则产生的机器代码长度就会有所差异。 - 编译时实际采用的编译器选项。有些编译器带有选项, 如优化模式、 覆盖模式等等。 所取的选项不同, 产生的机器代码也会不同。 - 目标计算机。目标计算机的配置也会影响代码的规模。例如, 如果计算机具有浮点处理硬件, 那么, 每个浮点操作可以转化为一条机器指令。否则, 必须生成仿真的浮点计算代码,使整个机器代码加长。
(2) 数据空间分成两部分:存储常量和简单变量+存储复合变量。
- 存储常量和简单变量。取决于所用的计算机和编译器,以及变量与常量的数目。 - 存储复合变量。包括数据结构所需的空间及动态分配的空间。
(3) 环境栈空间:调用一个函数时,下面数据保存在环境栈中:
- 返回地址。 - 所有局部变量的值、传值形式参数的参数值。 - 所有引用参数的定义。
也可将一个程序所需要的空间分为两部分:① 固定部分。独立于实例特征,主要包括指令空间、简单变量以及定长复合变量占用的空间、常量占用的空间。② 可变部分。主要包括复合变量所需空间、 动态分配的空间、递归栈所需要的空间。
- 存储算法本身所占用的存储空间(指令空间):存储经过编译之后的程序指令,指令有操作数和操作码构成。
空间换时间:
我们在写代码时,完全可以用空间来换取时间,比如说,要判断某某年是不是闰年,你可能会花一点心思写了一个算法,而且由于是一个算法,也就意味着,每次给一个年份,都是要通过计算得到是否是闰年的结果。还有另一个办法就是,事先建立一个有2 050个元素的数组(年数略比现实多一点),然后把所有的年份按下标的数字对应,如果是闰年,此数组项的值就是1,如果不是值为0。这样,所谓的判断某一年是否是闰年,就变成了查找这个数组的某一项的值是多少的问题。此时,我们的运算是最小化了,但是硬盘上或者内存中需要存储这2050个0和1。
三. 时间复杂度计算举例
时间复杂度的计算
在各种不同算法中,若算法中语句执行次数为一个常数,则时间复杂度为O(1),另外,在时间频度不相同时,时间复杂度有可能相同,如T(n)=n^2+3n+4与T(n)=4n^2+2n+1它们的频度不同,但时间复杂度相同,都为O(n^2)。 按数量级递增排列,常见的时间复杂度有:常数阶O(1),对数阶O(log2n),线性阶O(n),线性对数阶O(nlog2n),平方阶O(n^2),立方阶O(n^3) …, k次方阶O(n^k),指数阶O(2^n)。随着输入规模的增大,上述事件复杂度也增大,算法效率变低。常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n^2)<Ο(n^3)<…<Ο(2^n)<Ο(n!)
求解算法的时间复杂度的具体步骤
找出算法中的基本语句;
算法中执行次数最多的那条语句就是基本语句,通常是最内层循环的循环体。
计算基本语句的执行次数的数量级;
只需计算基本语句执行次数的数量级,这就意味着只要保证基本语句执行次数的函数中的最高次幂正确即可,可以忽略所有低次幂和最高次幂的系数。这样能够简化算法分析,并且使注意力集中在最重要的一点上:增长率。
用大Ο记号表示算法的时间性能。
将基本语句执行次数的数量级放入大Ο记号中。如果算法中包含嵌套的循环,则基本语句通常是最内层的循环体,如果算法中包含并列的循环,则将并列循环的时间复杂度相加。
在计算算法时间复杂度时有以下几个简单的程序分析法则
(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是一个正常数
时间复杂度的计算例子
(1) 常数阶 O(1) 首先顺序结构的时间复杂度
void main() { int sum=0,n=100; sum=(1+n)*n/2; printf(“%d”,sum);}
算法的时间复杂度为O(1)。 这个算法的运行次数函数是f(n)=3。根据我们推导的方法,第一步就是把常数项3改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)。
(2) 线性阶 O(n) 要确定某个算法的阶次,需要确定某个特定语句或某个语句集运行的次数。因此,要分析算法的复杂度,关键就是要分析循环结构的运行情况。
int i; for(i=0;i<n;i++){ /*时间复杂度为O(1)的程序步骤序列*/}
int FindMaxElement(int[] array) { int max = array[0]; for (int i = 0; i < array.Length; i++) { if (array[i] > max) { max = array[i]; } } return max; }
n 为数组 array 的大小,则最坏情况下需要比较 n 次以得到最大值,所以算法复杂度为 O(n)。
decimal Factorial(int n) { if (n == 0) return 1; else return n * Factorial(n - 1); }
阶乘(factorial),给定规模 n,算法基本步骤执行的数量为 n,所以算法复杂度为 O(n)。
(3) 对数阶 O(log2n)
int count=1;while(count<n){ count=count*2; /*时间复杂度为O(1)的程序步骤序列*/}
由于每次count乘以2之后,就距离n更近了一点。也就是说,有多少个2相乘后大于n,则会退出循环。由2x=n得到x=log2n。所以这个循环的时间复杂度为O(log2n)。
- 再看一个二分查找的例子
int BinarySearch2(const int* ptr,const int x,const int left,const int right){ int mid=(left+right)/2; while(left<=right) { if(x<ptr[mid]) { return BinarySearch2(ptr,x,left,mid-1); } else if(x>ptr[mid]) { return BinarySearch2(ptr,x,mid+1,right); } return mid; }}
假设以最坏情况考虑,二分查找第一次在n/2中查找(n为元素个数);第二次在一半的一半中查找,即n/2/2=n/4……第x次在n/2^x范围内查找,即2^x=n(x=log2n),所以时间复杂度为O(log2n)。
(4) 平方阶 O(n^2)
long Sum(int n, int m) { long sum = 0; for (int x = 0; x < n; x++) for (int y = 0; y < m; y++) sum += x * y; /*时间复杂度为O(1)的程序步骤序列*/ return sum; }
循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数,时间复杂度为O(n^2)。
- 一个插入排序的例子
private static void InsertionSortInPlace(int[] unsorted) { for (int i = 1; i < unsorted.Length; i++) { if (unsorted[i - 1] > unsorted[i]) { int key = unsorted[i]; int j = i; while (j > 0 && unsorted[j - 1] > key) { unsorted[j] = unsorted[j - 1]; j--; } unsorted[j] = key; } } }
插入排序的基本操作就是将一个数据插入到已经排好序的有序数据中,从而得到一个新的有序数据。算法适用于少量数据的排序,时间复杂度为 O(n2)。
(5) 立方阶 O(n^3)
decimal Sum(int n) { decimal sum = 0; for (int a = 0; a < n; a++) for (int b = 0; b < n; b++) for (int c = 0; c < n; c++) sum += a * b * c; return sum; }
给定规模 n,则基本步骤的执行数量约为 n*n*n ,所以算法复杂度为 O(n^3)。
(6) 指数阶 O(2^n)
斐波那契数列 Fib(0) = 0 Fib(1) = 1 Fib(n) = Fib(n-1) + Fib(n-2)F() = 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 ...
- 斐波那契数列:递归
int Fibonacci(int n) { if (n <= 1) return n; else return Fibonacci(n - 1) + Fibonacci(n - 2); }
给定规模 n,计算 Fib(n) 所需的时间为计算 Fib(n-1) 的时间和计算 Fib(n-2) 的时间的和。
T(n<=1) = O(1)
T(n) = T(n-1) + T(n-2) + O(1)
fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1)/ \ / \ / \ 通过使用递归树的结构描述可知算法复杂度为 O(2^n)。
- 斐波那契数列算法优化:数组
int Fibonacci(int n) { if (n <= 1) return n; else { int[] f = new int[n + 1]; f[0] = 0; f[1] = 1; for (int i = 2; i <= n; i++) { f[i] = f[i - 1] + f[i - 2]; } return f[n]; } }
同样是斐波那契数列,我们使用数组 f 来存储计算结果,这样算法复杂度优化为 O(n)。
- 斐波那契数列算法优化:中间变量
int Fibonacci(int n) { if (n <= 1) return n; else { int iter1 = 0; int iter2 = 1; int f = 0; for (int i = 2; i <= n; i++) { f = iter1 + iter2; iter1 = iter2; iter2 = f; } return f; } }
同样是斐波那契数列,由于实际只有前两个计算结果有用,我们可以使用中间变量来存储,这样就不用创建数组以节省空间。同样算法复杂度优化为 O(n)。
- 斐波那契数列算法优化:矩阵乘方
static int Fibonacci(int n) { if (n <= 1) return n; int[,] f = { { 1, 1 }, { 1, 0 } }; Power(f, n - 1); return f[0, 0]; } static void Power(int[,] f, int n) { if (n <= 1) return; int[,] m = { { 1, 1 }, { 1, 0 } }; Power(f, n / 2); Multiply(f, f); if (n % 2 != 0) Multiply(f, m); } static void Multiply(int[,] f, int[,] m) { int x = f[0, 0] * m[0, 0] + f[0, 1] * m[1, 0]; int y = f[0, 0] * m[0, 1] + f[0, 1] * m[1, 1]; int z = f[1, 0] * m[0, 0] + f[1, 1] * m[1, 0]; int w = f[1, 0] * m[0, 1] + f[1, 1] * m[1, 1]; f[0, 0] = x; f[0, 1] = y; f[1, 0] = z; f[1, 1] = w; }
优化之后算法复杂度为O(log2n)。
通常时间复杂度与运行时间有一些常见的比例关系:
四. 空间复杂度计算举例
空间复杂度,它是对一个算法在运行过程中临时占用存储空间大小的量度。所以它强调的是使用的辅助空间的的大小,而不是指所有的数据所占用的空间。
要注意的是递归算法的空间复杂度:递归深度N*每次递归的辅助空间大小。如果每次递归的辅助空间为常数,则空间复杂度为O(N)。
- 通过斐波那契数列对时间,空间复杂度进行分析一下:
(1) 递归方式
递归算法的时间复杂度计算方法是:递归总次数*每次递归次数;递归算法的时间复杂度计算方法是:递归深度*每次递归所需的辅助空间个数。
long long* fib(long long n){ assert(n>=0); long long* ptr=new long long[n+1]; ptr[0]=0; ptr[1]=1; for(int i=2;i<=n;++i) { ptr[i]=ptr[i-1]+ptr[i-2]; } return ptr;}
对于这种算法,函数真正执行次数为n-1,所以忽略常数后,时间复杂度为O(n);因为开辟了n+1个空间,有n+1个辅助空间,所以空间复杂度为O(n)。
(2) 非递归方式
long long fib(long long n){ assert(n>=0); long long first=0; long long second=1; long long ret=0; for(int i=2;i<=n;i++) { ret=first+second; first=second; second=ret; } return ret;}
这是非递归的另一种算法,函数真正执行次数依然为n-1,所以忽略常数后,时间复杂度还是O(n);由于采用变量交换的方式,所以在这里辅助空间个数为一个常数,空间复杂度为O(1)。
2.通过二分查找对时间,空间复杂度进行分析:
(1) 递归方式
int BinarySearch2(const int* ptr,const int x,const int left,const int right){ int mid=(left+right)/2; while(left<=right) { if(x<ptr[mid]) { return BinarySearch2(ptr,x,left,mid-1); } else if(x>ptr[mid]) { return BinarySearch2(ptr,x,mid+1,right); } return mid; }}
1) 假设以最坏情况考虑,二分查找第一次在n/2中查找(n为元素个数);第二次在一半的一半中查找,即n/2/2=n/4……第x次在n/2^x范围内查找,即2^x=n (x=log2n),所以时间复杂度为O(log2n)。2) 递归情况下的空间复杂度:递归深度为N*每次递归的辅助空间大小,如果每次递归的辅助空间为常数,则空间复杂度为O(N)。对于递归的二分查找,递归深度是log2n,每次递归的辅助空间为常数,所以空间复杂度为O(log2n)。
(2) 非递归方式
int BinarySearch1(const int* ptr,const int x,const int len){ int left=0; int right=len-1; int mid=(left+right)/2; while(left<=right) { if(x<ptr[mid]) { right=mid-1; } else if(x>ptr[mid]) { left=mid+1; } else { return mid; } } return -1;}
对于非递归的二分查找与递归查找的时间复杂度一样的分析方法,所以时间复杂度为O(log2n);但是在这个过程中,辅助空间为常数级别,所以空间复杂度为O(1)。
对于一个算法,其时间复杂度和空间复杂度往往是相互影响的。当追求一个较好的时间复杂度时,可能会使空间复杂度的性能变差,即可能导致占用较多的存储空间;反之,求一个较好的空间复杂度时,可能会使时间复杂度的性能变差,即可能导致占用较长的运行时间。另外,算法的所有性能之间都存在着或多或少的相互影响。因此,当设计一个算法(特别是大型算法)时,要综合考虑算法的各项性能,算法的使用频率,算法处理的数据量的大小,算法描述语言的特性,算法运行的机器系统环境等各方面因素,才能够设计出比较好的算法。
常用排序算法的时间复杂度和空间复杂度表格
参考文章:
http://www.cnblogs.com/liaokang/p/3700903.html
http://www.cnblogs.com/davygeek/p/4375497.html
http://www.cnblogs.com/TangBiao/p/5856695.html
http://blog.csdn.net/zolalad/article/details/11848739
http://kb.cnblogs.com/page/79213/
http://blog.csdn.net/qq_29503203/article/details/52464306
2017.04.02
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- 算法的时间复杂度和空间复杂度
- Angular4.0.0正式版发布
- python1-3月25日
- Java中HashMap源码浅析
- list遍历方式效率分析
- RecyclerView系列之(1):为RecyclerView添加Header和Footer
- 算法的时间和空间复杂度
- 详解js中的apply与call的用法
- hdu 2047 EOF 串
- 递归递推练习 N
- python2-3月26日
- maven项目构建index.jsp报错
- 如何更高效的对首页数据的展示
- Android代码规范----一位2年外包开发者的心声
- 20170321多益在线笔试(二笔)