《算法复杂度分析》原文链接,阅读体验更佳
数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。但是当我们写完一个算法的时候,我们怎么才能知道它具体有“多快”,具体有“多省”呢?
最简单直接的方式就是,把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用内存的大小。首先,可以肯定的是,这样的分析方法是正确的。我们称这种统计方法为事后统计法。但是这种方法由非常大的局限性:
-
测试结果非常依赖测试环境
测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用Inter Core i9处理器和Inter Core i3处理器来运行,不用说,i9处理器要比i3处理器执行的速度快很多。还有一种情况,原本在这台机器上,a代码的执行速度比b代码要快,但是等我们换到另一台机器上,可能会得到截然相反的结果。
-
测试结果受数据规模和数据不同实际情况的影响很大
后面我们会讲排序算法,这里先拿它举个例子。对于同一个排序算法,待排序数据的有序度不一样,排序执行时间就会有很大的差异。极端情况下,如果数据已经是有序的,那么排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反映算法的性能。比如,对于小规模的数据排序,插入排序可能反而必快速排序要快!
时间复杂度分析——大O复杂度表示法
既然事后统计法限制太多,成本太高,那么就需要一种方法能在不运行代码的情况下估计代码的效率,但是,如何在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?这就是我们接下来要介绍的复杂度分析。
我们看一下下面的求从1到n的累加和的Java代码:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
从CPU的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的CPU的指令个数和执行时间都不一样,但是这里我们只是粗略估计代码的执行时间,所以我们可以忽略这些差异,假设每行代码执行时间都是一样的,为unit_time。在这个假设之上,这段代码的总执行时间是多少呢?
第2、3行代码分别需要1个unit_time的执行时间,第4、5行代码都需要运行n词,所以分别需要n个unit_time的执行时间,所以上面的代码总的执行时间就是(2n+2) * unit_time。可以看出,所有代码的执行时间T(n)与每行代码的执行次数成正比。
按照这个分析思路,我们继续看下面的代码:
int cal(int n) {
int sum = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum = sum + i * j;
}
}
}
我们依旧假设每个语句的执行时间是unit_time。那上面的代码的执行时间T(n)是多少呢?
第2、3、4行代码都只执行一次,需要3个unit_time;第5、6行代码需要循环执行n次,需要2n个unit_time;第7、8行代码都会重复执行n2次,需要2n2个unit_time。所以上面的代码总共需要(2n2 + 2n + 3)个unit_time。
尽管我们不知道unit_time的具体值,但是通过对上面两段代码的分析,我们可以得到一个非常重要的规律——所有代码的执行时间T(n)与代码中实际执行的代码行数f(n)成正比。这里所说的实际执行的代码行数指的是每行代码乘以这行代码的执行次数的总和。
我们把这个规律总结成一个公式,就得到了大名鼎鼎的大O表示法:T(n)=O(f(n))
。
我们来具体解释一下这个公式。其中,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),简称时间复杂度。
只关注主要矛盾——循环执行次数最多的一段代码
上面例子中的T(n) = O(2n+2) 和 T(n) = O(2n2+2n+3)都是非常常见的多项式复杂度,但是当我们的代码非常长的时候,我们所总结出来的多项式可能项数就会非常的多,这个时候书写起来就会非常不方便。
然而,算法复杂度分析主要针对的是代码执行时间随着数据规模的增长的变化趋势,而在f(n)这个多项式中,n永远是自然数,这个时候我们其实可以只关心f(n)这个多项式中次数最大的一项,因为:
- 对于常数项,无论我们的数据规模是怎样的,它的执行次数都是不受影响的,也就是说常数项是代码的固定开销,与输入数据的规模无关。
- 对于次数较小的项,当我们的输入规模足够大的时候,它对代码执行时间的影响将远远小于幂次更高的项,比如数据输入规模是1000,f(n)=2n2+2n+3中的2n这一项的值为2000,而2n2这一项的值则为2000000,它们之间差了整整三个数量级。
而我们在上一篇文章中提到过,当计算机在解决一个问题的时候,一般都存在多种不同的方法。对于小型问题,只要管用,采用什么样的方法体现不出太大的差别。**但是对于大型问题(或者需要解决大量小型问题的应用),我们就需要设计和选择能够有效利用时间和空间的方法了。**可见,我们在评估一个算法的效率的时候主要是针对大规模的数据的。
在大规模数据量的情况下,代码执行时间的变化主要受多项式中最高次幂项的影响,因此,我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。
那在我们具体分析一段代码的时间复杂度的时候应该怎么做呢?**在我们分析一个算法、一段代码的时间复杂度的时候,只需要关注循环次数最多的那一段代码就可以了。**这段核心代码执行次数的n的量级,就是整段要分析代码的时间复杂度。
为了便于理解,我们还是以下面的代码为例:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
其中第2、3行代码都是常量级的执行时间,与n的大小无关,所以对于复杂度并没有影响。循环次数最多的是第4、5行代码,所以这块代码要重点分析。前面我们也分析过了,这两行代码被执行了n次,所以总的时间复杂度就是O(n)。
不是所有的语句的执行时间都可以认为是unit_time
上文我们在进行分析的时候提到,我们在分析一段代码的时间复杂度的时候,可以认为每一行代码的执行时间都是unit_time,其实这是不全面的,因为在上文中我们给出的代码示例中并不涉及函数(过程)调用、也不涉及成本较高的IO操作,更没有考虑并发问题,而只是给出了一些简单的表达式。对于只有简单表达式的代码,我们认为每一行代码的执行时间都是unit_time是没有问题的,但是在遇到下面几种情况的时候,我们就不能简单地认为一行代码的执行时间是unit_time了:
- 函数调用:在我们的代码中调用了另一个函数的时候,这一行代码的执行时间量级可能并不是常量级,这要视被调用函数的具体情况而定。
- 比较耗时的IO等操作:如果我们的代码中发送了网络请求,或者进行了文件读取,这样的代码有别于普通的计算,它们的执行成本是比较高的。
- 并发情况下发生的资源竞争:在代码并行运行的时候,为了并发安全性,我们会对共享资源进行并发控制,在代码进入临界区的时候可能会发生线程阻塞,我们在上面的示例中也没有考虑这一点。
其实不能简单认为执行时间是unit_time的代码情况还是比较多的,但是在我介绍数据结构和算法的系列文章中,我们只讨论在单线程情况下对内存中大批量数据的处理,所以我们这里只需要注意函数调用这一种特殊的情况就可以了。
加法法则:总复杂度等于量级最大的那段代码的复杂度
现在我们又知道了,不是所有的“一行代码”都可以认为其执行时间是unit_time,下面我们再来试着分析一下下面这段代码的复杂度:
int cal(int n) {
int sum1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum1 = sum1 + p;
}
int sum2 = sum2(n);
int sum3 = sum3(n);
return sum1 + sum2 + sum3;
}
其中的sum2和sum3这两个方法的代码如下:
int sum2(int n) {
int sum_2 = 0;
int q = 1;
for (; q < n; ++q)
{
sum_2 = sum_2 + q;
}
return sum2;
}
int sum3(int n) {
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 sum3;
}
我们在分析上面cal方法代码的复杂度的时候就一定要注意了,在cal方法里面包含了对sum2方法和sum3方法的调用,这个时候我们不能认为第7行和第8行的执行时间都是unit_time,这个时候我们需要把sum2和sum3的代码“内联”到底7行和第8行,然后才能分析出真正的时间复杂度,也就是说,分析上面cal方法的时间复杂度其实就等价于分析如下代码的时间复杂度:
int cal(int n) {
int sum1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum1 = sum1 + p;
}
int sum2 = 0;
int q = 1;
for (; q < n; ++q) {
sum2 = sum2 + q;
}
int sum3 = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum3 = sum3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
上面的等价代码其实包含了三个部分,即求解sum1、sum2和sum3。根据我们上面刚刚介绍的只需要关注主要矛盾,我们可以分别分析每一个部分的时间复杂度,然后把它们放到一起,再取一个量级最大的作为整段代码的复杂度。
第一段代码的时间复杂度是多少呢?这段代码循环执行了100次,是一个常量执行时间,与n的规模无关。
固定循环一亿次也是常量级
这里我们要再次强调一下,就像上面代码中4、5、6行这种循环指定固定次数的代码,无论它是循环10000次、100000次,只要循环执行的次数是一个固定的值,照样也是常量级的执行时间。当n无限大的时候,就可以忽略。
**尽管大次数的循环体可能会对代码的执行时间有非常大的影响,但是渐进时间复杂度的概念是,表示一个算法执行效率与数据规模增长的变化趋势。**所以不管循环执行固定次数的代码需要执行多长时间,我们都可以忽略掉,因为它对增长趋势并没有影响。
第二段代码和第三段代码的时间复杂度我们就不一一分析了,它们的复杂度分别是O(n)和O(n2)。最终的分析结果就是cal方法的时间复杂度是O(n2)。
这里的cal方法与我们在文章开头举的例子的区别在于在cal方法中调用了sum2和sum3方法,我们刚才介绍的思路是把sum2和sum3方法的代码“内联”到cal方法之后再分析整段代码的时间复杂度,那么如果有其他的地方也调用的sum2和sum3方法我们要重复内联这个过程吗?其实,我们只需要事先分析好sum2和sum3方法的复杂度,在调用到它们的地方直接使用这个值就可以了。
这就是大O时间复杂度表示法的加法法则:**总的时间复杂度就等于每行代码的时间复杂度的和,经过简化之后就是时间复杂度最大的那行代码的复杂度。**我们把这个规律抽象成一个公式,就是:
如果 T1(n)=O(f(n)),T2(n)=O(g(n));
那么 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
下面我们在来看cal方法的代码,为了方便阅读,我把他的代码贴到下面:
int cal(int n) {
int sum1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum1 = sum1 + p;
}
int sum2 = sum2(n);
int sum3 = sum3(n);
return sum1 + sum2 + sum3;
}
cal方法的第2~6行代码的时间复杂度都是O(1),第7行调用sum2,时间复杂度是O(n),第8行调用sum3,时间复杂度是O(n2),第9行的时间复杂度是O(1),最后我们取次数最高的项,可以知道cal方法的时间复杂度是O(n2)。
乘法发则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
上面我们介绍了时间复杂度分析中的加法法则,加法法则可以解决向上面cal方法中那样在方法体中直接调用另一个方法的复杂度分析,但是如果在循环中嵌套调用另一个方法的情况,我们就需要用到时间复杂度分析中的另一个法则——乘法法则了。
类比加法法则的公式,我们不难推断出乘法法则的公式:
如果 T1(n)=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)).
落实到具体的代码上,我们可以把乘法法则看成是嵌套循环,我用下面这个例子来解释一下:
int cal(int n) {
int ret = 0;
int i = 1;
for (; i < n; ++i) {
ret = ret + f(i);
}
}
int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
我们单独看cal方法,假设f方法只是一个普通的操作,这个时候我们可以认为它的执行时间是unit_time,那么4 ~ 6行的复杂度就是,T1(n) = O(n)。但是,第5行中调用了f方法,我们其实不能认为它的执行时间是unit_time,f函数的时间复杂度T2(n)=O(n),当我们假设f方法时间是unit_time也就是其时间复杂度是O(1)的时候,第5行代码的时间复杂度已经是O(n),而f方法本身的时间复杂度又是O(n),这个时候第5行的实际时间复杂度其实就是O(n)*O(n),也就是O(n2)了。
你可以把f方法的代码内联到第5行代码分析一下,看看分析出来的时间复杂度是不是O(n2)。
空间复杂度分析
上文花了非常大的篇幅来介绍算法的时间复杂度分析,解决了如何通过肉眼预估一个算法执行的有多“快”,对应的,用肉眼评判一个算法有多“省”的方法就是空间复杂度分析。相比于时间复杂度分析,空间复杂度就简单的多了。
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。同样的,对于空间复杂度,我们也使用大O复杂度表示法。
这里还是以一段代码来说明:
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i < n; ++i) {
a[i] = i * i;
}
for (i = n-1; i >= 0; --i) {
System.out.println(a[i]);
}
}
跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
我们常见的空间复杂度就是 O(1)、O(n)、O(n2)。而且,空间复杂度分析比时间复杂度分析要简单很多,而且,在实际编码的时候,对存储空间的优化也比对执行时间的优化简单很多,所以,对于空间复杂度,这里就不做过多的介绍了。
总结
上文,我们总结了算法时间复杂度的大O表示法也就是渐进时间复杂度以及算法时间复杂度分析的基本方法。简单介绍了算法的空间复杂度分析。复杂度分析非常重要,可以这么说,它几乎占据了数据结构与算法这门学科的半壁江山,是数据结构和算法学习的精髓。
复杂度分析就是算法世界基本的是非观,只有掌握了复杂度分析,我们才能对代码的效率好坏有一个基本的判断能力。如果你只是掌握了数据结构和算法的特点、用法,但是没有学会复杂度分析,那就相当于学武功只学习了招式,而没有掌握内功心法,只有把心法了然于胸,才能做到无招胜有招。
文章开头的时候我们提到过,事后统计法的缺陷之一就是测试结果受数据规模和数据实际情况的影响很大,但是在上文中我们给出的所有示例中,输入的数据都是循环的次数。在这种情况下其实只能体现出数据规模对算法效率的影响,但是输入数据的实际情况并不仅仅包含数据的规模。
而数据的实际情况可能也会对算法的执行效率造成很大的影响,这也是我们在进行算法时间复杂度分析时不得不考虑的。
下文我们就来介绍一下如何在分析算法时间复杂度的时候对数据的实际情况进行分情况讨论,也就是最好、最坏、平均、摊还时间复杂度,同时我们也会介绍一些常见的时间复杂度,以及它们之间的优劣。
本人深知自己技术水平和表达能力有限,文章中一定存在不足和错误,欢迎与我进行交流(laomst@163.com),跟我一起交流,修改文中的不足和错误,感谢您的阅读。