Skip to content

时间复杂度和空间复杂度 #1

Open
@chris-paul

Description

@chris-paul

如何表示复杂度

如何表示算法复杂度,具体来讲就是代码执行的时间、执行消耗的存储空间。例如:

function cal(n) {
    let sum = 0; // 1 unit_time
    let i = 0; // 1 unit_time
    for(; i <= n; i++) { // n unit_time
        sum += i; // n unit_time
    }
    return sum
}

从 CPU 的角度看,每段代码不过是读写数据或操作数据,尽管每次操作 CPU 执行的个数、执行的时间都不同,但我们粗咯把每次执行的时间都一致,称为 unit_time

所以上述代码总共执行 (2n+2)*unit_time 时间,即:T(n)=(2n+2)*unit_time ,所以,我们可以写成:

T(n) = O(f(n))

其中:

  • n:表示数据规模的大小

  • f(n):表示每行代码执行的次数总和,也就是2n + 2

  • O:表示代码的执行时间 T(n) 与 f(n) 表达式成正比

当 n 很大时,例如 10000,甚至更大,T(n) = O(f(n)) 可以表示为 T(n) = O(n) 。

这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

时间复杂度

当 n 无限大时,时间复杂度 T(n) 受 n 的最高数量级影响最大,与f(n) 中的常量、低阶、系数关系就不那么大了。所以我们分析代码的时间复杂度时,仅仅关注代码执行次数最多的那段就可以了

function fun1(n) {
    let sum = 0,i = 0; 
    for(; i <= n; i++) {
        sum += i; 
    }
    return sum
}
function fun2(n) {
    let sum = 0, sum1 = 0, i = 0, j = 0; 
    for(; i <= n; i++) { // 循环1
        sum += i; 
    }
    for(i = 0; i <= n; i++) { // 循环2
        for(j = 0; j <= n; j++) { 
            sum += i * j; 
        }
    }
    return sum
}
function fun3(n) {
    let sum = 0, i = 0; 
    for(; i <= n; i++) { 
        sum += fun(i); 
    }
    return sum
}

fun1 中第1行都是常量,对 n 的影响不大,所以总的时间复杂度要看第2、3行的循环,即时间复杂度为: O(n)

fun2 中循环1的时间复杂度为 O(n) ,循环2的时间复杂度为 O(n2),当 n 趋于无穷大时,总体的时间复杂度要趋于 O(n2) ,即上面代码的时间复杂度是 O(n2)

fun3 的时间复杂度是 O(n * T(fun)) = O(n*n) ,即 O(n2)

所以:T(c+n)=O(n),T(m+n)=O(max(m, n)),T(n) = T1(n) T2(m) = O(nm),其中 c 为常量

常见复杂度(按数量阶递增)

多项式量级:

  • 常量阶: O(1):当算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

  • 对数阶:O(logn): 每次循环 i 都乘以 2 ,直至 i > n ,即执行过程是:20、21、22、…、2k、…、2x、 n
    所以总执行次数 x ,可以写成 2x = n ,则时间复杂度为 O(log2n) 。这里是 2 ,也可以是其他常量 k ,时间复杂度也是: O(log3n) = O(log32 * log2n) = O(log2n)

    let i=1;
    while (i <= n)  {
        i = i * 2;
    }
  • 线性阶:O(n)

  • 线性对数阶:O(nlogn),归并排序、快排的时间复杂度

  • 平方阶、立方阶、….、k次方阶:O(n2)、O(n3)、…、O(nk),循环里嵌套一层循环的复杂度,冒泡排序、插入排序等排序的复杂度O(n2)

非多项式量阶

  • 指数阶:O(2k),已经是很难接受的时间效率,如未优化的斐波拉契数列的求值

  • 阶乘阶:O(n!)

如果a的x次方等于N(a>0,且a不等于1),那么数x叫做以a为底N的对数,记作x=logaN。其中,a叫做对数的底数,N叫做真数

递归函数的时间复杂度

如果一个递归函数再每一次调用自身时,只是调用自己一次,那么它的时间复杂度就是这段递归调用栈的最大深度

使用递归对一段字符串进行翻转,因为每次调用都会截取字符串的最后一位,所以这段程序的递归调用次数就是递归的深度,也就是字符串自身的长度,也就是O(n),这也是递归最简单的调用,每一次只调用自身一次

function reversetStr (s) {
	if (s === '') {
    	return ''
    }
	return s[s.length - 1] + reversetStr(s.slice(0, -1))
}

接下来我们使用递归求解斐波拉契数列,也就是这样的一堆数,后面一个数等于前面两个之和

1 1 2 3 5 8 13 21 34 55 89 ...

function fib (n) {
	if (n === 1 || n === 2) {
    	return n
    }
    return fib(n - 1) + fib(n - 2)
}

这个递归函数在调用自身的时,又调用了两次自身,我们可以看到,当要计算7时,需要计算出6和5;当要计算6和5时,又要分别计算出5和4以及4和3;每次这颗递归树展开的叶子节点都是上一层的两倍,也就说这是一个指数级的算法,时间复杂度为O(2ⁿ)

其他几种复杂度分析

以上说的时间复杂度指的是一段程序的平均时间复杂度,其实还会有最坏时间复杂度,最好时间复杂度以及均摊时间复杂度

  • 最好时间复杂度:例如我们要从数据里找到一个数字,数组的第一项就符合要求,这个时候就表示数组取值最好的时间复杂度是O(1),当然了这种概率是极低的,所以并不能作为算法复杂度的指导值

  • 最坏时间复杂度:数组取值直到最后一个才找到符合要求的,那就是需要O(n)的复杂度;又例如快排的平均时间复杂度是O(nlogn),但一个没经过优化的快排去处理一个已经排好序的数组,会退化成O(n²)

  • 均摊时间复杂度:表示的是一段程序出现最坏和最好的频次不同,这个时候复杂度的分析就是将它们的操作进行均摊,取频次的多操作,然后得出最终的复杂度

空间复杂度

时间复杂度表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度表示算法的存储空间与数据规模之间的增长关系。例如

function test(arr) {
    const a = 1
    const b = 2
    let res = 0
    for (let i = 0; i < arr.length; i++) {
    	res += arr[i]
    }
    return res
}

我们定义了三个变量,空间复杂度是O(3),又是常数级别的,所以这段程序的空间复杂度又可以表示为O(1)。只用记住是另外开辟的额外空间,例如额外开辟了同等数组大小的空间,数组的长度可以表示为n,所以空间复杂度就是O(n),如果开辟的是二维数组的矩阵,那就是O(n²),因为空间度基本也就是以上几种情况,计算会相对容易

function fun(n) {
    let a = [];
    for (let i = 0; i < n; i++) {
        a.push(i);
    }
    return a;
}

以上代码我们可以清晰的看出代码执行的空间为 O(1+n) = O(n),即为 i 及数组 a 占用的储存空间。

参考链接

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions