哈希表的原理與實現

[轉自]:http://my.oschina.net/chape/blog/132533java

目錄[-]node

哈希表的原理與實現

一列鍵值對數據,存儲在一個table中,如何經過數據的關鍵字快速查找相應值呢?不要告訴我一個個拿出來比較key啊,呵呵。 算法

你們都知道,在全部的線性數據結構中,數組的定位速度最快,由於它可經過數組下標直接定位到相應的數組空間,就不須要一個個查找。而哈希表就是利用數組這個可以快速定位數據的結構解決以上的問題的。 數組

具體如何作呢?你們是否有注意到前面說的話:「數組能夠經過下標直接定位到相應的空間」,對就是這句,哈希表的作法其實很簡單,就是把Key經過一個固定的算法函數,既所謂的哈希函數轉換成一個整型數字,而後就將該數字對數組長度進行取餘,取餘結果就看成數組的下標,將value存儲在以該數字爲下標的數組空間裏,而當使用哈希表進行查詢的時候,就是再次使用哈希函數將key轉換爲對應的數組下標,並定位到該空間獲取value,如此一來,就能夠充分利用到數組的定位性能進行數據定位。服務器

不知道說到這裏,一些不瞭解的朋友是否大概瞭解了哈希表的原理,其實就是經過空間換取時間的作法。到這裏,可能有的朋友就會問,哈希函數對key進行轉換,取餘的值必定是惟一的嗎?這個固然不能保證,主要是因爲hashcode會對數組長度進行取餘,所以其結果因爲數組長度的限制必然會出現重複,因此就會有「衝突」這一問題,至於解決衝突的辦法其實有不少種,好比重複散列的方式,大概就是定位的空間已經存在value且key不一樣的話就從新進行哈希加一併求模數組元素個數,既 (h(k)+i) mod S , i=1,2,3…… ,直到找到空間爲止。還有其餘的方式你們若是有興趣的話能夠本身找找資料看看。 數據結構

Hash表這種數據結構在java中是原生的一個集合對象,在實際中用途極廣,主要有這麼幾個特色:dom

  1. 訪問速度快
  2. 大小不受限制
  3. 按鍵進行索引,沒有重複對象
  4. 用字符串(id:string)檢索對象(object)

今天整理之前寫的一些算法,翻出來一個hash表的實現,就貼出來,本身也溫習溫習。先看看頭文件,也就是數據結構的定義,至關於java中的接口的概念:分佈式

01 01  #include <stdio.h>
02 02  
03 03  #define    HASHSIZE 256
04 04  
05 05  //定義hash表中的節點的類型
06 06  struct    nlist{
07 07      struct    nlist    *next;
08 08      char    *name;
09 09      char    *defn;
10 10  };
11 11  
12 12  //定義接口中的函數,也就是對外來講,這個程序能夠作什麼
13 13  unsigned    hash(char *s);//計算一個串的hash值
14 14  struct    nlist    *lookup(char *s);//查找一個value,根據key
15 15  struct    nlist    *install(char *name,char *defn);//插入一個key=value的對象

而後是具體實現:函數

01 01  #include <string.h>
02 02  #include "list.h"
03 03  
04 04  static struct nlist *hashtab[HASHSIZE];
05 05  
06 06  unsigned    hash(char *s)   //取得hash值
07 07  {
08 08      unsigned    hashval;
09 09  
10 10      for(hashval = 0; *s != '\0';s++)
11 11              hashval = *s + 31 * hashval;
12 12      return hashval % HASHSIZE;
13 13  }
14 14  
15 15  struct    nlist    *lookup(char *s)
16 16  {
17 17      struct    nlist    *np;
18 18  
19 19      for(np = hashtab[hash(s)]; np != NULL; np = np->next)
20 20          if(strcmp(s,np->name) == 0)
21 21              return np;
22 22      return NULL;
23 23  }
24 24  
25 25  struct    nlist    *install(char *name,char *defn)
26 26  {
27 27      struct    nlist    *np;
28 28      unsigned    hashval;
29 29  
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)
33 33                  return NULL;
34 34          hashval = hash(name);
35 35          np->next= hashtab[hashval];
36 36          hashtab[hashval] = np;
37 37      }else
38 38          free((void *)np->defn);
39 39      if((np->defn = strdup(defn)) == NULL)
40 40              return NULL;
41 41      return np;
42 42  }

很簡單,只有兩個外部接口,性能

  1. install(key, value),用來插入一個新的節點
  2. lookup(key),根據一個鍵來進行搜索,並返回節點

代碼很簡單,主要用到的hash算法跟java中的String的hashcode()方法中用到的算法同樣,使用:

1 1   unsigned hash(char *s)
2 2   {
3 3       unsigned    hashval;
4 4   
5 5       for(hashval = 0; *s != '\0';s++)
6 6               hashval = *s + 31 * hashval;
7 7       return hashval % HASHSIZE;
8 8   }

這裏的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

一切都運行正常,再考慮以下的兩種狀況:

  1.  一個 cache 服務器 m down 掉了(在實際應用中必需要考慮這種狀況),這樣全部映射到 cache m 的對象都會失效,怎麼辦,須要把 cache m 從 cache 中移除,這時候 cache 是 N-1 臺,映射公式變成了 hash(object)%(N-1) ;
  2. 因爲訪問加劇,須要添加 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;
2 2   … …
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;
2 2   … …
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 ;假設一種比較理想的狀況,參見下圖 。

引入「虛擬節點」後的映射關係

此時,對象到「虛擬節點」的映射關係爲:

1 1   objec1->cache A2
2 2   objec2->cache A1
3 3   objec3->cache C1
4 4   objec4->cache C2 ;

所以對象 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");  // cache A1
2 2   Hash("202.168.14.241#2");  // cache A2

小結

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 小明

這三我的名分別存儲在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; // 用很是大的整數表明這個位置沒有存儲元素
2 2   p=9997; // 表的大小
3 3   procedure makenull;
4 4   var i:integer;
5 5   begin
6 6   for i:=0 to p-1 do
7 7   A[i]:=empty;
8 8   End; 

哈希函數值的運算根據函數的不一樣而變化,例如除餘法的一個例子:

1 1   function h(x:longint):Integer;
2 2   begin
3 3   h:= x mod p;
4 4   end;

咱們注意到,插入和查找首先都須要對這個元素定位,即若是這個元素若存在,它應該存儲在什麼位置,所以加入一個定位的函數 locate。

01 01  function locate(x:longint):integer;
02 02  var orig,i:integer;
03 03  begin
04 04  orig:=h(x);
05 05  i:=0;
06 06  while (i < S)and(A[(orig+i)mod S]<>x)and(A[(orig+i)mod S]<>empty) do
07 07  inc(i);
08 08  //當這個循環停下來時,要麼找到一個空的存儲單元,要麼找到這個元
09 09  //素存儲的單元,要麼表已經滿了
10 10  locate:=(orig+i) mod S;
11 11  end; 

插入元素:

1 1   procedure insert(x:longint);
2 2   var posi:integer;
3 3   begin
4 4   posi:=locate(x); //定位函數的返回值
5 5   if A[posi]=empty then A[posi]:=x
6 6   else error; //error 即爲發生了錯誤,固然這是能夠避免的
7 7   end; 

查找元素是否已經在表中:

1 1   procedure member(x:longint):boolean;
2 2   var posi:integer;
3 3   begin
4 4   posi:=locate(x);
5 5   if A[posi]=x then member:=true
6 6   else member:=false;
7 7   end;

這些就是創建在哈希表上的經常使用基本運算。

當數據規模接近哈希表上界或者下界的時候,哈希表徹底不可以體現高效的特色,甚至還不如通常算法。可是若是規模在中央,它高效的特色能夠充分體現。試驗代表當元素充滿哈希表的 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 的約數越多,發生這種餘數分佈不均勻的狀況就越頻繁,衝突的概率越高。而素數的約數是最少的,所以咱們選用大素數。記住「素數是咱們的得力助手」。 

另外一方面,一味的追求低衝突率也很差。理論上,是能夠設計出一個幾乎完美,幾乎沒有衝突的函數的。然而,這樣作顯然不值得,由於這樣的函數設計 很浪費時間並且編碼必定很複雜,與其花費這麼大的精力去設計函數,還不如用一個雖然衝突多一些可是編碼簡單的函數。所以,函數還須要易於編碼,即易於實現。 

綜上所述,設計一個好的哈希函數是很關鍵的。而「好」的標準,就是較低的衝突率和易於實現。 

另外,使用哈希表並非記住了前面的基本操做就能以不變應萬變的。有的時候,須要按照題目的要求對哈希表的結構做一些改進。每每一些簡單的改進就能夠帶來巨大的方便。 

這些只是通常原則,真正遇到試題的時候實際狀況變幻無窮,須要具體問題具體分析才行。

相關文章
相關標籤/搜索