复杂度分析(下)

浅析最好、最坏、平均、均摊时间复杂度
最好、最坏时间复杂度
// 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;
}
这段代码要实现的功能是
在一个无序的数组中,查找变量x出现的位置
如果没有找到,就返回-1
这段代码的复杂度时O(n)
其中,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;
}
这段代码的时间复杂度还是O(n)吗?
因为要查找的变量X可能出现在数组的任意位置
-
如果数组中第一个元素正好是要查找的变量X
那就不需要继续遍历剩下的n-1个数据了
时间复杂度为O(1)
-
如果数组中不存在变量X
那我们就需要把整个数组都遍历一遍
时间复杂度为O(n)
不同的情况下,这段代码的时间复杂度是不一样的
为了表示代码再不同情况下的不同时间复杂度
需要引入三个概念
- 最好情况时间复杂度
- 最坏情况时间复杂度
- 平均情况时间复杂度
最好情况时间复杂度就是在最理想的情况下,执行这段代码的时间复杂度
-
在最理想的情况下,要查找的变量X正好是数组的第一个元素
这是对应的时间复杂度就是最好情况时间复杂度
最坏情况时间复杂度就是在最糟糕的情况下,执行这段代码的时间复杂度
-
如果数组中没有要查找的变量X,我们需要把整个数组都遍历一遍才行
所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度
平均情况时间复杂度
最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度
发生的概率其实并不大
为了更好地表示平均情况下的复杂度,我们需要引入另一个概念
平均情况时间复杂度
要查找的变量x在数组中的位置,有n+1种情况:
- 在数组的0~n-1位置中
- 不在数组中
把每种情况下,查找需要遍历的元素个数累加起来
然后再除以n+1,得到需要遍历的元素个数的平均值
时间复杂度的大O标记法中,可以省略掉系数、低阶、常量
所以简化之后,得到的平均时间复杂度就是O(n)
这个结论虽然正确,但是计算过程稍微有些问题
这n+1中情况,出现的概率并不是一样的
要查找的变量x,要么在数据里,要么不在数组里
我们假设在数组中与不在数组中的概率都为1/2
要查找的数据出现在0~n-1这n个位置的概率也是一样,为1/n
根据概率乘法法则,要查找的数据出现在0~n-1中任意位置的概率就是1/(2n)
前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去
我们把每种情况发生的概率也考虑进去,那么平均时间复杂度的计算过程为:
这个值就是概率论中的 加权平均值,也叫期望值
平均时间复杂度的全称为加权平均时间复杂度或期望时间复杂度
用大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值放到数组的第一个位置
然后再将新的数据插入
如果数组一开始就有空闲空间,则直接将数据插入数组
-
最理想的情况下,数组中有空闲空间
我们只需要将数据插入到数组下标为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()函数,O(1)时间复杂度的插入和O(n)时间复杂度的插入
出现的频率是非常有规律的,而且有一定的前后时序关系
一般都是一个O(n)插入之后,紧跟着n-1个O(1)的插入操作,循环往复
针对这样一种特殊场景的复杂度分析,我们引入一种更加简单的分析方法:摊还分析法,均摊时间复杂度
每一次O(n)的插入操作,都会跟着n-1次O(1)的插入操作
所以把耗时多额的那次操作均摊到接下来的n-1次耗时少的操作上
均摊下来,这一组连续的操作的均摊时间复杂度就是O(1)
均摊时间复杂度和摊还分析应用场景比较特殊,所以我们不会经常用到
对一个数据结构进行一组连续操作中
大部分情况下时间复杂度都很低
只有个别情况下时间复杂度比较高
而且这些操作之间存在前后连贯的时序关系
这个时候,我们就可以将这一组操作放在一块儿分析
看是否能将较高时间复杂度那次操作的耗时
平摊到其他那些时间复杂度较低的操作上
而且能够应用均摊时间复杂度分析的场合
一般均摊时间复杂度就等于最好情况时间复杂度
均摊时间复杂度就是一种特殊的平均时间复杂度
我们没必要花太多精力去区分它们
最应该掌握的是它的分析方法,摊还分析法
内容小结
- 最好情况时间复杂度
- 最坏情况时间复杂度
- 平均情况时间复杂度
- 均摊时间复杂度
之所以引入这几个复杂度概念
是因为同一段代码,在不同输入的情况下,复杂度量级有可能是不一样的
练习题
// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10];
int len = 10;
//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;
}
最好是O(1)
最差是O(n)
均摊是O(1)
当i < len时,即 i = 0,1,2,…, n-1的时候,for循环不走,所以这n次的时间复杂度都是O(1);
当i >=len时,即i = n的时候,for循环进行数组的copy,所以只有这次的时间复杂度是O(n);