1.前言
今天看博客,有這麼一篇文章,他以一道面試題引出了布隆過濾器的概念。這道題大致意思是這樣的:假設現在有1000瓶水,其中有一瓶有毒,只要喝一滴,過30天就會毒發身亡。問最少需要多少隻小白鼠可以找到有毒的那瓶水,當然是要求30天找到。不然我可以用一隻小白鼠實驗30*1000=30000天(大約82年)[想想好多人連30000天都活不了,不談這個傷心的話題了]。那麼這個問題怎麼解決呢?這裏就用到了布隆過濾器。爲了便於說明問題,我們假定有10瓶水。我們大致說一下解決思路:
1.先對10瓶水進行編號,並且轉換爲2進制。所以就有了10串二進制的數。
2.讓一號小白鼠和最右邊一列爲1的水,分別爲1、3、5、7、9(只要一滴,撐不死的)
3.讓二號、三號、四號小白鼠分別喝相對應列中爲1的水。
4.30天后統計被毒死的小白鼠。可以算出那一瓶有毒。(如下圖)
分析:
假如我們發現第一隻小白鼠和第三隻小白鼠被毒死了。二號和四號無恙。我們首先找B列和D列爲1的,A列和C列爲0的。所以我們找到爲0101,即5號瓶子有毒。
那麼我們回到上訴問題,如果有1000瓶水呢?我們可以將1000轉換爲2進制,應該是10位。所以需要10只小白鼠。
11 1110 1000
2.概述
通過上面的實例相信大家對布隆過濾器有了初步的瞭解。那麼什麼是布隆過濾器呢?布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難。布隆其實有布爾的諧音,也不知道是不是巧合。我們不去關注這個問題。
如果想要判斷一個元素是不是在一個集合裏,一般想到的是將所有元素保存起來,然後通過比較確定。鏈表,樹等等數據結構都是這種思路. 但是隨着集合中元素的增加,我們需要的存儲空間越來越大,檢索速度也越來越慢(O(n),O(logn))。不過世界上還有一種叫作散列表(又叫哈希表,Hash table)的數據結構。它可以通過一個Hash函數將一個元素映射成一個位陣列(Bit array)中的一個點。這樣一來,我們只要看看這個點是不是1就可以知道集合中有沒有它了。這就是布隆過濾器的基本思想。
Hash面臨的問題就是衝突。假設Hash函數是良好的,如果我們的位陣列長度爲m個點,那麼如果我們想將衝突率降低到例如 1%, 這個散列表就只能容納m / 100個元素。顯然這就不叫空間效率了(Space-efficient)了。解決方法也簡單,就是使用多個Hash,如果它們有一個說元素不在集合中,那肯定就不在。如果它們都說在,雖然也有一定可能性它們在說謊,不過直覺上判斷這種事情的概率是比較低的。
但是布隆過濾器也有缺點。誤算率是其中之一。隨着存入的元素數量增加,誤算率隨之增加。常見的補救辦法是建立一個小的白名單,存儲那些可能被誤判的元素。但是如果元素數量太少,則使用散列表足矣。
3.應用案例
其實在讀了那篇文章好,我突然想起之前寫的一段自我感覺很牛的代碼是不是可以通過布隆過濾器優化一下呢?這個業務邏輯是這樣的,假設現在有10個按鈕,分別爲btn1、btn2、btn3...btn10。我們怎麼快速判斷那一個按鈕可以點擊,哪一個不可以點擊。如果按照正常情況我們會分成2的10次方進行分別討論,那代碼量可想而知。那麼我們現在有沒有一個通用的方法通過給定一個模數,然後返回需要打開可編輯的按鈕呢?
3.1我最原始的代碼
@Test
public void test1() {
//構造符合要求的按鈕集合
List < FunBtn > btns = new ArrayList < > ();
for (int i = 9; i >= 0; i--) {
//這裏用到左移,也就是2的N次方
btns.add(new FunBtn("btn" + i, 1 << i));
}
changeEnableFun1(btns, 50);
}
public void changeEnableFun1(List < FunBtn > funBtns, int funMode) {
for (FunBtn funBtn: funBtns) {
funMode = checkFun(funMode, funBtn);
}
}
private int checkFun(int funMode, FunBtn funBtn) {
//從後往前查找,如果模數大於最大按鈕的權重,說明此按鈕包含在模數中,最終減去此按鈕的權重
//否則不包含
//
int weigth = funBtn.getWeight();
if (funMode >= weigth) {
System.out.println(funBtn.name);
funMode -= weigth;
}
return funMode;
}
/**
* 按鈕對象
* name:假定按鈕對象
* weight:權重,這裏的權重按照2的n次方
*/
public static class FunBtn {
private String name;
private Integer weight;
public FunBtn(String name, Integer weight) {
this.name = name;
this.weight = weight;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getWeight() {
return weight;
}
public void setWeight(Integer weight) {
this.weight = weight;
}
}
我們可以事先算好一種業務模式。比如業務場景一,我們需要將按鈕btn0、btn1、btn3可編輯,那麼我們可以傳入的數爲2^0+2^1+2^3 = 11。所以,我們將funModel傳入11的時候,按鈕btn0、btn1、btn3可編輯。大家可以嘗試一下。
3.2 通過布隆過濾器改造的代碼
@Test
public void test2() {
List < String > btns = new ArrayList < > ();
for (int i = 0; i < 10; i++) {
btns.add("btn" + i);
}
changeEanbleFun2(btns, 50);
}
public void changeEanbleFun2(List < String > btns, int funMode) {
Integer result = Integer.valueOf(Integer.toBinaryString(funMode));
String s = String.format("%010d", result);
for (int i = 0; i < 10; i++) {
if (s.charAt(i) == 49) {
System.out.println(btns.get(9 - i));
}
}
}
首先我們不需要定義那個FunBtn對象了,changeEanbleFun2在處理邏輯上也精簡了不少。處理邏輯如下:
1.首先先將funMode轉換爲2進制數。如果不夠10位的左側補0.
2.接着我們對這個二進制的字符串逐個檢查,如果等於1,即符合條件的輸出結果,否則不變。此時需要注意的是我們通過按鈕集合btns的索引位置確定按鈕的權重。
我們可以和上面小白鼠對照一下。10個按鈕->10只小白鼠,funMode->有毒的水瓶編號,可編輯的按鈕->被毒死的小白鼠。好像這裏求的過程和上面的不一樣。上面例題中是通過毒死的小白鼠求有毒的水瓶編號,而這裏編程通過有毒水瓶的編號求被毒死的小白鼠。不管怎麼樣,最終還是可以求出結果的。
3.3 兩種思路的耗時比較
private static final Integer LOOP = 100000;
@Test
public void test1() {
List < FunBtn > btns = new ArrayList < > ();
for (int i = 9; i >= 0; i--) {
btns.add(new FunBtn("btn" + i, 1 << i));
}
for (int i = 0; i < LOOP; i++) {
changeEnableFun1(btns, 50);
}
}
@Test
public void test2() {
List < String > btns = new ArrayList < > ();
for (int i = 0; i < 10; i++) {
btns.add("btn" + i);
}
for (int i = 0; i < LOOP; i++) {
changeEanbleFun2(btns, 50);
}
}
爲了區別明顯,我們讓其循環10000次,test1是我最開始寫的代碼,test2是通過布隆過濾器調用的方法。
是不是有點驚訝,我們費了半天寫的代碼竟然沒有之前執行的效率高。打擊可不是一般的小啊。不過我們還是要分析一下原因。講過對比,其實問題還是出現在Integer result = Integer.valueOf(Integer.toBinaryString(funMode));。在Integer.toBinaryString(funMode)中,JDK對於數字轉換也是通過遍歷的方式。
static int formatUnsignedInt(int val, int shift, char[] buf, int offset, int len) {
int charPos = len;
int radix = 1 << shift;
int mask = radix - 1;
do {
buf[offset + --charPos] = Integer.digits[val & mask];
val >>>= shift;
} while (val != 0 && charPos > 0);
return charPos;
}
其實核心代碼還是代碼6、7行。這個在下面會有分析。說白了,我們在test2中,其實是執行了兩次循環。這樣一來,時間就有差距了。
既然test2進行了兩次循環,我們能不能在一次循環中解決問題,換句話說在生成二進制的期間就把問題解決了?順着這個思路,我們有了以下的改進。
3.4 代碼改進
3.4.1 思路
學過計算機的都應該瞭解一個十進制轉換二進制的方法。我們在這裏使用其中一種思路:
1)對十進制與2取模,寫到第一位;
2)對十進制的數除以2,得到新的數,然後在對2取模。
3)依次類推。。。
3.4.2 代碼樣例
private void changeEanbleFun3(List < String > btns, int funMode) {
int position = 0;
while (funMode != 0) {
if (funMode % 2 == 1) {
System.out.println(btns.get(position));
}
funMode = funMode / 2;
position++;
}
}
看起來代碼更簡潔了。那麼我們看一下執行效率。
這次終於有些成就感了。不知道你們是否還記得上面提到JDK實現十進制轉換二進制的方式?他主要是通過位運算實現的。那麼我們是否可以借鑑一下想法。
3.5 終極解決
3.5.1 JDK轉換二進制源碼分析
static int formatUnsignedInt(int val, int shift, char[] buf, int offset, int len) {
int charPos = len;
int radix = 1 << shift;
int mask = radix - 1;
do {
buf[offset + --charPos] = Integer.digits[val & mask];
val >>>= shift;
} while (val != 0 && charPos > 0);
return charPos;
}
我們主要分析一下代碼6行和代碼7行。
1)代碼6行中主要實現爲val & mask。這裏再次用到了掩碼的思想。mask的英文就是掩碼的意思,掩碼一般和&一起使用。這裏mask爲1,通過運算,也就是對val對2取模。
2)代碼7行中val >>>= shift;中,是無符號右移1位,說白了就是執行了val/2的運算。其實思路和上面一樣,先取模,在除以2,只不過這裏使用位運算。感覺效率會比四則運算高。
3.5.2 代碼展示
private void changeEanbleFun4(List < String > btns, int funMode) {
int position = 0;
do {
if ((funMode & 1) == 1) {
System.out.println(btns.get(position));
}
funMode >>>= 1;
position++;
} while (funMode != 0);
}
這裏就不做解釋了,相信大家能看懂。我們看一下耗時吧。
test3和test4基本差不多。不過實現起來還是顯得高大上了寫。
4.總結
在實際工作中,我們對於同一個問題往往有多種不同的解決思路。只要我們肯去思考,就一定能找出一條最優的方案。