數據結構 Hash表(哈希表)--附C語言實現源碼

  在順序表查找時,如果要查找某個關鍵字的記錄,就是從表頭開始,挨個的比較記錄a[i]key的值是=還是, 直到有相等纔算是查找成功,返回i
  有序表查找時,我們可以利用a[i]key<>來折半查找,直到相等時,查找成功返回i。最終的目的都是爲了查找那個i,其實也就是相對應的下標,再通過順序存儲位置計算的方法,LOC(ai)=LOC(a1)+(i-1) * c,得到最終的內存地址。
 可以發現,爲了查找到結果,用順序表和有序表查找的方式,都會進行比較,這種比較是否真的有必要?能否通過關鍵字key得到要查找的記錄的存儲位置LOC(ai)?

一、哈希(Hash)的基本概念

  我們需要一種技術,使得不需要經過比較式的查找,就能獲得記錄的存儲位置。這種技術就是哈希技術,也稱作散列技術(哈希就是散列,哈希是音譯,散列是字譯)。
  哈希技術是在記錄的存儲位置和它的關鍵字之間建立一個確定的對應關係f,使得每個關鍵字key對應一個存儲位置f(key)。查找時,根據這個確定的對應關係找到給定值key的映射f(key),若查找集合中存在這個記錄,則必定在f(key)的位置上。
換成數學的表達方式就是
  存儲位置=f(關鍵字)
  即:LOC(ai)=f(key)
  這種對應關係f稱爲稱爲哈希(Hash) 函數
  採用哈希技術將記錄存儲在一塊連續的存儲空間中,這塊連續存儲空間稱爲哈希表(Hash table)。關鍵字對應的記錄存儲位置我們稱爲哈希地址
哈希表或者哈希技術最主要的用途就是查找與給定值相等的元素,具體來說是:查找與給定值相等的記錄。對於查找來說,簡化了比較過程(時間複雜度爲O(n)),降低時間複雜度,效率就會大大提高(時間複雜度僅爲O(1))。
  但萬事有利就有弊,哈希技術不具備很多常規數據結構的能力。同時,相當於犧牲空間換時間。本文暫時不討論哈希表的弊端。

二、哈希表查找

2.1哈希查找步驟概要

  (1)在存儲時,通過哈希函數計算記錄的哈希地址,並按此哈希地址存儲該記
錄。
  (2)當查找記錄時,我們通過同樣的哈希函數計算記錄的哈希地址,按此哈希地址訪問該記錄。
說起來很簡單,在哪存的,上哪去找,由於存取用的是同一個哈希函數,因此結果當然也是相同的。
由此可見,哈希技術既是一種存儲方法,也是一種查找方法
然而它與線性表、樹、圖等結構不同的是,前者,數據元素之間都存在某種邏輯關係,可以用連線圖示表示出來,而哈希技術的記錄之間不存在什麼邏輯關係,它只與關鍵字有關。

2.2 設計哈希函數

  同樣的關鍵字,它能對應很多記錄的情況,不適合用哈希技術。
  在理想的情況下,每一個關鍵字,通過哈希函數計算出來的地址都是不一樣的。但我們時常會碰到兩個關鍵字key1≠key2,但是卻有f(key) =f(key2), 這種現象我們稱爲衝突(collision), 並把key1, 和key2稱爲這個哈希函數的同義詞(synonym)。 可以通過精心設計的哈希函數讓衝突儘可能的少,但是不能完全避免。
設計哈希函數遵循以下原則:

  1. 計算簡單:降低比較的時間複雜度
  2. 哈希地址分佈均勻:減少衝突

2.2.1直接定址法

  取關鍵字的某個線性函數值爲哈希地址,即
f(key) = a × key + b (a、b爲常數)

  這樣的哈希函數優點就是簡單、均勻,也不會產生衝突。但是需要事先知道關鍵字的分佈情況,適合查找表較小且連續的情況。
  如:對某個指定的數組進行查找關鍵字。此時關鍵字就是數組元素的值,

2.2.2數字分析法

  如果我們的關鍵字是位數較多的數字,比如我們的11位手機號"130xxxx1234",其中前三位是接入號,一般對應不同運營商公司的子品牌,如130是聯通如意通、136 是移動神州行、153 是電信等;中間四位是HLR識別號,表示用戶號的歸屬地;後四位纔是真正的用戶號。如表2-2-1所示:

表2-2-1
重複分佈太集中的幾個數字 分佈均勻可以作爲哈希地址
130xxxx 1234
130xxxx 2345
138xxxx 4829
138xxxx 2396
138xxxx 8354

  若我們現在要存儲某家公司員工登記表,如果用手機號作爲關鍵字,那麼極有可能前7位都是相同的。那麼我們選擇後面的四位成爲哈希地址就是不錯的選擇。如果這樣的抽取工作還是容易出現衝突問題,還可以對抽取出來的數字再進行反轉(如1234改成4321)、右環位移(如1234改成4123)、左環位移、甚至前兩數與後兩數疊加(如1234改成12+34=46)等方法。總的目的就是爲了提供一個哈希函數,能夠合理地將關鍵字分配到哈希表的各位置。
   抽取方法是使用關鍵字的一部分來計算哈希存儲位置的方法,這在哈希函數中是常常用到的手段。
   數字分析法通常適合處理關鍵字位數比較大的情況,如果事先知道關鍵字的分佈且關鍵字的若干位分佈較均勻,就可以考慮用這個方法。

2.2.3平方取中法

 這個方法計算很簡單,假設關鍵字是1234,那麼它的平方就是1522756,再抽取中間的3位就是227, 用做哈希地址。再比如關鍵字是4321,那麼它的平方就是18671041,抽取中間的3位就可以是671,也可以是710,用做哈希地址。平方取中法比較適合於不知道關鍵字的分佈,而位數又不是很大的情況。

2.2.4摺疊法

  摺疊法是將關鍵字從左到右分割成位數相等的幾部分(注意最後一部分位數不夠時可以短些),然後將這幾部分疊加求和,並按哈希表表長,取後幾位作爲哈希地址。
  比如我們的關鍵字是9876543210,哈希表表長爲三位,我們將它分爲四組,987|654|321|0,然後將它們疊加求和987+654+321+0=1962,再求後3位得到哈希地址爲962。
  有時可能這還不能夠保證分佈均勻,不妨從一-端向 另一端來回摺疊後對齊相加。比如我們將987和321反轉,再與654和0相加,變成789+654+123+0=1566,此時哈希地址爲566。
摺疊法事先不需要知道關鍵字的分佈,適合關鍵字位數較多的情況。

2.2.5除留餘數法

  此方法爲最常用的構造哈希函數方法。對於哈希表長爲m的哈希函數公式爲:
f(key) = key mod p (p≤m)

  mod是取模(求餘數)的意思。事實上,這方法不僅可以對關鍵字直接取模,也可在摺疊、平方取中後再取模。

  很顯然,本方法的關鍵就在於選擇合適的p, p如果選得不好,就可能會容易產生同義詞。
例如表2-2-2,對於有12個記錄的關鍵字構造哈希表時,就用了f (key)=key mod 12的方法。比如29 mod 12=5,所以它存儲在下標爲5的位置。

表2-2-2
下標 0 1 2 3 4 5 6 7 8 9 10 11
關鍵字 12 25 38 15 16 29 78 67 56 21 22 47

不過這也是存在衝突的可能的,因爲12=2×6=3×4。如果關鍵字中有像18 (3×6)、30 (5×6)、42 (7×6)等數字,它們的餘數都爲6,這就和78所對應的下標位置衝突了。

甚至極端一些,對於表2-2-3的關鍵字,如果讓p爲12的話,就可能出現下面的情況,所有的關鍵字都得到了0這個地址數。

表2-2-3
下標 0 0 0 0 0 0 0 0 0 0 0 0
關鍵字 12 24 36 48 60 72 84 96 108 120 132 144

因此根據前輩們的經驗,若哈希表表長爲m,通常p爲小於或等於表長(最好接近m)的最小質數或不包含小於20質因子的合數。

2.2.5隨機數法

  選擇一個隨機數,取關鍵字的隨機函數值爲它的哈希地址。也就是f(key)=random (key)。 這裏random是隨機函數。當關鍵字的長度不等時,採用這個方法構造哈希函數是比較合適的。

2.3 處理哈希衝突的方法

2.3.1開放定址法

  所謂的開放定址法就是一旦發生了衝突,就去尋找下一個空的哈希地址,只要散
列表足夠大,空的哈希地址總能找到,並將記錄存入。
它的公式是:

f(key)=(f(key) +di) MOD m ( di=1,2…m-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}時, 都是沒有衝突的哈希地址,直接存入,如表2-3-1所示。

下標 0 1 2 3 4 5 6 7 8 9 10 11
關鍵字 12 25 16 67 56

  計算key=37時,發現f (37) =1,此時就與25所在的位置衝突。於是我們應用上面的公式f(37) = (f(37) +1) mod 12=2。於是將37存入下標爲2的位置。這其實就是房子被人買了於是買下一間的作法,如表2-3-2所示。

下標 0 1 2 3 4 5 6 7 8 9 10 11
關鍵字 12 25 37 16 67 56

  接下來22,29,15,47都沒有衝突,正常的存入,如表2-3-3所示。

下標 0 1 2 3 4 5 6 7 8 9 10 11
關鍵字 12 25 37 15 16 29 67 56 22 47

  到了key=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時,纔有空位,存入後如表2-3-4所示。

下標 0 1 2 3 4 5 6 7 8 9 10 11
關鍵字 12 25 37 15 16 29 48 67 56 22 47

  把種解衝突的開放定址法稱爲線性探測法。在解決衝突的時候,會碰到如48和37這種本來都不是同義詞卻需要爭奪一個地址的情況, 稱這種現象爲堆積。堆積的出現,使得我們需要不斷處理衝突,無論是存入還是查找效率都會大大降低。

  注意到,數組現在還差最後一個元素34沒有存入哈希表。

  key=34, f(key)=10, 與22所在的位置衝突,可是22後面沒有空位置了,反而它的前面有一個空位置, 儘管可以
不斷地求餘數後得到結果,但效率很差。因此我們可以改進d=1^2, -1^2, 2^2, -22…q2, -q^2, (q≤m/2),這樣就等於是可以雙向尋找到可能的空位置。對於34來說,我們取di=-1即可找到空位置了。

  增加平方運算的目的是爲了不讓關鍵字都聚集在某一塊區域。稱這種方法爲二次探測法

f(key)=(f(key) +di) MOD m (di=1^2, -1^2, 2^2, -22…q2, -q^2, q≤m/2)

這裏有個疑問,爲什麼q≤m/2,不是q≤m或者q ≤m^(1/2)向上取整

  還有一種方法是,在衝突時,對於位移量di採用隨機函數計算得到,稱之爲隨機探測法

f(key)=(f(key) +di) MOD m (di是一個隨機數列)

  此時一定有人問,既然是隨機,那麼查找的時候不也隨機生成嗎?如何可以獲得相同的地址呢?這是個問題。這裏的隨機其實是僞隨機數。僞隨機數是說,如果我們設置隨機種子相同,則不斷調用隨機函數可以生成不會重複的數列,我們在查找時,用同樣的隨機種子,它每次得到的數列是相同的,相同的d; 當然可以得到相同的哈希地址。

2.3.2再散列函數

  對於我們的哈希表來說,我們事先準備多個哈希函數。fi(key) =RHi (key) (i=1,2,…,k)這裏RH就是不同的哈希函數,可以把前面說的什麼除留餘數、摺疊、平方取中全部用上。每當發生哈希地址衝突時,就換一個哈希函數計算,相信總會有一個可以把衝突解決掉。這種方法能夠使得關鍵字不產生聚集,當然,相應地也增加了計算的時間。

2.3.3鏈地址法

  將所有關鍵字爲同義詞的記錄存儲在一一個 單鏈表中,我們稱這種表爲同義詞子表,在哈希表中只存儲所有同義詞子表的頭指針。對於關鍵字集合{12,67,56,16,25,37,22,29,15,47,48,34},我們用前面同樣的12爲除數,進行除留餘數法,可得到如圖2-3-5結構,此時,已經不存在什麼衝突換址的問題,無論有多少個衝突,都只是在當前位置給單鏈表增加結點的問題。圖片來源:《大話數據結構》Page.364.圖8-11-1
  鏈地址法對於可能會造成很多衝突的哈希函數來說,提供了絕不會出現找不到地址的保障。當然,這也就帶來了查找時需要遍歷單鏈表的性能損耗。

2.3.4公共溢出區法

  這個方法其實就更好理解,不就是衝突嗎?凡是衝突的都跟我走,我給你們這些衝突找個地兒待着。爲所有衝突的關鍵字建立了一個公共的溢出區來存放。
  就前面的例子而言,我們共有三個關鍵字{37,48,34}與之前的關鍵字位置有衝突,那麼就將它們存儲到溢出表中,如圖2-3-6所示。

基本表 0 1 2 3 4 5 6 7 8 9 10 11
\ 12 25 ^ 15 16 29 ^ 67 57 ^ 22 47
溢出表 0 1 2 3 4 5 6 7 8 9 10 11
\ 37 48 34

  在查找時,對給定值通過哈希函數計算初哈希地址後,先與基本表進行比對,如果相等則查找成功;如果不相等再到溢出表進行順序查找。

  相對於基本表而言,在有衝突數據很少的情況下,公共溢出區法的查找效率還是非常高的。

三、哈希表查找實現

代碼以C語言爲例。

3.1開放地址法

/* 頭文件和頭部聲明 */
#include <stdio.h>
#include <stdlib.h>

#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12
#define NULLKEY -32768
#define STATUS unsigned int
#define ELEMTYPE int
int M = 0; 		   /* 哈希表表長,全局變量 */
/* 定義哈希表結構體 */
typedef struct HashNode
{
  ELEMTYPE *elem;  /* 數據元素存放基址,動態分配數組 */
  int count;       /* 當前數據元素個數 */
}HashTable;
/* 初始哈希表 */
STATUS InitHashTable(HashTable *_hashTable)
{
  int ii;
  M = HASHSIZE;
  _hashTable->count = M;
  _hashTable->elem = (int *)malloc(sizeof(int) * M);

  for (ii=0; ii<M; ii++)
  {
    _hashTable->elem[ii] = NULLKEY;
  }
    
  return SUCCESS;
}
/* 定義哈希函數 */
int Hash(ELEMTYPE key)
{
  return key % M;                             /* 除留餘數法 */
}
/* 將關鍵字插入哈希表 */
void InsertHash(HashTable *_hashTable, ELEMTYPE key)
{
  int addr = Hash(key);                       /* 求哈希地址 */

  while (_hashTable->elem[addr] != NULLKEY)   /* 哈希表某個位置上的值不爲空,則衝突 */
  {
    addr = (addr+1) % M;                      /* 開放地址法的線性探測 */
  }

  _hashTable->elem[addr] = key;
}
/* 函數功能:利用哈希表查找關鍵字
 * 參數列表:
 * 1、h     初始化過的哈希表
 * 2、key   查找的關鍵字
 * 3、*addr 用於保存關鍵字位置的指針
 * 函數返回值:
 * SUCCESS:1  UNSUCCESS:0
*/
Status SearchHash(HashTable *h, int key, int *addr)
{
  *addr = Hash(key);

  while (h->elem[*addr] != key)                 /* 不匹配,則衝突 */
  {
    *addr = (*addr+1) % M;
    if (h->elem[*addr]==NULLKEY || *addr==Hash(key))
    {
      return UNSUCCESS;
    }
  }
    
  return SUCCESS;
}
/* 測試代碼 */
int main(void)
{
  int arr[12] = {12,67,56,16,25,37,22,29,15,47,48,34};
  HashTable hashTable;
  InitHashTable(&hashTable);

  int ii;
  for (ii=0; ii<12; ii++)
  {
    InsertHash(&hashTable,arr[ii]);
  }
  /* 查看初始化後的哈希表 */
  for (ii=0; ii<12; ii++)
  {
    printf(" %2d |",ii);
  }
  printf("\n------------------------------------------------------------\n");
  for (ii=0; ii<12; ii++)
  {
    printf(" %2d |",hashTable.elem[ii]);
  }
  printf("\n");

  /* 在哈希表中搜索關鍵字 69*/
  int addr = 0;
  int key = 69;
  if (SearchHash(&hashTable, key, &addr))
  {
    printf("找到關鍵字!位置在%d\n",addr);
  }else
  {
    printf("未找到關鍵字\n");
  }
    
    return 0;
}

運行結果:
在這裏插入圖片描述

四、參考文獻

  1. 程傑. 大話數據結構[M]. 清華大學出版社, 2011.
  2. 張乃孝, 陳光, 劉筠,等. 算法與數據結構——C語言描述[M]. 高等教育出版社, 2002.
  3. 王翠茹, 袁和金, 劉軍. 數據結構, C語言版[M]. 中國電力出版社, 2012.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章