資料結構:雜湊表(散列表)
轉自:http://blog.chinaunix.net/uid-26548237-id-3480645.html
一、散列表相關概念
雜湊技術是在記錄的儲存位置和它的關鍵字之間建立一個確定的對應關係f,使得每個關鍵字key對應一個儲存位置f(key)。
這裡把這種對應關係f稱為雜湊函式,又稱為雜湊(Hash)函式。按這個思想,採用雜湊技術將記錄存在在一塊連續的儲存空間中,這塊連續儲存空間稱為散列表或雜湊表。那麼,關鍵字對應的記錄儲存位置稱為雜湊地址。
雜湊技術最適合的求解問題是查詢與給定值相等的記錄。對於查詢來說,簡化了比較過程,效率會大大 提高。但是,雜湊技術部具備很多常規資料結構的能力,如
在理想的情況下,每一個關鍵字,通過雜湊函式計算出來的地址都是不一樣的,可現實中,這只是一個理想。市場會碰到兩個關鍵字key1 != key2,但是卻有f(key1) = f(key2),這種現象稱為衝突。出現衝突將會造成查詢錯誤,因此可以通過精心設計雜湊函式讓衝突儘可能的少,但是不能完全避免。
二、雜湊函式的構造方法
2.1 直接定址法
所謂直接定址法就是說,取關鍵字的某個線性函式值為雜湊地址,即
優點:簡單、均勻,也不會產生衝突。
缺點:需要事先知道關鍵字的分佈情況,適合查詢表較小且連續的情況。
由於這樣的限制,在現實應用中,此方法雖然簡單,但卻並不常用。
2.2 數字分析法
如果關鍵字時位數較多的數字,比如11位的手機號"130****1234",其中前三位是接入號;中間四位是HLR識別號,表示使用者號的歸屬地;後四為才是真正的使用者號。如下圖所示。
如果現在要儲存某家公司的登記表,若用手機號作為關鍵字,極有可能前7位都是相同的,選擇後四位成為雜湊地址就是不錯的選擇。若容易出現衝突,對抽取出來的數字再進行反轉、右環位移等。總的目的就是為了提供一個雜湊函式,能夠合理地將關鍵字分配到散列表的各個位置。
數字分析法通過適合處理關鍵字位數比較大的情況,如果事先知道關鍵字的分佈且關鍵字的若干位分佈比較均勻,就可以考慮用這個方法。
2.3 平方取中法
這個方法計算很簡單,假設關鍵字是1234,那麼它的平方就是1522756,再抽取中間的3位就是227,用做雜湊地址。
平方取中法比較適合不知道關鍵字的分佈,而位數又不是很大的情況。
2.4 摺疊法
摺疊法是將關鍵字從左到右分割成位數相等的幾部分(注意最後一部分位數不夠時可以短些),然後將這幾部分疊加求和,並按散列表表長,取後幾位作為雜湊地址。
比如關鍵字是9876543210,散列表表長為三位,將它分為四組,987|654|321|0,然後將它們疊加求和987 + 654 + 321 + 0 = 1962,再求後3位得到雜湊地址962。
摺疊法事先不需要知道關鍵字的分佈,適合關鍵字位數較多的情況。
2.5 除留餘數法
此方法為最常用的構造雜湊函式方法。對於散列表長為m的雜湊函式公式為:
mod是取模(求餘數)的意思。事實上,這方法不僅可以對關鍵字直接取模,也可以再摺疊、平方取中後再取模。
很顯然,本方法的關鍵在於選擇合適的p,p如果選不好,就可能會容易產生衝突。
根據前輩們的經驗,若散列表的表長為m,通常p為小於或等於表長(最好接近m)的最小質數或不包含小於20質因子的合數。
2.6 隨機數法
選擇一個隨機數,取關鍵字的隨機函式值為它的雜湊地址。也就是f(key) = random(key)。這裡random是隨機函式。當關鍵字的長度不等時,採用這個方法構造雜湊函式是比較合適的。
總之,現實中,應該視不同的情況採用不同的雜湊函式,這裡只能給出一些考慮的因素來提供參考:
(1)計算雜湊地址所需的時間
(2)關鍵字的長度;
(3)散列表的長度;
(4)關鍵字的分佈情況;
(5)記錄查詢的頻率。
綜合以上等因素,才能決策選擇哪種雜湊函式更合適。
三、處理雜湊衝突的方法
3.1 開放定址法
所謂的開放定址法就是一旦發生了衝突,就去尋找下一個空的雜湊地址,只要散列表足夠大,空的雜湊地址總能找到,並將記錄存入。
它的公式為:
比如說,關鍵字集合為{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34},表長為12。雜湊函式f(key) = key mod 12。
當計算前5個數{12, 67, 56, 16, 25}時,都是沒有衝突的雜湊地址,直接存入,如下表所示。
計算key = 37時,發現f(37) = 1,此時就與25所在的位置衝突。於是應用上面的公式f(37) = (f(37) + 1) mod 12 =2,。於是將37存入下標為2的位置。如下表所示。
接下來22,29,15,47都沒有衝突,正常的存入,如下標所示。
到了48,計算得到f(48) = 0,與12所在的0位置衝突了,不要緊,我們f(48) = (f(48) + 1) mod 12 = 1,此時又與25所在的位置衝突。於是f(48) = (f(48) + 2) mod 12 = 2,還是衝突......一直到f(48) = (f(48) + 6) mod 12 = 6時,才有空位,如下表所示。
把這種解決衝突的開放定址法稱為線性探測法。
考慮深一步,如果發生這樣的情況,當最後一個key = 34,f(key) = 10,與22所在的位置衝突,可是22後面沒有空位置了,反而它的前面有一個空位置,儘管可以不斷地求餘後得到結果,但效率很差。因此可以改進di=12,
-12,
22,
-22.........q2,
-q2(q<=
m/2),這樣就等於是可以雙向尋找到可能的空位置。對於34來說,取di =
-1即可找到空位置了。另外,增加平方運算的目的是為了不讓關鍵字都聚集在某一塊區域。稱這種方法為二次探測法。
還有一種方法,在衝突時,對於位移量di採用隨機函式計算得到,稱之為隨機探測法。
既然是隨機,那麼查詢的時候不也隨機生成di嗎?如何取得相同的地址呢?這裡的隨機其實是偽隨機數。偽隨機數就是說,如果設定隨機種子相同,則不斷呼叫隨機函式可以生成不會重複的數列,在查詢時,用同樣的隨機種子,它每次得到的數列是想通的,相同的di當然可以得到相同的雜湊地址。
總之,開放定址法只要在散列表未填滿時,總是能找到不發生衝突的地址,是常用的解決衝突的方法。
3.2 再雜湊函式法
對於散列表來說,可以事先準備多個雜湊函式。
這裡RHi 就是不同的雜湊函式,可以把前面說的除留餘數、摺疊、平方取中全部用上。每當發生雜湊地址衝突時,就換一個雜湊函式計算。
這種方法能夠使得關鍵字不產生聚集,但相應地也增加了計算的時間。
3.3 鏈地址法
將所有關鍵字為同義詞的記錄儲存在一個單鏈表中,稱這種表為同義詞子表,在散列表中只儲存所有同義詞子表前面的指標。對於關鍵字集合{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34},用前面同樣的12為餘數,進行除留餘數法,可以得到下圖結構。
此時,已經不存在什麼衝突換地址的問題,無論有多少個衝突,都只是在當前位置給單鏈表增加結點的問題。
鏈地址法對於可能會造成很多衝突的雜湊函式來說,提供了絕不會出現找不到地址的保證。當然,這也就帶來了查詢時需要遍歷單鏈表的效能損耗。
3.4 公共溢位區法
這個方法其實更好理解,你衝突是吧?那重新給你找個地址。為所有衝突的關鍵字建立一個公共的溢位區來存放。
就前面的例子而言,共有三個關鍵字37、48、34與之前的關鍵字位置有衝突,那就將它們儲存到溢位表中。如下圖所示。
在查詢時,對給定值通過雜湊函式計算出雜湊地址後,先與基本表的相應位置進行比對,如果相等,則查詢成功;如果不相等,則到溢位表中進行順序查詢。如果相對於基本表而言,有衝突的資料很少的情況下,公共溢位區的結構對查詢效能來說還是非常高的。
四、散列表查詢實現
#include <stdio.h>
#include <stdlib.h>
#define OK 1
#define ERROR 0
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //定義散列表表未陣列的長度
#define NULLKEY -32768
typedef struct
{
int *elem; //資料元素儲存基地址,動態分配陣列
int count; //當前資料元素個數
}HashTable;
int m = 0; //散列表長,全域性變數
//初始化散列表
int InitHashTable(HashTable *h)
{
int i;
m = HASHSIZE;
h->elem = (int *)malloc(sizeof(int) * m );
if(h->elem == NULL)
{
fprintf(stderr, "malloc() error.\\n");
return ERROR;
}
for(i = 0; i < m; i++)
{
h->elem[i] = NULLKEY;
}
return OK;
}
//雜湊函式
int Hash(int key)
{
return key % m; //除留餘數法
}
//插入關鍵字進散列表
void InsertHash(HashTable *h, int key)
{
int addr = Hash(key); //求雜湊地址
while(h->elem[addr] != NULLKEY) //如果不為空,則衝突
{
addr = (addr + 1) % m; //開放地址法的線性探測
}
h->elem[addr] = key; //直到有空位後插入關鍵字
}
//散列表查詢關鍵字
int SearchHash(HashTable h, int key)
{
int addr = Hash(key); //求雜湊地址
while(h.elem[addr] != key) //如果不為空,則衝突
{
addr = (addr + 1) % m; //開放地址法的線性探測
if(h.elem[addr] == NULLKEY || addr == Hash(key))
{
//如果迴圈回原點
printf("查詢失敗, %d 不在Hash表中.\\n", key);
return UNSUCCESS;
}
}
printf("查詢成功,%d 在Hash表第 %d 個位置.\\n", key, addr);
return SUCCESS;
}
int main(int argc, char **argv)
{
int i = 0;
int num = 0;
HashTable h;
//初始化Hash表
InitHashTable(&h);
//未插入資料之前,列印Hash表
printf("未插入資料之前,Hash表中內容為:\\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\\n");
//插入資料
printf("現在插入資料,請輸入(A代表結束哦).\\n");
while(scanf("%d", &i) == 1 && num <HASHSIZE)
{
if(i == 'A')
{
break;
}
num++;
InsertHash(&h, i);
}
if(num > HASHSIZE)
{
printf("插入資料超過Hash表大小\\n");
return ERROR;
}
//列印插入資料後Hash表的內容
printf("插入資料後Hash表的內容為:\\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\\n");
printf("現在進行查詢.\\n");
SearchHash(h, 12);
SearchHash(h, 100);
return 0;
}
五、散列表的效能分析
如果沒有衝突,雜湊查詢是所介紹過的查詢中效率最高的。因為它的時間複雜度為O(1)。但是,沒有衝突的雜湊只是一種理想,在實際應用中,衝突是不可避免的。
那雜湊查詢的平均查詢長度取決於哪些因素呢?
(1)雜湊函式是否均勻
雜湊函式的好壞直接影響著出現衝突的頻繁程度,但是,不同的雜湊函式對同一組隨機的關鍵字,產生衝突的可能性是相同的(為什麼??),因此,可以不考慮它對平均查詢長度的影響。
(2)處理衝突的方法
相同的關鍵字、相同的雜湊函式,但處理衝突的方法不同,會使得平均查詢長度不同。如線性探測處理衝突可能會產生堆積,顯然就沒有二次探測好,而鏈地址法處理衝突不會產生任何堆積,因而具有更好的平均查詢效能。
(3)散列表的裝填因子
所謂的裝填因子a = 填入表中的記錄個數/散列表長度。a標誌著散列表的裝滿的程度。當填入的記錄越多,a就越大,產生衝突的可能性就越大。也就說,散列表的平均查詢長度取決於裝填因子,而不是取決於查詢集合中的記錄個數。
不管記錄個數n有多大,總可以選擇一個合適的裝填因子以便將平均查詢長度限定在一個範圍之內,此時散列表的查詢時間複雜度就是O(1)了。為了這個目標,通常將散列表的空間設定的比查詢表集合大。