前言
该笔记以清华大学出版社《算法设计与分析:微课视频版》(2024年1月第1版)为教材,总结了该教材第3章知识点,该笔记可能有不准确或不严谨之处,所有以教材为主。
算法分析方法
概率分析
平均时间复杂度可以通过计算所有问题实例的运算时间,然后取平均值。但这种方式在面对有大量问题实例时会很乏力。这时候可以借助概率分析(Probabilistic Analysis)来帮助我们计算分析平均时间复杂度。
引例
给定具有n个数的数组A,回答是否存在一个指定的数x,如果存在,则给出其在数组的位置;否则回答不存在。设计代码如下:
Search (int x,int *A,int n)
{
for(int i=0;i<n;i++)
{
if(*A==x)
{
return i;
}
}
return -1;
}
由前篇笔记可知,其最好情形时间复杂度与最坏情形时间复杂度分别为Ω(1)与O(n)。若此时需要分析该算法的平均时间复杂度,便可利用概率分析来计算。
该数组共有n个位置,数字x出现在随机的其中一个位置的概率便是1/n。假设x出现在第k个位置上时,这种情形下需要比较k次,同时由于随机位置的概率为1/n,可得平均比较次数为,而整个算法的平均时间复杂度由平均比较次数决定,最后可知,其平均情形时间复杂度为Θ(n)。
总结
概率分析方法的核心思想是通过分析情形的概率分布来简化计算,但是在一般情况下,这种分析方法需要一个假设,即每个问题的实例都有同样的概率作为算法的输入而被算法计算(换句话说,会提前假设概率分布是均匀的,所有的实例概率是相同的)。有时面对实际情形,概率分布可能不会是均匀的,需要根据实际情形来调整我们的概率模型。
分摊分析
我们有时会遇到很复杂的算法,按照前面分析最坏情形时间复杂度地方法可能会高,而由于算法本身的复杂性使得用概率分析会很吃力,这时可以借助分摊分析(Amortized Analysis),来得出合理的时间复杂度。分摊分析通过研究执行一系列所需要的操作费用,来研究各个操作之间的关系。分摊分析能够证明:如果一系列操作的总费用是小的,则其中的一个操作费用也是小的,即使该算法中有某个操作的费用很高。
举个例子,父母每个月都会向孩子给一定量的生活费,孩子每个月的花销有多有少。如果父母每次已最高金额向孩子拨付,将会是一笔很大的开支。但是实际上可以利用分摊分析的思想,如果孩子上个月有剩余,那么这个月便可以少付一些生活费,而上个月剩余的钱也可以在下个月继续使用。
分摊分析有其限制,一系列数据结构的操作必须是相互关联的,从而才能利用分摊分析的思想。下面梳理分摊分析的三种方法。
合计方法
合计方法(Aggregate Method)分析由n个操作构成的序列在最坏的情况下的运行时间总和T(n)。因而在最坏情况下,每个操作的平均费用,或者说分摊费用可定义为T(n)/n(分摊费用的计算方法对每个操作都适用)。
引例
这里引用教材进栈与出栈的例子:给出三种对栈的操作Push(S, x)(进栈操作:将元素x压入栈S中)、Pop(S)(出栈操作:将栈S最顶端的元素弹出)、MultiPop(S, k)(特殊的出栈操作:弹出栈S顶端的k个元素,当k大于栈S中的元素时为清空栈S),操作如下图。
现假设有一空栈S,执行一个由Push(S, x)、Pop(S)、MultiPop(S, k)构成的序列,现对其所花费的时间进行分析。
(一)、简单最坏情况分析
假设每个 MultiPop(S,k) 操作都为最坏情形,则其最坏时间复杂度为O(n)(当k >= n 时弹出所有元素)。并假设这n次操作都是 MultiPop(S, k)。此时该序列的总时间复杂度为 O(n)*n = O(n^2 ),而平均每个操作的时间复杂度为 O(n^2)/n = O(n)。
这种分析有一种问题,它的假设前提是每个操作都是独立的,并没有考虑各个操作之间的关系与约束(Push(S, x) 对 Pop(S) 与 MultiPop(S, k) 的约束,只有进行了 Push 操作,才能进行 Pop 与 MultiPop),从而高估了实际成本。
(二)、分摊分析(合计方法)
在(一)的分析中,我们可以推出以下条件:总Push次数最多n次;总Pop(包括MultiPop)次数 = 总Push次数。
Push总费用 = O(1)*n = O(n);
Pop(包括MultiPop)总费用 = O(n);
序列总费用 = O(n) + O(n) = O(n);
平均每个操作的时间复杂度为 O(n)/n = O(1)。
通过分摊分析,得出了一个更加紧凑的上界,会更加贴近实际情况。
记账方法
记账方法(Accounting Method)的思想是对不同的操作赋予不同的费用,对于某操作赋予的费用称为分摊费用。分摊费用可能比实际的多,也可能比实际的少。当分摊费用比实际费用多时,多出来的费用(即余款)将作为存款保存在数据结构中的一些特定的对象中。这笔存款将支付给分摊费用比实际费用少的操作中。一个操作的分摊费用便可以分为两个部分:1、实际费用;2、存款或透支。
如果要利用分摊费用来证明在最坏情况下每个操作的费用是小的,那么操作序列的总分摊费用必须是其总实际费用的上界,从而保证与该数据结构的总存款始终为非负值。记分摊费用为C1,实际费用为C2,则须满足。
引例
再次引用之前栈操作的例子来描述记账方法。
栈操作 | 实际费用 |
Push | 1 |
Pop | 1 |
MultiPop | min{s, k} |
栈操作 | 分摊费用 |
Push | 2 |
Pop | 0 |
MultiPop | 0 |
当Push一个元素时,这个元素的分摊费用为2,由于实际费用为1,所以每个被Push的元素都有1存款。当Pop一个元素时,这个元素便透支了1费用,由于每个元素都有1费用的存款,于是不必给这个元素分摊费用。MultiPop元素同理,不需要分摊费用。所以不论进行何种操作,总实际费用永远不会超过总分摊费用,此时总分摊费用便是总实际费用的上界,故总实际费用不会超过O(n)。
势能方法
一根弹簧有弹性势能,当对这根弹簧进行操作(压缩、拉伸)时,弹簧的弹性势能会随着操作的变化而变化。类似于这种现象,势能方法(Potential Method)因此得名。这种方法先将数据结构的当前状态映射到一个非负实数,表示系统的“能量”;而分摊成本 = 实际成本 +
,其中
(本次操作时的势能 - 上一次操作时的势能)。
引例
同样以栈操作来举例:
定义势能函数: = 栈中当前的元素量
各种栈操作实际费用参考表1,每个操作的分摊成本 = 实际成本 + 。
Push操作的分摊成本(PuC) = 1 + (+1)= 2;
Pop操作的分摊成本 (PoC)= 1 + (-1) = 0;
Multi Pop操作的分摊成本 (MPoC)= k + (-k) = 0;
总分摊成本 = PuC + PoC + MPoc = 2 + 0 + 0 = 2;
由于有n次操作,所以总分摊成本 = 2n;最终可得时间复杂度为O(n)。
总结
概率分析属于两者中较为简单的一种,在面对概率分布较为均匀,或者说可以总结出概率分布模型时可以考虑用概率分析来简化计算;如果遇见算法本身比较复杂时可以考虑使用分摊分析,但在使用分摊分析时要注意各个操作之间的关联性,不能过于独立各个操作;如果操作较为单一,可以使用合计算法,核心思想是计算整个操作序列的总成本,再均摊到每个操作上(单次分摊成本=总成本/操作次数);在操作类型简单、信用分配直观的问题上时,可以使用记账方法,主要为每个操作分配一个虚拟的“分摊费用”,用存款补齐透支,但不能“欠债”;在面对操作类型复杂,需全局分析的问题时,可以使用势能方法,核心是“能量守恒”思想,分摊成本 = 实际成本 + 。在数学上势能方法与记账方法等价。