首页 > 代码库 > 平摊分析 --- 算法导论读书笔记

平摊分析 --- 算法导论读书笔记

       我们经常会说一个算法快不快,这个可以由实验得出,也可以通过分析复杂度得出。实验需要大量不同的输入才更全面准确,否则片面地看某个输入下的表现,是比较偏颇的。分析复杂度(通常分析最坏,因为平均涉及输入的概率分布,依靠假设或者实验和经验)有时候并不是一个简单的事,简单的情况是遍历 for(int i = 0; i != n; i++) 的这种情况,显然是O(n)的复杂度。但是一些复杂的情况就比较难办了,举例来说:

         a.   栈操作:  除了PUSH,POP,添加一个操作叫MULTIPOP。

                     
MULTIPOP(S,k)
   while not EMPTY(S) && k != 0
       do POP(S)
           k <- k-1

                 假设栈的大小为s,那么MULTIPOP的复杂度为min(s,k)。
                 那么现在有一个空栈,进行n次操作,三种操作各种可重排列都有可能。问这n次操作的复杂度是多少?

         b.    二进制计数器: 一个k位的二进制数A,不断加1。这个二进制数用k位数组表示,每一位为0或者1。 加1的操作叫INCREMENT。这个操作leetcode上也正好有类似的题目Plus One,它是十进制数。
             
INCREMENT(A)
  i <- 0
  while i < length[A] && A[i] == 1
      do A[i] <- 0
          i <- i+1
  if i < length[A]
     then A[i] <- 1
                显然最差情况末尾k-1个1的时候,复杂度为O(k)。
                那么,现在从0开始加到n。问复杂度是多少?

      初看上去,第一个问题MULTIPOP最差是当有n-1个元素时,之前PUSH了n-1次,需要O(n),也就是一次操作最差O(n),n次操作最差O(n*n)。第二个类似地也是O(n*n)。但是这个复杂度估计得太宽松了,要求更精确一点的复杂度就不是一眼看出的了。这个时候,引入了平摊分析。

      平摊分析被引入实际上是为了解决某操作被多次调用,不同情况下各次调用复杂度不同的问题。该操作如果分别考虑不同情况的各次来计算,过于复杂,而作为一个整体的时候,考虑该操作各情况调用的平摊复杂度,计算起来会容易不少(或者说更容易描述)。用书上的话说,“平摊分析可以用来证明在一系列的操作中,通过对所有操作求平均之后,即使对其中单一的操作具有较大的代价,平均代价还是很小的。平摊分析与平均情况分析的不同情况在于它不牵涉到概率;平摊分析保证在最坏的情况下,每个操作具有平均性能。”后面一句就是表示,单次操作平摊代价累积起来,大于等于总的最坏情况,当然,从复杂度上尽可能地逼近最坏情况。

       介绍了三种分析方法:1.聚集分析;2,记账方法;3势能方法。

       1.聚集分析
       所谓聚集分析,实际上就是从整体考虑,考虑多次操作总的复杂度。
       比如问题a,对于一个空栈,n次操作的复杂度为O(n)。因为对象被压入后最多被弹出一次,则调用POP的次数(包括了MULTIPOP里的POP)最多等于PUSH的次数,最多为n。通常我们算总的复杂度其实就到此为止了,一定要算出平摊复杂度的话就除以n,三个栈操作的平摊复杂度都是O(1)。
       问题b中,对A的操作,每次调用,A[0]都会翻转,A[1]每两次翻转一次,A[2]每4次翻转一次...如果进行n次操作,那么他们分别有n,n/2,n/4...(下取整)次翻转。等比数列,加起来为小于2n次。n次总复杂度为O(n),单次的平摊代价为O(1)。

       2.记账方法
       书上原话是“我们对不同的操作赋予不同的费用,某些操作的费用比它们的实际代价或多或少。我们对一个操作的收费的数量称为平摊代价。”。其实,操作起来,就相当于我先假设单次某操作的代价,假如我估计某费时的瓶颈操作平摊复杂度为O(1),那我完全可以让单次操作的代价为某一个常数k(通常要略大于这个操作的实际代价),多的存下来,可以把存款放出去给其他操作。那么,因为有的操作会存,有的操作会花,不同操作有不同的平摊代价,聚集分析中平摊代价是一样的。要保持分析中代价一直是上界,存款不允许为负
       对问题a,令PUSH代价为2。相当于1用来支付PUSH,同时放一个在上面,POP的时候拿走。那么POP和MULTIPOP都为O(0)。总的为O(n)。
       对问题b,把某一位设为1代价为2,存1用来支付把这一位设为0,那么设为0代价为0。总的为O(n)。

      3.势能方法
      势能方法英文叫potential method,势能还是翻译得很好的,因为它是与数据结构相关而不是与动作相关。比如拿栈来说,栈里有多少元素我们就认为它有多少势能,这是一种可能的做法,这也正是这个结构的potential。平摊代价就由实际代价加上增加势能变化量。CP(i) = C(i) + S(i) - S(i-1)。n个操作就是ΣCP(i) = ΣC(i) + S(n) - S(0)。要保持上界,就得让S(n)-S(0)永远非负。通常,让S(0)等于0,那么接下来S(n)>= 0就好。我觉得势也是一种存款。因而平摊操作重点在于考虑势的增加而不是减少(势差为负可以用来减实际代价,使平摊代价变少,如下ab问题)
       对问题a,把势函数定义为栈里元素个数。那么PUSH平摊就为1+1=2,POP1-1= 0,MULTIPOP同POP一样也为0.所以O(n)。
       对问题b,把势函数定义为计数器中1的个数。第i次操作复位t(i),置位1,C(i)=t(i)+1,势差为1-t(i),所以平摊代价为2.所以为O(n)。
       其实,注意如果考虑S(n)-S(0)的复杂度,那么就可以不要求S(n)-S(0)。因为ΣCP(i) = ΣC(i) + S(n) - S(0),现在知道了ΣCP(i)的复杂度,又知道 S(n) - S(0),显然可以知道ΣC(i) 的复杂度。

       最后,书里用动态表的操作来为一个例子再阐述了分析过程。这里我用KMP为例来阐述。论KMP-COMPUTE-PREFIX过程。
       
KMP-COMPUTE-PREFIX(P)

m <- length[P]
π[1] <- 0
k <- 0               // just like q above, number of char matched
for q <-  1 to n     // like i above, and actually it is state, so its name is q
  while k>0 && P[k+1] != S[q]  // P range from 1 to n, not 0 to n-1
    do k <- π[k]
  if P[k+1] == S[q] then k <- k+1
  π[q] <- k
return π

      首先,用记账法。对π[q]赋值这个操作我们给它记2,1用于自己的开销,存1到q位置上if P[k+1] == S[q] then k <- k+1 这个开销复杂度是等同于π[q] <- k,自然可以用π[q]赋值这个操作代表了。问题在于while循环,这个循环是一个让k退步的一个操作,它不断地取π[q],这个取的开销可以用放在上面的存款支付了.由于π[q]总是指向一个有存款的地方(一开始我们在0,1两处各放上1,这是O(1)的)。所以总是有存款可花的,保证了上界。也就是while循环平摊为了0.总开销为O(n)。
   也可以用势能法。也是书上的方法(在KMP一节)。k的势就是当前状态k。显然开始为0,以后总大于等于0.显然势最多加1,因为状态最多前进一步(也就是if语句)。后退(也就是while循环)是可以很多的,这些代价被负的势差所抵消。while和if都是在操作势,后面赋值的开销是O(1),势的平摊开销(至多)是O(1).所以最后为O(n)。
   对KMP-MATCH(S,P) 可以用类似的方法,不再赘述。



转载注明出处。

平摊分析 --- 算法导论读书笔记