说明
【数据结构与算法之美】专栏学习笔记。
为什么引入这些时间复杂度
先看下面代码
// 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; }
上面代码中如果没有 break;那么代码的时间复杂度就是 O(n),但是代码的循环中存在提前退出循环的操作,普通的时间复杂度分析解决不了这个问题。为了表示代码在不同情况下的不同时间复杂度,就引入了最好、最坏、平均、均摊时间复杂度。
最好、最坏情况时间复杂度
最好情况时间复杂度
:在最理想的情况下,执行代码的时间复杂度。最坏情况时间复杂度
:在最糟糕的情况下,执行代码的时间复杂度。
上面代码在最理想的情况下,查找的变量 x 正好是数组的第一个元素,时间复杂度就是 O(1);在最糟糕的情况下,就是把整个数组都遍历一遍,时间复杂度就成了 O(n)。
平均情况时间复杂度
最好、最坏比较极端,为了更好地表示平均情况下的复杂度,引入了平均情况时间复杂度。
平均时间复杂度:又叫加权平均时间复杂度(或者期望时间复杂度),用代码在所有情况下执行的次数的加权平均值表示。
上面的代码中查找的变量 x 在数组中的位置,有 n+1 种情况:
在数组的 0~n-1 位置中:n 种
不在数组中:1 种
假设在数组中与不在数组中的概率都为 1/2,那么出现在 0~n-1 中任意位置的概率就是 1/(2n)。
根据下面等差数列的公式:
1+2+3+…+n = n(1+n)/2
可以快速计算出上面计算式等于(3n + 1)/4,推出平均时间复杂度为 O(n)。
大多数情况下,不需要区分最好、最坏、平均情况时间复杂度三种情况。只有同一块代码在不同的情况下,时间复杂度有量级的差距,才会使用这三种复杂度表示法来区分。
均摊时间复杂度
下面看一个往数组中插入数据的例子:当数组满了之后,也就是代码中的 count == array.length 时,用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。这里均摊的话不止一次调用 insert 的,可以理解为有外循环。
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; }
最理想的情况下,数组中有空闲空间,时间复杂度为 O(1);最坏的情况下,数组中没有空闲空间,需要遍历一遍,其时间复杂度为 O(n)。
根据数据插入的位置的不同,可以分为 n 种情况,每种情况的时间复杂度是 O(1)。另外一种在数组没有空闲空间时插入一个数据,时间复杂度是 O(n)。这样就有 n + 1 种情况:得到平均时间复杂度为 O(1)。
这里并不需要像之前的平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。针对这种特殊的场景,就引入了一种更加简单的分析方法均摊时间复杂度,又叫摊还分析(或者叫平摊分析)。
均摊分析的大致思路:这里对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,数组已经满了,也就是 O(n) 是无空闲的状态,每满一次就会清空数组,清空数组后重新开始写 n - 1 次才会进行下一次清空,每次写入的复杂度就是O(1),有 O(n) 后接着 n - 1 个 O(1),循环往复。所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1),可以理解为 (n + n - 1)/n。
均摊时间复杂度就是一种特殊的平均时间复杂度,能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。