这一篇讲的是布谷过滤器(cuckoo fliter),这个名字来源于更早发表的布谷散列(cuckoo hash),尽管我也不知道为什么当初要给这种散列表起个鸟名=_=

由于布谷过滤器本身的思想就源自于布谷散列,那么我们就从布谷散列开始说它的设计思想。产生布谷散列表的一个重要背景是人们对于球盒问题的分析:给定N个球,随机的放在N个盒子里,在装球最多的盒子里,球的个数的期望是多少?答案是$\Theta (logN/loglogN)$,这个问题其实就是散列表装填因子为1时的情况分析。后来有一天,人们发现:每次放球的时候,如果随机选择两个盒子,将球放到当时较空的那个盒子里,那么这个期望就变成了$\Theta (loglogN)$,这个界小于之前的界,这给了布谷散列表作者启发。

一个布谷散列表通常有两张(一般来说)表,分别有一个对应的Hash函数,当有新的项插入的时候,它会计算出这个项在两张表中对应的两个位置,这个项一定会被存在这两个位置之一,而具体是哪一个却不确定。

下图是一个布谷散列表的初始化示意图:

image

现在我们假设有一些项要存入散列表,其每个项都有其对应的两个位置,先插入第一项A

image

由于插入A的时候其两个候选位置(0,2)都没有占用,所以选择第一张表或者是第二张表都可以,我们在这里默认先选择第一张表,然后插入第二项B image

我们看到原来的A的位置被B占用,而A被“踢”到它的备选位置表二的2号位置上了,这就是当发生位置冲突时,布谷散列表的处理逻辑,后来的数据项将会把之前占用的项踢到另一个位置上。我们接下来插入第三项C

image 没有冲突,顺利搞定,接着插入D

image D成功的把C踢走了,其实看到这里读者应该在猜想,会不会有一种情况,即被踢走的数据的另一个备选位置也被占用了,这样怎么办?答案是继续踢,一个踢一个,直到大家都找到自己合适的归宿为止。那么如果发现出现了循环怎么办?答案是GG,这代表布谷散列表走到了极限

image 插入E

image 这里就发生了多次替换的情况,F代替了E,E代替了A,A代替了B,B找到了空余的位子

读者可以考虑一下,如果这个时候要想插入一个“G”,其备选位置是1,2,这样的话会出现什么情况?

好了,布谷散列表大概介绍完了,现在该布谷过滤器了。布谷过滤器的主要也是通过布谷散列来实现的,其主要变化是: 1.我们不将原来的数据完整的存进来,作为过滤器,当然要省点空间,选用的办法设计一个指纹,将比较大的原数据变成了一个个指纹串,这样就大大节省了空间。 2.由于第一点所说,存储的不是原数据,那么在替换位置的时候,我们需要再次计算原来的数据的备选位置,这样一来布谷散列表的方法就失效了。在这里,作者设计了一个方法,他将两个Hash函数变成了一个Hash函数,第一张表的备选位置是Hash(x),第二张表的备选位置是$Hash(x) \oplus hash(fingerprint(x))$,即第一张表的位置与存储的指纹的Hash值做异或运算。这样做的优点就是不用再另外存储元素的备选位置,在替换时,可以直接用异或来计算出其另一个备选位置。(读者可以想想如何通过表二的位置计算出元素在表一中的位置) 3.插入时,优先选择空位置,而不是尽可能的踢走其他元素。

插入伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Algorithm 1: Insert(x)

f = fingerprint(x)
i1 = hash(x)
i2 = i1 xor hash(f)
if bucket[i1] or bucket[i2] has an empty entry then
    //只要有空位就先插入空位里
    add f to that bucket
    return Done
i  = randomly pick i1 or i2
for n=0;n<MaxNumKicks;++n
    randomly select an entry e from bucket[i];
    swap f and the fingerprint stored in entry e;
    i = i xor hash(f)
    if bucket[i] has an empty entry then
        add f to bucket[i]
        return Done
return Failure // 已经出现循环情况了

查找伪代码如下:

1
2
3
4
5
6
7
Algorithm 2: Lookup(x)
f = fingerprint(x)
i1 = hash(x)
i2 = i1 xor hash(f)
if bucket[i1] or bucket[i2] has f then
    return True
return False

删除伪代码如下:

1
2
3
4
5
6
7
8
Algorithm 3: Delete(x)

f = fingerprint(x)
i1 = hash(x)
i2 = i1 xor hash(f)
if bucket[i1] or bucket[i2] has f then
    remove a copy of f from this bucket
return False

删除这部分值得注意,当被删除元素的另一个备选位置有其他元素的指纹的时候,我们不能确定到底哪一个是要删除的元素,其实我们也不去关心到底是不是要删除的元素,我们直接删除掉其中的一个。这样一来,我们其实并没有真正的删除掉元素,为什么这么说,因为当你删除掉这个元素的时候我们再查找这个元素,按照算法来看我们还是一样能检索出来这个元素在我们的布谷过滤器里,这就是假阳率的其中一个来源(还有一个重要来源是指纹构造的重复,即多个元素产生相同指纹)

下面我们来分析一下布谷过滤器的平均每个元素占用的比特数,设每个桶里装$b$个指纹,要求错误率的上界为$\epsilon$,$f$为指纹长度。

错误率的上界 = $1-(1-1/2^f)^{2b} \approx 2b/2^f$

那么这个上界要求小于要求的上界,即$2b/2^f \le \epsilon$,得到

$f \ge log_2^{2b/\epsilon} = log_2^{1/\epsilon} + log_2^{2b}$

则平均每个元素占用的比特数为$C \le (log_2^{1/\epsilon} + log_2^{2b}) / \alpha$

在原论文中,作者其实后面还做了一个比较强行的优化,在此不提,后面设计其他过滤器的作者也没有把这个优化算数。。。。不过作者提到了在实际测试中,他们发现当b=4的时候是空间性能最好的情况,所以一般说来,我们直接把b当做常数4,代入到前面算出来的公式中,

$C \le (log_2^{1/\epsilon} + 3) / \alpha$

布谷过滤器就说到这,布谷过滤器在错误率小于3%的时候空间性能是优于布隆过滤器的,而这个条件在实际应用中常常满足,所以一般来说它的空间性能是要优于布隆过滤器的。而且,布谷过滤器按照普通设计,只有两个桶,在查找的时候可以确保两次访存就可以做完,相比于布隆过滤器的K个Hash函数K次访存,在数据量很大不能全部装载在内存中的情况下,多一次访存那么时间上就输了。不过说完优点,布谷过滤器也有其相应的缺点,当装填因子较高的时候,容易出现循环的问题,即插入失败的情况,到这时就很难办。另外,它还有跟布隆过滤器共有的一个缺点,就是访问空间地址不连续,通常可以认为是随机的,这样严重破坏了程序局部性,对于Cache流水线来说非常不利,这为之后的过滤器设计埋下了一个伏笔。