哈希表之bkdrhash算法解析及擴展

轉自:http://www.it165.net/pro/html/201410/24949.html

BKDRHASH是一種字符哈希算法,像BKDRHash,APHash,DJBHash,JSHash,RSHash,SDBMHash,PJWHash,ELFHash等等,這些都是比較經典的,通過http://blog.csdn.net/wanglx_/article/details/40300363(字符串哈希函數)這篇文章,我們可知道,BKDRHash是比較好的一個獲取哈希值的方法。下面就講解這個BKDRHash函數是如何推導實現的。

當我看到BKDRHash的代碼時,不禁就疑惑了,這裏面有個常數Seed,取值爲31、131等,爲什麼要這麼取,我取其他的值不行嗎?還有爲什麼要將每個字符相加,並乘以這個Seed? 這些到底是什麼含義? 最後想了老半天都是不得其解,最後繞進素數裏面出不來了……最後在一位牛人的指點下,才茅塞頓開,下面把我的想法和推導過程記錄如下。

BKDRHash計算公式的推導

由一個字符串(比如:ad)得到其哈希值,爲了減少碰撞,應該使該字符串中每個字符都參與哈希值計算,使其符合雪崩效應,也就是說即使改變字符串中的一個字節,也會對最終的哈希值造成較大的影響。我們直接想到的辦法就是讓字符串中的每個字符相加,得到其和SUM,讓SUM作爲哈希值,如SUM(ad)= a+d;可是根據ascii碼錶得知a(97)+d(100)=b(98)+c(99),那麼發生了碰撞,我們發現直接求和的話會很容易發生碰撞,那麼怎麼辦哪?我們可以對字符間的差距進行放大,乘以一個係數:

SUM(ad) =係數1 * a + 係數2 * d

SUM(bc)= 係數1 * b + 係數2 * c

係數1不等於係數2,這樣SUM(ad)等於SUM(bc)的概率就會大大減小。

可是我們的字符串不可能只有兩位或者三位,我們也不可能爲每個係數去人爲的賦值,但是字符串中有位數的順序,比如在”ab”中,b是第0位,a是第1位,那麼我們可以用係數的n次方作爲每個字符的係數,但這個係數不能爲1:

SUM(ad) =係數^1 * a + 係數^0 * d

SUM(bc)= 係數^1 * b + 係數^0 * c

這樣我們就大大降低了碰撞的發生,下面我們假設有個字符數組p,有n個元素,那麼


即:


下面就是這個“係數”取值的問題,取什麼值那?從上面的分析來看,取除1之外的什麼值都可以,我們知道整數不是奇數就是偶數,爲了便於推算我們將偶數分爲2的冪的偶數和非2的冪的偶數,也就是分3種取值討論

係數的推導

現在我們的任務是推導係數的值,分2的冪的偶數、非2的冪的偶數、奇數三個部分討論。

a. 取2的冪

假如我們取32,也就是2^5,那麼我們計算SUM(ad)和SUM(bc)結果如下:

結果不同,有效處理了碰撞。

但是當我們進一步測試會發現,當我們取SUM(ahijklmn)和SUM(hijklmn)時計算得:

取SUM(abhijklmn)和SUM(abchijklmn)時計算得:

SUM(abcdefghijklmn)和SUM(123456hijklmn)時計算得:

我們會發現,只要最末尾的”hijklmn”這幾個字符不變,不管前面怎麼變,得到的哈希值都是一樣的,完全碰撞了!這是爲什麼那?

首先哈希值SUM的存儲類型用什麼?當然用unsigned int ,因爲值會很大,unsigned int 是32位,而只要計算就可能會溢出,CPU對於溢出的處理是拋棄最高位,比如兩個unsigned int 的值相加結果爲33位,那麼最高位33位就會被拋棄,那麼我們對上面的情況進行計算:

計算SUM(ahijklmn)和SUM(bhijklmn):

SUM(ahijklmn)= 32^7*a + 32^6*h + 32^5*I + 32^4*j + 32^3*k + 32^2*l + 32^1*m + 32^0*n

SUM(bhijklmn)= 32^7*b + 32^6*h + 32^5*I + 32^4*j + 32^3*k + 32^2*l + 32^1*m + 32^0*n

將32換爲2^5得:

SUM(ahijklmn)= 2^35*a + 2^30*h + 2^25*I + 2^20*j + 2^15*k + 2^10*l + 2^5*m + 2^0*n

SUM(bhijklmn)= 2^35*b + 2^30*h + 2^25*I + 2^20*j + 2^15*k + 2^10*l + 2^5*m + 2^0*n

由此可知SUM(ahijklmn)和SUM(bhijklmn)都大於unsignedint所能表達的最大值,所以需要拋棄最高位,也就是對0x100000000(也就是2^33)取餘,根據同餘定理:

(a+b)%m= (a%m + b%m)%m

(a*b)%m= (a%m * b%m)%m

可知

SUM(ahijklmn)%2^33 = (2^35*a% 2^33 + 2^30*h% 2^33 + … + 2^0*n%2^33)% 2^33

SUM(bhijklmn)%2^33 = (2^35*b % 2^33 + 2^30*h % 2^33 + … + 2^0*n%2^33) 2^33

2^35*a% 2^33和 2^35*b % 2^33 爲零,所以因溢出被CPU捨棄,得

SUM(ahijklmn)%2^33 = (2^30*h% 2^33 + … + 2^0*n% 2^33) 2^33

SUM(bhijklmn)%2^33 = (2^30*h % 2^33 + … + 2^0*n% 2^33) 2^33

最終他們的哈希值爲

SUM(ahijklmn)= 2^30*h + 2^25*I + 2^20*j + 2^15*k + 2^10*l + 2^5*m + 2^0*n

SUM(bhijklmn)= 2^30*h + 2^25*I + 2^20*j + 2^15*k + 2^10*l + 2^5*m + 2^0*n

所以SUM(ahijklmn)等於SUM(bhijklmn),這就是爲什麼” hijklmn”不變時,不管前面是什麼字符串都會被捨棄,得到一樣的字符串。這裏用的是32=2^5,只要你用2^n,n不管爲多少都不行,都會因爲字符串的長度達到一定值而造成前面的被捨棄,造成一直碰撞。

b. 取非2的冪的偶數

既然去取2的冪不行,那麼我們取非2的冪的偶數,假如我們取6作爲係數,6爲2^2+2,我們由上面取2的冪的推導可知,當字符的長度大於等於33時,係數就會變爲6^32=3*2^33,可知係數大於2^32,對2^33取餘,被捨棄,那麼造成只要後32個字符不變,前面不管有多少個同的字符,都會被捨棄,計算所得的哈希值也就一樣。

由上面兩塊可知,係數取偶數行不通

c. 取奇數(大於1)

假如我們取9=2^3+1,9^2=81=80+1,9^3=729=728+1,… ,9^n=9^n-1+1,我們知道9的冪肯定是奇數,那麼9^n-1肯定爲偶數,由上面的推論可知字符串達到一定的長度時,偶數係數前面的字符是可以捨棄的,可是9^n=9^n-1+1,最後的1是永遠不會被捨棄的,所以每個字符都會參與運算,取大於1的奇數可行。

結論

由上面三步的推導可知,這個係數應當選擇大於1的奇數,這樣可以很好的降低碰撞的機率,那麼我們就可以根據上面推導的公式,用代碼實現:

bkdrhash的初步代碼實現如下:

01.#include <iostream>
02.#include <MATH.H>
03. 
04.unsigned int str_hash_1(const char* s)
05.{
06.unsigned char *p = (unsigned char*)s;
07.unsigned int hash = 0;
08.unsigned int seed = 3;//3,5,7,9,...,etc奇數
09.unsigned int nIndex = 0;
10.unsigned int nLen = strlen((char*)p);
11.while( *p )
12.{
13.hash = hash + pow(3,nLen-nIndex-1)*(*p);
14.++p;
15.nIndex++;
16.}
17.return hash;
18.}
19. 
20.int main(int argc, char* argv[])
21.{
22.std::cout << str_hash_1("hijklmn")<<std::endl;
23.std::cout << str_hash_1("bhijklmn")<<std::endl;
24.getchar();
25.return 0;
26.}
其實我們可以對代碼進行簡化,即利用遞歸進行實現,但是在使用bkdrhash時你會發現裏面大多源碼使用的都是特殊的奇數2^n-1(一般取素數,那是因爲在CPU的運算中移位和減法比較快。代碼如下:

01.#include <iostream>
02. 
03.unsigned int bkdr_hash(const char* key)
04.{
05.char* str = const_cast<char*>(key);
06. 
07.unsigned int seed = 31// 31 131 1313 13131 131313 etc.. 37
08.unsigned int hash = 0;
09.while (*str)
10.{
11.hash = hash * seed + (*str++);
12.}
13.return hash;
14.}
15. 
16.int main(int argc, char* argv[])
17.{
18.std::cout << bkdr_hash("hijklmn")<<std::endl;
19.std::cout << bkdr_hash("bhijklmn")<<std::endl;
20.getchar();
21.return 0;
22.}

擴展

注意:即使最終求得的bkdrhash值幾乎不會衝突碰撞,但他們都是很大的值,不可能直接映射到哈希數組地址上,所以一般都是直接對哈希數組大小取餘,以餘數作爲索引地址,但是這就造成了,可能的地址衝突。bkdrhash值不一樣,但是取餘後得到的索引地址一樣,也就是衝突,只是這種衝突的概率很小。對於哈希表不可能完全消除碰撞,只能降低碰撞的機率。作爲對哈希知識的進一步熟悉,下面羅列幾點提升哈希表效率的注意點:

1.選用的哈希函數

哈希函數的目的就是爲了產生譬如字符串的哈希值,讓不同的字符串儘量產生不同的哈希值的函數就是好的哈希函數,完全不會產生相同的哈希函數就是完美的。

2.處理衝突的方法

處理衝突的方法有多種,拉鍊法、線性探測等,我喜歡用拉鍊法

3.哈希表的大小

這個哈希表的大小是固定的,但可以動態調整,也就是創建個新的數組,用舊的給新的循環重新計算Key賦值,刪除舊的。但最好根據需求數據量設置足夠大的初始值,防止動態調整的頻繁,因爲調整是很費時又費空間的。還有重要的是,這個哈希表的大小要設爲一個質數,爲什麼是質數?因爲質數只有1和它本身兩個約數,當用bkdrhash算得的key對哈希表大小取餘時,不會因爲存在公約數而縮小余數的範圍,如果餘數範圍縮小的話,就會加大碰撞的機率(說法有點牽強,知道的童鞋請給個合理的解釋)。

4.裝載因子,即哈希表的飽和程度

一般來說裝載因子越小越好,裝載因子越小,碰撞也就越小,哈希表的速度就會越快,可是這樣會大大的浪費空間,假如裝載因子爲0.1,那麼哈希表只有10%的空間被真正利用,其餘的90%都浪費了,這就是時間和空間的矛盾點,爲了平衡,現在大部分採用的是0.75作爲裝載因子,裝載因子達到0.75,那麼就動態增加哈希表的大小。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章