這一篇講的是布谷過濾器(cuckoo fliter),這個名字來源於更早發表的布谷散列(cuckoo hash),盡管我也不知道為什么當初要給這種散列表起個鳥名=_=
由於布谷過濾器本身的思想就源自於布谷散列,那么我們就從布谷散列開始說它的設計思想。產生布谷散列表的一個重要背景是人們對於球盒問題的分析:給定N個球,隨機的放在N個盒子里,在裝球最多的盒子里,球的個數的期望是多少?答案是\(\Theta (logN/loglogN)\),這個問題其實就是散列表裝填因子為1時的情況分析。后來有一天,人們發現:每次放球的時候,如果隨機選擇兩個盒子,將球放到當時較空的那個盒子里,那么這個期望就變成了\(\Theta (loglogN)\),這個界小於之前的界,這給了布谷散列表作者啟發。
一個布谷散列表通常有兩張(一般來說)表,分別有一個對應的Hash函數,當有新的項插入的時候,它會計算出這個項在兩張表中對應的兩個位置,這個項一定會被存在這兩個位置之一,而具體是哪一個卻不確定。
下圖是一個布谷散列表的初始化示意圖:

現在我們假設有一些項要存入散列表,其每個項都有其對應的兩個位置,先插入第一項A

由於插入A的時候其兩個候選位置(0,2)都沒有占用,所以選擇第一張表或者是第二張表都可以,我們在這里默認先選擇第一張表,然后插入第二項B

我們看到原來的A的位置被B占用,而A被“踢”到它的備選位置表二的2號位置上了,這就是當發生位置沖突時,布谷散列表的處理邏輯,后來的數據項將會把之前占用的項踢到另一個位置上。我們接下來插入第三項C

沒有沖突,順利搞定,接着插入D

D成功的把C踢走了,其實看到這里讀者應該在猜想,會不會有一種情況,即被踢走的數據的另一個備選位置也被占用了,這樣怎么辦?答案是繼續踢,一個踢一個,直到大家都找到自己合適的歸宿為止。那么如果發現出現了循環怎么辦?答案是GG,這代表布谷散列表走到了極限

插入E

這里就發生了多次替換的情況,F代替了E,E代替了A,A代替了B,B找到了空余的位子
讀者可以考慮一下,如果這個時候要想插入一個“G”,其備選位置是1,2,這樣的話會出現什么情況?
好了,布谷散列表大概介紹完了,現在該布谷過濾器了。布谷過濾器的主要也是通過布谷散列來實現的,其主要變化是:
1.我們不將原來的數據完整的存進來,作為過濾器,當然要省點空間,選用的辦法設計一個指紋,將比較大的原數據變成了一個個指紋串,這樣就大大節省了空間。
2.由於第一點所說,存儲的不是原數據,那么在替換位置的時候,我們需要再次計算原來的數據的備選位置,這樣一來布谷散列表的方法就失效了。在這里,作者設計了一個方法,他將兩個Hash函數變成了一個Hash函數,第一張表的備選位置是Hash(x),第二張表的備選位置是\(Hash(x) \oplus hash(fingerprint(x))\),即第一張表的位置與存儲的指紋的Hash值做異或運算。這樣做的優點就是不用再另外存儲元素的備選位置,在替換時,可以直接用異或來計算出其另一個備選位置。(讀者可以想想如何通過表二的位置計算出元素在表一中的位置)
3.插入時,優先選擇空位置,而不是盡可能的踢走其他元素。
插入偽代碼如下:
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 // 已經出現循環情況了
查找偽代碼如下:
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
刪除偽代碼如下:
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流水線來說非常不利,這為之后的過濾器設計埋下了一個伏筆。
