算法时间复杂度计算公式

 

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

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

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

  • 时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
  • 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。

因此,评价一个算法的效率主要是看它的时间复杂度和空间复杂度情况。然而,有的时候时间和空间却又是「鱼和熊掌」,不可兼得的,那么我们就需要从中去取一个平衡点。

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

一、时间复杂度

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

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

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

我们先来看个例子:

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(logN)
  • 线性阶O(n)
  • 线性对数阶O(nlogN)
  • 平方阶O(n²)
  • 立方阶O(n³)
  • K次方阶O(n^k)
  • 指数阶(2^n)

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

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

  1. 常数阶O(1)

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

int i = 1;
int j = 2;
++i;
j++;
int m = i + j;

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

  1. 线性阶O(n)

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

for(i=1; i<=n; ++i)
{
   j = i;
   j++;
}

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

  1. 对数阶O(logN)

还是先来看代码:

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(logn)

  1. 线性对数阶O(nlogN)

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

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

for(m=1; m<n; m++)
{
    i = 1;
    while(i<n)
    {
        i = i * 2;
    }
}
  1. 平方阶O(n²)

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

for(x=1; i<=n; x++)
{
   for(i=1; i<=n; i++)
    {
       j = i;
       j++;
    }
}

这段代码其实就是嵌套了2层n循环,它的时间复杂度就是 O(n*n),即 O(n²)
如果将其中一层循环的n改成m,即:

for(x=1; i<=m; x++)
{
   for(i=1; i<=n; i++)
    {
       j = i;
       j++;
    }
}

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

  1. 立方阶O(n³)、K次方阶O(n^k)

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

除此之外,其实还有 平均时间复杂度、均摊时间复杂度、最坏时间复杂度、最好时间复杂度 的分析方法,有点复杂,这里就不展开了。

二、空间复杂度

既然时间复杂度不是用来计算程序具体耗时的,那么我也应该明白,空间复杂度也不是用来计算程序实际占用的空间的。

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。

空间复杂度比较常用的有:O(1)、O(n)、O(n²),我们下面来看看:

  1. 空间复杂度 O(1)

如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
举例:

int i = 1;
int j = 2;
++i;
j++;
int m = i + j;

代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)

  1. 空间复杂度 O(n)

我们先看一个代码:

int[] m = new int[n]
for(i=1; i<=n; ++i)
{
   j = i;
   j++;
}

这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)

以上,就是对算法的时间复杂度与空间复杂度基础的分析,欢迎大家一起交流。

 

什么是 算法复杂度

首先,作为一个算法来说,有两个特性:

  1. 它用来处理一定规模的数据
  2. 它处理数据 需要时间

有了这两个基础的认知,我们可以简单的认为:随着数据规模的变化,算法处理数据需要的时间也会变化

算法时间复杂度就是:用一个公式来表示,这个数据规模和时间变化的关系

比如:

假设一个算法 F,一个数据集 N;

  • 当 N 中包含 10个元素时, 使用算法 F 进行处理,需要花费 5s。
  • 当 N 中包含 30个元素时,使用算法 F 进行处理,需要花费 15s。
  • 当 N 中包含 300 个元素时,使用算法 F 进行处理,需要花费 150s。
  • ……

可以看到,随着数据规模的增长,算法处理需要的时间也在变化。如果我们想要表达这个时间变化的趋势和数据集N的关系的话,我们假设这个关系是 T(N)T(N) 我们可以写下面的一个算式:

 

T(N)=F(N)T(N)=F(N)

 

这里的 T(N)T(N) 表示了一个算法对于数据集N需要使用的时间的精确计算。

Big O 表示法

在实际上,精确计算公式是不可能被统计计算出来的,因为不同的语句,不同的操作,耗费的时间也是不一样。所以,在实际应用中,我们不需要精确计算,只需要一个公式,来表达 时间花费和数据集大小的渐近关系。

所以,在描述算法的时候,一般采用 大O(Big-O notation)表示,又称为渐进符号,

Big-O 是用于描述函数渐近行为的数学符号。更确切地说,它是用另一个(通常更简单的)函数来描述一个函数数量级的渐近上界。 -- 《维基百科》

上面的解释太玄幻。

其实简单来说,一个函数是几个项的和,每个项都能被归入到一个数量级,比如(平方、立方)。当数据集(自变量)趋于无穷大时,数量级大的变化太大,造成了数量级小的部分对函数结果的影响小到可以被忽略。

所以,我们就简单的使用数量级大的部分来替代整个函数,来简化表达。

比如:

 

T(N)=2N3+N2+1024T(N)=2N3+N2+1024

 

这个 T(N)T(N) 表达了某个算法的时间复杂度。

我们可以将这个等式分为三个项,按照数量级大小降序:

  • A: 2N32N3
  • B: N2N2
  • C: 10241024

我们对N取几个值来分别看一下 A 、B 、C 三个部分的值:

  1. 当N=10;A=2000,B=100,C=1024
  2. 当N=100;A=2000000,B=10000,C=1024
  3. 当N=1000;A=2000000000(20亿),B=1000000(100万),C=1024
  4. 当N=10000;A=2000000000000(2兆),B=100000000(1亿),C=1024
  5. 就不计算了吧……

可以看到,当数据集变大的时候,数量级大的部分增长的速度远远超过数量级小的部分。当N增大时,A占主导,B 和 C 两个部分的值相对于A部分来说,对结果的影响就可以忽略不计了。

然后,再看 A 部分,决定 A 部分大小的是 n3n3。当我们要表达一个渐进关系的时候,常数并不影响这个关系的表达,所以 A 部分中的 常数就可以忽略。

最终,变成了下面:

 

T(N)=O(N3)T(N)=O(N3)

 

这个 O(N3)O(N3) ,就是算法的时间复杂度。

TIPS: 上面的例子中,A部分常数是 2。可以放心的忽略。但如果是常数是 103103,忽略的话,结果就是错误的了。所以要对可能的大常数敏感。

计算代码的时间复杂度

明白了如何表示算法的复杂度。那如何计算复杂度呢?

简单计算

给出一个段简单的代码:将数组中每个元素的值加1。

我们分析它的语句执行的次数:

所以这里的时间复杂度是:

 

T(N)=n+1O(n)T(N)=n+1→O(n)

 

冒泡排序分析

再来一个例子: 计算冒泡排序的算法复杂度

void sort(int *a, int len) {
    for (int i = 0; i < len; i++) {
        int min = i;
        for(int j = i; j< len; j++){
            if (a[j] < a[min]) // 比较动作
                min = j;
        }
        swap(a, min, i);
    }
}

对于基于比较的排序算法来说,我们在计算复杂度的时候,是计算比较了多少次。

  • 第一次循环,比较了 N次;
  • 第二次循环,比较了 N-1次;
  • 第三次循环,比较了 N-2次;

总的比较次数就是 N+(N1)+(N2)++1N+(N−1)+(N−2)+…+1,就是N的阶加。根据阶加公式可得:

 

N=n(n+1)2O(n2)N∼=n(n+1)2⇒O(n2)

 

那么,冒泡排序的时间复杂度就是 O(n2)O(n2)。

更复杂的有什么?

在上面的两个例子中, 算法是稳定的。什么叫稳定呢? 就是不管数据集的内容是什么样的,以什么方式来组合,算法都是以这个复杂度来运行。

拿冒泡排序来说,不管给到的原始数组是排好序的、倒序的、乱序的,比较次数都是一样的,不存在算法的最好情况和最坏情况,这样的算法叫做稳定算法。

但是比如像 快速排序、堆排序 都是不稳定的算法。因为这类算法依赖于原始数据集的内容和其内容的组合方式。例如快速排序,如果原始数组是一个接近有序的,那么它的速度会快很多,如果原始数组是倒序的,那么快排的性能会急剧的恶化。而且,快排的效率还依赖于基准数的选择。

但是,对于数据集的分析这件事情,实在是太难了,远远超过了普通程序员的能力(包括我😂),所以,这里我们就不再做数据的分析了。如果以后,我能说明白的时候,再写一篇另外的。

实例分析:快速排序复杂度

快排的文章详见 这里。快排几乎是使用最广泛的排序了,所以用它来做例子很合适。

同冒泡排序一样,快速排序是比较排序算法。我们只计算他比较的次数。

假设 n 是要排序的数据集规模,d 是递归的深度。

最好的情况

观察法

每一次递归都能分成两个子数组,大小 n2≤n2,这样,递归的深度是最小的,就有:2d=n2d=n ,那么容易知道,递归深度是: d=log2nd=log2⁡n 。在每一层的递归中,快排都要将所有的元素比较一遍,所以每一层的比较次数为 nn 。那么,整体的比较次数为 nlog2nn⋅log2⁡n , 那么,时间复杂度就是 O(nlogn)O(n⋅log⁡n) 。

推理法

有一个东东叫 主定理,这个东西广泛用在了递推分治算法的复杂度计算上,具体就不说了,大家自己上 Wikipedia 上看吧。这里,我们用它来进行推导。

假设时间复杂度是 T(n)T(n) ,那么,很明显, T(1)=T(0)=O(1)T(1)=T(0)=O(1)

快排中的分治算法符合主定理的递推关系式,所以有:

 

T(n)=2T(n2)+O(n)T(n)=2T(n2)+O(n)

 

假定:

f(n)=O(n)=Θ(nlog22logkn)f(n)=O(n)=Θ(nlog2⁡2logk⁡n)

 

可得 k=0k=0 ;符合 主定理的第二种情形。

可得:

T(n)=Θ(nlog22logn)O(nlogn)T(n)=Θ(nlog2⁡2⋅log⁡n)⇒O(n⋅log⁡n)

 

最坏的情况

每一次递归,划分出两个部分,一个部分中元素个数为0,另一个部分中,为 n1n−1 ,这样,就需要递归 nn次,每一层递归都需要将所有元素比较一次,那么整体的比较次数就是 n2n2 。那么,时间复杂度就是 O(n2)O(n2) 。

平均情况

平均情况推算下来稍有点麻烦。如果我的推论讲得不清楚,大家只需要记住:

在平均的情况下,快排的算法复杂度和其在最好的情况下是一样的,都是 O(nlogn)O(n⋅log⁡n) 。

区别在于Big-O中隐藏的常量会大一点。

平均情况,就是在递归划分数组的过程中,有好的情况,有坏的情况。我们将两种情况极端化,即:使用最好的情况和最坏的情况,在递归树上交替出现,来观察算法的变化。

如下图:

在根节点书,并划分出两个子数组: 0 和 n-1,这是最坏情况。在下一层,划分的大小为 n12n−12 和 n121n−12−1。

这样每两层递归,会产生三种情况的组合:0、n12n−12 和 n121n−12−1 。这个组合的划分代价为 O(n)+O(n1)+O(1)=O(n)O(n)+O(n−1)+O(1)=O(n) 。可以注意到,这个代价和平等划分的每次递归的时间是一样的!坏情况产生的代价被好的情况吸收。

区别在于,这种划分的方法会使得递归树变深,也就是常数会变得大一点,而最后的结果依然是 O(nlogn)O(n⋅log⁡n) 。

 

 

 

posted @ 2021-01-20 20:17  方东信  阅读(7735)  评论(0编辑  收藏  举报