复杂度小结
本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。
基础概念
- 时间复杂度: 执行当前算法所消耗的时间
- 空间复杂度: 执行当前算法需要占用多少内存空间
为什么需要复杂度分析
我们很多情况下,都会在一段代码的头部和尾部分别加上 console.time()
和 console.timeEnd()
,然后通过统计和监控就可以直观的看到算法执行的时间和占用内存的大小,为什么还要做时间和空间复杂度分析呢?
首先,上面的方法在某些角度来讲,是没有问题的,是正确的评估算法的执行效率的方法,我们可以美其名曰 事后统计法,但是它有一些局限性。
1. 测试结果非常依赖测试环境
也就是不一样的硬件条件,同样的一串代码在不同的硬件下执行结果肯定是不一样的
2. 测试结果受数据规模的影响很大
也就是我们在一般测试的时候并没有去刻意构造太大的数据规模的话,测试结果可能无法真实的反应算法的性能
时间复杂度分析
1. 只关注循环执行次数最多的一段代码
这里循环执行最多的就是中间一段代码,循环次数就是 arr 的长度,循环总次数随着 arr 的长度增加而增加
function sum(arr) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
所以上面一段代码就可以用 O(n) 来表示它的时间复杂度
2. 总复杂度等于量级最大的那段代码的复杂度
这里有三段循环代码,分别是求出 sum 、 sum1 、 sum2,然后返回三个值的和
function sum(arr) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
let sum1 = 0;
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length; j++) {
sum1 = sum1 + arr[i] + arr[j];
}
}
let sum2 = 0;
for (let k = 0; k < 100; k++) {
sum2 += k;
}
return sum + sum1 + sum2;
}
第一段循环时间复杂度是 O(n),是随着 arr 的长度而增加的, 第二段循环的时间复杂度就是 O(n2),第三段由于是执行了 100 次,和n的规模无关,所以是 O(1),所以最后这段代码的时间复杂度就是 O(n2)
3. 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
这个在上面的代码中已经有体现,第二段循环,由于外面是一层循环,里面又套了一层循环,所以时间复杂度就是 O(n) * O(n) = O(n2)
常见时间复杂度
- O(1)
- O(logn)
- O(n)
- O(nlogn)
- O(n2)
上面比较难理解的是 logn,我们来看下面这一段代码
function sum (arr) {
let i = 1;
let sum = 0;
while(i < arr.length) {
sum += arr[i];
i = i * 2;
}
return sum;
}
很容易可见,每次 while 循环, i 都会 乘以 2,所以我们只要知道这行代码被执行了多少次,就可以知道整段代码的时间复杂度
从上面看出,每循环一次就会乘以2,当大于 n 的时候,就停止循环,所以 i 的取值其实是一个等比数列,到最后 2x >= n 的时候退出循环,其中 n 为数组的长度,那么 x 的取值就很容易能够计算出来为 x = log2n,所以这段代码的时间复杂度就是 log2n
现在我们来改动一下上面的代码
function sum (arr) {
let i = 1;
let sum = 0;
while(i < arr.length) {
sum += arr[i];
i = i * 3;
}
return sum;
}
我们可以很快的得出答案,上面代码的时间复杂度是 x = log3n
实际上,不管是以2为底,还是以3为底,还是以其他数字为底,我们都可以将所有对数阶的时间复杂度都记为 O(logn),为什么呢?
我们知道,对数之间是可以相互转换的,比如上面的 log3n 如果想转换成 log2n ,只需要写成 log32 * log2n ,其中前面是一个常数,所以我们可以忽略系数,因此,在对数阶时间复杂度的表示方法中,我们忽略对数的底,统一表示为 O(logn)
如果理解了上面 O(logn) 的计算由来,那么 O(nlogn) 就很容易理解了,就是一段代码的时间复杂度是 O(logn),恰好这段代码又循环了 n 次,最终时间复杂度就算出来为 O(nlogn),最后再附上一张时间复杂度的渐近图
空间复杂度分析
上面讲述的时间复杂度其实表示的是 算法执行的时间与数据规模之间的增长关系
同理空间复杂度表示的是 算法的存储空间与数据规模之间的增长关系
function print(n) {
let i = 0;
let a = [];
for (i = 0; i < n; i++) {
a[i] = i * i;
}
for (i = n - 1; i >= 0; i--) {
console.log(a[i])
}
}
上面的代码第二行申明了一个变量 i,但是它是常量级的,所以跟数据规模没有关系,但是第三行申请了一个数组,根据下面的循环可以看出,数组的大小会根据数据规模 n 进行线性增长,所以整段代码的空间复杂度就是 O(n)
我们常用的空间复杂度就是 O(1) O(n) O(n2),像对数阶复杂度基本遇不到
最好、最坏、平均时间复杂度
当然,在一些教材中,还会有以上四个时间复杂度的分析,如果使用上面总结出来计算时间复杂度的方法的话,很好的能够分析每段代码的时间复杂度
function find(arr, x) {
let index = -1;
for (let i = 0; i < arr.length; i++) {
if (arr[i] === x) {
index = i;
}
}
return index;
}
上面的代码其实起到的作用就是找到 x 在 arr 数组中的索引,如果没有找到就返回 -1,所以上面的时间复杂度就是 O(n)
但是有点经验的小伙伴就会提出问题,我们明明可以在找到 x 的位置的时候直接 return index,为什么还要继续进行搜索呢,所以这段代码不够高效,很棒,您已经在写代码的同时考虑到代码的执行效率了,那我们接下来来优化一下
function find(arr, x) {
let index = -1;
for (let i = 0; i < arr.length; i++) {
if (arr[i] === x) {
index = i;
return index;
}
}
return index;
}
这个时候,问题来了,优化之后它的时间复杂度还是 O(n) 吗?
因为这个时候 x 可能出现在 arr 的任意位置,如果正好第一个元素就是 x,那么时间复杂度就是 O(1),如果arr中不存在变量x,那么就需要遍历整个数组,这个时候的时间复杂度就是 O(n),所以在不同情况下,代码的时间复杂度是不一样的
为了表示代码在不同情况下的不同时间复杂度,就有了上面的 最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度
最好情况时间复杂度:在最理想的情况下,执行这段代码的时间复杂度
最坏情况时间复杂度: 在最坏的情况下,执行这段代码的时间复杂度
平均情况时间复杂度:概率论中学到的加权平均值(期望值)
还是用上面的代码举例,来解释一下什么是 平均时间复杂度,上面说到涉及到数学中或是概率论中的期望值,你就能大概联想到了,首先需要查找的变量 x 在数组中的位置,有 n+1 中情况,也就是在数组的 0 ~ n-1 位置中和不在数组中。首先我们从概率论的角度来看变量在数组中和不在数组中的概率分别为 1/2,另外,变量出现在 0~n-1 位置上的概率也是一样的都是 1/n,所以要查找的数据出现在 0 ~ n-1 中任意位置的概率为 1/2n
所以计算过程就变成了
其中左侧的参数代表了变量x可能出现在数组中的位置,最后一个代表了变量不在数组中,且前面已经走过了整个数组长度,根据计算的结果去掉系数和常量结果还是 O(n)
可能有同学会说,那这样计算的意义在哪里,确实,很多情况下我们并不需要进行这样的计算,但是在同一块代码在不同的情况下,时间复杂度上有量级的差距,我们才会用这三种时间复杂度来区分
时间、空间复杂度取舍
空间换时间:在很多情况下,我们在开发的过程中会遇到一些性能瓶颈的问题,比如,我们的某个接口性能达不到要求的100ms,那就需要考虑我们是否可以通过空间换时间的方式来提高接口处理速度,这经常适用于一些时间和速度更加重要而且空间尚有富余的场合,比如负载均衡,就是通过使用多台服务器(空间)来换取延迟的减少(时间),来提高用户的使用体验
时间换空间:还有一种情况是时间换空间,这种通常用来跑一些计划任务会多一些,并不需要主要时间上的问题,只需要最后跑完告诉我们执行完成就可,它的出发点是内存和存储这样的空间资源我们需要减少占用,
当然如果上面两种都需要保证的情况下,就需要我们去采用更先进的算法和数据结构,采用缓存等等技术来达到我们的要求