写在前面,本章将了解到:
1.什么是时间、空间复杂度?为什么要进行时间、空间复杂度分析?
2.大O表示法
3.分析时间、空间复杂度的方法
4.复杂度分析的四个方面:最好情况时间复杂度,最坏情况时间复杂度,平均情况时间复杂度,均摊时间复杂度
数据结构和算法本身解决的是“快”和“省”的问题,如何让代码运行的更快,更省存储空间?执行效率就是算法一个非常重要的考量指标,那么如何来衡量算法的执行效率呢?就是用时间、空间复杂度分析。
关键点:
原因:
所以,我们需要一个不用具体测数据,就可以粗略估计算法执行效率的方法。
我们看一段代码,求 1,2,3,4,…,n的累加和:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
当我们粗略估计这段代码的执行时间时,我们假设每行代码的执行时间一样,用 unit_time 来表示,
2,3行分别都需要 一个 unit_time,
4,5行在for 循环里,也就是 2 * n * unit_time,
所以这段代码的总执行时间为 T(n) = (2 + 2n)* unit_time
由上述计算可以看出,所有代码的执行时间T(n)与每行代码的执行次数 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
2,3,4行代码总共是 3 * unit_time
5,6行代码是 2 * n * unit_time
7,8行代码是 2 * n2 * unit_time
所以,整段代码执行时间为T(n)=(2n2 + 2*n +3)*unit_time
依旧符合,我们之前发现的规律:所有代码的执行时间T(n)与每行代码的执行次数 n 成正比
我们把这个规律总结成一个公式:
T(n) = O(f(n))
T(n) 代码代码执行时间, n 表示数据规模的大小 ,**f(n)表示每行代码执行的次数总和,O 表示代码的执行时间T(n)和f(n)**表达式成正比
第一个例子中T(n) = O(2n+2),
第二个例子中T(n) = O(2n2 + 2n +3)
这就是大O表示法,代表代码执行时间随数据规模增长的变化趋势,也叫渐进时间复杂度,简称时间复杂度
我们在分析一个算法,一段代码的时间复杂度时候,只关注循环执行次数最多的那一段代码就可以
我们拿第一个例子来说:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
这个例子中,2、3行代码都是常量执行时间,循环次数最多的是4、5行代码,这两行代码被执行 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,我们可以分析每一部分的时间复杂度,然后放到一起,取一个量级最大的作为整段代码的复杂度
第一段,sum_1的被循环执行了100次,是一个常量级的执行时间,跟 n 无关
第二段是O(n),第三段是O(n2)
根据我们之前说的,总的时间复杂度就等于量级最大的那段时间复杂度,也就是这个例子整段代码的时间复杂度就为O(n2)
先来看一个例子:
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(n2)
我们先了解下,复杂度量级可以粗略的分为两类:多项式量级和非多项式量级
非多项式量级只有两个 O(2n) O(n!),非多项式量级的算法问题叫做NP(Non-Deterministic Polynomial,非确定多项式)问题
当数据规模 n 越来越大的时候,非多项式量级算法的执行时间会急剧增加,所以效率低下。
多项式时间复杂度:
O(1)是常量级时间复杂度的一种表示方法,并不是只执行了一行代码,只要代码的执行时间不随 n 的增大而增长,这样的代码都可以是O(1),一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万的代码,其时间复杂度也是O(1)
我们先看一段代码,
i=1;
while (i <= n) {
i = i * 2;
}
根据我们之间所说的,第三行代码执行次数最多,我们只要计算这个行代码执行了多少次就行
根据代码,我们可以看到,变量 i 从1开始取值,每次循环就乘以 2,大于 n 时就循环结束。实际上就是一个等比数列
所以我们只要知道 x 是多少,就知道执行了多少次
所以 x = log2n,时间复杂度就是 O(log2n)
当我们一段代码的时间复杂度是O(logn),那么当这段代码被执行了 n 次,那时间复杂度就是O(nlogn) ,有点类似于我们之前讲的乘法法则
复杂度由两个数据规模来决定
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表示的是两个数据规模,但是无法评估是哪个数据规模较大,所以我们就干脆把两个加起来 O(m + n)
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系,
空间复杂度就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i = 0; --i) {
print out a[i]
}
}
我们看一段代码,在第 2 行代码中,我们申请了一个空间存储变量 i,但是是常量阶,跟数据规模 n 没有关系,第 3 行申请了一个大小为 n 的int类型数组,除此之外,并没有其他的空间,所以代码的空间复杂度就是 O(n)
总结:
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,越高阶复杂度的算法,执行效率越低
最好情况时间复杂度,最坏情况时间复杂度,平均情况时间复杂度,均摊时间复杂度
我们看一段代码:
// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) pos = i;
}
return pos;
}
这段代码功能就是在数组中,查找变量 x 出现的位置,如果没有出现就返回 -1,这段代码的时间复杂度就是O(n),执行了 n 次
但是并不是 x都在数组最后,也许 x 出现在了中间呢,假如我们在找到 x 以后,break 跳出了循环呢?那意味着这段代码的时间复杂度就不是 O(n) 了
// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}
假如,正好第一个元素就是我们要找的 x ,那就不需要遍历剩下的 n - 1个数据了,时间复杂度就是O(1)
假如,数组中不存在 x ,那我们就遍历了整个数组,时间复杂度就是 O(n)
鉴于上面的这种情况,我们才有了最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度
最好情况时间复杂度:在最理想的情况下,执行这段代码的时间复杂度,就比如我们刚刚说的,第一个元素就是我们要找的 x
最坏情况时间复杂度:在最糟糕的情况下,执行这段代码的时间复杂度,我们数组中没有要查找的 x,遍历了整个数组
平均情况时间复杂度:最好情况时间复杂度和最坏情况时间复杂度都是很极端的情况,为了更好的表示时间复杂度,就有了平均情况时间复杂度
如何分析平均情况时间复杂度呢?
还是刚刚那个例子,要查找 x 在数组中的位置, 有 n+1 中情况,即在数组的 0 到 n-1 位置中和不在数组中,我们把每种情况需要遍历的元素个数相加,除以 n+1 ,
当我们省略掉低阶,常量和系数的时候,得到的平均时间复杂度就是O(n)
但是,这 n + 1 种情况的概率并不是一样的,在数组中和不在数组中的概率是 1/2,在数组中的情况下,每种情况的概率又是1/n,当我们考虑概率的情况之后,
这个值去掉系数和常量以后时间复杂度还是 O(n),
其实这个值就是概率中的加权平均值,也叫作期望,所以平均时间复杂度的全称应该叫做加权平均时间复杂度或者期望时间复杂度
先看一段代码:
// array 表示一个长度为 n 的数组
// 代码中的 array.length 就等于 n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
这段代码的功能就是,在一个数组中插入了数据,当数组满了以后,就用for循环去遍历数组求和,并且清空数组,将求和之后的 sum 放到数组的第一个位置,再将新的数据插入
我们来分析下这段代码的时间复杂度,
最好的情况:数组中有空闲的空间,每次只需要插入就行,时间复杂度为O(1),
最坏的情况:数组中没有空闲的空间,需要遍历整个数组求和,再将数据插入,时间复杂度为O(n)
那么此时,平均时间复杂度是多少呢?
假设数组长度是 n,根据数据插入的位置不同,
我们分为 n 种情况每种情况的时间复杂度都是O(1)
还有一种额外的情况,就是没有空闲位置的时候,时间复杂度是O(n),但是概率也是一样的,都是1/(n+1),按照我们加权平均的算法,平均时间复杂度就是
我们想想看是不是有更好的办法呢????
先对比一下,这个insert() 的例子和前面的那个 find() 的例子,
find()在极端情况下才会出现 O(1)的情况,但是inser()在大部分情况下都是O(1),只有个别情况是O(n),这是第一个区别
第二个区别,对于insert()函数来说,O(1)和O(n)出现的频率是非常有规律的,一般都是O(n)之后紧跟着 n - 1 个O(1)
我们引入了均摊时间复杂度,通过均摊分析得到的时间复杂度。。。
那究竟如果使用均摊分析来分析均摊时间复杂度呢?
例如,我们拿插入数据的这个例子,每一次O(n)的插入操作,都会跟着 n - 1 次O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,那么这一组连续的操作的均摊时间复杂度就是 O(1)
个人观点,均摊时间复杂度是一种特殊的平均时间复杂度,主要是针对一些很特殊的情况,例如,刚刚的那个例子,大部分情况都是低复杂度,只有个别情况出现了高复杂度的,而且还有一定的规律,我们就可以试试用均摊复杂度的分析方法