• 【数据结构与算法之美】03-复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度


      上一篇博客中,我们总结了复杂度的大 O 表示法和分析技巧,还列举了一些常见复杂度分析的示例。本篇博客来看看以下四个知识点:

    • 最好情况时间复杂度(best case time complexity)
    • 最坏情况时间复杂度(worst case time complexity)
    • 平均情况时间复杂度(average case time complexity)
    • 均摊时间复杂度(amortized time complexity)

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

      老规矩,我们首先来看个示例:

     1 // n表示数组array的长度
     2 int find(int[] array, int n, int x)
     3 {
     4     int i = 0;
     5     int pos = -1;
     6 
     7     for (; i < n; ++i)
     8     {
     9         if (array[i] == x) pos = i;
    10     }
    11     
    12     return pos;
    13 }

      这段代码的功能时在无序数组中查找变量 x 的下标,如未找到,则返回 -1。不难看出,上面代码的时间复杂度为 O(n)。

      但是,在数组中查找一个数据,并非每次都要遍历整个数组。如果中途找到,就可以提前结束了。上面代码的处理不够高效,我们来增加一个跳出循环的处理:

     1 // n表示数组array的长度
     2 int find(int[] array, int n, int x)
     3 {
     4     int i = 0;
     5     int pos = -1;
     6 
     7     for (; i < n; ++i)
     8     {
     9         if (array[i] == x)
    10         {
    11             pos = i;
    12             break;
    13         }
    14     }
    15     
    16     return pos;
    17 }

      此时问题来了,经过优化的代码有可能在中间就跳出了,它的时间复杂度还是 O(n) 吗?如果数组中第一个元素就是 x,时间复杂度应为 O(1)。如果数组中不存在 x,那时间复杂度就是 O(n)。前面讲的方法似乎无法解决这个问题。

      因此,我们要引入三个概念:最好情况时间复杂度最坏情况时间复杂度平均情况时间复杂度。最好情况时间复杂度和最坏情况时间复杂度不难理解,我们着重分析平均情况时间复杂度。

    二、平均情况时间复杂度

      最好情况时间复杂度和最坏情况时间复杂度过于极端,发生的概率并不大。采用平均情况时间复杂度更为科学。

      我们还是以上面的示例来分析。x 在数组中的位置,有 n+1 种情况,分别是 0~n-1 和不在数组中。我们把各种情况需要遍历的元素数量累加,再除以 n+1,就是需要遍历的元素个数的平均值:

      

      依据之前讲的简化方法,把系数、低阶、常量都去掉,可得平均情况时间复杂度为 O(n)。

      这个结论虽然正确,但实际上计算过程略有问题——这 n+1 种情况的概率不同。x 要么在数组里,要么不在数组里,但这两种情况对应的概率很难算。为了便于表述,我们不妨设其概率均为 1/2。若 x 在数组内,出现在每个位置的概率相同,均为 1/n。综上,x 出现在数组中任一位置的概率为 1/(2n)。如果考虑以上概率,则平均情况时间复杂度的数学表述如下:

       

      这个值就是概率论中的加权平均值(期望值)。所以平均情况时间复杂度的全称应该是加权平均时间复杂度期望时间复杂度

      引入概率之后,这段代码的加权平均时间复杂度仍是 O(n)。

      在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

    三、均摊时间复杂度

       到此为止,我们已经梳理完算法复杂度分析的大部分内容了。下面我们来看一个更高级的概念,均摊时间复杂度,以及它对应的分析方法,摊还分析(或者叫平摊分析)。

      均摊时间复杂度,听起来跟平均时间复杂度有点儿像,这两个概念也确实非常容易弄混。前面提到,大部分情况下,并不需要区分最好、最坏、平均三种复杂度,平均复杂度只在某些特殊情况下才会用到。均摊时间复杂度应用的场景则更加特殊、更加有限。

      老规矩,我们还是通过示例来分析:

     1 // array表示一个长度为n的数组
     2 // 代码中的array.length就等于n
     3 int[] array = new int[n];
     4 int count = 0;
     5 
     6 void insert(int val)
     7 {
     8     if (count == array.length)
     9     {
    10         int sum = 0;
    11 
    12         for (int i = 0; i < array.length; ++i)
    13         {
    14             sum = sum + array[i];
    15         }
    16 
    17         array[0] = sum;
    18         count = 1;
    19     }
    20 
    21     array[count] = val;
    22     ++count;
    23 }

      以上代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,我们用 for 循环遍历数组求和,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

      那这段代码的时间复杂度是多少呢?

      最理想的情况下,数组中有空闲空间,我们只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 O(1)。

      最坏的情况下,数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。

      那平均时间复杂度是多少呢?答案是 O(1)。我们还是可以通过前面讲的概率论的方法来分析。

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

      

      实际上,这个示例的平均复杂度并不需要这么复杂,不用引入概率论的知识。

      我们来对比 find() 和 insert() 这两个示例,来进行说明:

    • find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。
    • 对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

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

      如何使用摊还分析法来分析算法的均摊时间复杂度呢?我们还是以 insert() 为例分析。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上。均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

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

      均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。我们最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。简单总结一下它们的应用场景,如果遇到,了解原理即可。

    四、内容小结

      本篇博客介绍四种时间复杂度:

    • 最好情况时间复杂度
    • 最坏情况时间复杂度
    • 平均情况时间复杂度
    • 均摊时间复杂度

      之所以引入这几个复杂度概念,是因为,同一段代码,在不同输入的情况下,复杂度量级有可能是不一样的。引入这几个概念之后,我们可以更加全面地表示一段代码的执行效率。

    思考题

      分析下面 add() 函数的时间复杂度:

     1 // 全局变量,大小为10的数组array,长度len,下标i。
     2 int array[] = new int[10]; 
     3 int len = 10;
     4 int i = 0;
     5 
     6 // 往数组中添加一个元素
     7 void add(int element)
     8 {
     9     if (i >= len)
    10     {
    11         // 数组空间不够了, 重新申请一个2倍大小的数组空间
    12         int new_array[] = new int[len*2];
    13         // 把原来array数组中的数据依次copy到new_array
    14         for (int j = 0; j < len; ++j)
    15         {
    16             new_array[j] = array[j];
    17         }
    18         // new_array复制给array,array现在大小就是2倍len了
    19         array = new_array;
    20         len = 2 * len;
    21     }
    22     // 将element放到下标为i的位置,下标i加一
    23     array[i] = element;
    24     ++i;
    25 }

    我的看法

      共有 n+1 中情况,当数组空间足够时,时间复杂度为 O(1)。当数组放满时,时间复杂度为 O(n)。

      平均时间复杂度:

      

      此时为 O(1)。

      均摊时间复杂度:O(1)。

  • 相关阅读:
    STM32中GPIO的8种工作模式
    robots.txt与搜索引擎
    关于《腾讯工具类APP的千年老二》的读后感
    PCB布线的操作步骤
    c语言数据库编程ODBC
    锂电池相关结构优势特点及其保护电路解析方案
    C语言中的#与##字符的作用
    PADS中Layer的描述说明
    吃了单片机GPIO端口工作模式的大亏——关于强推挽输出和准双向口(弱上拉)的实际应用
    Protel与PADS之间相关文件的转换
  • 原文地址:https://www.cnblogs.com/murongmochen/p/12688624.html
Copyright © 2020-2023  润新知