理解算法的复杂度

630 阅读10分钟

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

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。也就是说

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

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

再来段代码

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;
}

第 2、3 行代码执行各需要 1t,第 4、5 行各需要 nt,第 6、7 行各需要 n^2t,T(n) = 2t + 2nt + 2n^2t = (2n^2 + 2n + 2)t = O(2n^2 + 2n + 2)。

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

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

1.2 分析方法

如果我们每次都以上面的方式去算时间复杂度,不免觉得繁琐,所以,这里说两个法则,方便我们比较快的算出复杂度。

1.2.1 加法法则

总复杂度等于量级最大的那段代码的复杂度。1.1 节的第一个例子中,前面一段代码(第 2、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))。如果你喜欢的话,也可以构造出底为 5、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)。

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