算法复杂度
目录
简单来说,算法复杂度就是用来描述程序运行所花费的时间和空间,因此又分为了时间复杂度和空间复杂度。
1、时间复杂度
1.1 时间复杂度公式
时间复杂度可以说是一个函数,它定性描述了算法的运行时间。这里假设算法的问题规模为n,使用函数f(n)表示算法执行时每条语句执行的次数之和,则有时间复杂度
T(n)=O(f(n))
那‘大O’又是什么含义呢???
这里我理解为 大O 则表示正比例关系,即算法执行时间的增长率和f(n)的增长率相同,我们称这个公式为算法的渐进时间复杂度,不过根据算法导论中解释:大O表示上界,用它表示算法的最坏运行情况的上界,是对任意数据输入后运行时间的上界。
这里列举插入排序和快速排序的例子:插入排序的时间复杂度是O( n 2 n^2 n2),快速排序的时间复杂度是O( n l o g n nlogn nlogn),但是这都是一般情况下,因为输入数据不同,从而会导致运行时间不一样。对于插入排序,当输入数据有序时,时间复杂度为O( n n n),但如果是逆序时,时间复杂度为O( n 2 n^2 n2)。同理,对于快速排序,当数据有序时,时间复杂度为O( n 2 n^2 n2),当数据无序时,时间复杂度为O( n l o g n nlogn nlogn)。日常来说我们更关注于一般情况,其次考虑的是最坏的情况。
但是并不是时间复杂度越低越好,因为简化后省略了常数项,主要根据输入的数据规模。这里假设数据规模极大为n,则有以下排列(a,b为常数):
O(1)常数阶<O(logn)对数阶<O(n)线性阶<O(n^a)次方阶<O(b^n)指数阶
如果常数足够大哦,超过了10的五次方则不能随意忽视
1.2 常见问题
如何求解复杂度公式?
这里可以分为三步:
- 去除加法的常数项
- 去除常数系数
- 保留最高项
O(logn)的log以什么为底?
这里的底数并没有定值,可以为2、3、4、10等等,同时表示忽略底数。我们可以根据对数的数学公式来理解:
log i n \log_i^n login= log i j ⋅ log j n \log_i^j\cdot\log_j^n logij⋅logjn
这里公式右端的前部分 log i j \log_i^j logij可以看成常数,即所有的log函数都可以化简成同一个底数的对数,因此可以忽略底数。
1.3 从硬件分析时间复杂度
计算机的运算速度主要取决于自身的配置,这里以自己的CPU为例,
CPU:Intel® Core™ i7-8550U CPU @ 1.80GHz 2.00 GHz,这里有两个频率,一个为主频1.80GHz,一个为最大睿频2.00Hz,前者可以理解为平均值,后者可以理解为最大值。1Hz=1/s,即1Hz为电脑的一次脉冲(可以理解为改变一次电平的高低状态),称为赫兹,
1
G
H
z
=
1
0
9
H
z
1GHz=10^9Hz
1GHz=109Hz,即一秒可以改变10亿次状态。但是一台电脑的CPU需要运行多个进程,因此我们的程序只会占用少部分的CPU,下面通过一些测试检验1s内cpu可以处理的数据的数量级。
测试用例
这里使用我自己的电脑运行,分别计算时间复杂度为 O ( n ) O(n) O(n)、 O ( n 2 ) O(n^2) O(n2)、 O ( n log n ) O(n\log n) O(nlogn)的程序,并用加法统一测试,以下分别为对应的三段代码:
// O(n)
void function1(long long n) {
long long k = 0;
for (long long i = 0; i < n; i++) {
k++;
}
}
O ( n ) O(n) O(n)的结果为:
input: 100000000
consume: 219 ms
input: 10000000000
consume: 22208 ms
input: 1000000000
consume: 2157 ms
input: 500000000
consume: 1084 ms
// O(n^2)
void function2(long long n) {
long long k = 0;
for (long long i = 0; i < n; i++) {
for (long j = 0; j < n; j++) {
k++;
}
}
}
O ( n 2 ) O(n^2) O(n2)的结果为:
input: 100000
consume: 16360 ms
input: 1000
consume: 2 ms
input: 10000
consume: 164 ms
input: 50000
consume: 4093 ms
// O(nlogn)
void function3(long long n) {
long long k = 0;
for (long long i = 0; i < n; i++) {
for (long long j = 1; j < n; j = j*2) { // 注意这里j=1
k++;
}
}
}
O ( n log n ) O(n\log n) O(nlogn)的结果为:
input: 10000
consume: 1 ms
input: 1000000
consume: 53 ms
input: 100000000
consume: 6312 ms
input: 10000000
consume: 558 ms
综合以上测试这里可以检测得到1s内可以处理的数据的数量级:
时间复杂度 | 数量级 |
---|---|
O ( n ) O(n) O(n) | 1 0 8 10^8 108 |
O ( n 2 ) O(n^2) O(n2) | 1 0 4 10^4 104 |
O ( n log n ) O(n\log n) O(nlogn) | 1 0 7 10^7 107 |
这里附上主函数的代码:
int main() {
long long n; // 数据规模
while (1) {
cout << "input: ";
cin >> n;
clock_t start,ends; //定义一个clock_t的数据,可以精确到ms,需要应用time.h库
start=clock(); //开始时间
function1(n); //调用对应时间复杂度的函数
ends=clock(); //结束时间
cout << "consume: " << ends - start << " ms"<< endl;
}
}
1.4 从内存分析时间复杂度
理解代码的内存消耗,可以更有利于对算法的选择。而不同的编程语言都有各自的内存管理方式
- C/C++语言的内存堆空间的申请和释放完全依靠自己管理
- Java语言依赖JVM来管理内存,对JVM机制的不熟悉容易造成内存泄漏或者内存溢出
- Python语言的内存管理是由私有堆空间负责的,所有的Python对象和数据结构都存在私有堆空间中,因此python的基本数据类型所用的内存会远大于存放数据类型所占的内存,而且采用了垃圾回收机制和内存计数机制管理内存。
这里以C++为例,介绍一下它的内存管理,可以分为两部分——固定部分和可变部分。如下图:
这里主要考虑可变部分的内存占用情况,而栈区会系统自动回收,因此只需关注堆区,在分配内存时就应该考虑内存的回收,防止内存泄漏(程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果)。
由于CPU读取内存不是一次读取单个字节,而是一块一块的来读取内存,块的大小可以是2,4,8,16个字节,具体取多少个字节取决于硬件。这也就产生了内存对齐的方式,那为什么会采用内存对齐的方式呢?
* 方便不同的硬件平台都可以正常访问对应地址的数据
* 方便CPU对内存数据的读取
1.5 递归算法时间复杂度的分析
递归算法的时间复杂度关键是看:递归的次数*每次递归的操作次数,但是不同的递归写法会耗费不同的时间,因此优化递归非常重要,即用空间换时间策略。
2、空间复杂度
空间复杂度是一个程序在运行时所占用内存空间的量度,记作:
S(n)=O(f(n))
2.1 常见问题
空间复杂度是考虑程序(可执行文件)占有的空间吗?
不是的,空间复杂度是考虑程序运行时占用内存的大小,而不是可执行文件的大小
空间复杂度表示的就是程序运行占用的内存吗?
不是的,程序运行时占用的内存受很多因素影响,空间复杂度只是在程序运行之前对占用内存的一个预估。
总结
本篇文章只是对时间和空间复杂度进行了一个分析,具体的熟悉掌握还需要不断的具体实例的练习。