算法的复杂度估算是计算机中很重要的一块内容,通过复杂度分析我们可以估算程序和算法的运行时间,使用内存,随着输入数据的规模变大的增长规律,从而分析一个算法的优劣
算法分析在竞赛中的实际意义就是可以通过算法的复杂度分析估计可以得到的分数,以及选择更好的算法。通常初学者常常遇到的问题是自己写了一个暴力算法,却没有算法复杂度的概念,从而也不知道写的是一个暴力算法,也无法理解自己的算法为什么会超时(TLE),超内存(MLE)
不论在何种环境,我们学习数据结构和算法以及算法的时空复杂度分析的最终目的就是让我们设计出的算法在计算机上运行的速度更快,所用的内存更小
算法的复杂度分析在许多大牛的的成长路上以及对于写出更加优秀的算法是十分必要的环节
理论内容取材于维基百科,详见链接
算法的时间复杂度是一个函数,用于定性描述这个算法的运行时间。这是一个代表算法输入值的字符串的长度函数,时间复杂度常用大O符号表示,不包括这个函数的低阶项和首项系数,使用这种方式的时候,时间复杂度可以被称为是渐进的,即考察输入值大小趋近无穷时的情况,例如一个算法对于任何大小为n(大于n0)的输入,它至少需要5n^3 + 3n的时间来完成,那么这个算法的时间复杂度就是O(n^3)
通常算法时间复杂度分析有如下的两种方法:
i. 运行后分析
这种方法通常是将写出的算法在机器上运行,统计这个算法使用了多少时间和内存,但是这种方法存在缺陷:这种测试方式对机器的依赖性较强,性能比较强的机器相对的运行时间,占用内存当然比较小;同时测试的结果依赖于测试用的数据,例如二分法,很依赖于数据的排列方式和所查找数据的位置,测试得出的结果参考的意义较低
ii. 运行前分析
因为运行后才对算法的时空复杂度进行分析这样的分析方法存在问题,于是我们考虑到在纸上提前模拟计算一个算法所需要的大概的执行时间和占用内存,于是我们用到了大O复杂度表示法
我们从下面的代码来了解大O复杂度表示法:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int main() {
int n;
scanf("%d", &n);
for (R int i = 0; i < n; ++i) {
for (R int j = 0; j < n; ++j) {
printf("%d ", n);
}
}
return 0;
}
我这里展示了一个两层循环嵌套的简单的代码,这段代码对于CPU而言需要分三步进行:读取代码和指令数据,计算,输出结果如果学习过汇编的同学可以更能明白这里。
我们假设每条代码在CPU上的运行时间为cpu_time,那么我们可以粗略的估计这里使用的时间。
对于定义n和输出n的步骤,分别需要一个cpu_time,这里需要2 × cpu_time
对于每一个单层的循环,我们都需要2n个cpu_time,因此循环嵌套的部分需要4n^2 × cpu_time
循环内的输出函数需要一个cpu_time,因此对于输出我们需要n^2 × cpu_time
这样,对于这个程序,我们总的运行时间为:
T ( n ) = 4 n 2 + n 2 + 2 = 5 n 2 + 2 T(n) = 4n^2 + n^2 + 2 = 5n^2 + 2 T(n)=4n2+n2+2=5n2+2
对于我们每一次输入数据,代码执行时间随n的增大而增大,这个公式的系数是CPU执行每一次代码的时间,即我们之前定义的cpu_time,此时我们将上述公式写成大O复杂度表示法即:
T ( n ) = O ( f ( n ) ) T(n) = O(f(n)) T(n)=O(f(n))
其中T(n)表示算法执行的时间,f(n)表示算法执行的总次数,O()表示 T(n) 和 f(n) 的关系,即大O的由来
因此我们可以将上面的公式换成:
T ( n ) = O ( 5 n 2 + 2 ) T(n) = O(5n^2 + 2) T(n)=O(5n2+2)
但是我们这里的大O复杂度表示法并不需要计算代码的准确执行时间,而是需要表示一种代码的执行时间或者占用内存随着数据规模增长的一个变化趋势,这里需要的不是准确时间,而是变化趋势,因为实际工作的算法的时间可能需要大量的数据,通过分析算法的运行时间和输入数据的规模的变化趋势就能大致的了解一个算法在其相应的环境中较好的工作
上面的表示的方法并不简洁,在绝大多数情况下,我们设计的算法时间复杂度的多项式式子可能很长,这样仍旧不方便,那么我们**可以将一些常数,低阶项等运行次数对最高阶多项式影响不大的项删去,我们还可以将多项式的系数删去,**因为我们需要了解的是一个算法时间随数据的变化趋势,多项式的系数对时间的影响并不大,因此我们同样可以删去
因此,上述代码的时间复杂度如下:
T ( n ) = O ( n 2 ) T(n) = O(n^2) T(n)=O(n2)
因此我们说这个算法的时间复杂度是n^2级别的
上述就是大O复杂度分析
算法的复杂度根据量级可以分为多项式(Polynomial)和非多项式量级(Non-Deterministic Polynomial),举例O(2^n)和O(n!)属于非多项式量级的时间复杂度,即当数据规模n越来越大的时候,非多项式量级的算法执行时间将急剧增加,且增加速度很恐怖,所以非多项式量级的算法通常情况下是不可接受的低效的算法
当我们拿到一段代码,我们如何分析这段代码,如下是几个比较实用的方法:
i. 只关注循环执行次数做多的一段代码
刚才有讲过,对于大O复杂度分析只是一种变化趋势,我们通常会忽略掉公式中的常量,低阶项,系数等,我们只需要记录一个最大阶的量即可,因此我们在分析算法的复杂度的时候,只需要关注循环执行次数最多的一段代码即可。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int main() {
int n;
scanf("%d", &n);
int count = 0;
for (R int i = 0; i < n; ++i) {
++count;
}
return 0;
}
例如上述代码在定义变量和输入数据的时候时间复杂度是常量级别的时间复杂度,与数据规模无关,当数据规模变化的时候,时间复杂度基本没有影响,而主要影响因素在循环中,因此这段代码的总的时间复杂度就是O(n)
ii. 加法法则:总复杂度等于量级最大的代码的时间复杂度
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int main() {
int n;
scanf("%d", &n);
int count_1 = 0, count_2 = 0, count_3 = 0;
for (R int i = 0; i < n; ++i) {
++count_1;
}
for (R int i = 0; i < n; ++i) {
++count_2;
}
for (R int i = 0; i < n; ++i) {
for (R int j = 0; j < n; ++j) {
++count_3;
}
}
return 0;
}
对于上述代码,我们可以分析到这段代码的时间复杂度是:
T ( n ) = O ( 5 ) + O ( n ) + O ( n ) + O ( n 2 ) T(n) = O(5) + O(n) + O(n) + O(n^2) T(n)=O(5)+O(n)+O(n)+O(n2)
对于这样的时间复杂度,我们取其中最大的量级,所以这个算法总的时间复杂度是O(n^2)。因此**总的时间复杂度等于量级最大的代码的时间复杂度,我们可以将公式总结为:
若 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) 若 T1(n) = O(f(n)), T2(n) = O(g(n)) 若T1(n)=O(f(n)),T2(n)=O(g(n))
则 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) 则 T(n) = T1(n) + T2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n))) 则T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))
iii. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
inline int add(int n) {
int cnt = 0;
for (R int i = 0; i < n; ++i) {
cnt += i;
}
return cnt;
}
int main() {
int n;
scanf("%d", &n);
int count = 0;
for (R int i = 0; i < n; ++i) {
count += add(i);
}
return 0;
}
对于上述代码,我们实现了代码的嵌套,循环每进行一次就调用一次函数,循环的时间复杂度是O(n)的,函数的时间复杂度是O(n)的,因此这段代码的时间复杂度是O(n^2)的
因此乘法法则的公式我们可以总结为:
T ( n ) = T 1 ( n ) × T 2 ( n ) = O ( n × n ) = O ( n 2 ) T(n) = T1(n) × T2(n) = O(n × n) = O(n^2) T(n)=T1(n)×T2(n)=O(n×n)=O(n2)
对于对数级的时间复杂度,我们通常使用换底公式将对数换为以2为底的对数,因此我们通常忽略对数的底,统一将时间复杂度表示为O(logN)
对于O(n + m) / O(n × m)量级的时间复杂度,我们无法事先评估大小,那么表述时间复杂度的时候需要全部写出
摘自维基百科->https://zh.wikipedia.org/zh-hans/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6
非常常见已加粗表示
名称 | 运行时间(T(n)) | 算法举例 |
---|---|---|
常数时间 | O(1) | 判断数字(二进制)奇偶性 |
反阿克曼时间 | O(alpha (n)) | 并查集单个操作的平摊时间 |
迭代对数时间 | O(logn) | 分散式圆环着色问题 |
对数对数时间 | O(loglogn) | 有界优先队列的单个操作 |
对数时间 | O(logn) | 二分查找 |
幂对数时间 | O(logN^2) | |
幂时间 | O(n^c)(0 < c < 1) | K-d树的搜索操作 |
线性时间 | O(n) | 无序数组的搜索 |
线性迭代对数时间 | O(nlogn) | 莱姆德·赛德尔的三角分割多边形算法 |
线性对数时间 | O(nlogn) | 最快的比较排序 |
二次时间 | O(n^2) | 冒泡排序,插入排序 |
三次时间 | O(n^3) | 矩阵乘法的基本实现,计算部分相关性 |
多项式时间 | 线性规划中的卡马卡演算法,AKS质数测试 | |
准多项式时间 | ||
次指数时间(第一定义) | ||
次指数时间(第二定义) | ||
指数时间 | 2^O(n) | 使用动态规划解决旅行推销员问题 |
阶乘时间 | O(n!) | 通过暴力搜索解决旅行推销员问题 |
指数时间 | 2^poly(n) | |
双重指数时间 | 22poly(n) | 在预膨胀算数中决定一个给定描述的真实性 |
具体各时间复杂度详解参见维基百科
内存复杂度的概念与时间复杂度类似,是计算一个程序使用的内存的增长趋势的方式
与时间复杂度类似,渐进空间复杂度表示的是算法的存储空间随着数据规模变化的趋势,空间复杂度的分析较为容易
例如下面的代码:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int main() {
int n;
scanf("%d", &n);
int count = 0;
int *number = new int[n];
for (R int i = 0; i < n; ++i) {
scanf("%d", &number[i]);
}
return 0;
}
上述代码申请了n个int大小的数组,并且数组大小随数据规模的变化而变化,此时的空间复杂度就是O(n)
常用的空间复杂度有O(1),O(n),O(n^2)
空间复杂度的分析比较简单,主要看是否有与数据规模相关的内存申请操作即可
一般的算法竞赛会提供64MB/128MB/256MB的内存,一个int的大小是4B,假设给出128MB的空间,可以估算可以放得下的int的数组的大小为
128 × 1 0 6 4 = 32 × 1 0 6 \frac{128 × 10^6}{4} = 32 × 10^6 4128×106=32×106
所以说开百万级别的int数组是安全的(不过大的数组尽量不要开在子函数内,可能会爆栈),开千万级别的数组需要考虑,更不要开int flag[100000][100000](NOIP2012铺地毯出现过)
除了上述各种情况下的复杂度分析外,我们还需要知道不同情况下的复杂度,主要的有如下的四种情况:最好,最坏;平均;均摊
i. 最好最坏情况时间复杂度
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int main() {
int n;
scanf("%d", &n);
int number[100];
int search;
scanf("%d", &search);
for (R int i = 0; i < n; ++i) {
scanf("%d", &number[i]);
}
for (R int i = 0; i < n; ++i) {
if (number[i] == search) {
printf("%d", i);
break;
}
}
return 0;
}
比如上面的例子,在数组种查找元素search并返回其位置,假如要查找的元素是数组的第一个元素,那么我们只需要执行一次就可以结束程序,那么这个算法的时间复杂度为O(1),即最好情况下的时间复杂度,假如我们需要查找的元素不在数组种,我们就需要执行n次才能结束算法,此时的时间复杂度即为O(n)此时对应的是最坏情况下的时间复杂度
ii. 平均情况时间复杂度
因为此时我们求的是平均情况,所以我们可以做个假设:这个数字在数组中的概率是1/2;不在数组中的概率是1/2;这个数字出现在0-1/n的概率是1/n,所以根据概率的乘法原理,要查找的数据出现在数组中的概率为1/2n,现在数组中的概率乘以任意位置的概率,然后我们就可以将每个元素被查找到时要查找的次数和对应的概率相乘,最后进行求和就可以得到算法的平均复杂度:
1 × 1 2 n + 2 × 1 2 n + 3 × 1 2 n + . . . + n × 1 2 n + n × 1 2 = 3 n + 1 4 1 × \frac{1}{2n} + 2 × \frac{1}{2n} + 3 × \frac{1}{2n} + ... + n × \frac{1}{2n} + n × \frac{1}{2} = \frac{3n + 1}{4} 1×2n1+2×2n1+3×2n1+...+n×2n1+n×21=43n+1
因为我们可以省略系数,所以上述的代码查找元素search的时间复杂度同样也是O(n),通常情况我们不需要严格分析这三种不同的情况。一般情况下,使用前面的复杂度分析方法计科,如果需要详细推导可以计算平均时间复杂度
iii. 均摊时间复杂度
均摊时间复杂度是一种特殊情况下的时间复杂度,并不是很常见,主要思想是把运行时间多的情况下的复杂度拆分,并均摊到运行时间少的情况下
如下所示下面的代码:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define R register
#define LL long long
#define pi 3.141
#define INF 1400000000
using namespace std;
int array_length;
int number[100];
inline void add(int value) {
if (array_length < 100) {
number[array_length] = value;
++array_length;
}
else {
int sum = 0;
for (R int i = 0; i < 100; ++i) {
sum += number[i];
}
number[0] = sum;
}
}
int main() {
int n;
scanf("%d", &n);
for (R int i = 0; i < n; ++i) {
int num;
scanf("%d", &num);
add(num);
}
return 0;
}
上述代码实现了下述功能,向数组中从插入一个元素,当元素个数大于100后,将数组求和并将和放在数组第一个元素上;当元素少于100是,直接将元素插入空闲的位置上
显然这两种情况的时间复杂度为O(n)和O(1),但是考虑到实际情况,实际情况下总是先将一个个位置存满后才执行求和操作,并且这两种情况的发生有规律
我们可以将耗时的O(n)复杂度的代码均摊到不太耗时的O(1)上,这样总体的时间复杂度就会变成O(1),这就是均摊时间复杂度的均摊的思想,通常情况下均摊时间复杂运用在绝大多数情况下不太耗时,少数情况下耗时的操作,并且两种情况逻辑联系,有前后顺序
对比不同复杂度的增长,大概最大可以接收的数据如下表所示(具体情况具体分析)
算法时间复杂度 | 建议不超过的n的范围 |
---|---|
O(logn) | 很大,long long内均可 |
O(n) | 10^7 |
O(nlogn) | 10^5 - 5 * 10^5 |
O(n^2) | 1000 - 5000 |
O(n^3) | 200 - 500 |
O(2^n) | 20 - 24 |
O(n!) | 12 |
我们在计算时间复杂度的时候,取代码中对时间增长贡献最大的一部分,从数学的角度来看就是取这个多项式的最大的一项
分析时间的复杂度通常并不容易,对于只有很多for循环的程序,分析复杂度当然是容易的,但是当遇到递归的情况的时候,分析时间复杂度就相对麻烦
另外时间复杂度是可以估算的,例如整个程序要从1到n进行二分,这边的时间复杂度是O(logn),每一次枚举需要进行一次k次验证的操作,算法的时间复杂度是O(k),那么整个程序的时间复杂度就是O(klogn)