今天这篇文章主要说下如何分析、统计算法的执行效率、资源消耗
我们知道数据结构和算法本质上解决的是 「快」和 「省」的问题,那就是如何在占用更少资源的情况下更高效的执行算法。这就是今天我们要说到的 时间复杂度与空间复杂度。只要讲到数据结构和算法,就一定离不开时间复杂度与空间复杂度。复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。复杂度分析 就像是内功心法,如果我们只掌握了数据结构和算法用法、特点,那我们只是学到上乘武功的招式,我们知道上乘武功还需要搭配牛逼的内功心法才能发挥它的能力
为什么需要复杂度分析
有些朋友可能会有些疑惑,我把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据还要准确吗?
这种统计方法不是不行,也能得出相关算法的执行时间和占用内存大小,很多数据结构和算法的相关书籍还给这种统计方法起了一个名字,叫 事后统计法。但是,这种统计方法存在着一些局限性
测试结果非常依赖测试环境
测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同一段代码,分别用 Intel Core i7 处理器和 Intel Core i3 处理器来运行,不用说,i7 处理器要比 i3 处理器的执行速度要快得多
测试结果受数据规模的影响很大
我们拿排序算法举个例子,对于同一个排序算法,待排序的数据有序度不一样,排序的执行时间就会有很大的差别
极端情况下,待排序的数据是有序的,那排序算法不需要做出任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能就无法真实的反映出算法的性能
比如,对于小规模的数据排序,插入排序可能反倒要比快速排序要快
所以,我们需要一个不用具体的测试数据测试,就能粗略的估计算法执行效率的方法。这就是下面要说到的时间、空间复杂度分析方法
大 O 复杂度表示法
算法的执行效率,简单的说,就是算法执行的时间。但是,如何不在运行代码的情况下就能估算出算法的执行时间
下面我们来看一段代码,该代码块主要实现的功能是计算 1,2,3......n 的累加和,现在,一起来估算下这段代码的执行时间
function cal($n) {
$sum = 0;
for($i = 1;$i < $n;$i++) {
$sum += $i;
}
return $sum;
}
我们这里只是粗略的估算,所以假设每一行代码执行的时间都一样,为 unit_time
在这个假设的基础上,这段代码的执行时间是多少呢?
第 2 行代码需要 1 个 unit_time 的执行时间,第 3、4 行代码都已经运行了 n 遍,所以需要 2n * unit_time 的执行时间,所以这段代码总的执行时间就是 (2n + 1) * unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比
我们继续分析下面这段代码
function cal($n) {
$sum = 0;
for($i = 1;$i < $n;$i++) {
for($j = 1;$j < $n;$j ++) {
$sum = $sum + $i * $j;
}
}
return $sum;
}
我们依然假设每一行代码的执行时间为 unit_time。那么这段代码总的执行时间是多少呢?
第 2 行代码需要 1 个 unit_time 的执行时间,第 3 行代码循环执行了 n 遍,需要 n * unit_time 的执行时间,第 4、5 行代码循环执行了 2n^2 遍,所以需要 2n^2 * unit_time 的执行时间。所以,这段代码总的执行时间 T(n) = (2n^2 + n + 1) * unit_time
尽管我们不知道 unit_time 的具体值,但是通过上面两段代码执行时间的推导过程,我们得到一个非常重要的规律,所有代码的执行时间 T(n) 与每行代码执行次数 n 成正比
我们就可以把这个规律总结成一个公式
T(n) = O(f(n))
其中,T(n) 表示所有代码总的执行时间,n 表示代码的执行次数也可以表示数据规模的大小;f(n) 表示每行代码执行次数的总和。因为这是一个公式,所以使用 f(n) 表示,公式中的 O 表示代码的执行时间 T(n) 与 f(n)表达式成正比
所以,第一个例子中的 T(n) = O(2n + 1),第二个例子中的 T(n) = O(2n^2 + n + 1)。这就是 大 O 时间复杂度表示法
大 O 时间复杂度表示法并不具体表示代码真正的执行时间,而是表示 代码执行时间随数据规模增长的变化趋势,所以,也叫 渐进时间复杂度(asymptotic time complexity),简称 时间复杂度。
注意:时间复杂度表示的是变化趋势,并不是真正的执行时间
当 n 很大时,公式中的 常量、系数、低阶 并不能影响到整体的变化趋势,所以这些是可以忽略的,所以,用大 O 时间复杂度表示法表示上面两个例子的时间复杂度,就可以表示为:
T(n) = O(n)
T(n) = O(n ^ 2)
时间复杂度分析
通过上面的介绍我们已经了解了大 O 时间复杂度表示法。接下来我们来看下如何分析一段代码的时间复杂度?
只关注循环次数最多的一段代码
大 O 时间复杂度表示法只是表示一段代码的变化趋势。我们通常会忽略公式中的 常量、系数、低阶,所以,我们在分析一个算法的时间复杂度的时候,只需要关注循环次数最多的那一段代码就可以了,如下:
function cal($n) {
$sum = 0;
for($i = 1;$i < $n;$i++) {
$sum += $i;
}
return $sum;
}
这段代码第 2 行的执行时间是一个常量,与 n 的大小没有关系,对于时间复杂度并没有影响。第 3、4 行是执行次数最多的,所以我们重点关注这块代码,通过上面的介绍,这两行代码都被执行了 n 次,所以总的时间复杂度就是 O(n)
加法法则:总复杂度等于量级最大的那段代码的复杂度
看下面这段代码:
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;
}
这段代码可以分为 3 部分,分别计算 sum_1、sum_2、sum_3。我们可以分别估算出每段代码的时间复杂度,现在我们来看下这三段代码的时间复杂度:
- 第一段代码:第 2、3 行代码都需要 1 个 unit_time 的执行时间,第 4、5 行代码执行了 2 * 100 次,故需要 2 * 100 * unit_time 的执行时间,可以看到这段代码的执行时间是一个常量,与 n 无关,是一个常量级的执行时间,当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来讲,它表示的是一个算法的执行效率与数据规模增长的变化趋势,所以不管常量的执行时间有多大,我们都可以把它忽略掉,因为它本身对增长趋势并没有影响
- 第二段代码和第三段代码的时间复杂度分别是:O(n)、O(n^2)
综合这三段代码的时间复杂度,我们取其中最大的量级。所以,上面这段代码总的时间复杂度为 T(n) = O(n^2)。也就是说,总的时间复杂度就等于量级最大的那段代码的时间复杂度。我们将这个规律抽象成公式就是:
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)))
乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
如果 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))
也就是说,假设 T1(n) = O(n),T2(n) = O(n^2),则:
T1(n) * T2(n) = O(n^3)
下面我们看一个具体的代码:
int cal(int n) {
int ret = 0;
int i = 1;
for(x=1; i <= n; x++){
for(i = 1; i <= n; i++) {
j = i;
j++;
}
}
}
分析这段代码的时间复杂度。假设该段代码的第 5-8 行是一个简单的操作,那么第 4 行代码的时间复杂度就是,T1(n) = O(n)。但是第 5-8 行代码不是一个简单的操作,它的时间复杂度是 T2(n) = O(n),所以,整段代码的时间复杂度就是:
T(n) = T1(n) * T2(n) = O(n) * O(n) = O(n^2)
几种常见的时间复杂度实例
虽然代码千差万别,但是常见的复杂度量级并不多,下面我们将常见的几种复杂度量级列举出来,这些复杂度量级几乎涵盖了日常开发中接触到的所有代码的复杂度量级:
- 常量阶 O(1)
- 对数阶 O(logn)
- 线性阶 O(n)
- 线性对数阶 O(nlogn)
- 平方阶 O(n^2)、立方阶 O(n^3)......k 次方阶 O(n^k)
- 指数阶 O(2^n)
- 阶乘阶 O(n!)
对于上面罗列的常见的复杂度量级我们可以粗略的分为两类:多项式量级 和 非多项式量级。其中,非多项式量级只有两个:O(2^n) 和 O(n!)
多项式量级就是说这个时间复杂度是以 n 作为底数,如:对数阶 O(logn)、k 次方阶 O(n^k)等
非多项式量级就是说这个时间复杂度不是以n作为底数,如:指数阶 O(2^n)、阶乘阶 O(n!)
当数据规模 n 越来越大的时候,非多项式量级的算法执行时间会急剧增加,所以,非多项式量级的算法其实是非常低效的算法,一般情况下,我们把非多项式量级的算法问题成为 NP 问题(Non-Deterministic Polynomial)-非确定多项式
我们主要看几种常见的多项式时间复杂度
1.常量阶 O(1)
首先我们必须要明确一个概念,O(1) 只是常量级时间复杂度的一种表示法,并不是指只执行一行代码。比如下面这段代码,即使只有 3 行,它的时间复杂度也是 O(1),并不是 O(3)
int a = 1;
int b = 2;
int c = 3;
只要代码的执行时间不随着数据规模 n 的增大而增长,这样代码的时间复杂度我们都记做 O(1)。或者说,一般情况下,只要代码中不存在循环语句、递归语句,即使代码中存在成千上万的代码,其时间复杂度也是 O(1)
2.对数时间复杂度和线性对数时间复杂度 O(logn)、O(nlogn)
对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度
i = 1;
while(i < n) {
i = i * 2;
}
根据前面我们讲的时间复杂度分析法 「只关注循环次数最多的一段代码」,第三行代码的循环次数最多。所以,我们只需要计算出这段代码的执行次数,就能知道这段代码的时间复杂度
从上面代码中可以看出,变量 i 的值从 1 开始取值,每循环一次就乘以 2。当大于 n 时,循环结束。这有点类似我们高中学习的等比数列,实际上,变量 i 的取值就是一个等比数列。如下所示:
2^0*2^1*2^2*2^3......2^x = n
所以,我们只需要知道 x 值是多少,就能知道这段代码的执行次数了。那么我们只需要计算 2^x = n 就行了,在这里经过计算 x = log2n,所以,这段代码的时间复杂度就是 O(log2n)
换底公式:logab = logcb/logca
我们把上面那段代码稍微改下,看下下面这段代码的时间复杂度是多少?
i = 1;
while(i <= n) {
i = i * 3;
}
同样使用上面的分析方法可知,这段代码的时间复杂度为 O(log3n)
实际上,不管是以 2 为底、以 3 为底,还是以 10为底,我们都可以把所有对数阶的时间复杂度都记为 O(logn),为什么呢?
我们知道对数之间是可以互相转换的,log3n = log32 * log 2n,所以,O(log3n) = O(log32 * log2n) = O(C * log2n) ,其中 C = log32 是一个常量。基于我们前面介绍的,使用大 O 时间复杂度表示法的时候 常量、系数、阶数 都可以忽略不计,即 O(Cf(n)) = O(f(n))。所以,O(log3n) = O(log2n),因此,在对数阶时间复杂度表示方法中,我们可以忽略对数的 底,统一表示为 O(logn)
下面我们看下线性对数阶 O(nlogn),还记得上面我们介绍过的乘法法则吗?嵌套代码的时间复杂度等于嵌套内外代码时间复杂度的乘积。如果一段代码的时间复杂度是 O(logn),我们将这段代码执行 遍,时间复杂度就是 O(nlogn) 了。*线性对数阶 O(nlogn) *也是一种非常常见的算法时间复杂度
如下代码时间复杂度就是 O(nlogn),内部 while 循环的时间复杂度是 O(logn),被外层 for 循环包起来,所以就是 O(nlogn)
for(m = 1;m < n;m++) {
i = 1;
while(i < n) {
i = i * 2;
}
}
3.线性阶
看下面这段代码会执行多少次
for(i = 1;i <= n;i++) {
j = i;
j++
}
第 1 行代码会执行 n + 1 次,第 2、3 行代码会分别执行 n 次,总的执行时间是 3n + 1次,根据前面我们介绍的 大 O 时间复杂度表示法 可知,它的时间复杂度是 O(n)
4.平方阶(O(n^2))
for(x=1; i <= n; x++){
for(i = 1; i <= n; i++) {
j = i;
j++;
}
}
把时间复杂度为 O(n) 的代码再嵌套循环一次,它的时间复杂度就是 O(n^2)
立方阶 O(n^3)、k 次方阶 O(n^k)
5.O(m + n)、O(m * n)
我们再来介绍一种和前面都不一样的时间复杂度,通过上面的介绍我们知道 大 O 时间复杂度表示法并不真正表示算法的执行时间,只是表示算法的执行时间随着数据规模 n 的增大的增长趋势,在这里我们知道代码的复杂度只取决于一个数据规模 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) * g(n))
空间复杂度分析
时间复杂度的全称是 渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度的全称就是 渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系
void print(int n) {
int i = 0;
int[] a = newint[n];
for (i; i <n; ++i) {
a[i] = i * i;
}
}
通过上面这段代码我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略不计。第 3 行代码申请了一个大小为 n 的 int 类型的数组,除此之外,剩下的代码都没有占用更多的空间,所以,整段代码的空间复杂度就是 O(n)
我们常见的空间复杂度就是 O(1)、O(n)、O(n^2),像 O(logn)、O(nlogn) 这样的对数阶空间复杂度平时都用不到。而且空间复杂度分析要比时间复杂度分析要简单很多,下面我们介绍下平时使用的比较多的空间复杂度
空间复杂度 O(1)
如果算法执行所需要的存储空间不随着某个变量 n 的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
上述代码中 i、j 变量所分配的内存空间都不会随着处理数据量的变化而变化,因此,它的空间复杂度是 O(1)
空间复杂度 O(n)
int[] m = newint[n]
for(i=1; i <= n; ++i) {
j = i;
j++;
}
这段代码中第 1 行申请了大小为 n 的 int 类型数组,后面虽然有循环,但是没有再分配新的内存空间,因此,这段代码的空间复杂度主要看第一行即可
总结
复杂度也叫渐进复杂度,包括时间复杂度、空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略的表示,越高阶复杂度的算法,执行效率越低
常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)