理解算法的复杂度

要写出高效的代码,理解代码的复杂度是必要的,本文就聊下算法的复杂度。算法

1 时间复杂度

1.1 理解 O(n)

说句实话,虽然说从刚开始接触算法开始就知道了大 O 表示法,可是怎么来的,还真没怎么想过,这里捋一捋。后端

先上段代码数组

int sum(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; i++) {
        sum += i;
    }
    return sum;
}
复制代码

进行复杂度分析的时候,咱们会假定每行代码的执行时间是固定的,假设为 1t。第 2 行代码执行须要 1t 的时间,第 3 行 1t,第 4 行,因为是循环,须要 nt 的时间,第 5 行也是 nt,总共须要的时间 T(n) = 2t + 2nt = (2 + 2n)t。也就是说bash

T(n) = O(f(n))
复制代码

这里,T(n) 表示代码的执行时间,f(n) 表示代码的执行次数,整体理解为代码的执行时间和代码的执行次数成正比。将次数带入,T(n) = O(2n + 2)。数据结构

再来段代码ui

int sum(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; i++) {
        int j = 1;
        for (; j <= n; j++) {
            sum += i * j;
        }
    }
    return sum;
}
复制代码

第 二、3 行代码执行各须要 1t,第 四、5 行各须要 nt,第 六、7 行各须要 n^2t,T(n) = 2t + 2nt + 2n^2t = (2n^2 + 2n + 2)t = O(2n^2 + 2n + 2)。spa

当 n 特别大的时候,常量、低阶、系数均可以忽略,因此前一个例子 T(n) = O(n),后一个例子 T(n) = O(n^2)。3d

所谓时间复杂度,就是算法的执行时间与数据规模之间增加关系code

1.2 分析方法

若是咱们每次都以上面的方式去算时间复杂度,难免以为繁琐,因此,这里说两个法则,方便咱们比较快的算出复杂度。cdn

1.2.1 加法法则

总复杂度等于量级最大的那段代码的复杂度。1.1 节的第一个例子中,前面一段代码(第 二、3 行)复杂度为 O(1),后面(第 4 到 6 行)为 O(n),最后总的时间复杂度是最大的那个,即 O(n)。

1.2.2 乘法法则

嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。1.1 节的第二个例子中,后面一段代码(第 4 到 9 行),外层循环时间复杂度为 O(n),内层也为 O(n),因此,时间复杂度为 O(n^2)。

1.3 常见 O(n)

复杂度量级能够大体的分为两类:多项式量级和非多项式量级。其中,非多项式量级只有 2 个:指数阶 O(2^n) 和 阶乘阶 O(n!)。因为非多项式量级的算法执行时间随数据规模的增加会急剧增长,比较低效,这里就不作更多说明。下面是常见的多项式量级

1.3.1 O(1) 常量阶

int a = n;
int b = 2 * n;
int c = 3 * n;
return a + b + c;
复制代码

相似这种,代码的执行次数必定,不会由于数据规模 n 的变化而变化,时间复杂度就是常量阶的。

1.3.2 O(logn) 对数阶

int i = 1;
while (i < n)  {
    i = i * 2;
}
复制代码

代码中,咱们容易得出 i 的变化规律

2^0, 2^1, 2^2, ..., 2^k
复制代码

循环中的代码执行次数 k 是能够计算出来的,知足 2^k = n 便可,得出 k = log_2(n),即时间复杂度为 O(log_2(n))

若是将代码改成

int i = 1;
while (i < n)  {
    i = i * 3;
}
复制代码

相似的能够推导出时间复杂度为 O(log_3(n))。若是你喜欢的话,也能够构造出底为 五、7 等的对数复杂度,不过因为对数是能够互相转化的,咱们能够统一下。

以上面的两个复杂度为例,O(log_3(n)) = O(log_3(2) * log_2(n)) = O(C * log_2(n))。其中的系数 C 是一个常量,能够忽略掉,从而获得 O(log_3(n)) = O(log_2(n))。因此,咱们能够忽略底数,直接将这种类型的复杂度记为 O(logn)。

1.3.3 O(n) 线性阶

1.1 节的第一个例子中的循环的复杂度就是线性阶的,这里再也不赘述。

1.3.4 O(m + n)、O(m * n)

int sum(int m, int n) {
    int sum1 = 0;
    int i = 1;
    for (; i < m; ++i) {
        sum1 = sum1 + i;
    }

    int sum2 = 0;
    int j = 1;
    for (; j < n; ++j) {
        sum2 = sum2 + j;
    }

    return sum1 + sum2;
}
复制代码

分析这段代码的时候就不能使用加法法则,由于数据规模 m 和 n 无法比较大小,也就无法取最大的那个,因此复杂度为 O(m + n)。

不过,乘法法则依然有效,好比下面这段代码

int sum(int m, int n) {
    int sum = 0;
    int i = 1;
    for (; i <=m; i++) {
        int j = 1;
        for (; j <=n; j++) {
            sum += i * j;
        }
    }
    return sum;
}
复制代码

复杂度为 O(m * n)。

1.3.5 O(nlogn) 线性对数阶

当复杂度为线性阶和对数阶的代码嵌套时,使用乘法法则可知此时的复杂度就为线性对数阶,这里不作过多说明。

1.3.6 O(n^k) k 次方阶,k >= 2

当复杂度为线性阶代码嵌套时,使用乘法法则可知此时的复杂度就为 k 次方阶,这里也不作过多说明。

1.4 最好、最坏、平均、均摊时间复杂度

通常状况下,复杂度分析知道前面几节的内容就好了。只有当同一块代码,在不一样状况下,复杂度有量级差距的时候,咱们才会用到这几种复杂度。

int find(int* a, int n, int d) {
    int i = 0;
    int pos = -1;
    for (; i < n; i++) {
        if (a[i] == d) {
            pos = i;
            break;
        }
    }
    return pos;
}
复制代码

这段代码的目的是在长度为 n 的数组 a 中查找目标值 d,若是找到返回其位置,找不到返回 -1。

1.4.1 最好状况时间复杂度

所谓最好状况时间复杂度,也就是在最理想状况下,执行这段代码的时间复杂度。在这种状况下,d 恰好是数组的第一个元素,这个时候的复杂度就是最好状况时间复杂度,这里为 O(1)。

1.4.2 最坏状况时间复杂度

所谓最好状况时间复杂度,也就是在最糟糕状况下,执行这段代码的时间复杂度。若是数组中恰好没有元素 d,此时须要遍历完数组才能肯定结果,在这种状况下对应的就是最坏状况时间复杂度,这里为 O(n)。

1.4.3 平均状况时间复杂度

固然,最好和最坏状况时间复杂度都是对应极端状况下的复杂度,发生的几率都比较低,这里咱们引入平均状况时间复杂度来表示平均状况下的复杂度。为了方便,后面简称为平均时间复杂度。

d 在数组 a 中的位置有 n + 1 种状况,在 0 到 n-1 位置中和不在数组中,咱们将每种状况下需遍历的元素个数加起来除以 n + 1,能够获得遍历元素个数的平均值

(1 + 2 + 3 + ... + n + n) / (n + 1) = n(n + 3) / 2(n + 1)

去掉常量、低阶、系数,就获得了平均时间复杂度为 O(n)。

虽然说结果咱们算对了,但计算过程是有些问题的,由于这 n+1 种状况出现的几率并不相同。要查找的元素 d 在数组 a 中,要么不在。为了方便,假定出现的几率都是 1/2。存在状况下,要查找的元素出如今 0 到 n-1 的位置的几率相同,因此要查找的元素出如今 0 到 n-1 的位置的几率是 1/2n。这时的计算方法为

1 * 1/2n + 2 * 1/2n + 3 * 1/2n + ... + n * 1/2n + n * 1/2 = (3n + 1) / 4

所以,平均时间复杂度为 O(n)。

1.4.4 均摊时间复杂度

从新给个例子

int* a = new int[n];
int count = 0;

void insert(int val) {
    if (count == n) {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum = sum + a[i];
        }
        a[0] = sum;
        count = 1;
    }

    a[count] = val;
    count++;
}
复制代码

这段代码的做用是,从数组 a 的第 0 个位置开始插入数字 0,每次插入位置位置和数字都加 1。当数组满时,将元素累加并将和插入到第 0 个位置,而后从后面继续插入数字。

这个例子中,不可贵出,最好状况时间复杂度是 O(1),最坏状况时间复杂度是 O(n)。平均复杂度呢?此时有 n + 1 种状况,前 n 种出如今数组没满的状况下,最后一种恰好出如今数组满的时候,而且这些状况出现的几率都是同样的。平均复杂度就是

1 * 1/(n+1) + 1 * 1/(n+1) + ... + 1/(n+1) + n * 1/(n+1) = 2n / (n + 1) = O(1)

其实这里的平均复杂度分析能够不用这么复杂。和前面的 find() 方法不一样,insert() 方法中,O(1) 和 O(n) 复杂度的插入是十分有规律的,通常是 1 个 O(n) 的插入后跟 n-1 个 O(1) 插入。这种状况下,有一种更简单的分析方法:摊还分析法,经过这种方式分析获得的复杂度就是均摊时间复杂度。好比这个例子中,1 个 O(n) 的插入后跟 n-1 个 O(1) 插入,咱们能够将耗时多的操做均摊到 n-1 次耗时少的操做上,均摊下来,这一组操做的均摊时间复杂度就是 O(1)。

摊还分析使用的场景比较特殊,通常是在对一个数据结构进行连续操做时,大部分状况时间复杂度较低,少部分较高,而且操做之间有必定时序关系。这个时候,咱们将这组操做放在一块儿分析,看可否将耗时高的操做均摊到耗时低的操做上。在可以应用均摊时间复杂度分析的场合,通常均摊时间复杂度就等于平均时间复杂度。

至于均摊时间复杂度和平均时间复杂度有啥区别,将前者理解为后者的一种特殊状况就行,不必去死抠,理解这种分析方法就好了。

2 空间复杂度

和时间复杂度相似,所谓空间复杂度,就是算法的存储空间与数据规模之间的增加关系

void print(int n) {
    int i = 0;
    int* a = new int[n];

    for (i; i < n; i++) {
        a[i] = i * i;
    }

    for (i = n - 1; i >= 0; i--) {
        std::cout << a[i] << ' ';
    }
}
复制代码

第 2 行代码申请了一个空间存储变量 i,与数据规模 n 没有关系,因此复杂度是 O(1)。第 3 行申请了一个长度为 n 的 int 类型数组,后面的代码没有使用更多的空间,因此整段代码的空间复杂度就是 O(n)。

空间复杂度常见的就是 O(1)、O(n)、O(n^2),像 O(logn)、O(nlogn) 的复杂度平时比较少用,因此理解了前三种也差很少了。

3 小结

复杂度有时间复杂度和空间复杂度,复杂度越高阶,效率越低。

时间复杂度表示算法执行时间与数据规模之间的增加关系,分析时可使用加法和乘法法则,常见的有 O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)。大体增加趋势能够看下下面的图

另外,对于同一段代码,若是在不一样状况下时间复杂度存在量级的差距的话,能够分析下最好、最坏、平均和均摊时间复杂度。

空间复杂度表示算法的存储空间与数据规模之间的增加关系,常见的有 O(1)、O(n)、O(n^2)。

本文首发于公众号「小小后端」。

相关文章
相关标签/搜索