关注公众号 MageByte,设置星标点「在看」是我们创造好文的动力。后台回复 “加群” 进入技术交流群获更多技术成长。 本文来自 MageByte-青叶编写
上次我们说过 时间复杂度与空间复度,列举了一些分析技巧以及一些常见的复杂度分析比如 O(1)、O(logn)、O(n)、O(nlogn),今天会继续细化时间复杂度。
1. 最好情况时间复杂度(best case time complexity)
2.最坏情况时间复杂度(worst case time complexity)
3. 平均情况时间复杂度(average case time complexity)
4.均摊时间复杂度(amortized time complexity)
复杂度分析
public int findGirl(int[] girlArray, int number) {
int i = 0;
int pos = -1;
int n = girlArray.lentgh();
for (; i < n; ++i) {
if (girlArray[i] == number) {
pos = i;
break;
}
}
return pos;
}
代码逻辑你应该很容易看出来,在无序数组 中查找 number 出现的位置,如果没找到就返回 -1。《唐伯虎点秋香》主角星爷通过这个方法遍历数组找到秋香,因为此刻我们还没有学会各种风骚的算法,只能从头到尾查验是不是秋香,所以只能遍历数组。girlArray 数组保存着秋香、冬香、春香……的编码,现在唐伯虎通过 选择 number 这个编码比对是否是秋香。
这段代码在不同的情况下,时间复杂度是不一样的,所以为了描述代码在不同情况下的不同时间复杂度,我们引入了==最好、最坏、平均时间复杂度==。n = girlArray 数组的长度。
- 当如秋香在第一个,那 代码的时间复杂度就是 O(1)。
- 当秋香在队伍的最后一个,那代码的时间复杂度就是 O(n)。
- 当 秋香在队伍中但是不在队伍第一个,也不再最后一个,那么就不确定。
- 假如华府使诈,队伍里也根本不存在秋香,唐波徐也需要把队伍一个个查验完毕才知道,时间复杂度就成了 O(n)
最好情况时间复杂度
在最理想的情况下,执行这段代码的时间,也就是「唐伯虎」最快点中秋香。假如 这一排姑娘就代表 girlArray 数组,number 变量就是秋香的编码。假如第一个姑娘就是「秋香」那时间复杂度就是 O(1)。
最坏情况时间复杂度
在最糟糕的情况下,执行这段代码的时间复杂度。也就是要一个个查验真个数组的长度 O(n)。
平均情况时间复杂度
其实最好与最坏情况是极端情况,发生的概率并不大。所以为了更准确的表示平均情况下的时间复杂度,引入另一个改变:平均情况时间复杂度。
还是上面的「找秋香」代码,判断 number 编码在循环中出现的位置,有 ==n + 1==种情况:
在数组 0~n-1 中和不在这个数组中。在数组中共有 n 种情况,加上不在数组中则就是 n + 1 种了。 每种情况要遍历的姑娘人数都不同。我们把每种情况需要查找姑娘的数量累加,然后再除以 所有情况数量 (n + 1),就得到需要遍历次数的平均值。敲黑板了:公式就是平均情况复杂度 = 累加每种遍历的元素个数 / 所有的情况数量
平均情况复杂度为:
$$\frac {((1+2+3… +n) + n)} {(n+1)} = \frac {n(n+3)} {2(n+1)}$$
推导过程:
$$\because 1+2+3 …+ n = n + (n-1) + (n-2)… + 1$$
$$\therefore (1 +2 +3… + n) = \frac {n(1+n)} {2}$$
$$\therefore (1+2+3+…+n) + n = \frac {n(3+n)} {2}$$
根据我们之前学的 时间复杂度与空间复度 大 O 表示法,省略系数、地接、常量,所以平均情况时间复杂度是 O(n)。
期望时间复杂度
上面的平均情况时间复杂度推导没有考虑每种情况的发生概率,这里的 n+1 种情况,每种情况发生的概率是不一样的,所以还要引入各自发生的概率再具体分析。
秋香的编号 number 要么在 0 ~ n-1 中,要么不在 0~n-1 中,所以他们的概率是 $\frac {1} {2}$。
同时 number 在 0~n-1 各个位置的概率是一样的为 1/n。根据概率乘法法则,number 在 0~n-1 中任意位置的概率是 $$\frac {1} {2n}$$。
所以在前面推导的基础上,我们再把每种情况发生的概率考虑进去,那么平均情况时间复杂度的计算过程就是:
考虑概率的平均情况复杂度:
$$(1 \frac {1} {2n} + 2 \frac {1} {2n}+ 3 \frac {1} {2n}…+n\frac {n} {2n} ) + n \frac {1} {2} = \frac {3n+1} {4}$$
这就是概率论中的加权平均值,也叫做期望值,所以平均时间复杂度全称叫:加权平均时间复杂度或者期望时间复杂度。
引入概率之后,平均复杂度变为 O($$\frac {3n+1} {4}$$),忽略系数以及常量,最后得到的加权平均时间复杂度为 O(n)。终于分析推导完了,同学们可以松一口气。
注意:
多数情况下,我们不需要区分最好、最坏、平均情况时间复杂度。只有同一块代码在不同情况下时间复杂度有量级差距,我们才会区分 3 种情况,为的是更有效的描述代码的时间复杂度。
均摊情况时间复杂度
最后一个硬骨头来了,了解了上面加上概率的期望时间复杂度再看这个就容易多了。均摊时间复杂度,听起来跟平均时间复杂度有点儿像。
均摊复杂度是一个更加高级的概念,它是一种特殊的情况,应用的场景也更加特殊和有限。
对应的分析方式称为:摊还分析或平摊分析。
// array 表示一个长度为 n 的数组
// 代码中的 array.length 就等于 n
int[] array = new int[n];
int count = 0;
public 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.lenth,遍历数组求和,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。这里的数据满:对于可反复读写的存储空间,使用者认为它是空的它就是空的。如果你定义清空是全部重写为 0 或者某个值,那也可以!使用者只关心要存的新值!
分析上述的时间复杂度:
- 最理想情况,有空闲空间则直接插入到数组下标 count 的位置即可。所以是 O(1)。
- 最坏的情况,数组没有空闲空间,需要先做一次循环遍历求和,然后再插入。时间复杂度 O(n)。
平均时间复杂度
数组长度为 n,因为可以插入不同位置,所以有 n 种情况,每种复杂度为 O(1)。
还有一种特殊情况,没有空闲空间插入的时候,复杂度是 O(n),一共就是 n+1 种情况,且每种情况的概率都是 $$\frac{1} {n+1}$$。所以根据加权平均计算法,平均时间复杂度:
$$(1 \frac {1} {n+1} + 1 \frac {1} {n+1}+ 1 \frac {1} {n+1}…+1\frac {1} {n+1} ) + n \frac {1} {n+1} = \frac {2n} {n+1}$$
当省略系数及常量后,平均时间复杂度为 O(1)。
其实我们不需要这么复杂,对比 findGirl 跟 insert 方法。
- findGirl 在极端情况下复杂度 O(1),而 insert 基本情况是 O(1)。只有当数组满了才是 O(n)。
- 对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。
摊还分析法
分析上述示例的平均复杂度分析并不需要如此复杂,无需引入概率论的知识。
因为通过分析可以看出,上述示例代码复杂度大多数为 O(1),极端情况下复杂度才较高为 O(n)。同时复杂度遵循一定的规律,一般为 1 个 O(n),和 n 个 O(1)。针对这样一种特殊场景使用更简单的分析方法:摊还分析法。
通过摊还分析法得到的时间复杂度为均摊时间复杂度。
大致思路:每一次 O(n)都会跟着 n 次 O(1),所以把耗时多的复杂度均摊到耗时低的复杂度。得到的均摊时间复杂度为 O(1)。
应用场景:均摊时间复杂度和摊还分析应用场景较为特殊,对一个数据进行连续操作,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度较高。而这组操作其存在前后连贯的时序关系。
这个时候我们将这一组操作放在一起分析,将高复杂度均摊到其余低复杂度上,所以一般均摊时间复杂度就等于最好情况时间复杂度。
注意: 均摊时间复杂度是一种特殊的平均复杂度(特殊应用场景下使用),掌握分析方式即可。
均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。
文末思考
最后留一个问题给大家,用本文学习的只是分析下面代码的「最好」、「最坏」、「均摊」时间复杂度。
/ 全局变量,大小为 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;
}
总体的含义就是向数组添加一个元素,当空间不够的时候重新生情一个原来两倍空间的数组并把原来的数组数据依次复制到新数组中。
其实同学们这里还可以拓展到 HashMap 的拓容,当元素大刀负载因子 0.75 的容量,HashMap 需要拓容为原来的两倍然后再重新 把元素放到新数组中。那么时间复杂度又是多少呢?
关注公众号 MageByte 后台回复 「add」获取本题目答案,也可以回复「加群」加入技术群跟我们一起分享你的看法,我们第一是时间反馈。
参考文献:《数据结构与算法之美》