复杂度分析(上)
如何分析、统计算法的执行效率和资源消耗?
数据结构和算法本身解决的是“快”和“省”的问题
即如何让代码运行得更快,如何让代码更省存储空间
为什么需要复杂度分析?
我把代码跑一遍,通过统计、监控
就能得到算法执行的时间和占用的内存大小
为什么还要做时间、空间复杂度分析呢?
这比我实实在在跑一遍得到的数据更准确吗?
这种评估算法执行效率的方法是正确的,叫做事后统计法
这种统计方法会非常大的局限性
1.测试结果非常依赖测试环境
-
测试环境中硬件的不同会对测试结果有很大的影响
同样一段代码,分别用Intel Core i9处理器和Intel Core i3处理器运行
不用说,i9处理器要比i3处理器执行的速度快很多
2.测试结果受数据规模的影响很大
-
对同一个排序算法,待排序数据的有序度不一样
排序的执行时间就会有很大的差别
极端情况下,如果数据已经是有序的
那排序算法不需要做任何操作,执行时间就会非常短
-
如果测试数据规模太小
测试结果可能无法真实地反应算法的性能
比如,对于小规模的数据排序
插入排序可能反倒会比快速排序要快!
因此,我们需要一个不用具体的测试数据来测试
就可以粗略地估计算法的执行效率的方法 —— 时间、空间复杂度分析方法
大 O 复杂度表示法
算法的执行效率,粗略地讲,就是算法代码执行的时间
如何在不运行代码的情况下
用“肉眼”得到一段代码的执行时间呢?
例如:求1,2,3…n的累加和
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遍,所以需要2n*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;
}
}
}
- 第2、3、4行代码,每行都需要1个unit_time
- 第5、6行代码循环执行了n遍,需要2n*unit_time的执行时间
- 第7、8行代码循环执行了n²遍,需要2n²*unit_time的执行时间
所有代码的执行时间T(n)=(2n²+2n+3)*unit_time
尽管我们不知道unit_time的具体值
但是通过这两段代码执行时间的推导过程
我们可以得到一个重要的规律
所有代码的执行时间T(n)与每行代码的执行次数n成正比
我们可以把这个规律总结成一个公式。大O登场!
-
T(n)代表代码执行的时间
-
n代表数据规模的大小
-
f(n)代表每行代码执行的次数总和
-
O表示代码的执行时间T(n)与f(n)表达式成正比
第一个例子中的T(n) = O(2n+2)
第二个例子中的T(n) = O(2n²+2n+3)
这就是 大 O 时间复杂度表示法
大O时间复杂度实际上并不具体表示代码真正的执行时间
而是表示代码执行时间随数据规模增长的变化趋势
因此也叫渐进时间复杂度,简称时间复杂度
当n很大时,可以把它想象成10000、100000
而公式中的低阶、常量、系数三部分并不左右增长趋势
所以都可以忽略
我们只需要记录一个最大量级就可以了
因此,用大O表示法表示上面两段代码的时间复杂度
- T(n)=O(n)
- T(n)=O(n²)
时间复杂度分析
1.只关注循环执行次数最多的一段代码
大O这种复杂度表示法只是表示一种变化趋势
通常我们忽略掉公式中的常量、低阶、系数
只需记录一个最大阶的量级就可以,因此
我们在分析一个算法、一段代码的时间复杂度的时候
只关注循环执行次数最多的那一段代码就可以了
这段核心代码执行次数的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)
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_1、sum_2、sum_3
我们可以分别分析每一部分的时间复杂度
然后把它们放到一块儿
再取一个量级最大的作为整段代码的复杂度
第一段的时间复杂度是多少?
这段代码循环执行力100次
所以是一个常量的执行时间,跟n的规模无关
再强调一下,即便这段代码循环10000次、1000000次
只要是一个已知的数,跟n无关,照样也是常量级的执行时间
当n无限大的时候,就可以忽略
尽管对代码的执行时间会有很大影响
但是回到时间复杂度的概念来说
它表示的是一个算法执行效率与数据规模增长的变化趋势
所以不管常量的执行时间多大,我们都可以忽略掉
因为它本身对增长趋势并没有影响
第二段和第三段代码的时间复杂度是多少?
O(n)和O(n²)
综合这三段代码的时间复杂度,我们取其中最大的量级
因此, 整段代码的时间复杂度就是O(n²)
总的时间复杂度就等于量级最大的那段代码的时间复杂度
将这个规律抽象成公式:
如果T1=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=O(g(n))
那么T(n) = T1(n) * T2(n)
=O(f(n)) * O(g(n))
=O(f(n) *g(n))
也就是说,假设T1=O(n) T2=O(n²)
则T1(n) * T2(n) = O(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 () 只是一个普通的操作
那第4~6行的时间复杂度就是,T1(n)=O(n)
但是f () 函数本身不是一个简单的操作
它的时间复杂度是,T2(n) = O(n)
所以整个cal()函数的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n*n)=O(n²)
不用刻意去记忆,复杂度分析关键在于“熟练”
只要多看案例,多分析,就能做到“无招胜有招”
几种常见时间复杂度实例分析
虽然代码千差万别,但是常见的复杂度量级并不多
我们可以粗略地分为两类
- 多项式量级
- 非多项式量级
非多项式量级只有两个:O(2^n) 、O(n!)
我们把时间复杂度为非多项式量级的算法问题叫做NP问题
(Non—Deterministic Polynomial, 非确定多项式)
当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加
求解问题的执行时间会无限增长
所以,非多项式时间复杂度的算法其实是非常低效的算法
常见的多项式时间复杂度
1. O(1)
O(1)只是常量级时间复杂度的一种表示方法
并不是指只执行了一行代码
int i = 8;
int j = 6;
int sum = i + j;
这段代码,即便有3行,它的时间复杂度也是O(1),而不是O(3)
只要代码的执行时间不随n的增大而增长
这样代码的时间复杂度都记作O(1)
一般情况下,只要算法中不存在循环语句、递归语句
即使有成千上万行的代码,其时间复杂度也是O(1)
2.O(logn) 、O(nlogn)
对数阶时间复杂度非常常见
同时也是最难分析的一种时间复杂度
i=1;
while (i <= n) {
i = i * 2;
}
第三行代码是循环次数最多的
我们只要能计算出这行代码被执行了多少次
就能知道整段代码的时间复杂度
从代码中可以看出,变量i的值从1开始取,每循环一次就乘以2
当大于n时,循环结束
这相当于高中学过的等比数列
所以,我们只要知道x值是多少,就知道这行代码执行的次数了
显然 x=log2n,因此这段代码的时间复杂度就是O(log2n)
同样,下面这段代码的时间复杂度就是O(log3^n)
i=1;
while (i <= n) {
i = i * 3;
}
实际上,不管是以2为底还是以3为底,甚至以10为底
我们可以把所有对数阶的时间复杂度都记为O(logn)
对数之间是可以互相转换的,log3^n = log3^2 * log2^n
因此,O(log3^n) =O(C * log2^n)
其中C = log3^2是一个常量
基于前面的一个理论:
在采用大O标记复杂度时,可以忽略系数 O(C f(n))=O(f(n))
因此,O(log3^n) =O( log2^n)
在对数阶时间复杂度的表示方法里,我们忽略对数的“底”
统一表示为O(logn)
运用乘法原则
如果一段代码的时间复杂度是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))
空间复杂度分析
前面说过,时间复杂度的全称是
渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系
类比一下,空间复杂度的全称是
渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系
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) {
print out a[i]
}
}
跟时间复杂度分析一样
第二行代码中,申请了一个空间存储变量 i
但是它是常量阶的,跟数据规模 n 没有关系 , 可以忽略
第三行申请了一个大小为 n 的 int 类型数组
除此之外,剩下的代码都没有占用更多的空间
因此整段代码的空间复杂度就是O(n)
常见的空间复杂度就是O(1)、O(n) 、O(n²)
像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到
空间复杂度比时间复杂度分析要简单的多
掌握这些内容就足够了!
内容小结
复杂度也叫渐进复杂度
包括时间、空间复杂度
用来分析算法执行效率与数据规模之间的增长关系
粗略地表示:越高阶复杂度的算法,执行效率越低
从低阶到高阶有:O(1)、O(logn) 、O(n) 、O(nlogn) 、O(n²)
几乎所有的数据结构和算法的复杂度都跑不出这几个
复杂度分析并不难,关键在于多练
思考题
项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析
是不是多此一举?
渐进时间、空间复杂度分析为我们提供了一个很好的理论分析的方向
并且它与宿主平台无关,能够让我们对程序或算法有个大致的认识
让我们知道,在最坏的情况下程序的执行效率如何
也为我们交流提供了一个不错的桥梁
- 算法1的时间复杂度是O(n)
- 算法2的时间复杂度是O(logn)
这样我们很快就对不同的算法有了一个“效率”上的感性认识
渐进时间、空间复杂度分析只是一个理论模型
提供粗略的估计分析
我们不能直接断定O(logn)的算法一定优于O(n)
针对不同的宿主环境,不同的数据集,不同的数据量的大小
在实际应用上真正的性能可能会不同
针对不同的实际情况,进而进行一定的性能基准测试是很有必要的
比如在统一一批手机上(同样的硬件、系统)进行横向基准测试
进而选择适合特定应用场景下的最有效算法
时间复杂度、空间复杂度分析与性能基准测试并不冲突矛盾
而是相辅相成的
但是一个低阶的时间复杂度有极大的可能性会优于一个高阶的时间复杂度程序
在实际编程中,时刻关心理论时间,空间模型有助于产出效率高的程序
并且时间、空间复杂度分析只是提供一个粗略的分析模型
因此不会浪费太多时间,重点在于编程时,要具有这种复杂度分析的思维