哈希表的原理與實現
一列鍵值對數據,存儲在一個table中,如何通過數據的關鍵字快速查找相應值呢?不要告訴我一個個拿出來比較key啊,呵呵。
大家都知道,在所有的線性數據結構中,數組的定位速度最快,因為它可通過數組下標直接定位到相應的數組空間,就不需要一個個查找。而哈希表就是利用數組這個能夠快速定位數據的結構解決以上的問題的。
具體如何做呢?大家是否有注意到前面說的話:“數組可以通過下標直接定位到相應的空間”,對就是這句,哈希表的做法其實很簡單,就是把Key通過一個固定的算法函數,既所謂的哈希函數轉換成一個整型數字,然后就將該數字對數組長度進行取余,取余結果就當作數組的下標,將value存儲在以該數字為下標的數組空間里,而當使用哈希表進行查詢的時候,就是再次使用哈希函數將key轉換為對應的數組下標,並定位到該空間獲取value,如此一來,就可以充分利用到數組的定位性能進行數據定位。
不知道說到這里,一些不了解的朋友是否大概了解了哈希表的原理,其實就是通過空間換取時間的做法。到這里,可能有的朋友就會問,哈希函數對key進行轉換,取余的值一定是唯一的嗎?這個當然不能保證,主要是由於hashcode會對數組長度進行取余,因此其結果由於數組長度的限制必然會出現重復,所以就會有“沖突”這一問題,至於解決沖突的辦法其實有很多種,比如重復散列的方式,大概就是定位的空間已經存在value且key不同的話就重新進行哈希加一並求模數組元素個數,既 (h(k)+i) mod S , i=1,2,3…… ,直到找到空間為止。還有其他的方式大家如果有興趣的話可以自己找找資料看看。
Hash表這種數據結構在java中是原生的一個集合對象,在實際中用途極廣,主要有這么幾個特點:
- 訪問速度快
- 大小不受限制
- 按鍵進行索引,沒有重復對象
- 用字符串(id:string)檢索對象(object)
今天整理以前寫的一些算法,翻出來一個hash表的實現,就貼出來,自己也溫習溫習。先看看頭文件,也就是數據結構的定義,相當於java中的接口的概念:
03 |
03 #define HASHSIZE 256 |
07 |
07 struct nlist *next; |
13 |
13 unsigned hash( char *s); |
14 |
14 struct nlist *lookup( char *s); |
15 |
15 struct nlist *install( char *name, char *defn); |
然后是具體實現:
01 |
01 #include <string.h> |
04 |
04 static struct nlist *hashtab[HASHSIZE]; |
06 |
06 unsigned hash( char *s) |
10 |
10 for (hashval = 0; *s != '\0' ;s++) |
11 |
11 hashval = *s + 31 * hashval; |
12 |
12 return hashval % HASHSIZE; |
15 |
15 struct nlist *lookup( char *s) |
19 |
19 for (np = hashtab[hash(s)]; np != NULL; np = np->next) |
20 |
20 if ( strcmp (s,np->name) == 0) |
25 |
25 struct nlist *install( char *name, char *defn) |
30 |
30 if ((np = lookup(name)) == NULL){ |
31 |
31 np = ( struct nlist *) malloc ( sizeof ( struct nlist)); |
32 |
32 if (np == NULL || (np->name = strdup(name)) == NULL) |
34 |
34 hashval = hash(name); |
35 |
35 np->next= hashtab[hashval]; |
36 |
36 hashtab[hashval] = np; |
38 |
38 free (( void *)np->defn); |
39 |
39 if ((np->defn = strdup(defn)) == NULL) |
很簡單,只有兩個外部接口,
- install(key, value),用來插入一個新的節點
- lookup(key),根據一個鍵來進行搜索,並返回節點
代碼很簡單,主要用到的hash算法跟java中的String的hashcode()方法中用到的算法一樣,使用:
1 |
1 unsigned hash( char *s) |
5 |
5 for (hashval = 0; *s != '\0' ;s++) |
6 |
6 hashval = *s + 31 * hashval; |
7 |
7 return hashval % HASHSIZE; |
這里的31並非隨意,乃是一個經驗值,選取它的目的在於減少沖突,當然,hash沖突這個問題是不能根本避免的。這里只是一個人們在測試中發現的可以相對減少hash沖突的一個數字,可能以后會發現更好的數值來。
一致性 hash 算法
consistent hashing 一致性 hash 算法早在 1997 年就在論文 Consistent hashing and random trees 中被提出,目前在 cache 系統中應用越來越廣泛。
基本場景
比如你有 N 個 cache 服務器(后面簡稱 cache ),那么如何將一個對象 object 映射到 N 個 cache 上呢,你很可能會采用類似下面的通用方法計算 object 的 hash 值,然后均勻的映射到到 N 個 cache:
hash(object)%N
一切都運行正常,再考慮如下的兩種情況:
- 一個 cache 服務器 m down 掉了(在實際應用中必須要考慮這種情況),這樣所有映射到 cache m 的對象都會失效,怎么辦,需要把 cache m 從 cache 中移除,這時候 cache 是 N-1 台,映射公式變成了 hash(object)%(N-1) ;
- 由於訪問加重,需要添加 cache ,這時候 cache 是 N+1 台,映射公式變成了 hash(object)%(N+1) ;
1 和 2 意味着什么?這意味着突然之間幾乎所有的 cache 都失效了。對於服務器而言,這是一場災難,洪水般的訪問都會直接沖向后台服務器;
再來考慮第三個問題,由於硬件能力越來越強,你可能想讓后面添加的節點多做點活,顯然上面的 hash 算法也做不到。有什么方法可以改變這個狀況呢,這就是 consistent hashing 一致性 hash 算法...
hash 算法和單調性
Hash 算法的一個衡量指標是單調性( Monotonicity ),定義如下:
單調性是指如果已經有一些內容通過哈希分派到了相應的緩沖中,又有新的緩沖加入到系統中。哈希的結果應能夠保證原有已分配的內容可以被映射到新的緩沖中去,而不會被映射到舊的緩沖集合中的其他緩沖區。
容易看到,上面的簡單 hash 算法 hash(object)%N 難以滿足單調性要求。
consistent hashing 算法的原理
consistent hashing 是一種 hash 算法,簡單的說,在移除 / 添加一個 cache 時,它能夠盡可能小的改變已存在 key 映射關系,盡可能的滿足單調性的要求。
下面就來按照 5 個步驟簡單講講 consistent hashing 算法的基本原理。
1. 環形hash 空間
考慮通常的 hash 算法都是將 value 映射到一個 32 為的 key 值,也即是 0~2^32-1 次方的數值空間;我們可以將這個空間想象成一個首( 0 )尾( 2^32-1 )相接的圓環,如下圖所示的那樣。
2. 把對象映射到hash 空間
接下來考慮 4 個對象 object1~object4 ,通過 hash 函數計算出的 hash 值 key 在環上的分布如下圖所示。
1 |
1 hash(object1) = key1; |
3 |
3 hash(object4) = key4; |
4 個對象的 key 值分布
3. 把cache 映射到hash 空間
Consistent hashing 的基本思想就是將對象和 cache 都映射到同一個 hash 數值空間中,並且使用相同的 hash 算法。假設當前有 A,B 和 C 共 3 台 cache ,那么其映射結果將如圖 3 所示,他們在 hash 空間中,以對應的 hash 值排列。
1 |
1 hash(cache A) = key A; |
3 |
3 hash(cache C) = key C; |
cache 和對象的 key 值分布
說到這里,順便提一下 cache 的 hash 計算,一般的方法可以使用 cache 機器的 IP 地址或者機器名作為 hash 輸入。
4. 把對象映射到cache
現在 cache 和對象都已經通過同一個 hash 算法映射到 hash 數值空間中了,接下來要考慮的就是如何將對象映射到 cache 上面了。
在這個環形空間中,如果沿着順時針方向從對象的 key 值出發,直到遇見一個 cache ,那么就將該對象存儲在這個 cache 上,因為對象和 cache 的 hash 值是固定的,因此這個 cache 必然是唯一和確定的。這樣不就找到了對象和 cache 的映射方法了嗎?!
依然繼續上面的例子(上圖),那么根據上面的方法:
- 對象 object1 將被存儲到 cache A 上;
- object2和 object3 對應到 cache C ;
- object4 對應到 cache B。
5. 考察cache 的變動
前面講過,通過 hash 然后求余的方法帶來的最大問題就在於不能滿足單調性,當 cache 有所變動時, cache 會失效,進而對后台服務器造成巨大的沖擊,現在就來分析分析 consistent hashing 算法。
考慮假設 cache B 掛掉了,根據上面講到的映射方法,這時受影響的將僅是那些沿 cache B 逆時針遍歷直到下一個 cache ( cache C )之間的對象,也即是本來映射到 cache B 上的那些對象。
因此這里僅需要變動對象 object4 ,將其重新映射到 cache C 上即可:
Cache B 被移除后的 cache 映射
再考慮添加一台新的 cache D 的情況,假設在這個環形 hash 空間中, cache D 被映射在對象 object2 和 object3 之間。這時受影響的將僅是那些沿 cache D 逆時針遍歷直到下一個 cache ( cache B )之間的對象(它們是也本來映射到 cache C 上對象的一部分),將這些對象重新映射到 cache D 上即可。
因此這里僅需要變動對象 object2 ,將其重新映射到 cache D 上:
添加 cache D 后的映射關系
虛擬節點
考量 Hash 算法的另一個指標是平衡性 (Balance) ,定義如下:
平衡性是指哈希的結果能夠盡可能分布到所有的緩沖中去,這樣可以使得所有的緩沖空間都得到利用。
hash 算法並不是保證絕對的平衡,如果 cache 較少的話,對象並不能被均勻的映射到 cache 上,比如在上面的例子中,僅部署 cache A 和 cache C 的情況下,在 4 個對象中, cache A 僅存儲了 object1 ,而 cache C 則存儲了 object2 、 object3 和 object4 ;分布是很不均衡的。
為了解決這種情況, consistent hashing 引入了“虛擬節點”的概念,它可以如下定義:
“虛擬節點”( virtual node )是實際節點在 hash 空間的復制品( replica ),一實際個節點對應了若干個“虛擬節點”,這個對應個數也成為“復制個數”,“虛擬節點”在 hash 空間中以 hash 值排列。
仍以僅部署 cache A 和 cache C 的情況為例,在前面 中我們已經看到, cache 分布並不均勻。現在我們引入虛擬節點,並設置“復制個數”為 2 ,這就意味着一共會存在 4 個“虛擬節點”, cache A1, cache A2 代表了 cache A ; cache C1, cache C2 代表了 cache C ;假設一種比較理想的情況,參見下圖 。
引入“虛擬節點”后的映射關系
此時,對象到“虛擬節點”的映射關系為:
因此對象 object1 和 object2 都被映射到了 cache A 上,而 object3 和 object4 映射到了 cache C 上;平衡性有了很大提高。引入“虛擬節點”后,映射關系就從 { 對象 -> 節點 } 轉換到了 { 對象 -> 虛擬節點 } 。查詢物體所在 cache 時的映射關系如圖 7 所示。
查詢對象所在 cache
“虛擬節點”的 hash 計算可以采用對應節點的 IP 地址加數字后綴的方式。例如假設 cache A 的 IP 地址為 202.168.14.241 。
引入“虛擬節點”前,計算 cache A 的 hash 值:Hash("202.168.14.241");
引入“虛擬節點”后,計算“虛擬節”點 cache A1 和 cache A2 的 hash 值:
1 |
1 Hash( "202.168.14.241#1" ); |
2 |
2 Hash( "202.168.14.241#2" ); |
小結
Consistent hashing 的基本原理就是這些,具體的分布性等理論分析應該是很復雜的,不過一般也用不到。
分布式哈希算法
我們從淺入深一步一步介紹什么是分布式哈希表。
哈希函數
哈希函數是一種計算方法,它可以把一個值A映射到一個特定的范圍[begin, end]之內。對於一個值的集合{k1, k2, … , kN},哈希函數把他們均勻的映射到某個范圍之中。這樣,通過這些值就可以很快的找到與之對應的映射地址{index1, index2, … , indexN}。對於同一個值,哈希函數要能保證對這個值的運算結果總是相同的。
哈希函數需要經過精心設計才能夠達到比較好的效果,但是總是無法達到理想的效果。多個值也許會映射到同樣的地址上。這樣就會產生沖突,如圖中的紅線所示。在設計哈希函數時要盡量減少沖突的產生。
最簡單的哈希函數就是一個求余運算: hash(A) = A % N。這樣就把A這個值映射到了[0~N-1]這樣一個范圍之中。
哈希表
哈希表的核心就是哈希函數hash()。
哈希表是一中數據結構,它把KEY 和 VALUE用某種方式對應起來。使用hash()函數把一個KEY值映射到一個index上,即hash(KEY) = index。這樣就可以把一個KEY值同某個index對應起來。然后把與這個KEY值對應的VALUE存儲到index所標記的存儲空間中。這樣,每次想要查找KEY所對應的VALUE值時,只需要做一次hash()運算就可以找到了。
舉個例子:圖書館中的書會被某人借走,這樣“書名”和“人名”之間就形成了KEY與VALUE的關系。假設現在有三個記錄:
這就是“書名”和“人名”的對應關系,它表示某人借了某本書。現在我們把這種對應關系用哈希表存儲起來,它們的hash()值分別為:
hash(簡明現代魔法) = 2 |
hash(最后一天) = 0 |
hash(變形記) = 1 |
然后我們就可以在一個表中存儲“人名”了:
這三個人名分別存儲在0、1和2號存儲空間中。當我們想要查找《簡明現代魔法》這本書是被誰借走的時候,只要hash()一下這個書名,就可以找到它所對應的index,為2。然后在這個表中就可以找到對應的人名了。在這里,KEY為“書名”, VALUE為“人名”。
當有大量的KEY VALUE對應關系的數據需要存儲時,這種方法就非常有效。
分布式哈希表
哈希表把所有的東西都存儲在一台機器上,當這台機器壞掉了之后,所存儲的東西就全部消失了。分布式哈希表可以把一整張哈希表分成若干個不同的部分,分別存儲在不同的機器上,這樣就降低了數據全部被損壞的風險。
分布式哈希表通常采用一致性哈希函數來對機器和數據進行統一運算。這里先不用深究一致性哈希究竟是什么,只需要知道它是對機器(通常是其IP地址)和數據(通常是其KEY值)進行統一的運算,把他們全都映射到一個地址空間中。假設有一個一致性哈希函數可以把一個值映射到32bit的地址空間中,從0一直到2^32 – 1。我們用一個圓環來表示這個地址空間。
假設有N台機器,那么hash()就會把這N台機器映射到這個環的N個地方。然后我們把整個地址空間進行一下划分,使每台機器控制一個范圍的地址空間。這樣,當我們向這個系統中添加數據的時候,首先使用hash()函數計算一下這個數據的index,然后找出它所對應的地址在環中屬於哪個地址范圍,我們就可以把這個數據放到相應的機器上。這樣,就把一個哈希表分布到了不同的機器上。如下圖所示:
這里藍色的圓點表示機器,紅色的圓點表示某個數據經過hash()計算后所得出的地址。
在這個圖中,按照逆時針方向,每個機器占據的地址范圍為從本機器開始一直到下一個機器為止。用順時針方向來看,每個機器所占據的地址范圍為這台機器之前的這一段地址空間。圖中的虛線表示數據會存儲在哪台機器上。
哈希表的工作原理與常用操作
哈希表(Hash Table)的應用近兩年才在NOI中出現,作為一種高效的數據結構,它正在競賽中發揮着越來越重要的作用。
哈希表最大的優點,就是把數據的存儲和查找消耗的時間大大降低,幾乎可以看成是常數時間;而代價僅僅是消耗比較多的內存。然而在當前可利用內存越來越多的情況下,用空間換時間的做法是值得的。另外,編碼比較容易也是它的特點之一。
哈希表又叫做散列表,分為“開散列” 和“閉散列”。考慮到競賽時多數人通常避免使用動態存儲結構,本文中的“哈希表”僅指“閉散列”,關於其他方面讀者可參閱其他書籍。
基礎操作
我們使用一個下標范圍比較大的數組來存儲元素。可以設計一個函數(哈希函數, 也叫做散列函數),使得每個元素的關鍵字都與一個函數值(即數組下標)相對應,於是用這個數組單元來存儲這個元素。也可以簡單的理解為,按照關鍵字為每一 個元素“分類”,然后將這個元素存儲在相應“類”所對應的地方。
但是,不能夠保證每個元素的關鍵字與函數值是一一對應的,因此極有可能出現對於不同的元素,卻計算出了相同的函數值,這樣就產生了“沖突”,換句話說,就是把不同的元素分在了相同的“類”之中。后面我們將看到一種解決“沖突”的簡便做法。
總的來說,“直接定址”與“解決沖突”是哈希表的兩大特點。
函數構造:構造函數的常用方法(下面為了敘述簡潔,設 h(k) 表示關鍵字為 k 的元素所對應的函數值):
- 除余法: 選擇一個適當的正整數 p ,令 h(k ) = k mod p ,這里, p 如果選取的是比較大的素數,效果比較好。而且此法非常容易實現,因此是最常用的方法。
- 數字選擇法: 如果關鍵字的位數比較多,超過長整型范圍而無法直接運算,可以選擇其中數字分布比較均勻的若干位,所組成的新的值作為關鍵字或者直接作為函數值。
沖突處理:線性重新散列技術易於實現且可以較好的達到目的。令數組元素個數為 S ,則當 h(k) 已經存儲了元素的時候,依次探查 (h(k)+i) mod S , i=1,2,3…… ,直到找到空的存儲單元為止(或者從頭到尾掃描一圈仍未發現空單元,這就是哈希表已經滿了,發生了錯誤。當然這是可以通過擴大數組范圍避免的)。
支持運算:哈希表支持的運算主要有:初始化(makenull)、哈希函數值的運算(h(x))、插入元素(insert)、查找元素(member)。 設插入的元素的關鍵字為 x ,A 為存儲的數組。 初始化比較容易,例如 :
1 |
1 const empty=maxlongint; |
哈希函數值的運算根據函數的不同而變化,例如除余法的一個例子:
1 |
1 function h(x:longint):Integer; |
我們注意到,插入和查找首先都需要對這個元素定位,即如果這個元素若存在,它應該存儲在什么位置,因此加入一個定位的函數 locate。
01 |
01 function locate(x:longint):integer; |
02 |
02 var orig,i:integer; |
06 |
06 while (i < S)and(A[(orig+i)mod S]<>x)and(A[(orig+i)mod S]<>empty) do |
10 |
10 locate:=(orig+i) mod S; |
插入元素:
1 |
1 procedure insert(x:longint); |
5 |
5 if A[posi]=empty then A[posi]:=x |
查找元素是否已經在表中:
1 |
1 procedure member(x:longint):boolean; |
5 |
5 if A[posi]=x then member:= true |
這些就是建立在哈希表上的常用基本運算。
當數據規模接近哈希表上界或者下界的時候,哈希表完全不能夠體現高效的特點,甚至還不如一般算法。但是如果規模在中央,它高效的特點可以充分體現。試驗表明當元素充滿哈希表的 90% 的時候,效率就已經開始明顯下降。這就給了我們提示:如果確定使用哈希表,應該盡量使數組開大,但對最太大的數組進行操作也比較費時間,需要找到一個平衡點。通常使它的容量至少是題目最大需求的 120% ,效果比較好(這個僅僅是經驗,沒有嚴格證明)。
應用舉例
什么時候適合應用哈希表呢?如果發現解決這個問題時經常要詢問:“某個元素是否在已知集合中?”,也就是需要高效的數據存儲和查找,則使用哈希表是最好不過的了!那么,在應用哈希表的過程中,值得注意的是什么呢?
哈希函數的設計很重要。一個不好的哈希函數,就是指造成很多沖突的情況,從前面的例子已經可以看出來,解決沖突會浪費掉大量時間,因此我們的目標就是盡力避免沖突。前面提到,在使用“除余法”的時候,h(k)=k mod p ,p 最好是一個大素數。這就是為了盡力避免沖突。為什么呢?假設 p=1000 ,則哈希函數分類的標准實際上就變成了按照末三位數分類,這樣最多1000類,沖突會很多。一般地說,如果 p 的約數越多,那么沖突的幾率就越大。
簡單的證明:假設 p 是一個有較多約數的數,同時在數據中存在 q 滿足 gcd(p,q)=d >1 ,即有 p=a*d , q=b*d, 則有 q mod p= q – p* [q div p] =q – p*[b div a] . ① 其中 [b div a ] 的取值范圍是不會超過 [0,b] 的正整數。也就是說, [b div a] 的值只有 b+1 種可能,而 p 是一個預先確定的數。因此 ① 式的值就只有 b+1 種可能了。這樣,雖然mod 運算之后的余數仍然在 [0,p-1] 內,但是它的取值僅限於 ① 可能取到的那些值。也就是說余數的分布變得不均勻了。容易看出, p 的約數越多,發生這種余數分布不均勻的情況就越頻繁,沖突的幾率越高。而素數的約數是最少的,因此我們選用大素數。記住“素數是我們的得力助手”。
另一方面,一味的追求低沖突率也不好。理論上,是可以設計出一個幾乎完美,幾乎沒有沖突的函數的。然而,這樣做顯然不值得,因為這樣的函數設計 很浪費時間而且編碼一定很復雜,與其花費這么大的精力去設計函數,還不如用一個雖然沖突多一些但是編碼簡單的函數。因此,函數還需要易於編碼,即易於實現。
綜上所述,設計一個好的哈希函數是很關鍵的。而“好”的標准,就是較低的沖突率和易於實現。
另外,使用哈希表並不是記住了前面的基本操作就能以不變應萬變的。有的時候,需要按照題目的要求對哈希表的結構作一些改進。往往一些簡單的改進就可以帶來巨大的方便。
這些只是一般原則,真正遇到試題的時候實際情況千變萬化,需要具體問題具體分析才行。