算法的复杂度学习笔记

同一个问题可以用不同的算法实现,而算法是有优劣之分的。我们经常需要对算法进行分析,以便于选择合适的算法和改进算法。

通常我们从两个维度来描述算法的优劣:程序代码的执行时间和代码占用的内存空间。两者分别叫做算法的时间复杂度和算法的空间复杂度,合称算法的复杂度。

时间复杂度和空间复杂度可以反映出算法的效率。

时间复杂度

时间复杂度用来衡量算法的执行时间,用 O 表示。

事实上,代码执行时所耗费的时间,只有在机器上运行后才能知道,从理论上是不能算出来的。为了方便,我们用执行到的语句数量来表示执行的时间。语句执行次数越多,代码所耗费的时间越长。

举些栗子。

function isEven(num) {
    let isEven = num % 2 === 0
    return isEven
}

这是一个判断一个数是否为偶数的函数,运行时执行到了两条语句,它的时间复杂度为 O(2)。

function sum(arr) {
    let total = 0
    for (let i = 0; i < arr.length; i++) {
        total += arr[i]
    }
    return total
}

这是一个求和的函数,它的时间复杂度取决于参数数组的大小。算法的执行时间往往取决于要处理的数据的大小,通常我们把要处理的数据的大小叫做问题的规模,用 n 表示。在这个示例中,n 就是数组的长度。

所以,这个求和算法的复杂度为 O(3n + 3)。

说实话,计算这个复杂度还挺麻烦的。很多时候,我们不需要计算得那么精确,我们只需要知道算法的大致时间就好了。对于计算机来说,多执行几条命令在时间上效率并没有提高多少。

为了方便计算和比较不同的时间复杂度,我们需要对结果去掉低阶项,去掉常数项,去掉高阶项的常参。这话涉及到多项式的知识,可能比较难理解,可以看下面示例。

O(3) = O(99999) = O(1)
O(2n + 4) = O(n + 999) = O(n)
O(2n^2) = O(3n^2 + 8) = O(8n^2 + 4n + 7) = O(n^2)
O(n^3 + 2n^2) = O(n^3)

这样的话,计算时间复杂度就方便很多了。

如果一个算法的时间复杂度是个常数,即随着问题的规模(n)的增大,它的时间复杂度不变,那么算法的时间复杂度为 O(1)。

let sum = 0
for (let i = 1; i <= 100; i++) {
    sum += sum
}

比如这个求 1 + 2 + 3 + ... + 100 的算法,它的时间复杂度是 O(1)。因为它的时间复杂度是个常数,大概 300 多,我们不需要知道具体的值是多少。

function sort(arr) {
    for (let out = 0; out < arr.length - 1; out++) {
        for (let j = 0; j < arr.length - out - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                let tmp = arr[j]
                arr[j] = arr[j + 1]
                arr[j + 1] = tmp
            }
        }
    }
    return arr
}

这是冒泡排序法,用到了二重循环,每重循环的次数大概为 n(arr.length),因此它的时间复杂度为 O(n^2)。

一个简单的判断时间复杂度的方法就是,如果算法中只用到了一重循环,并且循环的次数大致为 n,那么算法的时间复杂度为 O(n);如果算法中用到了二重循环,每重循环的次数大概为 n,因此它的时间复杂度为 O(n^2);以此类推。

我们再来看一个函数。

function find(arr, num) {
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] === num) {
            return true
        }
    }
    return false
}

这是一个判断数组中是否存在一个目标数的函数。它的执行时间更是不确定的。如果要查找的数在数组的第一个,那么它只需要执行几条语句能完成了。如果目标数是数组的最后一个,或者在数组中不存在,那么要执行的时间就很久了。通常我们在讨论算法的时间复杂度时,指的是在最坏的情况下,算法的时间复杂度。因此,这个算法的时间复杂度是 O(n)。

我们再来看一个例子:

for (let i = 1; i <= n; i *= 2) {
    console.log(i)
}

在这个示例中,i 是指数增长的,我们假设执行的次数为 m,那么 2^m = n,即 m = logx2(n)。因此,时间复杂度为 log2(n)。

常见的时间复杂度

常见的时间复杂度有下面这些(按数量级递增排列):

常数阶O(1) -> 对数阶O(log2n) -> 线性阶O(n) -> 线性对数阶O(nlog2n) -> 平方阶O(n^2) -> 立方阶O(n^3) -> k次方阶O(n^k) -> 指数阶O(2^n)。

空间复杂度

空间复杂度用来表示算法的执行时所需存储空间的度量。

计算的方法和时间复杂度类似,这里不再赘述。

比如上面的冒泡排序法,空间复杂度为 O(1)。

应用

前面说过,我们经常对算法进行分析,以便于选择合适的算法和改进算法。

在改进算法方面,如果程序注重运行时间,有时我们会选择牺牲空间复杂度的方式来换取算法的时间复杂度。

比如 LeetCode 的第一道算法题(有兴趣自行百度 LeetCode Two Sum),一般情况下我们采用双重循环来做,时间复杂度为 O(n),这样的话代码的执行时间就会超出限制的时间。所以只好采用一重循环 + Map 的思路来做。这是一个典型的“以空间换时间的”的例子。

熟悉算法复杂度的概念,也可以帮助我们选择适合的算法。

比如我们知道了冒泡排序法的平均时间复杂度为 O(n^2),空间复杂度为 O(1),快速排序法是的平均时间复杂度为 O(log2(n)),空间复杂度为 O(1)。那么很显然,当数据量比较大的时候,快速排序法明显会比冒泡排序法更加高效。

当然,算法复杂度并不是衡量算法时唯一考虑的因素。很多时候,我们还需要考虑算法是否容易实现、代码可读性等等。

就以上面的排序算法来说。快速排序算法不是稳定的,而冒泡排序是稳定的算法,稳定性也是选择排序算法考虑的因素之一。

原文地址:https://www.cnblogs.com/yunser/p/algorithm-complexity.html