哈希算法簡介
1. 常見的哈希算法
2. 碰撞與溢出問題的處理
3. 哈希表的動態擴容
哈希算法簡介
哈希算法又稱為散列法,任何通過哈希查找的數據都不需要經過事先的排序,也就是說這種查找可以直接且快速地找到鍵值(Key,訪問數據)所存放的地址。
通常判斷一個查找算法的好壞主要由其比較次數及查找所需時間來判斷,一般的查找技巧主要是通過各種不同的比較方法來查找所要的數據,反觀哈希算法則是直接通過數學函數來獲取對應的存放地址,因此可以快速地找到所要的數據。
哈希算法還具有保密性高的特點,因為不事先知道哈希函數就無法查找到數據。信息技術上有許多哈希法的應用,特別是在數據壓縮與加解密方面。
常見的哈希算法有除留余數法、平方取中法、折疊法、數字分析法以及鏈地址法。哈希算法並沒有一定的規則可循,可能是其中的某一種方法,也可能同時使用好幾種方法。
原理
- 哈希算法使用哈希函數(散列函數)來計算一個鍵值(訪問數據)所對應的地址(哈希值),進而建立哈希表(也叫散列表,是通過鍵值直接訪問數據的一種數據結構)。
- 之后每次通過鍵值和哈希函數,就可以直接獲得訪問數據的地址,實現 O(1) 的數據訪問效率。
哈希算法的查找速度與數據多少無關,在沒有碰撞和溢出的情況下,一次讀取即可完成。
設計原則
選擇哈希函數時,要特別注意不宜過於復雜,設計原則上至少必須符合計算速度快與碰撞頻率盡量低的兩個特點。
1. 常見的哈希算法
1.1 除留余數法
最簡單的哈希函數是將數據值(key)除以某一個常數后,取余數來當索引(哈希值)。可以用以下式子來表示:
h(key) = key mod B
一般而言,B(即常數)最好是質數。
例如,在一個有 13 個位置的數組中,只使用到 7 個地址,值分別是 12、65、70、99、33、67、48。我們可以把數據內的值除以 13,並以其余數來當數組的下標(作為索引)。在這個例子中,我們所使用的 B 即為 13,則 h(12) = 12、h(65) = 0、...,其所建立出來的哈希表為:
索引 | 數據 |
0 | 65 |
1 | |
2 | 67 |
3 | |
4 | |
5 | 70 |
6 | |
7 | 44 |
8 | 99 |
9 | 48 |
10 | |
11 | |
12 | 12 |
1.2 平方取中法
平方取中法和除留余數法相當類似,就是先計算數據的平方,之后再取中間的某段數字作為索引。
如下例所示,我們用平方取中法,並將數據存放在 100 個地址空間中,其操作步驟如下:
1)將 12、65、70、99、33、67、51 平方后如下:
144、4225、4900、9801、1089、4489、2601
2)再取百位數和十位數作為索引,分別為:
14、22、90、80、08、48、60
上述這7個數字的數列就對應於原先的 7 個數 12、65、70、99、33、67、51 存放在 100 個地址空間的索引值,即:
f(14) = 12 f(22) = 65 f(90) = 70 f(80) = 99 f(08) = 33 f(40) = 67 f(60) = 51
若實際空間介於 0~9(10 個空間),但取百位數和十位數的值介於 0~99(共 100 個空間),則必須將平方取中法第一次所求得的索引值再壓縮 1/10 才可以將 100 個可能產生的索引值對應到 10 個空間,即將每一個索引值除以 10 取整數(下例我們以 DIV 運算符作為取整數的除法),我們可以得到下列的對應關系:
f(14 DIV 10) = 12 f(1) = 12 f(22 DIV 10) = 65 f(2) = 65 f(90 DIV 10) = 70 f(9) = 70 f(80 DIV 10) = 99 ——> f(8) = 99 f(08 DIV 10) = 33 f(0) = 33 f(40 DIV 10) = 67 f(4) = 67 f(60 DIV 10) = 51 f(6) = 51
1.3 折疊法
折疊法是將數據轉換成一串數字后,先將這串數字拆成幾個部分,再把它們加起來,就可以計算出這個鍵值的 Bucket Address(桶地址)。
例如,有一個數據,轉換成數字后為 2365479125443,若以每 4 個數為一個部分則可拆為 2365、4791、2544、3。將這 4 組數字加起來后(9703)即為索引值。
在折疊法中有兩種做法,如上例直接將每一部分相加所得的值作為其 Bucket Address,被稱為“移動折疊法”。
哈希算法的設計原則之一就是降低碰撞,如果希望降低碰撞的機會,就可以將上述每一部分數字中的奇數或偶數翻轉,再相加取得其 Bucket Address,這種改進做法被稱為“邊界折疊法”(folding at the boundaries)。
- 情況一:將偶數反轉
2365(第一組數據為奇數,故不反轉) + 4791(奇數) + 4452(偶數,故反轉) + 3(奇數) = 11611(Bucket Address)
- 情況二:將奇數反轉
5631(第一組數據為奇數,故反轉) + 1974(奇數) + 2544(偶數,故不反轉) + 3(奇數) = 10153(Bucket Address)
1.4 數字分析法
數字分析法適用於數據不會更改,且為數字類型的靜態表。在決定哈希函數時先逐一檢查數據的相對位置和分布情況,將重復性高的部分刪除。
例如,下面這個電話號碼表時相當有規則性的,除了區號全部是 080 外,中間三個數字的變化也不大:
080-772-2234 080-772-4525 080-774-2604 080-772-4651 080-774-2285 080-772-2101 080-774-2699 080-772-2694
假設地址空間的大小為 999,那么我們必須從下列數字提取適當的數字,即數字不要太集中,分布范圍較為平均(或稱隨機度高),最后決定提取最后三個數字作為鍵值,故可得哈希表:
索引 | 電話 |
234 | 080-772-2234 |
525 | 080-772-4525 |
604 | 080-774-2604 |
651 | 080-772-4651 |
285 | 080-774-2285 |
101 | 080-772-2101 |
699 | 080-774-2699 |
694 | 080-772-2694 |
2. 碰撞與溢出問題的處理
在哈希法中,當標識符要放入某個桶(Bucket,哈希表中存儲數據的位置)時,若該桶已經滿了,就會發生溢出(Overflow);另一方面哈希法的理想情況是所有數據經過哈希函數運算后都得到不同的值,但現實情況是即使所有關鍵字段的值都不相同,還是可能得到相同的地址,於是就發生了碰撞(Collsion)問題。因此,如何在碰撞發生后處理溢出的問題就顯得相當重要。
常見的處理算法有線性探測法、平方探測法、再哈希法、鏈地址法等。
2.1 線性探測法
線性探測法是當發生碰撞情況時,若該索引對應的存儲位置已有數據,則以線性的方式往后尋找空的存儲位置,一旦找到位置就把數據放進去。
線性探測法通常把哈希的位置視為環形結構,如此一來若后面的位置已被填滿而前面還有位置時,可以將數據放到前面。
Python 線性探測算法
1 def create_table(num, index): 2 """ 3 :param num: 需要存放的數據 4 :param index: 哈希表 5 :return: None 6 """ 7 # 哈希函數:數據 % 哈希表最大元素(索引) 8 tmp = num % INDEXBOX 9 while True: 10 # 如果數據對應的位置是空的,則直接存入數據 11 if index[tmp] == -1: 12 index[tmp] = num 13 break 14 # 否則往后找位置存放 15 else: 16 # 遞增取余是為了將哈希表視為環形結構,后面的位置都被填滿時再從頭位置往后遍歷 17 tmp = (tmp+1) % INDEXBOX
示例程序
以除留余數法的哈希函數取得索引值,再以線性探測法來存儲數據。
1 import random 2 3 4 INDEXBOX = 10 # 哈希表最大元素(索引) 5 MAXNUM = 7 # 最大數據個數 6 7 8 # 線性探測算法 9 def create_table(num, index): 10 """ 11 :param num: 需要存放的數據 12 :param index: 哈希表 13 :return: None 14 """ 15 # 哈希函數:數據 % 哈希表最大元素 16 tmp = num % INDEXBOX 17 while True: 18 # 如果數據對應的位置是空的,則直接存入數據 19 if index[tmp] == -1: 20 index[tmp] = num 21 break 22 # 否則往后找位置存放 23 else: 24 # % 遞增取余是為了將哈希表視為環形結構,后面的位置都被填滿時再從頭位置往后遍歷 25 tmp = (tmp+1) % INDEXBOX 26 27 28 # 主程序 29 index = [-1] * INDEXBOX # 初始化哈希表 30 data = [random.randint(1, 20) for num in range(MAXNUM)] # 原始數組值 31 print(" 原始數組值:\n%s" % data) 32 33 # 使用哈希算法存入數據 34 print("哈希表內容:") 35 for i in range(MAXNUM): 36 create_table(data[i], index) 37 print(" %d => %s" % (data[i], index)) 38 39 print(" 完成哈希表:\n%s" % index)
執行結果:
原始數組值: [13, 17, 19, 10, 14, 20, 18] 哈希表內容: 13 => [-1, -1, -1, 13, -1, -1, -1, -1, -1, -1] 17 => [-1, -1, -1, 13, -1, -1, -1, 17, -1, -1] 19 => [-1, -1, -1, 13, -1, -1, -1, 17, -1, 19] 10 => [10, -1, -1, 13, -1, -1, -1, 17, -1, 19] 14 => [10, -1, -1, 13, 14, -1, -1, 17, -1, 19] 20 => [10, 20, -1, 13, 14, -1, -1, 17, -1, 19] 18 => [10, 20, -1, 13, 14, -1, -1, 17, 18, 19] 完成哈希表: [10, 20, -1, 13, 14, -1, -1, 17, 18, 19]
2.2 平方探測法
線性探測法有一個缺點,就是相類似的鍵值經常會聚集在一起,因此可以考慮以平方探測法來加以改進。
在平方探測法中,當發生溢出時,下一次查找的地址是 (f(x)+i2) mob B 或 (f(x)-i2) mob B,即讓數據值加或減 i 的平方。
例如數據值 key,哈希函數 h,其查找算法如下:
第一次查找:h(key)
第二次查找:(h(key)+12) % B 第三次查找:(h(key)-12) % B 第四次查找:(h(key)+22) % B 第五次查找:(h(key)-22) % B ... ... 第 n 次查找:(h(key)±((B-1)/2)2) % B,其中,B 必須為 4j + 3 型的質數,且 1 ≤ i ≤ (B-1)/2
2.3 再哈希法
再哈希法就是一開始就先設置一系列的哈希函數,如果使用第一種哈希函數出現溢出時就改用第二種,如果第二種也出現溢出則改用第三種,一直到沒有發生溢出為止。例如,h1 為 key%11、h2 為 key*key、h3 為 key*key%11、h4....。
示例:使用再哈希法處理下列數據碰撞的問題
681、467、633、511、100、164、472、438、445、366、118
其中哈希函數為(此處的 m=13):
h1(key) = key MOD m h2(key) = (key+2) MOD m h3(key) = (key+4) MOD m
1)使用第一種哈希函數 f(key) = key MOD 13,所得的哈希地址如下:
681 -> 5 467 -> 12 633 -> 9 511 -> 4 100 -> 9 164 -> 8 472 -> 4 438 -> 9 445 -> 3 366 -> 2 118 -> 1
2)其中 100、472、438 都發生碰撞,再使用第二種哈希函數 h2(key) = (key+2) MOD 13,進行數據的地址安排:
100 -> h2(100+2) = 102 mod 13 = 11 472 -> h2(472+2) = 474 mod 13 = 6 438 -> h2(438+2) = 440 mod 13 = 11
3)438 仍發生碰撞問題,故接着使用第三種哈希函數 h3(key+4) = (key+4) MOD 13,重新進行438 的地址安排:
438 -> h3(438+4) = 442 mod 13 = 0
經過三次再哈希后,數據的地址安排如下:
位置 | 數據 |
0 | 438 |
1 | 118 |
2 | 366 |
3 | 445 |
4 | 411 |
5 | 681 |
6 | 472 |
7 | null |
8 | 164 |
9 | 633 |
10 | null |
11 | 100 |
12 | 467 |
2.4 鏈地址法
鏈地址法是目前比較常用的沖突解決方法,一般可以通過數組和鏈表的結合達到沖突數據緩存的目的。

左側數組的每個成員包括一個指針,指向一個鏈表的頭。每發生一個沖突的數據,就將該數據作為鏈表的節點鏈接到鏈表尾部。這樣一來,就可以保證沖突的數據能夠區分並順利訪問。
考慮到鏈表過長造成的問題,還可以使用紅黑樹替換鏈表進行沖突數據的處理操作,來提高散列表的查詢穩定性。
3. 哈希表的動態擴容
重哈希和裝載因子
- 裝載因子:即關鍵字個數和哈希表長度之比,用於度量所有關鍵字填充后哈希表的飽和度。
- 哈希表的裝載因子 = 填入表中的元素個數 / 哈希表的長度。
- 重哈希:當裝載因子達到指定的閾值時,哈希表進行擴容的過程。
動態擴容過程
總結
- 每次插入時遷移一個數據,這樣不像集中一次性遷移數據那樣耗時,不會形成明顯的阻塞。
- 由於遷移過程中,有新舊兩個哈希表,查找數據時,先在新的哈希表中進行查找,如果沒有,再去舊的哈希表中進行查找。