咱们都知道,数据结构和算法自己解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。因此,执行效率是算法一个很是重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到咱们今天要讲的内容:时间、空间复杂度分析。其实,只要讲到数据结构与算法,就必定离不开时间、空间复杂度分析。算法
并且,我我的认为,复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。其实,只要讲到数据结构与算法,就必定离不开时间、空间复杂度分析。数组
复杂度分析实在过重要了,所以我准备用两节内容来说。但愿你学完这个内容以后,不管在任何场景下,面对任何代码的复杂度分析,你都能作到“庖丁解牛”般游刃有余。数据结构
你可能会有些疑惑,我把代码跑一遍,经过统计、监控,就能获得算法执行的时间和占用的内存大小。为何还要作时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍获得的数据更准确吗?数据结构和算法
首先,我能够确定地说,你这种评估算法执行效率的方法是正确的。不少数据结构和算法书籍还给这种方法起了一个名字,叫过后统计法。可是,这种统计方法有很是大的局限性。函数
测试环境中硬件的不一样会对测试结果有很大的影响。好比,咱们拿一样一段代码,分别用Intel Core i9 处理器和 Intel Core i3 处理器来运行,不用说,i9 处理器要比 i3 处理器执行的速度快不少。还有,好比本来在这台机器上 a 代码执行的速度比 b 代码要快,等咱们换到另外一台机器上时,可能会有截然相反的结果。性能
后面咱们会讲排序算法,咱们先拿它举个例子。对同一个排序算法,待排序数据的有序度不同,排序的执行时间就会有很大的差异。极端状况下,若是数据已是有序的,那排序算法不须要作任何操做,执行时间就会很是短。除此以外,若是测试数据规模过小,测试结果可能没法真实地反应算法的性能。好比,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!学习
因此,咱们须要一个不用具体的测试数据来测试,就能够粗略地估计算法的执行效率的方法。这就是咱们今天要讲的时间、空间复杂度分析方法。因此,咱们须要一个不用具体的测试数据来测试,就能够粗略地估计算法的执行效率的方法。这就是咱们今天要讲的时间、空间复杂度分析方法。测试
算法的执行效率,粗略地讲,就是算法代码执行的时间。可是,如何在不运行代码的状况下,用“肉眼”获得一段代码的执行时间呢?spa
这里有段很是简单的代码,求1,2,3…n+的累加和。如今,我就带你一块来估算一下这段代码的执行时间。blog
1 int cal(int n) { 2 int sum = 0; 3 int i = 1; 4 for (; i <= n; ++i) { 5 sum = sum + i; 6 } 7 return sum; 8 }
从+CPU+的角度来看,这段代码的每一行都执行着相似的操做:读数据-运算-写数据。尽管每行代码对应的+CPU+执行的个数、执行的时间都不同,可是,咱们这里只是粗略估计,因此能够假设每行代码执行的时间都同样,为+unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢
第二、3行代码分别须要1个unit_time的执行时间,第四、5行都运行了n遍,因此须要2n*unit_time的执行时间,因此这段代码总的执行时间就是(2n+2)*unit_time。能够看出来,全部代码的执行时间T(n)与每行代码的执行次数成正比。
按照这个分析思路,咱们再来看这段代码。
1 int cal(int n) { 2 int sum = 0; 3 int i = 1; 4 int j = 1; 5 for (; i <= n; ++i) { 6 j = 1; 7 for (; j <= n; ++j) { 8 sum = sum + i * j; 9 } 10 } 11 }
咱们依旧假设每一个语句的执行时间是unit_time。那这段代码的总执行时间+T(n)+是多少呢?&oq=咱们依旧假设每一个语句的执行时间是unit_time。那这段代码的总执行时间T(n)是多少呢?
第二、三、4行代码,每行都须要1个unit_time的执行时间,第五、6行代码循环执行了n遍,须要2n*unit_time的执行时间,第七、8行代码循环执行了n2遍,因此须要2n2*unit_time的执行时间。因此,整段代码总的执行时间T(n)=(2n2+2n+3)*unit_time。
尽管咱们不知道unit_time的具体值,可是经过这两段代码执行时间的推导过程,咱们能够获得一个很是重要的规律,那就是,全部代码的执行时间T(n)与每行代码的执行次数n成正比。
咱们能够把这个规律总结成一个公式。注意,大O就要登场了!
我来具体解释一下这个公式。其中,T(n)咱们已经讲过了,它表示代码执行的时间;n表示数据规模的大小;f(n)表示每行代码执行的次数总和。由于这是一个公式,因此用f(n)来表示。公式中的O,表示代码的执行时间T(n)与f(n)表达式成正比。
因此,第一个例子中的T(n)=O(2n+2),第二个例子中的T(n)+=O(2n2+2n+3)。这就是大O时间复杂度表示法。大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增加的变化趋势,因此,也叫做渐进时间复杂度(asymptotic+time+complexity),简称时间复杂度。
当n很大时,你能够把它想象成10000、100000。而公式中的低阶、常量、系数三部分并不左右增加趋势,因此均可以忽略。咱们只须要记录一个最大量级就能够了,若是用大O表示法表示刚讲的那两段代码的时间复杂度,就能够记为:T(n)=O(n);T(n)=O(n2)。
前面介绍了大O时间复杂度的由来和表示方法。如今咱们来看下,如何分析一段代码的时间复杂度?我这儿有三个比较实用的方法能够分享给你。
1.只关注循环执行次数最多的一段代码
我刚才说了,大O这种复杂度表示方法只是表示一种变化趋势。咱们一般会忽略掉公式中的常量、低阶、系数,只须要记录一个最大阶的量级就能够了。因此,咱们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就能够了。这段核心代码执行次数的n的量级,就是整段要分析代码的时间复杂度。
为了便于你理解,我还拿前面的例子来讲明。
1 int cal(int n) { 2 int sum = 0; 3 int i = 1; 4 for (; i <= n; ++i) { 5 sum = sum + i; 6 } 7 return sum; 8 }
其中第+二、3+行代码都是常量级的执行时间,与+n+的大小无关,因此对于复杂度并无影响。循环执行次数最多的是第+四、5+行代码,因此这块代码要重点分析。前面咱们也讲过,这两行代码被执行了+n+次,因此总的时间复杂度就是+O(n)。
2.加法法则:总复杂度等于量级最大的那段代码的复杂度
我这里还有一段代码。你能够先试着分析一下,而后再往下看跟个人分析思路是否同样。
int cal(int n) { int sum_1 = 0; int p = 1; for (; p < 100; ++p) { sum_1 = sum_1 + p; } int sum_2 = 0; int q = 1; for (; q < n; ++q) { sum_2 = sum_2 + q; } int sum_3 = 0; int i = 1; int j = 1; for (; i <= n; ++i) { j = 1; for (; j <= n; ++j) { sum_3 = sum_3 + i * j; } } return sum_1 + sum_2 + sum_3; }
这个代码分为三部分,分别是求sum_一、sum_二、sum_3。咱们能够分别分析每一部分的时间复杂度,而后把它们放到一起,再取一个量级最大的做为整段代码的复杂度。
第一段的时间复杂度是多少呢?这段代码循环执行了100次,因此是一个常量的执行时间,跟n的规模无关。
这里我要再强调一下,即使这段代码循环10000次、100000次,只要是一个已知的数,跟n无关,照样也是常量级的执行时间。当n无限大的时候,就能够忽略。尽管对代码的执行时间会有很大影响,可是回到时间复杂度的概念来讲,它表示的是一个算法执行效率与数据规模增加的变化趋势,因此无论常量的执行时间多大,咱们均可以忽略掉。由于它自己对增加趋势并无影响。
那第二段代码和第三段代码的时间复杂度是多少呢?答案是O(n)和O(n2),你应该能容易就分析出来,我就不啰嗦了。
综合这三段代码的时间复杂度,咱们取其中最大的量级。因此,整段代码的时间复杂度就为O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。那咱们将这个规律抽象成公式就是:
若是T1(n)=O(f(n)),T2=O(g(n));那么T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))
3.乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
我刚讲了一个复杂度分析中的加法法则,这儿还有一个乘法法则。类比一下,你应该能“猜到”公式是什么样子的吧?
若是T1=O(f(n)),T2(n)=O(g(n));那么T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)).
也就是说,假设T1(n)=O(n),T2(n)+=O(n2),则T1(n)*T2(n)=O(n3)。落实到具体的代码上,咱们能够把乘法法则当作是嵌套循环,我举个例子给你解释一下。
1int cal(int n) { 2 int ret = 0; 3 int i = 1; 4 for (; i < n; ++i) { 5 ret = ret + f(i); 6 } 7 } 8 9 int f(int n) { 10 int sum = 0; 11 int i = 1; 12 for (; i < n; ++i) { 13 sum = sum + i; 14 } 15 return sum; 16 }
咱们单独看cal()函数。假设f()只是一个普通的操做,那第4~6行的时间复杂度就是,T1(n)=O(n)。但+f()+函数自己不是一个简单的操做,它的时间复杂度是T2(n)=O(n),因此,整个cal()函数的时间复杂度就是,T(n)=T1(n)*T2(n)=O(n*n)=O(n2)。
我刚刚讲了三种复杂度的分析技巧。不过,你并不用刻意去记忆。实际上,复杂度分析这个东西关键在于“熟练”。你只要多看案例,多分析,就能作到“无招胜有招”。
几种常见时间复杂度实例分析
虽然代码千差万别,可是常见的复杂度量级并很少。我稍微总结了一下,这些复杂度量级几乎涵盖了你从此能够接触的全部代码的复杂度量级。
对于刚罗列的复杂度量级,咱们能够粗略地分为两类,多项式量级和非多项式量级。其中,非多项式量级只有两个:O(2n)和O(n!)。
咱们把时间复杂度为非多项式量级的算法问题叫做NP(Non-Deterministic+Polynomial,非肯定多项式)问题。
当数据规模n愈来愈大时,非多项式量级算法的执行时间会急剧增长,求解问题的执行时间会无限增加。因此,非多项式时间复杂度的算法实际上是很是低效的算法。所以,关于NP时间复杂度我就不展开讲了。咱们主要来看几种常见的多项式时间复杂度。
1.O(1)
首先你必须明确一个概念,O(1)只是常量级时间复杂度的一种表示方法,并非指只执行了一行代码。好比这段代码,即使有3行,它的时间复杂度也是O(1),而不是O(3)。
1 int i = 8; 2 int j = 6; 3 int sum = i + j;
我稍微总结一下,只要代码的执行时间不随n的增大而增加,这样代码的时间复杂度咱们都记做O(1)。或者说,通常状况下,只要算法中不存在循环语句、递归语句,即便有成千上万行的代码,其时间复杂度也是Ο(1)。
2.O(logn)、O(nlogn)
对数阶时间复杂度很是常见,同时也是最难分析的一种时间复杂度。我经过一个例子来讲明一下。
1 i=1; 2 while (i <= n) { 3 i = i * 2; 4 }
根据咱们前面讲的复杂度分析方法,第三行代码是循环执行次数最多的。因此,咱们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中能够看出,变量i的值从1开始取,每循环一次就乘以2。当大于n时,循环结束。还记得咱们高中学过的等比数列吗?实际上,变量i的取值就是一个等比数列。若是我把它一个一个列出来,就应该是这个样子的:
因此,咱们只要知道x值是多少,就知道这行代码执行的次数了。经过2x=n求解x这个问题咱们想高中应该就学过了,我就很少说了。x=log2n,因此,这段代码的时间复杂度就是O(log2n)。
如今,我把代码稍微改下,你再看看,这段代码的时间复杂度是多少?
1 i=1; 2 while (i <= n) { 3 i = i * 3; 4 }
根据我刚刚讲的思路,很简单就能看出来,这段代码的时间复杂度为O(log3n)。实际上,无论是以2为底、以3为底,仍是以10为底,咱们能够把全部对数阶的时间复杂度都记为O(logn)。为何呢?
若是你理解了我前面讲的O(logn),那O(nlogn)就很容易理解了。还记得咱们刚讲的乘法法则吗?若是一段代码的时间复杂度是O(logn),咱们循环执行n遍,时间复杂度就是O(nlogn)了。并且,O(nlogn)也是一种很是常见的算法时间复杂度。好比,归并排序、快速排序的时间复杂度都是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谁的量级大,因此咱们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。因此,上面代码的时间复杂度就是O(m+n)。
针对这种状况,原来的加法法则就不正确了,咱们须要将加法规则改成:T1(m) + T2(n) = O(f(m) + g(n))。可是乘法法则继续有效:T1(m) * T2(n) = O(f(m) * f(n))。
空间复杂度分析
前面,我们花了很长时间讲大O表示法和时间复杂度分析,理解了前面讲的内容,空间复杂度分析方法学起来就很是简单了。
前面我讲过,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增加关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic+space+complexity),表示算法的存储空间与数据规模之间的增加关系。
我仍是拿具体的例子来给你说明。(这段代码有点“傻”,通常没人会这么写,我这么写只是为了方便给你解释。)
1void print(int n) { 2 int i = 0; 3 int[] a = new int[n]; 4 for (i; i <n; ++i) { 5 a[i] = i * i; 6 } 7 for (i = n-1; i >= 0; --i) { 8 print out a[i] 9 } 10}
跟时间复杂度分析同样,咱们能够看到,第2行代码中,咱们申请了一个空间存储变量i,可是它是常量阶的,跟数据规模n没有关系,因此咱们能够忽略。第3行申请了一个大小为n的int类型数组,除此以外,剩下的代码都没有占用更多的空间,因此整段代码的空间复杂度就是O(n)。咱们常见的空间复杂度就是O(1)、O(n)、O(n2),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。并且,空间复杂度分析比时间复杂度分析要简单不少。因此,对于空间复杂度,掌握刚我说的这些内容已经足够了。
基础复杂度分析的知识到此就讲完了,咱们来总结一下。复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增加关系,能够粗略地表示,越高阶复杂度的算法,执行效率越低。常见的复杂度并很少,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2)。你就会发现几乎全部的数据结构和算法的复杂度都跑不出这几个。