线性时间查找固定频率的元素

转自:http://blog.ibread.net/467/linear-time-iceberg-query-algorithm/

一、从面试题开始

在进入到枯燥的正文之前,先来看一道据说在很多面试过程中都会问到的题目:

已知一个长度为n的数组,求出现半数以上的元素。

这道题目看似简单,其实得到完美的答案并不容易。首先,不难想到,出现半数以上的元素最多只有一个。而为了选出出现次数达到半数以上的元素,最笨的方法当然就是对数组中出现的每一个元素,都遍历一遍数组记录下其出现频率,通过与比较确定是否符合要求。这样就可以在的时间内,用O(1)空间解决问题。 但是,用Prof. Maggs 的话说,的时间开销显然是完全没有办法忍受的,:) 于是呢,为了解决这个问题,我们可以借助一个比较作弊的数据结构: hash表来存储每个元素出现的次数。这样就可以得到一个时间复杂度和空间复杂度都是O(n)的解决方案。 而之所以说hash表这个方案有点作弊呢,是因为我们假定有一个完美的hash函数,使得数组中的不同元素并不会有碰撞。所以要真的依靠设计良好的hash函数达到存取开销都是O(1),这本身就是不是一个好解决的问题。不过hash函数的实现并不在本文的讨论范围之内,所以就姑且默认为可以完美实现O(1)时间的存取吧。

以上我们分别讨论了时间 O(1)空间以及O(n)时间O(n)空间的两个方案,那么问题来了,有没有时间复杂度O(n)空间复杂度O(1)的算法呢? 实际上,早在91年就有人专门就这个问题发表了论文,介绍了一种线性时间的算法: Majority Vote Algorithm。通过名字就可以看出,这个算法是专门用来解决这个问题的。而由于作者是J Moore (目前是Utexas的计算机系主任),这个算法有时候也会被称为Moore’s Voting Algorithm (当然这个Moore并不是提出Moore’s Law的那个Gordon Moore)。

算法的基本思想非常简洁: 每次都找出一对不同的元素,从数组中删掉,直到数组为空或只有一种元素。 不难证明,如果存在元素e出现频率超过半数,那么数组中最后剩下的就只有e。当然,最后剩下的元素也可能并没有出现半数以上。比如说数组是[1, 2, 3],最后剩下的3显然只出现了1次,并不到半数。排除这种false positive情况的方法也很简单,只要保存下原始数组,最后扫描一遍验证一下就可以了。

现在来分析一下复杂度。删除元素可以在常数时间内完成,但找不同元素似乎有点麻烦。实际上,我们可以换个角度来想,用一个小trick来重新实现下该算法。

在算法执行过程中,我们使用常量空间实时记录一个候选元素c以及其出现次数f(c),c即为当前阶段出现次数超过半数的元素。在遍历开始之前,该元素c为空,f(c)=0。然后在遍历数组A时,

  • 如果f(c)为0,表示当前并没有候选元素,也就是说之前的遍历过程中并没有找到超过半数的元素。那么,如果超过半数的元素c存在,那么c在剩下的子数组中,出现次数也一定超过半数。因此我们可以将原始问题转化为它的子问题。此时c赋值为当前元素, 同时f(c)=1。
  • 如果当前元素A[i] == c, 那么f(c) += 1。(没有找到不同元素,只需要把相同元素累计起来)
  • 如果当前元素A[i] != c,那么f(c) -= 1 (相当于删除1个c),不对A[i]做任何处理(相当于删除A[i])

如果遍历结束之后,f(c)不为0,那么再次遍历一遍数组,记录c真正出现的频率,从而验证c是否真的出现了超过半数。上述算法的时间复杂度为O(n),而由于并不需要真的删除数组元素,我们也并不需要额外的空间来保存原始数组,空间复杂度为O(1)。实际上,在Moore大牛的主页上有针对这个算法的一个演示,感兴趣的同学可以直接移步观看。

这个问题看上去已经完美的解决了。

二、更一般的情况呢?

那么,如果我们想找的并不是超过半数的元素,而是出现频率超过一定频率的元素都要找出来,是否也存在一个类似的线性时间的算法呢?答案是肯定的。实际上,这一类从特定的数据集中找出出现频率超过某个阈值的元素的问题,有一个形象的名字叫做Iceberg query,或者叫做host list分析。而Richard Karp 老爷子当年就专门写了一篇论文来讨论这种一般性问题的解决方案,而通过下文的介绍,大家也可以发现,Karp的方案应该也是受到了Moore的算法的启发。

首先还是看一下问题的形式化定义吧:

对于一个序列 以及一个在(0,1)之间的实数。假定表示元素的出现频率,我们需要找到所有满足的元素。

首先,满足条件的元素个数必然小于。否则的话,。因此,如果令,我们可以知道最终的候选元素的个数不会超过K。与上文中介绍的算法相同,我们还是基于序列的遍历来完成新的算法。同样的,我们需要维护一个规模为K的候选元素表,其中存储候选元素c以及其出现频率f(c)。遍历开始前,所有的f(c)初始化为0。在遍历过程中:

  • 若A[i]已在候选表中,即存在c使得,那么f(c) += 1。
  • 若A[i]不在候选表中,且候选表仍有空位,即,那么将A[i]插入到表中,且f(A[i]) = 1。
  • 若A[i]不在候选表中,且候选表已满。那么丢弃A[i],且对于候选表中每一元素,做f(c) -= 1。该操作完毕之后,所有f(c)归零的元素从候选表中移除。

算法介绍完了,我们先来证明下其正确性。

首先,false positive的情况可以通过对序列做第二次遍历,同时记录候选元素的出现次数来排除。

然后,我们来排除false negative的情况,也就是说,对于元素c没有出现在候选元素表中,那么一定有。可以注意到,算法中的第三步,实际上相当于从目前遍历过的元素中移除个,其中个是候选表中元素,1个是当前元素。而如果c最终不在候选表中,那么要么c从来没有被选中过,要么是选中后又被排除了。对于前者,c一直扮演着第三步中当前A[i]的角色,被直接丢弃; 对于后者,c可能还扮演着第三步候选表中元素的角色,频率自减1。但不管怎样,可以肯定的是,每次丢弃c,都有另外K个元素也一起被丢弃。假定表示元素c的总体出现频率,由于元素c最终不在候选表中,我们可以认定所有的c都被丢弃掉,也就是说,我们一共丢弃了个元素。又 ,我们有 ,继而得到 。也就是说,false negative的情况也被排除了。

那么,这个算法的复杂度是多少呢? 由于采用了hash表,我们需要的空间。同时如果假定存取操作都可以O(1)完成的话,算法中的三个步骤时间复杂度分别是,  和 。然而如果使用平摊分析,我们可以在第一步和第二步都多计一个credit,用于今后可能进行的第三步中的删除操作,那么每一步的平摊时间代价就是,从而可以在时间内完成该算法。

三、看平摊分析不顺眼?

看样子对于一般性的iceberg query问题,我们也已经成功给出了一个比较完美的答案,复杂度为O(N)的时间,的空间。无论是时间和空间,都是没有办法再少了。事情到了这个地步,似乎已经可以结束了。但是要注意的是,对于时间复杂度,我们 采用的是平摊分析,虽然也是O(N)没错,但看上去总是不太爽(其实我还可以接受了…)。比如说如果在处理当前元素时,走到了上述算法的第3个分支, 那复杂度显然是与当前候选表中元素个数相关的,也就是说。那么,能不能想想办法,使得无论走哪个分支,都只用O(1)的时间复杂度,同时继续保持的空间呢?

其实很容易就想到一个小trick来解决这个问题。第三个分支所做的,不就是把所有f(c)都自减1么,那么如果我们把所有的f(c)都存成相对于一个变量的差值,每次只要改变这一个变量不就可以实现对所有count的减一操作了么?  但是这样还是没有完全解决问题,因为我们还有另外的操作,就是如果f(c)归零,要将该元素删除。要做到这一点,似乎还是得以此扫描候选表中的f(c)。要解决这个问题也很容易,只要把f(c)相同的元素归类到一起形成一个链表,然后按照f(c)的递增顺序将链表串起来就行了。每次只要检测最小的f(c)是否归零,如果归零,那么就把整个链表删掉。

图1:改进后的数据结构

基本的思想就是这样了,先来看最终的数据结构吧。如图1所示,利用hash表,我们可以将各元素一一对应到中间双向链表中的结点,每个结点存取相应元素的出现频率。而处于同一双向链表中的结点频率数相同。这里的双向链表实际上就起到我们上文提到的候选表的作用。最左侧的单向链表用于将各个双向链表按照频率从低到高的顺序串在一起。其中,头结点中存储着真正的频率值 (如图中的3,我们在下文中称之为base),其他结点皆存储相对其前驱结点的频率差值 (如图中所示的+3, +1)。理所当然的,base值只能为正。同时,每个单链表的结点也作为双向链表的头结点,维护相应双向链表的长度,我们还需要另外维护一个变量total表示当前处于双向链表中的元素个数,也就是候选表的大小。空间复杂度不难分析出来,是。

结合这个数据结构,再来把上面的操作重新分析一下吧。其实我们需要的只有3种操作: 查找当前元素所在的结点,插入新结点,已有结点频率+1,所有已有结点频率-1并删除所有频率降为0的结点,下面我们分别看一下时间复杂度复杂度。

  • 查找: O(1) 借助hash表,我们可以在O(1)时间内找到当前元素A[i]所在的结点,进而得知其频率。
  • 插入新结点: O(1) 如果没有找到该结点,我们首先需要判断候选表是否已满,如果未满 (),则需要将其插入到频率值为1的双向链表中。我们通过检测base可以判断该链表是否存在。有的话则直接插入,否则则为单链表新建一个头结点指向一个新的双向链表。当然同时也要更新hash表,以及单链表原头结点的数值 (自减1,变成相对值)。
  • 频率自增1: O(1) 如果找到该结点,则需要将其频率自增1。这个操作需要先将其从当前所在的双向链表中摘下来 (O(1) 时间),插入到f(c)+1所属的链表中。由于双向链表中每个结点我们都额外维护了指向表头的指针,所以我们可以在常量时间内找到其后继双线链表的表头。但注意这个表头未必合适。我们还需要查看其中维护的频率差值,如果为1则直接插入其中;否则的话则建立一个新结点,指向一个新的双向链表。同时也要把这个新结点插入到单链表中,更新其后继结点的差值。当然,链表长度也要进行相应的更新。由于我们采用了双向链表,所以删除和插入新结点都只需要常数时间开销; 同时,对于单链表来说,在当前结点之后插入新结点也只需要常数时间。总体来说,此步骤只需O(1)时间。
  • 所有频率自减1:O(1)并删除所有频率归零的结点: 由于双向链表是按照频率由低到高依次排列的,仅有第一个链表的频率可能降为0,因此我们只需要检查其频率,也就是base就可以了。如果base为1,那么直接删除该头结点,和其指向的双向链表;否则的话,base自减1就可以了。因为后继结点存储的都是差值,并不需要一一更新。容易看出,这一步步骤也只需O(1)的时间。

现在基本上是大功告成了,我们通过几个简单数据结构的组合,把每个步骤的时间复杂度从平摊的O(1)优化到了严格的O(1)。不过确实好累。。。其实对于这个问题,还可以继续做下去。比如说,是否存在线性时间的确定性online算法呢? 比如说,输入不是静态的序列,而是stream,也就是没有办法进行二次扫描。那么是否存在线性时间的online算法,来确定地找出iceberg query的结果呢? 说实话这部分内容我还没有深入的了解。目前只知道线性时间带有false positive结果的算法,且如果要做到online的话,至少需要的空间。另外,在这篇论文中,介绍了一种基于概率的方法,不过我没有仔细看 (惭愧啊。。。) 不过有点题外话就是,虽然这篇论文用了跟Karp老爷子一样的算法,但是并没有引用其论文,不知道是怎么个情况。。


你可能感兴趣的:(Algorithm,数据结构,c,算法,存储,query)