今天主要分析4个复杂度分析方面的知识点,最好情况时间复杂度(best case time complexity)、最坏情况时间复杂度(worst case time complexity)、平均情况时间复杂度(average case time complexity)、均摊时间复杂度(amortized time complexity)。
最好、最坏情况时间复杂度
先看下面的例子。
function find (arr, n, x) {
let pos = -1;
for (let i = 0; i < n; i++) {
if (arr[i] == x) pos = i;
}
return pos;
}
这段代码要实现的功能是,在一个无序数组中,查找变量 x 出现的位置,如果没有找到,就返回 -1。
按照之前讲的分析方法,这段代码的复杂度是 O(n),其中,n 代表数组长度。
但其实我们在数组中查找一个数据,并不需要每次把整个数组都遍历一遍,如果找到元素就可以结束循环。
我们可以优化一下这段代码。
function find (arr, n, x) {
let pos = -1;
for (let i = 0; i < n; i++) {
if (arr[i] == x) {
pos = i;
break;
};
}
return pos;
}
这时,就出现一个问题。在我们优化完之后,这段代码的时间复杂度还是 O(n) 吗?
案例中要查找的变量 x 可能出现在数组的任意位置。
如果数组第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n - 1 个数据,这时时间复杂度是 O(1)。
如果数据中不存在变量 x,那就需要把数组遍历一遍,时间复杂度就成了 O(n)。
所以,不同情况下,这段代码的时间复杂度是不同的。
为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:
- 最好情况时间复杂度
- 最坏情况时间复杂度
- 平均情况时间复杂度
顾名思义,最好情况时间复杂度就是,在理想的情况下,执行这段代码的时间复杂度。
同理,最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。
平均情况时间复杂度
最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。
我们需要引入另一个概念,平均情况时间复杂度。后面简称为平均时间复杂度。
我们继续以刚才的例子进行分析。
function find (arr, n, x) {
let pos = -1;
for (let i = 0; i < n; i++) {
if (arr[i] == x) {
pos = i;
break;
};
}
return pos;
}
要查找的变量 x 在数组中的位置,有 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)。
你可能会说,平均时间复杂度分析也太复杂了,还要涉及概率论的知识。实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。
均摊时间复杂度
现在,你应该已经掌握算法复杂度分析的大部分内容。下面来讲一个更加高级的概念,均摊时间复杂度以及它对应的分析方法,摊还分析(平摊分析)。
均摊时间复杂度,听起来和平均时间复杂度有点类似。初学者经常把这两个概念弄混。大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某种特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。
还是借助一个例子来理解。
const arr = new Array(10);
let count = 0;
function insert (val) {
if (count == arr.length) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum = sum + arr[i];
}
arr[0] = sum;
count = 1;
}
arr[count] = val;
count++;
}
这段代码实现了一个向数组中插入数据的功能。当数组满了之后,我们用 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)。这就是均摊分析的大致思路。你都理解了吗?
均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便你理解、记忆,我这里简单总结一下它们的应用场景。如果你遇到了,知道是怎么回事儿就行了。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
尽管很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实我个人认为,均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。
总结
今天我们学习了几个复杂度分析相关的概念,分别有:最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度、均摊时间复杂度。之所以引入这几个复杂度概念,是因为,同一段代码,在不同输入的情况下,复杂度量级有可能是不一样的。
在引入这几个概念之后,我们可以更加全面地表示一段代码的执行效率。而且,这几个概念理解起来都不难。最好、最坏情况下的时间复杂度分析起来比较简单,但平均、均摊两个复杂度分析相对比较复杂。