浅度解析最好、最坏、平均、均摊时间复杂度

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

下边是在无序数组中查找指定数据x的位置代码:

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

按照《时间复杂度分析三个实用技巧》和《复杂度实例分析》中提到的方法,这段代码的时间复杂度T(n)=O(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;
}

这样的话,可能第一个就是要找的元素,时间复杂度T(n)=O(1);要是最后一个元素才是我们要找的元素,时间复杂度T(n)=O(n)。所以这段代码的时间复杂度可能随着情况的变化而有不同。
为了能够更好分析不同情况的时间复杂度,需要引入最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度。
最好情况时间复杂度是在执行一段代码时最理想情况下的时间复杂度,比如上边这段代码第一个就是要找的元素,那么这种情况下就是最好情况时间复杂度。
最坏情况时间复杂度是在最糟糕情况下的时间复杂度,拿上边的代码而言,循环一遍,才发现最后一个元素是要找的元素或者这个数组中没有要找的元素,这两种情况下就是最坏情况时间复杂度。

平均情况时间复杂度

现在可以明确的一点是,最好情况时间复杂度和最坏情况时间复杂度都是极端情况下的时间复杂度,发生的概率都不大。接下来要讨论平均情况时间复杂度,简称平均时间复杂度。
还拿上边改良后代码来分析平均时间复杂度,要查找变量 x 在数组中的位置,有(n+1)种最基本的情况:在数组的 0~n-1 位置中(n种情况)和不在数组中(1种情况),把每种情况查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:

在时间复杂度的大O表示法,可以省略掉系数、低阶、常量,得到的平均时间复杂度是O(n)。这里的结论是正确的,但是分析方式有点问题。因为n+1种情况,出现的概率并不是一样的,所以上边分析没有考虑到概率。
要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。
要是把每种情况的概率都考虑进去,平均时间复杂度的计算就变成:
浅度解析最好、最坏、平均、均摊时间复杂度_第1张图片这个值就是概率论中的加权平均值,也叫期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度
引入概率之后,前面那段代码的加权平均值为 (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 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。
最理想情况下,就是数组中有空闲的位置,可以直接插入,时间复杂度就是O(1);最糟糕情况下,就是数据中没有空闲位置,那么就需要遍历整个数组,然后再输入数据,时间复杂度是O(n);
再来计算一下平均时间复杂度,假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复杂度是 O(1)。除此之外,还有一种“额外”的情况即数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1 / ( n + 1 ) 1/(n+1) 1/(n+1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:
在这里插入图片描述
这里好像没有用到概率,来分析一下insert()和前边改良之后find()的差别。
insert()第一个区别于 find() 的地方是,find() 函数在极端情况即最好情况下,复杂度才为 O(1)。而 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。
接着看第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。
针对这样一种特殊场景的复杂度分析,并不需要像平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。对于这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。
那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?我们还是继续看在数组中插入数据的这个例子。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。
均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便理解、记忆,我这里简单总结一下它们的应用场景。如果遇到了,知道这回事就行了。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
在《数据结构与算法之美》讲解老师看来,均摊时间复杂度就是一种特殊的平均时间复杂度,没必要花太多精力去区分它们。最应该掌握的是它的分析方法——摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。

——极客时间《数据结构与算法之美》学习笔记 Day 6

你可能感兴趣的:(数据结构和算法,算法)