算法的时间复杂度

算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,但在过程中消耗的资源和时间却会有很大的区别。

那么我们应该如何去衡量不同算法之间的优劣呢?

主要还是从算法所占用的「时间」和「空间」两个维度去考量。

时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
因此,评价一个算法的效率主要是看它的时间复杂度和空间复杂度情况。然而,有的时候时间和空间却又是「鱼和熊掌」,不可兼得的,那么我们就需要从中去取一个平衡点。

下面我来分别介绍一下「时间复杂度」的计算方式。

常见的时间复杂度量级有:

  • 常数阶 $ O(1) $
  • 对数阶 $ O(\log{N}) $
  • 线性阶 $ O(n) $
  • 线性对数阶 $ O(n\log{N}) $
  • 平方阶 $ O(n^2) $
  • 立方阶 $ O(n^3) $
  • K 次方阶 $ O(n^k) $
  • 指数阶 $ (2^n) $

时间复杂度计算

我们想要知道一个算法的「时间复杂度」,很多人首先想到的的方法就是把这个算法程序运行一遍,那么它所消耗的时间就自然而然知道了。

这种方式可以吗?当然可以,不过它也有很多弊端。
这种方式非常容易受运行环境的影响,在性能高的机器上跑出来的结果与在性能低的机器上跑的结果相差会很大。而且对测试时使用的数据规模也有很大关系。再者,并我们在写算法的时候,还没有办法完整的去运行呢。

因此,另一种更为通用的方法就出来了:「 大 O 符号表示法 」,即 $ T(n) = O(f(n)) $。

我们先来看个例子:

1
2
3
4
5
for(i=1; i<=n; ++i)
{
j = i;
j++;
}

通过「 大 O 符号表示法 」,这段代码的时间复杂度为:$ O(n) $ ,为什么呢?

在 大 O 符号表示法中,时间复杂度的公式是:$ T(n) = O( f(n) ) $,其中 $ f(n) $ 表示每行代码执行次数之和,而 O 表示正比例关系,这个公式的全称是:算法的渐进时间复杂度。

我们继续看上面的例子,假设每行代码的执行时间都是一样的,我们用 1 颗粒时间 来表示,那么这个例子的第一行耗时是1个颗粒时间,第三行的执行时间是 n 个颗粒时间,第四行的执行时间也是 n 个颗粒时间(第二行和第五行是符号,暂时忽略),那么总时间就是 1 颗粒时间 + n 颗粒时间 + n 颗粒时间 ,即 (1+2n) 个颗粒时间,即:T(n) = (1+2n) * 颗粒时间,从这个结果可以看出,这个算法的耗时是随着 n 的变化而变化,因此,我们可以简化的将这个算法的时间复杂度表示为: $ T(n) = O(n) $。

为什么可以这么去简化呢,因为大O符号表示法并不是用于来真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的。

所以上面的例子中,如果n无限大的时候,T(n) = time(1+2n)中的常量 1 就没有意义了,倍数 2 也意义不大。因此直接简化为 $ T(n) = O(n) $ 就可以了。

上面从上至下依次的时间复杂度越来越大,执行的效率越来越低。

下面选取一些较为常用的来讲解一下(没有严格按照顺序):

常数阶 $ O(1) $

无论代码执行了多少行,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是 $ O(1) $ ,如:

1
2
3
4
5
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;

上述代码在执行的时候,它消耗的时候并不随着某个变量的增长而增长,那么无论这类代码有多长,即使有几万几十万行,都可以用 $ O(1) $ 来表示它的时间复杂度。

线性阶 $ O(n) $

这个在最开始的代码示例中就讲解过了,如:

1
2
3
4
5
for(i=1; i<=n; ++i)
{
j = i;
j++;
}

这段代码,for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用 $ O(n) $ 来表示它的时间复杂度

对数阶 $ O(logN) $

1
2
3
4
5
int i = 1;
while(i<n)
{
i = i * 2;
}

从上面代码可以看到,在 while 循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。我们试着求解一下,假设循环x次之后,i 就大于 2 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 $ x = \log2^n $ 。
也就是说当循环 $ \log2^n $ 次以后,这个代码就结束了。因此这个代码的时间复杂度为:$ O(\log n) $ 。

线性对数阶 $ O(n\log N) $

线性对数阶 $ O(n\log N) $ 其实非常容易理解,将时间复杂度为 $ O(\log N) $ 的代码循环 N 遍的话,那么它的时间复杂度就是 $ n * O(\log N) $ ,也就是了 $ O(n\log N) $ 。

就拿上面的代码加一点修改来举例:

1
2
3
4
5
6
7
8
for(m=1; m<n; m++)
{
i = 1;
while(i<n)
{
i = i * 2;
}
}

平方阶 $ O(n^2) $

平方阶 $ O(n^2) $ 就更容易理解了,如果把 $ O(n) $ 的代码再嵌套循环一遍,它的时间复杂度就是 $ O(n^2) $ 了。

举例:

1
2
3
4
5
6
7
8
for(x=1; i<=n; x++)
{
for(i=1; i<=n; i++)
{
j = i;
j++;
}
}

这段代码其实就是嵌套了 2 层 n 循环,它的时间复杂度就是 $ O(n*n) $,即 $ O(n^2) $

如果将其中一层循环的 n 改成 m ,即:

1
2
3
4
5
6
7
8
for(x=1; i<=m; x++)
{
for(i=1; i<=n; i++)
{
j = i;
j++;
}
}

那它的时间复杂度就变成了 $ O(m*n) $ 。

立方阶 $ O(n^3) $ 、 K 次方阶 $ O(n^k) $

参考上面的 $ O(n^2) $ 去理解就好了,$ O(n^3) $ 相当于三层 n 循环,其它的类似。

结束语

常用的时间复杂度所耗费的时间从小到大依次是

$$ O(1 )< O(logn) < O(n) < O(n*logn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n) $$

后面我会整理出算法的空间复杂度和大家继续分享,欢迎大家持续关注我的 Blog 。