前言
前端技术日新月异,各种轮子百花齐放,但有些核心知识点是万变不离其宗的,比如 算法,而时间复杂度和空间复杂度就是衡量算法好坏的一个重要标准。
概念
对于同一个问题,我们可能有多个解决方案,但是不同解决方案消耗的时间和资源却是不一样的,有时候甚至天差地别。消耗的越少,代码的性能越高,这时候就引进了两个概念 ------ 时间复杂度和空间复杂度。
-
时间复杂度:描述了一个算法运行所需的时间量,通常用「 大O符号表示法 」,即
T(n) = O(f(n))
,但其并不表示具体的运行时间,而是表示算法运行时间的增长趋势。 -
空间复杂度:同样用「 大O符号表示法 」,主要指执行算法所需内存的大小,用于对程序运行过程中所需要的临时存储空间的度量,除了需要存储空间、指令、常数、变量和输入数据外,还包括对数据进行操作的工作单元和存储计算所需信息的辅助空间。
但偶尔会遇到这样一种情况,存在时间复杂度和空间复杂度之间的权衡关系,很难同时在时间和空间维度上达到最优。这是因为在优化时间复杂度的同时,可能会增加空间复杂度,反之亦然。以下是一些常见的情况:
- 时间优先: 当对算法的运行时间要求较高时,我们可能会牺牲一定的空间来提高算法的执行效率。这种情况下,我们会选择时间复杂度较低、但可能会占用更多内存空间的算法。
- 空间优先: 在某些场景下,内存空间可能是有限的资源,因此我们更关注节约内存空间。在这种情况下,我们可能会选择空间复杂度较低、但可能会牺牲一定的时间效率的算法。
- 折中方案: 有时候可以通过一些技巧和优化来在时间和空间维度上做出折中。例如,可以通过空间换时间的方式来优化算法,或者通过时间换空间的方式来减少内存占用。
总的来说,在实际应用中,根据具体的需求和场景来权衡时间和空间复杂度,并选择最适合的算法。算法设计的目标是在时间和空间维度上找到一个平衡点,以满足实际需求并提高算法的效率。
时间复杂度
上述介绍了复杂度的计算公式「 大O符号表示法 」,其随着问题规模n
的不断增大,时间复杂度不断增大,算法的执行效率不断降低,常见的复杂度排序如下图(来自维基百科):
常见时间复杂度
O(1) 常数阶
js
function O1() {
let a = 1;
let b = 2;
console.log(a + b);
}
上述函数执行了固定数量的操作,无论输入的大小如何,它始终只执行常数次数的操作。具体来说,它定义了两个变量a和b,然后执行一次加法操作并输出结果。由于这些操作的数量是固定的且与输入大小无关,因此该代码的时间复杂度被表示为O(1),即常数时间复杂度
O(logN) 对数阶
js
function OlogN() {
let i = 0;
while (i < n) {
console.log(i);
i *= 10;
}
}
上述函数包含一个while循环,循环的迭代次数取决于变量i的增长情况。在每次循环中,i会以指数级别增长(每次乘以10),而循环会在i超过n之前继续执行。由于i的增长是指数级的,因此循环的迭代次数与n的大小的对数关系,即log₂(n)。因此,这段代码的时间复杂度被表示为O(logN),其中N代表输入大小n
O(N) 线性阶
js
function ON() {
for (let i = 0; i < n; i++) {
console.log(i);
}
}
上述函数包含一个for循环,循环的次数取决于变量n的值。在每次循环中,只执行一次输出操作。因为for循环的迭代次数与输入大小n成正比,且循环体内的操作数量与n无关,所以整体的时间复杂度是O(N),其中N代表输入大小n。
O(nlogN) 线性阶
js
function OnlogN() {
for (let i = 0; i < n; i++) {
while (i < n) {
i *= 10;
}
}
}
这段代码中的函数包含了一个外部的for循环和一个内部的while循环。外部的for循环会执行n次,而内部的while循环会随着i的增长而增长,最终会在i超过n时结束。内部while循环的迭代次数不是简单地与n成正比,而是随着i的增长呈现对数级别的增长。因此,整体的时间复杂度为O(nlogN),其中N代表输入大小n。
O(N²) 平方阶
js
function ON2() {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {}
}
}
这段代码中的函数包含了两个嵌套的for循环,每个循环都会执行n次,其中n是输入大小。因为这两个循环是嵌套的,所以总体操作次数是n乘以n,即n²。因此,这段代码的时间复杂度被表示为O(N²),其中N代表输入大小n。
如果把外层循环的n改成m:
js
function ON2() {
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {}
}
}
那时间复杂度就会变成 O(m*n),
同理可得,O(N³)相当于三层for循环;并且从这个还可以延伸出,K次方阶的复杂度为 O(n^k)
除了这些典型的复杂度之外,还有像平方根,阶乘等,我们日常接触的都不多,如果小伙伴们对此感兴趣,可以自行学习下。
多个复杂度组合
当算法中存在多个操作,每个操作的时间复杂度不同,并且这些操作是顺序执行的时候,我们通常会将各操作的时间复杂度相加。
例如下面的代码,一共有3个for循环,根据刚才已学习的复杂度知识,很容易就得到复杂度分别为:O(n)
,O(m)
,O(1)
,由于O(1)
复杂度最小,可以忽略不计。但是前两个循环里的m和n数值大小无法判断,所以要算进来,故总的复杂度为:O(n+m)
js
function getTotal() {
for (let a = 0; a < n; a++) {
console.log(a);
}
for (let b = 0; b < m; b++) {
for (let b = 0; b < n; b++) { }
}
for (let c = 0; c < 10; c++) {
for (let c = 0; c < n; c++) { }
}
}
总的来说,在分析多个复杂度组合时,需要根据各部分操作的时间复杂度关系,找出对整体运行时间影响最大的那部分,作为整体算法的时间复杂度。
复杂度类型
除了上面介绍的各种复杂度,其实在实际使用中,我们还有另外一个角度的分析方法:
- 平均时间复杂度:在所有可能的输入实例上,根据不同实例出现的概率,对每个实例的运行时间取加权平均值,它更能真实反映算法在实际应用中的性能。但在许多情况下,计算平均时间复杂度并不容易,需要知道输入内容的具体分布情况(在随机均匀分布的情况下才可以)。例如下面代码,对于一个n个元素的数组,表面看着要循环n次,但在平均情况下它查找目标元素只需要遍历一半的元素,即平均时间复杂度为O(n/2):
js
function search(array, target) {
let count = 0;
for (let i = 0; i < array.length; i++) {
if (array[i] === target) {
return i;
}
count++;
}
return -1;
}
-
均摊时间复杂度:计算方式有点复杂,且实际使用场景不多,感兴趣的小伙伴可以自行去了解。
-
最坏时间复杂度 :最坏情况下的时间复杂度,比如上面的
平均复杂度
是找一半就找到了期望值,但现在可能要在数组的最后一个位置才找得到。在实际应用中,最坏时间复杂度可以帮助我们评估算法在极端情况下的表现,同时也是我们最常用的一个
,比如在上述复杂度组合
中就用的这个。 -
最好时间复杂度 :和
最坏时间复杂度
相反,我在数组的第一个位置就找到了期望值✌ (>‿◠)✌
空间复杂度
学了时间复杂度后,分析空间复杂度就比较简单了,主要就看我们在一个算法当中到底有没有使用到了额外的空间来进行存储数据,然后判断这个额外空间的大小会不会随着 n
的变化而变化,从而计算出空间复杂度。
举个最简单的例子(见下方代码),定义了一个arr数组,里面有n个值,需要占据内存空间n个内存单元, 所以空间复杂度是O(N):
js
function ON() {
const arr = []
for (let i = 0; i < n; i++) {
arr.push(i)
}
}
举一反三,可以概括出几个比较常用的空间复杂度:
- 需要的临时空间不随着某个变量n的大小而变化,即为一个常量,则空间复杂度为
O(1)
- 需要的临时空间是一维数组,队列或者链表等,则空间复杂度为
O(n)
- 需要的临时空间是二维数组,则空间复杂度为
O(n²)
附件
另外附上两张复杂度对照表:
图1
图2
总结
本文主要介绍了下时间复杂度和空间复杂度的一些基本概念,并举了一些简单的例子论证,希望对小伙伴们有所帮助。其实在日常的Coding中,不一定就得是复杂的算法才能帮助优化性能,一些小的改动也可以。比如,当for循环找到期望值后,我们就用break跳出循环,这样可以避免接下来无意义的遍历。
欢迎小伙伴留言讨论,互相学习!
❤❤❤ 如果对你有帮助,记得点赞收藏哦!❤❤❤