算法复杂度分析(上):分析算法运行时,时间资源及空间资源的消耗
前言
算法复杂度是指算法在编写成可执行程序后,运行时所需要的资源,资源包括时间资源和内存资源。
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来粗略分析执行效率与数据规模之间的增长趋势关系,越高阶复杂度的算法,执行效率越低。
复杂度分析是数据结构与算法的核心精髓,指在不依赖硬件、宿主环境、数据集的情况下,粗略推导,考究出算法的效率和资源消耗情况。
时间&空间复杂度
数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行的更快,如何让代码更节省存储空间。所以执行效率是算法非常重要的考量标准。
而衡量一个算法的执行效率就需要:时间复杂度分析和空间复杂度分析。
事后统计法:是让代码执行一遍,通过统计、监控等,得到算法执行时间和占用内存大小,事后统计法评估执行效率的方式并没有问题。但它存在2个局限性:
1.评估受测试环境所影响
2.评估受数据规模所影响
所以需要一个不依赖具体的运行环境及测试数据就可以粗略地估计执行效率的方法。这就需要:时间复杂度分析和空间复杂度分析。
如何进行复杂度分析
下面有段代码非常的简单,求1,2,3...n的累加和。
如何在不运行代码的情况下,粗略的推导代码的执行时间呢?以Demo1为例:
1 int Demo1(int n) 2 { 3 int sum = 0; //1个unit_time 4 for (int i = 1; i <= n; ++i)//n个unit_time 5 { 6 sum = sum + i; //n个unit_time 7 } 8 return sum; 9 } 10 /* 11 * 作者:Jonins 12 * 出处:http://www.cnblogs.com/jonins/ 13 */
首先在CPU的角度看待程序,那么每行代码执行的操作都是类似的:
1.读数据
2.写数据
3.运算
尽管每段代码执行的时间都不一定一样,但是我们这里只是粗略的估计,所以可以假设每行代码执行的时间都一样,为:单位时间(unit_time)。即每一行代码的执行时间为:1个单位时间(unit_time)。
以此假设基础,我们来分析上段代码的执行时间:
第3行:执行了1次,所以需要1个单位时间的执行时间。
第4行:因为执行了n遍,所以需要n个单位时间的执行时间。
第6行:因为执行了n遍,所以需要n个单位时间的执行时间。
所以这段代码的总执行时间就是:
(2n+1)*unit_time
本质:若要独立于机器的软、硬件系统来分析算法的时间耗费,则假设每条语句执行一次所需的时间均是单位时间,一个算法的时间耗费就是该算法中所有语句的执行时间之和。
按照这个分析方式,再分析下面Demo2代码:
1 public int Demo2(int n) 2 { 3 int sum = 0; //1个unit_time 4 for (int i = 1; i <= n; ++i) //n个unit_time 5 { 6 for (int j = 1; j <= n; ++j)//n*n 个unit_time 7 { 8 sum = sum + i* j; //n*n 个unit_time 9 } 10 } 11 return sum; 12 } 13 /* 14 * 作者:Jonins 15 * 出处:http://www.cnblogs.com/jonins/ 16 */
第3行:需要1个单位时间的执行时间。
第4行:执行了n遍,需要n个单位时间的执行时间。
第6,8行:循环执行了n2遍,所以需要2n2个单位时间的执行时间。
所以这段代码的总执行时间是:
(2n2+n+1)*unit_time
时间复杂度&大O表示法
明白了时间复杂度分析的方法。再了解几个概念。
1.时间频度
一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。
并且一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。
2.时间复杂度
在刚才提到的时间频度中,n通称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。
在计算机科学中,算法的时间复杂度是一个函数,它定性描述了该算法的运行时间。
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示。
若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度。
3.大O时间复杂度表示法
当问题规模增长时, 基本操作次数必定也会增长, 而我们关心的是这个执行次数以什么样的数量级增长。
所谓数量级可以理解为增长率。这个所谓的数量级本质上就是算法的渐近时间复杂度(asymptotic time complexity), 简称为时间复杂度。
由于基本操作的执行次数是问题规模n的一个函数T(n), 我们要确定这个函数T(n), 然后分析它的数量级。把拥有相同数量级的函数 f(n) 的集合表示为 O(f(n))。
O是数量级的标记(数学公式),函数f(n)为算法的增率函数(growth-rate function)。
在上面两个分析示例中,我们虽然不知道单位时间(unit_time)的具体值,但是依然可以推导一个非常重要的规律:所有代码的执行时间T(n)与代码执行次数成正比。
所以我们根据这个规律总结出一个公式:
T(n)=O(f(n))
大白话解读公式:
T(n):表示代码的总执行时间。
n:表示数据规模大小。
f(n):表示代码执行的次数总和,因为是一个公式,所以用f(n)表示。
O:表示代码的执行时间T(n)与f(n)表达式成正比。
所以上面的两个示例,用大O时间复杂度可以这样推导表示:
Demo1时间复杂度:
T(n)=O(f(n))
因为:f(n)=(2n+1)
所以Demo1时间复杂度:T(n)= O(2n+1)
Demo2时间复杂度:
T(n)=O(f(n))
因为:f(n)=(2n2+n+1)
所以Demo1时间复杂度:T(n)= O(2n2+n+1)
大O时间复杂度,实际上并不具体表示代码真正的执行时间,而是用来表示代码执行时间随数据规模增长的变化趋势。
复杂度分析原则
1.量级最大法则:忽略常量、低阶和系数
大O复杂度表示方法只表示一种变化趋势。所以忽略掉公式中的常量、低阶、系数、只关注最大阶的量级就可以。
我们在分析一个算法或一段代码的复杂度的时候,也只关注受问题规模n影响越大即相关执行次数最多的那段代码即可。
以Demo1为例:
1 int Demo1(int n) 2 { 3 int sum = 0; //1个unit_time 4 for (int i = 1; i <= n; ++i)//n个unit_time 5 { 6 sum = sum + i; //n个unit_time 7 } 8 return sum; 9 } 10 /* 11 * 作者:Jonins 12 * 出处:http://www.cnblogs.com/jonins/ 13 */
第3行代码是常量级的执行时间,与问题规模n无关,所以对复杂度并没有影响。
循环次数最多的是第4、6行,所致重点关注这段代码。这两行代码各被执行了n次,所以总的时间复杂度就是O(2n)。
说明:
固定的代码循环,哪怕循环一千万次,只要是一个已知的数,跟问题规模无关,它就是常量级的执行时间。当n无限大的时候常量可以忽略不计。
尽管这部分代码对执行时间会有很大影响,但是在我们探讨的时间复杂度概念上,时间复杂度表示的是一个算法执行效率与数据规模之前的一种变化趋势。
所以不管常量的执行时间多大,我们都可以忽略掉它,因为它本身对增长趋势并没有影响。
2.加法法则:总复杂度等于同级代码复杂度的总和
同样的分析思路分析下面Demo3:
1 int Demo3(int n) 2 { 3 int sum_1 = 0; 4 for (int i = 1; i <= 100; i++) 5 { 6 sum_1 = sum_1 + i; 7 } 8 9 int sum_2 = 0; 10 for (int i = 1; i <= n; i++) 11 { 12 sum_2 = sum_2 + i; 13 } 14 15 int sum_3 = 0; 16 for (int j = 1; j <= n; j++) 17 { 18 for (int q = 1; q <= n; q++) 19 { 20 sum_3 = sum_3 + j * q; 21 } 22 } 23 return sum_1 + sum_2 + sum_3; 24 }
这段代码分为3部分,分别求sum_1,sum_2,sum_3,这3段代码是“同级的”的,所以我们可以分别分析这三段代码的复杂度,最后相加在一起,取得总的时间复杂度。
第1段代码:执行100次,是一个常量执行时间跟n的规模无关。
第2段代码:执行了2n次,所以时间复杂度为O(2n)。
第3段代码:执行了2n2+n,所以时间复杂度为O(2n2+n)。
综合这3段代码的时间复杂度,我们省略常量并取其中最大量级。所以整段代码的时间复杂度为O(2n2)。
说明:加法法则公式
如果:T1(n)=O(g(n))、T2(n)=O(h(n))。
存在:T(n)=T1(n)+T2(n)。O(f(n))=O(g(n))+O(h(n))。
所以:T(n)=O(max(g(n)+h(n)))。
3.乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
分析下面的Demo4,我改写了上面Demo2为了便于理解:
1 public int Demo4(int n) 2 { 3 int sum = 0; 4 for (int i = 1; i <= n; ++i) //n个unit_time 5 { 6 sum = sum + Function(n); //n个unit_time 7 } 8 return sum; 9 } 10 public int Function(int n) 11 { 12 int sum = 0; 13 for (int j = 1; j <= n; ++j)//n 个unit_time 14 { 15 sum = sum + 1; //n 个unit_time 16 } 17 return sum; 18 } 19 /* 20 * 作者:Jonins 21 * 出处:http://www.cnblogs.com/jonins/
独立的看嵌套外的代码,将函数Function看作一个简单的操作。那么第4、6行执行了n次,所以这段段代码为2n次。
同时独立看嵌套内的代码,第13、15行执行了n次,所以也是2n次。但因为在嵌套内执行了n次个2n次,即n*2n。
所以我们忽略掉常量及取最高阶量值,得到整体的复杂度为:T(n)=O(2n2)。
说明:
1.嵌套循环并非一定是相乘
1 public int Function1(int n) 2 { 3 int sum = 0; 4 for (int i = 1; i <= n; ++i) 5 { 6 for (int j = 1; j <= i; ++j) 7 { 8 sum = sum + 1; 9 } 10 } 11 return sum; 12 } 13 public int Function2(int n) 14 { 15 int sum = 0; 16 for (int i = 1; i <= n; ++i) 17 { 18 for (int j = 1; j <= n; ++j) 19 { 20 sum = sum + 1; 21 } 22 } 23 return sum; 24 } 25 /* 26 * 作者:Jonins 27 * 出处:http://www.cnblogs.com/jonins/ 28 */
注意观察嵌套内循环的边界,Function1和Function2的执行次数有极大差异。
执行一下可以看到Function1的内部循环次数为n(n+1)/2。Function2的内部循环次数才是n2。
2.乘法法则公式
如果:T1(n)=O(g(n))、T2(n)=O(h(n))。
存在:T(n)=T1(n)*T2(n)。O(f(n))=O(g(n))*O(h(n))。
所以:T(n)=O(g(n)*h(n))。
常见复杂度量级
虽然算法各有千秋代码又千差万别。但最基础的复杂度并不多。根据复杂度的量级,我们可以将算法的复杂度分为两类:多项式量级、非多项式量级。
1.多项式量级
常量阶:O(1)
对数阶:O(log(n))
线性阶:O(n)
线性对数阶:O(nlog(n))
平方阶:O(n2)
立次方阶:O(n3)
K次方阶:O(nk)
2.非多项式量级:
指数阶:O(2n)
阶乘阶:O(n!)
3.一些重要的说明
1.O(1)
O(1)只是常量阶复杂度表示方法,并不一定指执行了一次,即使执行多次只要是一个确切的值,且不受问题规模n的影响时,我们就是用复杂度O(1)表示。
2.O(log(n))&O(nlog(n))
对数阶复杂度非常常见,二分查找的时间复杂度为O(log(n)),归并排序时间复杂度为O(nlog(n))。
通过案例了解下对数阶
1 public void Function(int n) 2 { 3 int i = 1; 4 while (i < n) 5 { 6 i = i * 2; 7 } 8 }
我们着重分析第6行代码,变量i从1开始取,每循环一次乘以2,当大于或等于n时循环解说。可以推算出变量i的值在整个循环过程中是一个等比数列,既:
20,22,23,24,25,26....=n。
而第6行的循环次数,就是数学题,存在2x=n,求解x的近似值。我们大致可以得出x=log(2n)。所以这段代码的复杂度就是O(log(n))。
如果理解了对数阶O(log(n)),那么线性对数阶O(nlog(n))就很好理解,上面的乘法法则,将O(log(n))执行n遍就得到了O(nlog(n))。
3.所有对数阶均为O(log2n)
上面已经讲过对数阶,但实际上,不管是以2为底,还是3为底,还是以x为低(x为已知量),我们都可以把所有的对数阶的时间复杂度记为O(log(n))。
我们可以通过换底公式推导:
换底公式:
$ \log(a^b)=\frac{\log(c^b)}{\log(c^a)} $
推导过程:
$\because \log(2^n)=\frac{\log(x^n)}{\log(x^2)}$
$\therefore \log(x^n)=\log(2^n)*\log(x^2)$
我们已知x的值,所以log(x2)为常量,基于分析原则可以忽略系数。所以所有的对数阶,我们忽略对数的“底”,统一用O(log(n))表示。
空间复杂度
学会大O表示法和时间复杂度分析,那么空间复杂度分析就非常简单。
空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法在运行过程中临时占用的存储空间大小的量度。
分析原则:时间复杂度关注代码的执行次数,而空间复杂度主要关注申请空间的数量。
常见的空间复杂度就是:O(1)、O(n)、O(n2),像对数阶等量级的空间复杂度平常用不上。
空间复杂度分析相对于时间复杂度分析要简单很多,所以在这里不再过多描述。