复杂度一词在程序的世界里经常听到,最常见的就是对于一个排序算法,我们都会去评估它的时间复杂度和空间复杂度。今天我们就来谈谈对复杂度的理解,这也是作为开发人员必备的技能之一。
什么是复杂度
复杂度是代码运行效率的重要度量因素,它直接影响了我们所开发出来的程序的运行效率,如果一个程序运行卡的要命,甚至出现死机的情况,我相信没有愿意去体验。所以说降低程序的复杂度是非常有必要的。
复杂度的分类
复杂度在程序中分为时间复杂度和空间复杂度两种。
- 时间复杂度:顾名思义就是程序锁运行的时间。
- 空间复杂度:程序运行所需要占用存储空间大小的度量。
对于复杂度的表示通常使用大写的O表示,例如:O(1),O(n),O(n²)…
复杂度的运算规则
-
复杂度于具体的常系数无关
例如 O(n) 与 O(2n) 他们表示相同的复杂度,O(2n) = O(n + n) = O(n) + O(n),一段O(n)复杂度的代码如果连续执行两遍,其复杂度是一样的。 -
多项式的复杂度相加时,选择高的作为结果
例如 O(n) + O(n²) 和O(n²) 表示同样的复杂度 -
O(1)复杂度
O(1)是一个特殊的复杂度,它与输入量n无关,例如处理5条数据需要消耗 5个资源,处理100条数据还是只需要消耗5个资源。
复杂度的计算
这里我将通过一个例子来更好的理解计算的过程。
假如有一个数组 a = [1, 2, 3, 4, 5] ,现在我想得到它逆序后的结果。
我的思路是新建一个等长度的数组 b ,然后通过 for 循环,将 a 中的每个元素逐个逆序放入b中,就像下面这样。
代码如下:
function fun() {
let a = [1, 2, 3, 4 , 5]
let b = new Array(a.length).fill(0)
for (let i = 0; i < a.length; i++) {
b[a.length - i - 1] = a[i];
}
return b
}
这个过程中经历了一次 for 循环,循环次数为 5 ,这里可以看做是一个变量 n ,那么该过程的时间复杂度就为 O(n)。
对于空间复杂度的计算,该过程中定义了一个新的数组 b ,长度等同于输入数组的长度,也就是 n ,那么在空间资源上需要分配 n 个空间,可以得到空间复杂度也是 O(n)。
降低复杂度
还是刚才这个例子,有没有其他的算法可以降低复杂度呢,当然有更佳的方案。
对于这个逆序问题,还可以采用二分的思路,定义一个缓存变量 tmp ,将第一个元素与最后一个元素进行交换,然后将第二个元素与倒数第二个元素交换,直到进行到数组长度一半的时候结束。
代码如下:
function fun() {
let a = [1, 2, 3, 4 , 5]
for (let i = 0; i < a.length/2; i++) {
let tmp = a[i]
a[i] = a[a.length - 1 - i]
a[a.length - 1 - i] = tep
}
return a
}
这种算法同样也是一个for循环,但是循环次数减少了一半,时间复杂度变成了 O(n/2)。但是根据复杂度与具体的常系数无关的性质,这段代码的时间复杂度还是 O(n)。
对于空间复杂度呢,我们只额外定义了一个tmp变量,无论我们输入的数组长度是多少,永远都只需要这一个额外的变量,与输入内容无关,那么空间复杂度即为O(1)。
通过这个问题可以看出,对于同一个问题,如果采用不同的算法结构,它的时间和空间的消耗是不同的,当数据量比较庞大的时候,降低它的空间复杂度和时间复杂度,在效率上会得到很明显的的提升效果。
分析冒泡排序
冒泡排序是最常见的排序算法之一,这里直接上代码
var arr = [3, 5, 6, 7, 1, 9, 2]
function bubbleSort(arr) {
var i = arr.length, j;
var tempExchangVal;
while (i > 0) {
for (j = 0; j < i - 1; j++) {
if (arr[j] > arr[j + 1]) {
tempExchangVal = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tempExchangVal;
}
}
i--;
}
return arr;
}
冒泡排序外层需要进行 n - 1 次循环,内层每次需要进行 n - i 次比较,每次比较需要移动记录三次来达到交换记录位置。当初始数据全部为逆序时,这是最坏的情况,比较次数和移动次数均达到最大值:
因此冒泡排序的最坏情况下的时间复杂度为O(n²)。
若初始数据是正序,只需要一趟就可以完成排序,这是最好的情况,比较次数和移动次数均达到最小值:
因此冒泡排序的最坏情况下的时间复杂度为O(n)。综上可得,冒泡排序的平均时间复杂度为 O(n²)。
在空间上只用到了一个额外的中间变量,与输入量无关,则空间复杂度为O(1)。
复杂度的优化与转换
通过前面的一些列的讲解,我们知道了复杂度分为时间复杂度和空间复杂度,对于同一个问题采用不同的编码方式可以改变复杂度。
时间复杂度消耗的是时间,空间复杂度消耗的资源。对于资源来说我们可以通过提高计算机性能,提高存储空间来满足需求;但是时间的话是固定的,无法使用金钱买到。如果你开发了一个软件,每次操作都需要等待大量的时间,估计没有用户愿意去接受,从此可以看出时间复杂度更为重要。
对复杂度的优化可分为3个阶段:
- 暴力解法:不考虑时间和空间的约束,只要完成目的就行
- 处理无效操作:将代码中的无效计算、无效存储等内容删除
- 复杂度转换:设计合理的数据结构,实现时间复杂度与空间复杂度之间的相互转换
来看个例子,在一个数组中找出出现次数最多的数字。
首先来看看暴力解法,内外两层循环,外层循环遍历每个数字,内层循环统计每个数字出现的次数,并记录下已统计过的数字中出现次数最多的数字。
function nummax() {
var a = [ 1, 2, 3, 4, 5, 5, 6 ];
var val_max = -1;
var time_max = 0;
var time_tmp = 0;
for (var i = 0; i < a.length; i++) {
time_tmp = 0;
for (var j = 0; j < a.length; j++) {
if (a[i] == a[j]) {
time_tmp += 1;
}
if (time_tmp > time_max) {
time_max = time_tmp;
val_max = a[i];
}
}
}
return val_max
}
```
这种暴力解法的时间复杂度为O(n²)。
我们能否只通过一次循环就可以找到答案呢?在ES6中,我们可以先声明一个Map对象 map,然后遍历数组,判断在map中是否有该属性,若无,则把该值作为Key存入map中,并赋值为1;若有,则将它的值加一。通过一次循环就可以统计出每个数字出现的次数,然后再通过一次循环找到map中的最大值。
```javascript
function nummax() {
var a = [ 1, 2, 3, 4, 5, 5, 6 ];
var map = new Map()
for(var i = 0; i < a.length; i++){
if(map.has(a[i]))
map.set(a[i], map.get(a[i]) + 1)
else
map.set(a[i], 1)
}
var time_max = 0;
var time_tmp = 0;
for(key in map){
if(map.get(key) > time_tmp){
time_tmp = key
time_tmp = map.get(key)
}
}
return time_tmp
}
虽然这种方式也要经历两次循环,但是他们不是嵌套关系,在最坏的情况下,假如每个元素只出现了1次,那么map的长度为n,两次循环得到的时间复杂度为O(n + n) = O(n),空间复杂度为O(n)。
两种算法的时间复杂度从O(n²)到O(n)得到了很大的提升,但是牺牲了空间复杂度,综合来看这种交易得到的最终效果还是很划算的。
通过这篇文章的介绍,希望可以让你对编码结构有了更加深入的了解,提高执行效率。
如果觉得我的文章还不错的话,可以点个关注,或者关注我的个人公众号【前端筱园】
我的个人网站:www.dengzhanyong.com
所发布的内容CSDN、公众号、个人网站同步更新