散列表只支持二叉查找樹所容許的一部分操做。散列是一種用於以常數平均時間執行插入、刪除和查找的技術。可是,那些須要元素間任何排序信息的操做將不會獲得有效的支持,例如FindMin、FindMax以及以線性時間將排過序的整個表進行打印的操做都是散列所不支持的html
5.2 散列函數
- 關鍵字是整數:保證表的大小爲素數。直接返回Key mod TableSize
- 關鍵字是字符串:根據horner法則,計算一個(32的)多項式函數。
Index
Hash(const char *Key, int TableSize)
{
unsigned int HashVal = 0;
while (*Key != '\0')
//HashVal = (HashVal << 5) + *Key++;
HashVal = (HashVal << 5) ^ *Key++;
return HashVal % TableSize;
}
若是關鍵字特別長,那麼散列函數計算起來會花過多的時間,並且前面的字符還會左移出最終的結果。所以這樣狀況下,不使用全部的字符。此時關鍵字的長度和性質會影響選擇。例如只取奇數位置上的字符來實現散列函數。這裏的思想是用計算散列函數省下來的時間來補償由此產生的對均勻分佈函數的輕微干擾
。前端
- 當一個元素插入的位置已經存在另外一個元素的時候(散列值相同),就叫作衝突。下面介紹解決衝突的兩種方法:分離連接法和開放定址法。
5.3 分離連接法(separate chaining)
- 將散列到同一個值的全部元素保留到一個表中。好比鏈表。爲方便起見,這些表都有表頭;若是空間很緊的話,則能夠不用表頭。
- 執行Find:首先根據散列函數判斷該遍歷哪一個表,而後遍歷鏈表返回元素位置
- 執行Insert: 首先根據散列函數判斷該插入哪一個表,而後插入元素到鏈表中。若是要插入重複元,那麼一般要留出一個額外的域,這個域當重複元出現時增1.一般將元素插入到表的前端,由於新元素最有可能被最早訪問
5.3.1 實現
- 節點定義: 這裏使用了typedef,避免雙重指針的混亂
#define MINTABLESIZE 11
struct HashTbl;
typedef struct HashTbl *HashTable;
typedef Stack List;
struct HashTbl
{
int TableSize;
List *TheLists;
};
HashTable
InitializeTable(int TableSize)
{
if (TableSize < MINTABLESIZE) {
printf("TableSize too small\n");
return NULL;
}
HashTable H = NULL;
H = (HashTable)malloc(sizeof(struct HashTbl));
if (H == NULL) {
printf("HashTable malloc failed\n");
return NULL;
}
memset(H, 0, sizeof(struct HashTbl));
H->TableSize = GetNextPrime(TableSize);
H->TheLists = (List *)malloc(sizeof(List) * H->TableSize);
if (H->TheLists == NULL) {
printf("HashTable TheLists malloc failed\n");
free(H);
H = NULL;
return NULL;
}
memset(H->TheLists, 0, sizeof(List) * H->TableSize);
int cnt, cnt2;
for (cnt = 0; cnt < H->TableSize; cnt++) {
H->TheLists[cnt] = CreateStack();
if (H->TheLists[cnt] == NULL) {
printf("H->TheLists[%d]malloc failed\n", cnt);
for (cnt2 = 0; cnt2 < cnt; cnt2++) {
if (H->TheLists[cnt2] != NULL) {
DistroyStack(H->TheLists[cnt2]);
H->TheLists[cnt2] = NULL;
}
}
if (H->TheLists != NULL) {
free(H->TheLists);
H->TheLists = NULL;
}
if (H != NULL) {
free(H);
H = NULL;
}
return NULL;
}
}
return H;
}
PtrToNode
Find(ElementType Key, HashTable H)
{
if (H == NULL) {
printf("ERROR: H is NULL\n");
return NULL;
}
PtrToNode tmp = NULL;
tmp = H->TheLists[GetHashSubmit(Key, H->TableSize)]->Next;
while (tmp != NULL && tmp->Element != Key) {
tmp = tmp->Next;
}
return tmp;
}
void
Insert(ElementType Key, HashTable H)
{
if (H == NULL) {
printf("HashTable is NULL\n");
return;
}
if (0 != Push(Key, H->TheLists[GetHashSubmit(Key, H->TableSize)])) {
printf("Insert Key failed\n");
}
}
- 散列表的裝填因子爲散列表的元素個數與散列表大小的比值。
- 執行一次查找所需時間是計算散列函數值所須要的常數事件加上遍歷表(list)所用的事件。不成功的查找,也就是遍歷整個鏈表長度。成功的查找則須要遍歷大約1+鏈表長度/2.
- 裝填因子是最重要的。通常法則是使得表的大小盡可能與預料的元素個數差很少,也就是讓裝填因子約等於1.
- 同時,使表的大小是一個素數以保證一個好的分佈,這也是一個好的想法
5.4 開放定址法(Open addressing hashing)
- 因爲分離連接法插入時須要申請內存空間,所以算法速度有些減慢
- 若有衝突發生,那麼就要嘗試選擇另外的單元,直到找出空的單元爲止。更通常的,單元h0(x), h1(x), h2(x),相繼被試選,其中hi(x) = (Hash(x) + F(i)) mod TableSize, 且F(0) = 0。函數F是衝突解決方法
- 由於全部的數據都要置於表內,因此開放定址散列法所須要的表比分離連接散列表大。通常說來,對開放定址散列算法來講,裝填因子應該低於0.5。
- 下面來考察三個一般的衝突解決方法
5.4.1 線性探測法
- 典型情形:F(i) = i。只要想插入的單元已經有元素,就繼續遍歷到下一個單元,直到找到空的單元插入爲止(解決衝突)。這樣花費的時間不少,並且即便表相對較空。這樣佔據的單元會開始造成一些區塊,其結果成爲一次彙集。因而,散列到區塊中的任何關鍵字都須要屢次試選單元才能解決衝突,而後該關鍵字被添加到相應的區塊中
- 插入 & 不成功的查找的預期探測次數大約都爲1/2 (1 + 1/(1 - 裝填因子)^2);
- 對於成功的查找來講,則是1/2(1 + 1/(1 - 裝填因子))。能夠看出成功查找應該比不成功查找平均花費較少的時間
- 空單元所佔份額爲1 - 裝填因子。所以預計要探測的單元數爲1 / (1 - 裝填因子)
- 一個元素被插入時,能夠當作是一次不成功查找的結果,所以可使用一次不成查找的開銷來計算一次成功查找的平均開銷
5.4.2 平方探測法
- 平方探測就是衝突函數爲二次函數的探測方法。典型是F(i) = i2。產生衝突時,先尋找當前單元的下20 = 1個單元,若是仍是衝突,則尋找當前單元的下2^2 = 4個單元,直到找到空單元爲止。
- 對於線性探測,讓元素幾乎填滿列表並非個好主意,由於表的性能會降低的厲害。而對於平方探測法,一旦表被填滿超過一半,當表的大小不是素數時甚至在表被填滿一半以前,就不能保證一次找到一個空單元了。這是由於最多有表的一半能夠用做解決衝突的被選位置
- 定理5.1:若是使用平方探測,且表的大小是素數,那麼當表至少有一半是空的時候,總可以插入一個新的元素。
證實:
令表的大小TableSize是一個大於3的素數。咱們證實,前[TableSize / 2]個備選位置是互異的。
h(X) + i^2(mod TableSize)和h(X) + j^2(mod TableSize)是這些位置中的兩個,其中0 < i, j <= [TableSize / 2]。爲推出矛盾,假設這兩個位置相同,但i != j,因而
1) h(X) + i^2 = h(X) + j^2 (mod TableSize)
2) i^2 - j^2 = 0
3) (i + j)(i - j) = 0
因此i = -j或者i = j,由於i != j,且i,j都大於0,因此前[TableSize / 2]個備選位置是互異的
- 因爲要被插入的元素,若無任何衝突發生,也能夠放到經散列獲得的單元,所以任何元素都有[TableSize / 2]個可能被放到的位置,若是最多有[TableSie / 2]個位置可使用,那麼空單元總可以找到
- 哪怕表有比一半多一個的位置被填滿,那麼插入都有可能失敗
- 表的大小是素數也很是重要,若是表的大小不是素數,則備選單元的個數也可能銳減
- 在開放定址散列表中,標準的刪除操做不能實行。由於相應的單元可能已經引發過沖突,元素繞過了它存在了別處。所以,開放定址散列表須要懶惰刪除。
- 雖然平方探測排除了一次彙集,可是散列到同一位置上的那些元素將探測相同的備選單元,這叫作二次彙集。對於每次查找,它通常要引發另外的少於一半的探測,所以可使用雙散列,經過一些額外的乘法和除法解決這個問題
5.4.3 雙散列
- F(i) = i * hash2(X)。將第二個散列函數應用到X並在距離hash2(X),2hash2(X)等處探測。hash2(X)選擇的很差將會是災難性的
- 保證全部的單元都能被探測到
- hash2(X) = R - (X mod R)這樣的函數將起到良好的做用;R爲小於TableSize的素數。舉例:hash2(49) = 7 - 0 = 7,若是位置9產生衝突,則9 + 7 - 10 = 6,看位置6是否產生衝突,若是仍然衝突,則 6 + 7 - 10 = 3,若是位置3沒有衝突則插入位置3.
- 若是散列表的大小不是素數,那麼備選單元就有可能提早用完。若是雙散列正確實現,則預期的探測次數幾乎和隨機衝突解決方法的情形相同,這使得雙散列理論上頗有吸引力,不過平方探測不須要使用第二個散列函數,從而在時間上可能更簡單而且更快
5.5 再散列
- 對於使用平方探測的開放定址散列法,若是表的元素填的太慢,那麼操做時間將會消耗過長,且Insert操做可能失敗。一種解決辦法是創建另一個大約兩倍大的表,並且使用一個相關的新散列函數。掃描整個原始散列表,計算每一個未刪除的元素的新散列值並將其插入到新表中
- 若是再散列是程序的一部分,那麼其效果是不顯著的,可是若是它做爲交互系統的一部分運行,那麼其插入引發的再散列的用戶就會感到速度緩慢
- 實現方法:
- 只要表填滿一半就再散列
- 只有插入失敗時纔再散列
- 當表達到某一個裝填因子時就再散列
- 再散列把程序員從表的大小的擔憂中解放出來,再散列還能用在其餘數據結構中,例如隊列變滿時,能夠聲明一個雙倍大小的數組,並將每個成員拷貝過來同時釋放原來的隊列
HashTable
ReHash(HashTable H)
{
if (H == NULL) {
printf("H is NULL!\n");
return NULL;
}
int cnt;
int OldTableSize = H->TableSize;
Cell *OldCells = H->TheCells;
HashTable newTable = InitializeTable(2 * OldTableSize);
for (cnt = 0; cnt < OldTableSize; cnt++) {
if (OldCells[cnt].Info == Legitimate) {
Insert(newTable, OldCells[cnt].Element);
}
}
DestroyTable(H);
return newTable;
}
5.6 可擴散列
- 若是數據量太大以致於裝不進主存,能夠考慮使用可擴散列。根據上一節的描述,若是表變得過滿就要執行再散列,這樣代價巨大,由於它須要O(N)次磁盤訪問。而可擴散列容許兩次磁盤訪問執行一次Find,插入操做也須要不多的磁盤訪問
- 目錄中的項數爲2^D,dL爲樹葉L全部元素共有的最高位的位數,dL將依賴於特定的樹葉,所以dL <= D
- 若是樹葉中的元素滿了,即 = M,這時再插入就會分裂成兩片樹葉,目錄也會更新大小。
- 有可能一片樹葉中的元素有多餘D + 1個前導位相同時,須要多個目錄分裂
- 存在重複關鍵字的可能性,若存在多於M個重複關鍵字,則該蘇納法根本無效,此時須要作出其餘的安排
- 這些比特徹底隨機是至關重要的,能夠經過把這些關鍵字散列到合理長的整數來完成
- 可擴散列的特性:基於合理假設即「位模式是均勻分佈的」。
- 樹葉的指望個數爲(N/M)log(2)e,所以平均樹葉滿的程度爲ln2 = 0.69。這和B樹是同樣的
- 目錄的指望大小即2^D, 爲O(N^(1+1/M)M),若是M很小,那麼目錄可能過度的大。這種狀況下,咱們可讓樹葉包含指向記錄的指針而不是實際的記錄,這樣能夠增長M的值,爲了維持更小的目錄,能夠把第二個磁盤訪問添加到每一個Find操做中去,若是目錄太大裝不進主存,那麼第二個磁盤訪問怎麼說也仍是須要的
總結
- 散列表能夠在常數平均時間實現Insert和Find操做
- 使用散列表時,設置裝填因子特別重要,不然時間界將再也不有效
- 當關鍵字不是短串或是整數時,仔細選擇散列函數也是很重要的
- 對於分離鏈接散列法,雖然裝填因子比較小時性能不明顯下降,可是裝填因子仍是應該接近1
- 對於開放定址散列法,除非徹底不可避免,不然裝填因子不該該超過0.5。若是使用線性探測,那麼性能隨着裝填因子接近於1而急速降低。再散列運算能夠經過使表增加或收縮來實現,這樣將會保持合理的裝填因子。對於空間緊缺而且不可能聲明巨大散列表的狀況,這是很重要的
- 二叉查找樹能夠用來實現Insert & Find。雖然平均時間界爲O(logN),可是二叉查找樹也支持那些須要序的例程從而更實用。使用散列表不可能找出最小元素。除非準確知道一個字符串,不然散列表也不可能有效的查找它。而二叉查找樹能夠迅速找到在必定範圍內的全部項,散列表是作不到的。不只如此,O(logN)並不比O(1)大那麼多,特別是由於查找樹不須要乘法和除法
- 散列的最壞狀況通常來自於實現的缺憾,而有序的輸入卻可能使二叉樹運行的不好。平衡查找樹實現的代價很高。所以,若是不須要序的信息以及對輸入是否被排序有懷疑,那麼就應該選擇散列這種數據結構
- 散列的應用:
- 編譯器使用散列表跟蹤源代碼中聲明的變量,即符號表。標識符通常都不長,所以其散列函數可以迅速被算出
- 圖論問題中節點有實際的名字而不是數字。並且輸入極可能是一組一組依字母順序排列的項。若是使用查找樹則在效率方面可能會很低
- 遊戲中的變換表
- 在線拼寫檢驗程序
參考文獻
- Mark Allen Weiss.數據結構與算法分析[M].America, 2007
本文做者: CrazyCatJackgit
本文連接: https://www.cnblogs.com/CrazyCatJack/p/13340018.html程序員
版權聲明:本博客全部文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!算法
關注博主:若是您以爲該文章對您有幫助,能夠點擊文章右下角推薦一下,您的支持將成爲我最大的動力!數組