复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度
最好、最坏时间复杂度
首先试着分析下面代码的时间复杂度
func Find(array *[]int,n int)int{
var p int = -1
for i:=0;i<len(*array);i++{
if (*array)[i] == n{
p = i
}
}
return p
}
能看出来,这段代码要实现的功能是,在一个无序数组中,查找出变量n的位置,如果没有,就返回-1。按照前面的分析方法,这段代码的时间复杂度为O(n)
我们在数组中查找一个数据时,并不需要每次把整个数组都遍历一遍,因为可能中途找到提前结束循环了。我们可以优化代码:
func Find(array *[]int,x int)int{
for i:=0;i<len(*array);i++{
if (*array)[i] == x{
return i
}
}
return -1
}
这时候问题来了,我们优化完代码后,这段代码的时间复杂度还是O(n)吗?很显然,我们前面学到的知识解决不了这个问题。
因为,要查找的变量x可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量x,那么就不需要继续遍历剩下的n-1个数据了,那时间复杂度就是O(1)。但如果数组 中不存在变量x,那么我们就要把整个数据都遍历一遍,时间复杂度就成了O(n)。所以,不同呢的情况下,这段代码的时间复杂度是不一样的。
为了表示代码在不同情况下的时间复杂度,我们需要引入三个概念,最好时间复杂度、最坏时间复杂度和平均情况时间复杂度。
顾名思义,最好情况时间复杂度就是,在理想情况下,执行这段代码的时间复杂度。最坏时间复杂度就是,在最糟糕的情况下,代码的时间复杂度。
平均情况时间复杂度
我们都知道,最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好表示平均情况下的复杂度,我们需要引入另外一个概念,平均情况时间复杂度,后面简称平均时间复杂度。
平均时间复杂度的分析:
要查找的变量x在数组中的位置有n+1总情况,包括在数组中(n总情况),不在数组中(1中情况)。我们把每种情况下,要查找遍历的元素个数加起来,再除以n+1,就可以得到需要遍历的元素个数平均值,即
(
1
+
2
+
3
+
.
.
.
.
.
.
+
n
+
n
)
/
2
=
n
(
n
+
3
)
/
2
(
n
+
1
)
(1+2+3+......+n+n)/2 = n(n+3)/2(n+1)
(1+2+3+......+n+n)/2=n(n+3)/2(n+1)
我们知道,时间复杂度大O标记法中,可以省掉系数、低阶、常量,所以,咱们把刚刚这个公式简化之后,得到的平均时间复杂度就是O(n).
这个结论虽然是正确的,但是计算过程稍微有点问题。我们刚刚讲的这n+1种情况,出现的概率并不相同、
我们知道,要查找的变量x,要么在数组里,要么不在数组里,这两种情况对应的概率统计起来很麻烦,为了方便理解,我们假设数据在与不在数组中的概率都为1/2,根据概率乘法法则,要查找的数据在0~n-1中任意位置的概率就是1/(2n)。因此前面推导过程中的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,平均时间复杂度的计算过程就会变成:
1
×
1
/
2
n
+
2
×
1
/
2
n
+
.
.
.
+
n
×
1
/
2
n
+
n
×
1
/
2
=
(
3
n
+
1
)
/
4
1 × 1/2n + 2 × 1/2n + ... + n × 1/2n + n × 1/2 = (3n + 1) / 4
1×1/2n+2×1/2n+...+n×1/2n+n×1/2=(3n+1)/4
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫做加权平均复杂度或期望时间复杂度。
引入概率之后,前面那段代码去掉常量与系数,依旧是O(n)。
实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,我们使用一个复杂度就能满足需求了。只有同一代码块在不同情况下,时间复杂度有量级的差距,我们才使用这三种情况区分
均摊时间复杂度
到此为止,我们掌握了算法复杂度分析的大部分内容。下面有一个更高级的概念,均摊时间复杂度,以及它的分析方法,摊还分析(或者平摊分析)
举个例子:
var array [20]int 这里的20代表n
var count = 0
func insert(val int){
var sum int = 0
if (count == cap(array)){
sum = 0
for i:=0;i< cap(array);i++{
sum += array[i]
array[i] = 0
}
array[0] = sum
count = 1
}
array[count] = val
count += 1
}
先解释这段,这段代码实现了一个往数组中插入数据的功能,当数组满了的时候,我们用for循环讲数组求和,放到第一位,并清空数组,如果有空闲就直接插入。
这段代码的时间复杂度是多少呢?我们先用之前的方法分析一下。
最好情况:数组有空闲,直接插入,时间复杂度为O(1).
最坏情况:数组满了,遍历数组求和,时间复杂度为O(n)
平均时间复杂度:由于每种情况,满与空闲的概率相同:
1
×
1
/
(
n
+
1
)
+
1
×
1
/
(
n
+
1
)
+
.
.
.
.
+
1
×
1
/
(
n
+
1
)
+
n
×
1
/
(
n
+
1
)
=
(
2
n
−
1
)
/
(
n
+
1
)
1 × 1/(n+1) + 1 × 1/(n+1) + .... + 1 × 1/(n+1) + n × 1/(n+1) = (2n-1)/(n+1)
1×1/(n+1)+1×1/(n+1)+....+1×1/(n+1)+n×1/(n+1)=(2n−1)/(n+1)
所以时间复杂度为O(1)。
到此为止,前面的最好,最坏,平均时间复杂度理解起来都没有问题。但是这个例子里的平均复杂度分析其实并不需要这么复杂,不需要引入概率论的知识,我们对比一下这个insert()方法与之前的find()方法,就会发现两者差别很大。
首先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),这就是大致思路。
均摊时间复杂度和摊还分析的使用场景比较特殊,所以我们不会经常遇到。为了方便理解,所以简单总结了它的使用场景。如果遇到,了解怎么回事就好了。
对一个数据进行一组连续操作,其中大部分操作的时间复杂度都很低,只有个别情况很高,而且操作之间存在前后连贯的时序关系,这时候我们就可以将数据放在一块分析。