一、散列表相關概念
散列技術是在記錄的存儲位置和它的關鍵字之間建立一個確定的對應關系f,使得每個關鍵字key對應一個存儲位置f(key)。建立了關鍵字與存儲位置的映射關系,公式如下:
存儲位置 = f(關鍵字)
這里把這種對應關系f稱為散列函數,又稱為哈希(Hash)函數。詳情見:Java中hashCode的作用。
采用散列技術將記錄存在在一塊連續的存儲空間中,這塊連續存儲空間稱為散列表或哈希表。那么,關鍵字對應的記錄存儲位置稱為散列地址。
散列技術既是一種存儲方法也是一種查找方法。散列技術的記錄之間不存在什么邏輯關系,它只與關鍵字有關,因此,散列主要是面向查找的存儲結構。
二、散列函數的構造方法
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)記錄查找的頻率。
綜合以上等因素,才能決策選擇哪種散列函數更合適。
三、處理散列沖突的方法
在理想的情況下,每一個關鍵字,通過散列函數計算出來的地址都是不一樣的,可現實中,這只是一個理想。市場會碰到兩個關鍵字key1 != key2,但是卻有f(key1) = f(key2),這種現象稱為沖突。出現沖突將會造成查找錯誤,因此可以通過精心設計散列函數讓沖突盡可能的少,但是不能完全避免。
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)了。為了這個目標,通常將散列表的空間設置的比查找表集合大。
六、散列表的適應范圍
散列技術最適合的求解問題是查找與給定值相等的記錄。對於查找來說,簡化了比較過程,效率會大大提高。
但是,散列技術不具備很多常規數據結構的能力,比如
- 同樣的關鍵字,對應很多記錄的情況,不適合用散列技術;
- 散列表也不適合范圍查找等等。
引用:http://blog.chinaunix.net/uid-26548237-id-3480645.html