。鏈地址法的性能假設任何一個關鍵字都以相等的相等的概率被映射到哈希表的任意一個槽位。
其中 表示哈希表的槽位數, 表示插入到哈希表中的關鍵字的數目。
裝填因子(load factor) (將 n 個球隨機均勻地扔進 m 個箱子裡的概率)。
期望的查找,插入和刪除的時間均等於
當 為 量級時,鏈地址法的插入、查找和刪除操作的時間複雜度就是 量級。
極端情況下我們將 n 個數都扔進了同一個槽,也就是 n 個數形成了一個單鍊表,那麼時間複雜度將降為 ,但這個概率微乎其微。
開放地址法 與鏈地址法一樣,開放地址法也是一個經典的處理衝突的方式。只不過,對於開放地址法,所有的元素都是存儲在哈希表當中的,所以無論任何時候都要保證哈希表的槽位數 大於或等於關鍵字的數目 (必要的時候,我們還會複製舊數據,然後對哈希表進行動態擴容)。
開放地址法分三種方式。
線性探測法(Linear Probing)設 Hash(key) 表示關鍵字 key 的哈希值, 表示哈希表的槽位數(哈希表的大小)。
線性探測法則可以表示為:
如果 Hash(x) % M 已經有數據,則嘗試 (Hash(x) + 1) % M ;
如果 (Hash(x) + 1) % M 也有數據了,則嘗試 (Hash(x) + 2) % M ;
如果 (Hash(x) + 2) % M 也有數據了,則嘗試 (Hash(x) + 3) % M ;
.
我們同樣以哈希函數 H(key) = key MOD 7 (除數取餘法)對 [50, 700, 76, 85, 92, 73, 101] 進行映射,來理解線性探測法處理 Hash 碰撞。
依次計算 50 % 7 = 1 、700 % 7 = 0 及 76 % 7 = 6 ,均沒有發生衝突(碰撞),則直接放入相應的位置:
計算 85 % 7 = 1 ,但是下標 1 的位置已經有元素 50 ,發生碰撞,則尋找下一個可以存放元素 85 的空位 (85 % 7 + 1) % 7 = 2 ,下標二沒有元素,所以將 85 放進去:
計算 92 % 7 = 1 ,50 已經在 1 的位置,發生碰撞;線性探測下一個位置 (92 % 7 + 1) % 7 = 2 ,85 已經在 2 的位置,發生碰撞;線性探測下一個位置 (92 % 7 + 2) % 7 = 3 ,此時為空位,填入 92 :
計算 73 % 7 = 3 ,92 已經佔用了 3 號位置,發生碰撞;線性探測下一個位置 (73 % 7 + 1) % 7 = 4 ,此時為空位,填入 73 :
計算 101 % 7 = 3 , 3 號位置已經有元素,發生碰撞;線性探測下一個位置 (101 % 7 + 1) % 7 = 4 ,4 號位置已有元素,發生碰撞;線性探測下一個位置 (101 % 7 + 2) % 7 = 5 , 此時為空,填入 101 :
到這裡你不難看出線性探測的缺陷,容易產生聚集(發生碰撞的元素形成組,比如 [50,85,92,73,101] 之間均是由於碰撞而緊挨著),而且發生碰撞的情況大大增加,需要花費更多的時間來尋找空閒的槽位和查找元素。
至於(lazy delete)懶刪除就是並不將存儲空間釋放,只是將裡面的數據清除。
比如刪除元素 92 ,先計算 92 % 7 = 1 ,但是發現下標 1 是 50,線性向下探測 (92 % 7 + 1) % 7 = 2 ,下標 2 為 85,繼續線性向下探測 (92 % 7 + 2) % 7 = 3 ,發現下標 3 剛好為 92,將該存儲單元的數據清空。
平方探測法(Quadratic Probing)所謂 Quadratic Probing,就是每次向下探測的寬度變成了 ,其中的 表示迭代次數。
設 Hash(key) 表示關鍵字 key 的哈希值, 表示哈希表的槽位數(哈希表的大小)。
平方探測法則可以表示為:
如果 Hash(x) % M 已經有數據,則嘗試 (Hash(x) + 1 * 1) % M ;
如果 (Hash(x) + 1 * 1) % M 也有數據了,則嘗試 (Hash(x) + 2 * 2) % M ;
如果 (Hash(x) + 2 * 2) % M 也有數據了,則嘗試 (Hash(x) + 3 * 3) % M ;
..
雙重哈希(Double Hashing)對於雙重哈希,我們需要一個新的哈希函數 Hash2(key) ,而且對於第 i 次迭代探測,我們查找的位置為 i * Hash2(key) .
設 Hash(key) 表示關鍵字 key 的一個哈希值, Hash2(key) 表示關鍵字 key 的另一個哈希值, 表示哈希表的槽位數(哈希表的大小)。
雙重哈希探測法則可以表示為:
如果 Hash(x) % M 已經有數據,則嘗試 (Hash(x) + 1 * Hash2(x)) % M ;
如果 (Hash(x) + 1 * Hash2(x)) % M 也有數據了,則嘗試 (Hash(x) + 2 * Hash2(x)) % M ;
如果 (Hash(x) + 2 * Hash2(x)) % M 也有數據了,則嘗試 (Hash(x) + 3 * Hash2(x)) % M ;
..
開放地址法的實現using namespace std; // 哈希表的大小 #define TABLE_SIZE 13 // 用於定義 Hash2(key) #define PRIME 7 class DoubleHash { // 定義一個哈希表 int* hashTable; //int[] hashTable; int curr_size; public: // 判斷哈希表是否滿了 bool isFull() { //當前的大小大於哈希表的大小 return (curr_size == TABLE_SIZE); } // 哈希函數1 int hash1(int key) { return (key % TABLE_SIZE); } // 哈希函數2 int hash2(int key) { return (PRIME - (key % PRIME)); } DoubleHash() { hashTable = new int[TABLE_SIZE]; curr_size = 0; for (int i = 0; i < TABLE_SIZE; i++){ hashTable[i] = -1; } } // 插入操作 void insertHash(int key) { // 判斷哈希表是否已滿 if (isFull()) return; // 獲取哈希函數1計算的結果 int index = hash1(key); // 如果發生碰撞 if (hashTable[index] != -1) { //由哈希函數2獲取另一個哈希值 int index2 = hash2(key); int i = 1; while (1) { //得到雙重哈希的索引,對於線性探測,平方探測改這裡就可以 int newIndex = (index + i * index2) % TABLE_SIZE; //如果新的哈希值newIndex為空,退出循環。 if (hashTable[newIndex] == -1) { hashTable[newIndex] = key; break; } i++; } } else{ hashTable[index] = key; } curr_size++; } // 哈希表查找操作 void search(int key) { int index1 = hash1(key); int index2 = hash2(key); int i = 0; while (hashTable[(index1 + i * index2) % TABLE_SIZE] != key) { if (hashTable[(index1 + i * index2) % TABLE_SIZE] == -1) { cout << key << ":\t哈希表中不存在" << endl; //java 這裡自己改成響應輸出 return; } i++; } cout << key << "找到啦!" << endl; } // 列印輸出 void displayHash() { for (int i = 0; i < TABLE_SIZE; i++) { if (hashTable[i] != -1) cout << i << " --> " << hashTable[i] << endl; else cout << i << endl; } } }; int main() { int a[] = {50, 700, 76, 85, 92, 73, 101}; int n = sizeof(a) / sizeof(a[0]); //創建哈希表,插入a[]中的元素 DoubleHash h; for (int i = 0; i < n; i++) { h.insertHash(a[i]); } //查找 h.search(36); //不存在 h.search(101); //存在 //列印輸出 h.displayHash(); return 0; } 至於線性探測和平方探測的實現比較簡單,只需要修改一下代碼中 newIndex 的計算方式即可。
線性探測:
int newIndex = (index + i) % TABLE_SIZE; 平方探測:
int newIndex = (index + i*i) % TABLE_SIZE; 以上三種探測方法的比較:
線性探測具有最佳的緩存性能,但會受到原始聚集(primary cluster)的影響。線性探測易於計算。
何為原始聚集(原始聚集是相對於線性探測而言的)?
如圖所示,如果對於任意一個關鍵字 key ,其哈希值 Hash(key) 指向圖中從左向右的紅色箭頭的任意位置,聚集(圖中的淺藍色區域)都將得到擴充,而隨著聚集的不斷擴充,這個聚集會越來越大。
還不理解聚集的概念的話,我們一起看個例子:
上圖中的 700,50,76 在插入的時候均未產生碰撞,所以以元素本身單位構成聚集(聚集的大小為 1,此時可以認為不是聚集)。
但是之後插入的元素就不一樣了,85 與 50 發生碰撞,插入到了 50 的後面,導致聚集變大。
插入 92 與 50 發生碰撞,線性探測插入到了 85 之後,聚集進一步擴大。
插入 73 與 92 產生碰撞,線性探測插入到 92 之後,聚集進一步擴大。
插入 101 與 92 產生碰撞,線性探測插入到 73 之後,聚集進一步擴大。
這就是原始聚集的概念,顯然聚集越大,產生碰撞的可能越來越大,哈希算法的性能也會受到影響。
平方探測在緩存性能和受聚集影響方面介於兩者中間,平方探測隨解決了線性探測的原始聚集問題,但是同時也會產生更細的二次聚集問題。
二重探測的緩存性能較差,但不用擔心聚集的情況,但同時由於要計算兩個散列函數,時間性能方面受限。
開放地址法的性能分析與鏈地址法類似,假設任何一個關鍵字都以相等的相等的概率被映射到哈希表的任意一個槽位。
其中 表示哈希表的槽位數, 表示插入到哈希表中的關鍵字的數目。
裝填因子(load factor) (對於開放地址法而言, m > n)。
期望的插入、查找和刪除的時間 .
所以對於開放地址法而言,插入、查找和刪除的時間複雜度為 .
這裡你一定會有困惑,舉個慄子, ,那麼 則表示最多探測10次就可以查找、插入或刪除一個元素。
至於為什麼是 ?
對這個感興趣的小夥伴,推薦看看嚴蔚敏老師的書籍《數據結構(C語言版)》262 頁的證明。
開放地址法與鏈地址法的比較 所謂比較,算是對前面的一個總結:
No鏈地址法開放地址法1易於實現需要更多的計算2使用鏈地址法,哈希表永遠不會填充滿,不用擔心溢出。哈希表可能被填滿,需要通過拷貝來動態擴容。3對於哈希函數和裝載因子不敏感需要額外關注如何規避聚集以及裝載因子的選擇。4適合不知道插入和刪除的關鍵字的數量和頻率的情況適合插入和刪除的關鍵字已知的情況。5由於使用鍊表來存儲關鍵字,緩存性能差。所有關鍵字均存儲在同一個哈希表中,所以可以提供更好的緩存性能。6空間浪費(哈希表中的有些鏈一直未被使用)哈希表中的所有槽位都會被充分利用。7指向下一個結點的指針要消耗額外的空間。不存儲指針。再哈希(Rehashing) 對於開放地址法而言,插入、查找和刪除的時間複雜度為 ,意味著哈希算法的時間複雜度取決於裝填因子 , 越大,開放地址法的時間複雜度也就越高。
裝填因子 表中填入的記錄數哈希表的長度 .
一般將裝填因子的值設置為 0.75,隨著填入哈希表中記錄數的增加,裝填因子就會增大甚至超過設置的默認值 0.75,意味著哈希算法的時間複雜度的增加。為了解決裝填因子超過默認設置的值 0.75,可以對數組(哈希表)進行擴容(二倍擴容),並將原哈希表中的值進行 再哈希 ,存儲到二倍大小的新數組(哈希表)中,從而保證裝填因子維持在一個較低的值(不超過 0.75),哈希算法的時間複雜度保證在 量級。
這就是為什麼需要在哈希的原因所在,下面我們一起看一下再哈希的實現。
再哈希可以大致分為四個步驟:
對於每一次向哈希表中添加新的鍵值對,檢查裝填因子 的大小;對於再哈希而言,創建一個新的數組(原數組的兩倍大小)作為哈希表。遍歷原數組中的每一個元素,並將其插入到新的數組當中。我們依舊以 [50, 700, 76, 85, 92, 73, 101] 為例進行說明,其中可能會省略部分計算步驟。
開闢一個大小為 7 的空數組:
接下來就是插入並檢查裝填因子 ,我們將裝填因子自己寫到了圖中:
此時要注意啦,此時裝填因子 ,創建一個長度為 14 的新數組(原始數組的兩倍):
此時的數組長度為 14 ,我們的哈希函數也變了,由原來的 Hash(key) = key % 7 變成了 Hash(key) = key % 14 。然後遍歷原始數組,並將原始數組中的元素映射到新的數組當中:
然後計算 101 % 14 = 3 ,插入到 73 的後面:
這就是再哈希,我相信你也理解啦!但是我們的實現可能不是這樣的,我們將插入的是一個鍵值對,比如 [<50, "I">, <700, "Love">, <76, "Data">, <85, "Structure">, <92, "and">, <73, "Jing">, <101, "Yu">] 。此外代碼是以鏈地址法來實現再哈希的奧!可不是畫圖講的開放地址法線性探測,如果不會寫開放地址法再哈希可以評論區留言,我寫了發你學習!
再哈希實現import java.util.ArrayList; class Map<K, V> { class MapNode<K, V> { K key; V value; MapNode<K, V> next; //鏈地址法 public MapNode(K key, V value) { this.key = key; this.value = value; next = null; } } // 鍵值對就存儲在 ArrayList<MapNode<K, V> > hashTable; // 裝填因子 alpha 的分子 -- 表中填入的記錄數 int size; // 裝填因子 alpha 的分母 -- 哈希表的長度。 int numHashTable; // 默認的裝填因子 0.75 final double DEFAULT_LOAD_FACTOR = 0.75; public Map() { numHashTable = 5; hashTable = new ArrayList<>(numHashTable); for (int i = 0; i < numHashTable; i++) { // 初始化哈希表 hashTable.add(null); } } private int getBucketInd(K key) { // Using the inbuilt function from the object class int hashCode = key.hashCode(); // array index = hashCode%numHashTable return (hashCode % numHashTable); } public void insert(K key, V value) { // 獲取插入的關鍵字 key 的哈希值 int bucketInd = getBucketInd(key); MapNode<K, V> head = hashTable.get(bucketInd); //插入 K-V while (head != null) { if (head.key.equals(key)) { head.value = value; return; } head = head.next; } MapNode<K, V> newElementNode = new MapNode<K, V>(key, value); head = hashTable.get(bucketInd); newElementNode.next = head; hashTable.set(bucketInd, newElementNode); size++; // 計算當前的裝填因子 double loadFactor = (1.0 * size) / numHashTable; // 裝填因子 > 0.75,執行再哈希 if (loadFactor > DEFAULT_LOAD_FACTOR) { rehash(); } } private void rehash() { System.out.println("\n***Rehashing Started***\n"); // 保存原始哈希表 ArrayList<MapNode<K, V> > temp = hashTable; //創建新的數組(原始數組的兩倍) hashTable = new ArrayList<MapNode<K, V> >(2 * numHashTable); for (int i = 0; i < 2 * numHashTable; i++) { buckets.add(null); } // 將舊數組中數據拷貝到新數組中 size = 0; numHashTable *= 2; for (int i = 0; i < temp.size(); i++) { MapNode<K, V> head = temp.get(i); while (head != null) { K key = head.key; V val = head.value; insert(key, val); head = head.next; } } } public void printMap() { ArrayList<MapNode<K, V> > temp = hashTable; System.out.println("當前的哈希表:"); for (int i = 0; i < temp.size(); i++) { // 獲取哈希表當前 i 的頭結點 MapNode<K, V> head = temp.get(i); while (head != null) { System.out.println("key = " + head.key + ", val = " + head.value); head = head.next; } } System.out.println(); } } public class Rehashing { public static void main(String[] args) { // 創建一個哈希表 Map<Integer, String> map = new Map<Integer, String>(); // 插入元素[<50, "I">, <700, "Love">, <76, "Data">, //<85, "Structure">, <92, "and">, <73, "Jing">, <101, "Yu">] map.insert(50, "I"); map.printMap(); map.insert(700, "Love"); map.printMap(); map.insert(76, "Data"); map.printMap(); map.insert(85, "Structure"); map.printMap(); map.insert(92, "and"); map.printMap(); map.insert(73, "Jing"); map.printMap(); map.insert(101, "Yu"); map.printMap(); } } 哈希算法的應用 哈希算法在日常生活中的應用非常普遍,包括信息摘要(Message Digest),密碼校驗(Password Verification),數據結構(程式語言),編譯操作(Compiler Operation),Rabin-Karp 算法(模式匹配算法),負載均衡等等。
當你註冊一個網站在客戶端輸入郵箱和密碼時,客戶端會對用戶輸入的密碼進行 hash 運算,然後在服務端的資料庫中保存用戶密碼的 Hash 值,從而保護用戶的數據。
C++ 當中的 unordered_set & unordered_map ,以及 Java 中 HashSet & HashMap 和 Python 中的 dict 等各種程式語言均實現了哈希表數據結構。
Rabin-Karp 算法利用哈希來查找字符串的任意一組模式,並且可以用來檢查抄襲。
可以利用一致性哈希解決負載均衡問題。
同樣可以用於版本校驗、防篡改、密碼校驗,此外還廣泛應用於各類資料庫當中(包括MySQL、Redis等等)。
關於哈希算法應用的詳細內容大家可以看看參考資料[1]。
參考資料
[1] https://www.zhihu.com/question/26762707/answer/890181997
[2] https://hit-alibaba.github.io/interview/basic/algo/Hash-Table.html
[3] 嚴蔚敏老師的《數據結構(C語言)》版。
[4] https://www.cs.princeton.edu/~rs/AlgsDS07/10Hashing.pdf
[5] http://courses.csail.mit.edu/6.006/fall09/lecture_notes/lecture05.pdf
[6] http://courses.csail.mit.edu/6.006/fall11/lectures/lecture10.pdf
[7] https://www.cse.cuhk.edu.hk/irwin.king/_media/teaching/csc2100b/tu6.pdf
推薦閱讀:
終於,一鍵打通了微信和QQ
雙系統的日子結束了:Windows和Linux將合二為一
學算法的那些年,吳師兄接觸的網站、軟體、視頻、書籍大揭秘