02 | 复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度

今天我会继续给你讲四个复杂度分析方面的知识点,最好状况时间复杂度(best+case+time+complexity)、最坏状况时间复杂度(worst+case+time+complexity)、平均状况时间复杂度(average+case+time+complexity)、均摊时间复杂度(amortized+time+complexity)。若是这几个概念你都能掌握,那对你来讲,复杂度分析这部份内容就没什么大问题了。算法

最好、最坏状况时间复杂度

上一节我举的分析复杂度的例子都很简单,今天咱们来看一个稍微复杂的。你能够用我上节教你的分析技巧,本身先试着分析一下这段代码的时间复杂度。数组

 

// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) pos = i;
  }
  return pos;
}

 

你应该能够看出来,这段代码要实现的功能是,在一个无序的数组(array)中,查找变量x出现的位置。若是没有找到,就返回-1。按照上节课讲的分析方法,这段代码的复杂度是O(n),其中,n表明数组的长度。数据结构

 

咱们在数组中查找一个数据,并不须要每次都把整个数组都遍历一遍,由于有可能中途找到就能够提早结束循环了。可是,这段代码写得不够高效。咱们能够这样优化一下这段查找代码。  数据结构和算法

 

// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) {
       pos = i;
       break;
    }
  }
  return pos;
}

  

这个时候,问题就来了。咱们优化完以后,这段代码的时间复杂度仍是O(n)吗?很显然,我们上一节讲的分析方法,解决不了这个问题。函数

由于,要查找的变量x可能出如今数组的任意位置。若是数组中第一个元素正好是要查找的变量x,那就不须要继续遍历剩下的n-1个数据了,那时间复杂度就是O(1)。但若是数组中不存在变量x,那咱们就须要把整个数组都遍历一遍,时间复杂度就成了O(n)。因此,不一样的状况下,这段代码的时间复杂度是不同的。学习

 

为了表示代码在不一样状况下的不一样时间复杂度,咱们须要引入三个概念:最好状况时间复杂度、最坏状况时间复杂度和平均状况时间复杂度。优化

 

顾名思义,最好状况时间复杂度就是,在最理想的状况下,执行这段代码的时间复杂度。就像咱们刚刚讲到的,在最理想的状况下,要查找的变量x正好是数组的第一个元素,这个时候对应的时间复杂度就是最好状况时间复杂度。blog

 

同理,最坏状况时间复杂度就是,在最糟糕的状况下,执行这段代码的时间复杂度。就像刚举的那个例子,若是数组中没有要查找的变量x,咱们须要把整个数组都遍历一遍才行,因此这种最糟糕状况下对应的时间复杂度就是最坏状况时间复杂度。element

 

平均状况时间复杂度

 

咱们都知道,最好状况时间复杂度和最坏状况时间复杂度对应的都是极端状况下的代码复杂度,发生的几率其实并不大。为了更好地表示平均状况下的复杂度,咱们须要引入另外一个概念:平均状况时间复杂度,后面我简称为平均时间复杂度。it

 

平均时间复杂度又该怎么分析呢?我仍是借助刚才查找变量x的例子来给你解释。

 

要查找的变量x在数组中的位置,有n+1种状况:在数组的0~n-1位置中和不在数组中。咱们把每种状况下,查找须要遍历的元素个数累加起来,而后再除以n+1,就能够获得须要遍历的元素个数的平均值,即:

 

 

咱们知道,时间复杂度的大+O+标记法中,能够省略掉系数、低阶、常量,因此,我们把刚刚这个公式简化以后,获得的平均时间复杂度就是O(n)。

 

这个结论虽然是正确的,可是计算过程稍微有点儿问题。到底是什么问题呢?咱们刚讲的这 n+1 种状况,出现的几率并非同样的。我带你具体分析一下。(这里要稍微用到一点儿几率论的知识,不过很是简单,你不用担忧。)

 

咱们知道,要查找的变量x,要么在数组里,要么就不在数组里。这两种状况对应的几率统计起来很麻烦,为了方便你理解,咱们假设在数组中与不在数组中的几率都为 1 / 2。另外,要查找的数据出如今 0~n-1 这 n 个位置的几率也是同样的,为1 / n。因此,根据几率乘法法则,要查找的数据出如今 0~n-1 中任意位置的几率就是 1 / (2n)。

 

所以,前面的推导过程当中存在的最大问题就是,没有将各类状况发生的几率考虑进去。若是咱们把每种状况发生的几率也考虑进去,那平均时间复杂度的计算过程就变成了这样: 

 

 

这个值就是几率论中的加权平均值,也叫做指望值,因此平均时间复杂度的全称应该叫加权平均时间复杂度或者指望时间复杂度。

 

引入几率以后,前面那段代码的加权平均值为(3n+1) / 4。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O(n)。

 

你可能会说,平均时间复杂度分析好复杂啊,还要涉及几率论的知识。实际上,在大多数状况下,咱们并不须要区分最好、最坏、平均状况时间复杂度三种状况。像咱们上一节课举的那些例子那样,不少时候,咱们使用一个复杂度就能够知足需求了。只有同一块代码在不一样的状况下,时间复杂度有量级的差距,咱们才会使用这三种复杂度表示法来区分。

 

均摊时间复杂度

到此为止,你应该已经掌握了算法复杂度分析的大部份内容了。下面我要给你讲一个更加高级的概念,均摊时间复杂度,以及它对应的分析方法,摊还分析(或者叫平摊分析)。

 

均摊时间复杂度,听起来跟平均时间复杂度有点儿像。对于初学者来讲,这两个概念确实很是容易弄混。我前面说了,大部分状况下,咱们并不须要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊状况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。

 

老规矩,我仍是借助一个具体的例子来帮助你理解。(固然,这个例子只是我为了方便讲解想出来的,实际上没人会这么写。)

 

 // array 表示一个长度为 n 的数组
 // 代码中的 array.length 就等于 n
 int[] array = new int[n];
 int count = 0;
 
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

  

我先来解释一下这段代码。这段代码实现了一个往数组中插入数据的功能。当数组满了以后,也就是代码中的 count == array.length 时,咱们用 for 循环遍历数组求和,并清空数组,将求和以后的 sum 值放到数组的第一个位置,而后再将新的数据插入。但若是数组一开始就有空闲空间,则直接将数据插入数组。

 

那这段代码的时间复杂度是多少呢?你能够先用咱们刚讲到的三种时间复杂度的分析方法来分析一下。

 

最理想的状况下,数组中有空闲空间,咱们只须要将数据插入到数组下标为 count 的位置就能够了,因此最好状况时间复杂度为 O(1)。最坏的状况下,数组中没有空闲空间了,咱们须要先作一次数组的遍历求和,而后再将数据插入,因此最坏状况时间复杂度为 O(n)。

 

那平均时间复杂度是多少呢?答案是 O(1)。咱们仍是能够经过前面讲的几率论的方法来分析。

 

假设数组的长度是+n,根据数据插入的位置的不一样,咱们能够分为+n+种状况,每种状况的时间复杂度是 O(1)。除此以外,还有一种“额外”的状况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。并且,这 n+1 种状况发生的几率同样,都是 1/(n+1)。因此,根据加权平均的计算方法,咱们求得的平均时间复杂度就是:

 

 

 

至此为止,前面的最好、最坏、平均时间复杂度的计算,理解起来应该都没有问题。可是这个例子里的平均复杂度分析其实并不须要这么复杂,不须要引入几率论的知识。这是为何呢?咱们先来对比一下这个 insert() 的例子和前面那个 find() 的例子,你就会发现这二者有很大差异。

 

首先,find() 函数在极端状况下,复杂度才为 O(1)。但 insert() 在大部分状况下,时间复杂度都为 O(1)。只有个别状况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。

 

咱们再来看第二个不一样的地方。对于 insert() 函数来讲,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是很是有规律的,并且有必定的先后时序关系,通常都是一个 O(n) 插入以后,紧跟着 n-1 个 O(1) 的插入操做,循环往复。

 

因此,针对这样一种特殊场景的复杂度分析,咱们并不须要像以前讲平均复杂度分析方法那样,找出全部的输入状况及相应的发生几率,而后再计算加权平均值。

 

针对这种特殊的场景,咱们引入了一种更加简单的分析方法:摊还分析法,经过摊还分析获得的时间复杂度咱们起了一个名字,叫均摊时间复杂度。

 

那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?

 

咱们仍是继续看在数组中插入数据的这个例子。每一次 O(n) 的插入操做,都会跟着 n-1 次 O(1) 的插入操做,因此把耗时多的那次操做均摊到接下来的 n-1 次耗时少的操做上,均摊下来,这一组连续的操做的均摊时间复杂度就是 O(1)。这就是均摊分析的大体思路。你都理解了吗?

 

均摊时间复杂度和摊还分析应用场景比较特殊,因此咱们并不会常常用到。为了方便你理解、记忆,我这里简单总结一下它们的应用场景。若是你遇到了,知道是怎么回事儿就好了。

 

对一个数据结构进行一组连续操做中,大部分状况下时间复杂度都很低,只有个别状况下时间复杂度比较高,并且这些操做之间存在先后连贯的时序关系,这个时候,咱们就能够将这一组操做放在一起分析,看是否能将较高时间复杂度那次操做的耗时,平摊到其余那些时间复杂度比较低的操做上。并且,在可以应用均摊时间复杂度分析的场合,通常均摊时间复杂度就等于最好状况时间复杂度。

 

尽管不少数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实我我的认为,均摊时间复杂度就是一种特殊的平均时间复杂度,咱们不必花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均仍是叫均摊,这只是个说法,并不重要。

 

内容小结

 

今天咱们学习了几个复杂度分析相关的概念,分别有:最好状况时间复杂度、最坏状况时间复杂度、平均状况时间复杂度、均摊时间复杂度。之因此引入这几个复杂度概念,是由于,同一段代码,在不一样输入的状况下,复杂度量级有多是不同的。

 

在引入这几个概念以后,咱们能够更加全面地表示一段代码的执行效率。并且,这几个概念理解起来都不难。最好、最坏状况下的时间复杂度分析起来比较简单,但平均、均摊两个复杂度分析相对比较复杂。若是你以为理解得还不是很深刻,不用担忧,在后续具体的数据结构和算法学习中,咱们能够继续慢慢实践!

 

课后思考

 

分析一下下面这个 add() 函数的时间复杂度。

 

// 全局变量,大小为 10 的数组 array,长度 len,下标 i。
int array[] = new int[10]; 
int len = 10;
int i = 0;

// 往数组中添加一个元素
void add(int element) {
   if (i >= len) { // 数组空间不够了
     // 从新申请一个 2 倍大小的数组空间
     int new_array[] = new int[len*2];
     // 把原来 array 数组中的数据依次 copy 到 new_array
     for (int j = 0; j < len; ++j) {
       new_array[j] = array[j];
     }
     // new_array 复制给 array,array 如今大小就是 2 倍 len 了
     array = new_array;
     len = 2 * len;
   }
   // 将 element 放到下标为 i 的位置,下标 i 加一
   array[i] = element;
   ++i;
}
相关文章
相关标签/搜索