按照算法导论的顺序,下面该介绍数据结构,动态规划,贪心算法了。但是基本的数据结构比较简单,靠数学直觉也能很好的理解,就不总结了。动规和贪心是那种听懂很简单,用起来很难得算法,准备放在后面总结。
摊还分析对一般人来说是比较新奇得技术,应该比较少的人会注意到这类问题,而且与图相关的算法复杂度分析经常遇到需要摊还分析的情况。所以接下来两节准备总结下摊还分析的技术和思考逻辑。并先用该技术来证明并查集的算法复杂度。
0 摊还分析
摊还分析基于这样一个观察:对一个数据结构,一半有多种操作方式,实际应用中,可能有更多组合操作。例如:栈有:入栈(A) ,有出栈(B),实际应用中可能还会定义一次出栈n个元素©这种操作。当程序中随机(实际并不随机,杂乱比较好一些)的运行这些操作时,会生成一个操作序列。这个操作序列的总体复杂度是多少?平均每个操作的复杂度又是多少?
这种情况下最坏复杂度不可以通过 单个操作最坏复杂度 x n
这种粗暴算法来计算,因为操作之间有一个隐形的约束。如果对上面的例子中,操作 C 有一次 pop 出 k 个元素,那么用
O
(
k
n
)
O(kn)
O(kn) 来计算整体复杂度显然是不合理的。
设栈中的元素的个数为 入栈操作 的个数 q。则 pop 也最多pop出 q 次。无论你一次性 pop 出几个,总共只能 pop q 次。所以总体复杂度是
O
(
2
q
)
O(2q)
O(2q),由于
q
≤
n
q \le n
q≤n,不妨视为
O
(
n
)
O(n)
O(n),摊还复杂度是
O
(
1
)
O(1)
O(1)。
为了解决这类问题,设计了三种证明思路,分别是:聚合分析、核算法、势能法。其中核算法是一个很不好的名字,不知道哪个大聪明起的。它英文是:accounting method。所以应该是:『核算』法,而不是『核』算法。其实三种证明思路是相似的,哪个好用用哪个。
1 聚合分析
聚合分析的思路是,一个长度为 n n n 的操作序列最坏情况下总花费时间为 T ( n ) T(n) T(n),然后得到每个操作的摊还代价为 T ( n ) / n T(n)/n T(n)/n。上面的栈序列操作用的就是这种思路。该思路比较直白,就是一个计算。作为该类思路的一个练习,考虑下面的问题:
练习:二进制计数模拟
一个 k 位 2 进制数。当对其进行 + 1操作时,要从低位向高位遍历,直到遇到一个0,才可以 + 1结束。其他情况下考虑进位,需要对该位进行翻转。所以一次+1操作的复杂度,跟当前数字末尾的1的个数有关。最坏情况下,全部需要反转。但总情况和平均情况呢?可以通过简单模拟找出翻转规律来计算总的复杂度为 O ( n ) O(n) O(n),摊还复杂度为 O ( 1 ) O(1) O(1)
2『核算』法
核算法是先给一个猜测的摊还代价,然后证明这个猜测正确。例如:我想证明摊还代价为 O(n) 的,我们不妨设每个操作的摊还代价
c
^
i
=
10
\hat c_i= 10
c^i=10,然后让他跟真是的代价
c
i
c_i
ci进行对比。多的代价可以存起来,少的代价用之前存起来的补,总之,只要其满足下面的不等式即可
∑
i
=
1
n
c
^
i
≥
∑
i
=
1
n
c
i
\sum_{i=1}^{n}\hat c_i \ge\sum_{i=1}^{n} c_i
i=1∑nc^i≥i=1∑nci
即在序列的每一个位置,我猜测的代价都足够满足真实的代价。
3 势能法
对于核算法,我们猜测了一个操作的摊还代价。但是这是不准确的。势能法将猜测的摊还代价比实际代价多出来的部分量化了。
设数据结构的初始状态为
D
0
D_0
D0,经过操作
i
i
i后,数据结构的状态会从
D
i
−
1
D_{i-1}
Di−1变换到
D
i
D_i
Di。也就是说我们将操作
i
i
i引起的数据结构状态也考虑进去了。为了量化这个状态,我们定义势能函数
Φ
(
D
i
)
\Phi(D_i)
Φ(Di)。势能法的摊还代价可以定义为:
c
^
i
=
c
i
+
Φ
(
D
i
)
−
Φ
(
D
i
−
1
)
\hat c_i= c_i + \Phi(D_i) - \Phi(D_{i-1})
c^i=ci+Φ(Di)−Φ(Di−1)
Φ
(
D
i
)
−
Φ
(
D
i
−
1
)
\Phi(D_i) - \Phi(D_{i-1})
Φ(Di)−Φ(Di−1)直接量化了摊还代价与实际代价的差异。所以代换到核算法的公式中,我们可以得到,只要找到势能公式满足
∑
i
=
1
n
[
Φ
(
D
i
)
−
Φ
(
D
i
−
1
)
]
=
Φ
(
D
n
)
−
Φ
(
D
0
)
≥
0
即可
\sum_{i=1}^{n}[\Phi(D_i) - \Phi(D_{i-1})] = \Phi(D_n)-\Phi(D_0) \ge 0 即可
i=1∑n[Φ(Di)−Φ(Di−1)]=Φ(Dn)−Φ(D0)≥0即可
所以,势能法与核算法的证明逻辑是一样的,只是方式有些差异,形式上更简单。但是势能函数的选取很考验技巧,要满足两点:
- 势能公式能够便于计算摊还代价
- 势能公式能够满足你需要的算法复杂度要求
以二进制计算算法为例,使用势能法证明如下:
选取势能公式为
Φ
(
D
i
)
=
二进制中
1
的数量
b
i
\Phi(D_i)=二进制中 1 的数量b_i
Φ(Di)=二进制中1的数量bi。考虑
b
i
−
b
i
−
1
b_i-b_{i-1}
bi−bi−1可能的值。算法把
D
i
−
1
D_{i-1}
Di−1中末尾的1全部设为0,并把下一位设为1。设末尾的1为
t
i
t_i
ti个,则
b
i
−
b
i
−
1
=
−
t
i
−
1
+
1
b_i-b_{i-1} = -t_{i-1} + 1
bi−bi−1=−ti−1+1。因此:
c
^
i
=
c
i
+
Φ
(
D
i
)
−
Φ
(
D
i
−
1
)
=
t
i
−
1
+
1
+
−
t
i
−
1
+
1
=
2
\hat c_i= c_i + \Phi(D_i) - \Phi(D_{i-1})=t_{i-1}+1+ -t_{i-1} + 1 = 2
c^i=ci+Φ(Di)−Φ(Di−1)=ti−1+1+−ti−1+1=2
所以摊还复杂度为
O
(
1
)
O(1)
O(1)
再考虑势能函数满足要求:
如果二进制计算从
D
0
D_0
D0开始,那么总会有
b
i
≥
0
b_i \ge 0
bi≥0,即$
Φ
(
D
i
)
≥
Φ
(
D
0
)
\Phi(D_i)\ge\Phi(D_0)
Φ(Di)≥Φ(D0)。证明完毕