一、概述
以 Key-Value 的形式進行數據存取的映射(map)結構
簡單理解:用最基本的向量(數組)作為底層物理存儲結構,通過適當的散列函數在詞條的關鍵碼與向量單元的秩(下標)之間建立映射關系
更詳細的定義:開辟物理地址連續的桶數組ht[],借助散列函數hash(),將詞條關鍵碼key映射為桶地址(數組下標),從而快速地確定待操作詞條的物理位置。
1.1 散列結構優點
- 可以實現O(1)時間的數據項查找(注:給定關鍵碼,通過散列函數可直接計算出所在地址)
- 能以節省空間的方式實現上述O(1)查找
1.2 部分概念
- 桶/桶單元(bucket):散列表的物理存儲結構,在物理上連續排列的用於存放詞條的單元。
- 桶數組(bucket array):用數組作為桶單元。
- 地址空間(address space):桶數組的合法秩區間,如容量為R 則地址空間為[0, R)。
- 散列函數(hash function):詞條與桶地址之間的映射關系,即從關鍵碼到桶數組地址空間的映射函數。
- 散列地址(hashing address):給定關鍵碼所對應的桶的秩,即 若給定散列函數hash(),關鍵碼key,則散列地址為hash(key)。
- 散列沖突(collision):關鍵碼不同的詞條被映射到統一散列地址的情況。
- 裝填因子(load factor):散列表中非空桶數量與桶單元總數的比值。
- 完美散列(perfect hashing):時間與空間性能均最優的散列。即給定問題實例下,對於任意關鍵碼,均可在O(1)時間查找確定,且每個桶恰好存放一個詞條,無空余無重復。完美散列實際上並不常見。
- 詞條的聚集(clustering):詞條集中到散列表內少數若干桶中(或附近)的現象。
1.3 簡單的散列表設計實例
需求
存儲某校2500門固定電話號碼及其相關信息,號碼隨機分布在 0000-0000~9999-9999之間,需在O(1)時間進行高效查找。
簡單設計
直接使用[99999999]的數組,以電話號碼直接作為秩,即 hash(key)=key
缺點
這樣設計缺點很明顯:在詞典中需要保存的詞條數遠小於關鍵碼所有可能的情況下,直接用hash(key)=key 作為秩,導致裝填因子太小,即實際裝填數遠小於桶單元總數,從而造成大量空間的浪費。
因此,需要合理設計散列函數,能夠只創建實際需要保存數量的桶,並將關鍵碼空間壓縮到散列地址空間
二、散列函數
2.1 設計原則
散列函數首先面對的問題就是 散列沖突(Collision),該問題必然存在,需盡可能避免,后續會提到相關解決方法。
其次便是以下一些設計原則:
- 確定性,不論所含數據項如何,詞條E的散列地址hash(E.key)必須完全取決於E.key。
- 映射過程自身不能過於復雜,保證散列地址計算可快速完成
- 所有關鍵碼經映射后應盡量覆蓋整個桶地址空間,以充分利用,即盡量滿射。
- (重要)關鍵碼映射到各桶的概率應盡量接近於1/M,M為桶總數(若關鍵碼本身均勻且獨立隨機分布,則這也是任意一對關鍵碼相互沖突的概率)
總之隨機性越強、規律性越弱的散列函數越好。(使得在M上分配均勻,降低沖突)
2.2 常見散列方法
1) 除余法(division method)
思路
最簡單的散列辦法,將散列表(桶)長度M取作素數,然后用關鍵碼key與M取余作為散列地址。
取余使得散列地址分配概率既均勻,也不會>=M,正好符合散列地址空間
表達式
hash(key) = key % M
為什么M需要為素數?
實際應用中,存儲的詞條關鍵碼往往具有某種周期性,如{1000,1015,1030...},若周期與M若具有公共素因子,則沖突概率急劇攀升。
一般地,若M與詞條關鍵碼間隔T(上例為5)之間的最大公約數越大,則發生沖突可能性也越大。【TODO:底層數學原理暫無法探究】
注:若關鍵碼本身獨立隨機,則概率還是平均的,只是在周期存取的情況下才會出現該情況。
2) MAD法(multiply-add-divide method)
除余法存在的不足
除余法雖能一定程度保證詞條均勻分布,但從關鍵碼空間到散列地址空間依然殘留有一定的連續性,如 相鄰關鍵碼對應散列地址也相鄰。
因此便有mad法,若常數ab選取得當,可以很好地克服除余法的這種連續性。除余法也可以看作Mad法a=1和b=0的特例,只是兩個常數並未發揮實質作用。
表達式
hash(key) = (a*key+b) % M, 其中M仍為素數,a>0,b>0,且a % M != 0
3) 數字分析法(selecting digits)
注:以下各方法為保證落在合法的散列地址空間上,最后通常還需對表長M取余。
思路
從關鍵碼key特定進制的展開中抽取特定的若干位,構成整型地址。
表達式
例:選取key十進制展開中的奇數位
hash(123456789) = 13579
4) 平方取中法(mid-square)
思路
從關鍵碼key的平方的十進制或二進制展開中取居中的若干位,構成一個整型地址。
表達式
例:取平方並用十進制展開中的居中3位作為散列地址
123^2 = 15129,hash(123) = 512
5) 折疊法(folding)
思路
將關鍵碼的十進制或二進制展開分割成等寬的若干段,取其總和作為散列地址。
表達式
例:以十進制三個數位為分割單位
hash(123456789) = 123+456+789 = 1368
6) 異或法(xor)
思路
將關鍵碼的二進制展開分割成等寬的若干段,經異或運算得到散列地址。
表達式
例:以二進制三個數位為分割單位
hash(411) = hash(110011011b) = 110011011 = 110b = 6
三、沖突及排解策略
3.1 沖突的普遍性
沖突必然的
因為用短位(散列地址空間)表示長位數據(關鍵碼空間),肯定會出現沖突。比如 常見的 MD5 碼,一共就128bit,但卻要表示無限的數據的散列碼,因此必然會出現不同數據具有相同MD5碼的情況。
3.2 沖突排解策略
沖突排解策略分為以下兩種類型:
- 開放定址(open addressing) / 閉散列(closed hashing):散列地址空間對所有詞條開放(即 桶單元允許裝hash(key)不對應的詞條);詞條存儲地址(散列地址)僅限於散列表所覆蓋的范圍之內。
如:線性試探、查找鏈法等。
注:因閉散列不得使用附加空間的原因,裝填因子通常<=0.5 - 封閉定址(closed addressing) / 開散列(open hashing):散列地址空間只對對應的詞條開放;詞條存儲地址不局限於散列表范圍之內。
如:多槽位法、獨立鏈法、公共溢出區等
1)多槽位法(multiple slots)
思路
每個桶本身再細分為若干槽位,用於存放彼此沖突的詞條。每個桶槽位的詞典結構為向量,因此整體物理存儲結構類似於二維數組。
如:put操作,首先通過hash(key)定位到對應的桶單元,並在該桶內部槽位中進一步查找key,若沒找到,則創建新詞條插入到該桶的空閑槽位中。
缺點
- 絕大多數的槽位都處於空閑狀態,造成空間浪費。若桶被細分為k個槽位,則裝填因子將直接降低為原來的1/k.
- 很難實現確定應該細分為多少個槽位,才能保證夠用。
2) 獨立鏈法(separate chaining) / 拉鏈法
思路
與多槽位思想類似,但每個桶的子詞典是使用鏈表實現,令彼此沖突的詞條互相串接。
優點
能靈活動態地調整子詞典的規模,有效地使用空間。
缺點
空間未必連續分布,會導致系統緩存失效。
3) 公共溢出區
原理
在原散列表之外另設一個詞典結構$$D_{overflow}$$,插入詞條一旦發生沖突,則轉存到該詞典中。$$D_{overflow}$$相當於存放沖突詞條的公共緩沖池。
4) 線性試探法
原理
在插入詞條時,若發生沖突,則轉而試探桶單元ht[hash(key)+1],若ht[hash(key)+1]也被占用,則繼續試探ht[hash(key)+2],如此不斷...直到找到空桶。
第i次試探的散列地址:(hash(key) + i) mod M, i=1,2,3...
具體查找邏輯
查找鏈(probing chain):對於待查找的key,從hash(key)桶單元開始,直接空桶結束的順序序列。
- 經hash(key)算得的當前桶單元,若關鍵碼相等,則成功返回。
- 當前桶單元非空,但關鍵碼不等,則轉入下一桶單元繼續試探。
- 當前桶為空,則返回查找失敗。
注:相互沖突的關鍵碼比屬於同一查找鏈(即中途不包含空桶),但同一查找鏈的關鍵碼未必相互沖突。多組各自沖突的關鍵碼所對應的查找鏈,有可能相互交織和重疊。
優點
具體由良好的數據局部性,試探地桶單元在物理空間上依次連貫,系統緩存能發揮作用。
懶惰刪除
定義:
從詞典刪除詞條時,暫時並不實際將桶置空,而是額外維護一個刪除標記Bitmap,標記該桶已刪除。
為什么需要懶惰刪除?
因為查找鏈中任何一環的缺失,都會導致后續詞條的“丟失”,即無法找到已存在詞條;同時因為開銷問題,不可能每次刪除操作都對查找鏈進行維護重建(在擴容時,才重建鏈)。
因此懶惰刪除機制既能保證查找鏈的完整,也不需要太多開銷。
加入懶惰刪除后,操作邏輯的變化:
- 在刪除等操作查詢指定詞條時,判斷失敗的條件變為:為空且不帶懶惰刪除標記。
- 在插入操作時,找空桶過程中,判斷桶為空條件為:帶有懶惰標記或當前桶為空。
5) 平方試探法
線性試探法的不足
線性試探法各查找鏈均由物理上連續的桶單元組成,會加劇關鍵碼的聚集趨勢。
定義
若發生沖突,則第j次試探地桶地址:(hash(key) + j^2) mod M, j=0,1,2...
該方式會使得試探地址加速逃離聚集區段。
該方式局部性會有所降低,但如今I/O頁面規模較大,不必過於擔心。
確保試探必然終止
只要散列表長度M為素數,且裝填因子$$\lambda<=50%$$,則平方試探必然會終止於某個空桶。(數學證明暫未探究)
6) 再散列法(double hashing)
選取一個適宜的二級散列函數$$hash_2()$$,一旦發生沖突,則將再散列的結果作為偏移量,公式如下:
第 j 次試探地址:$$[hash(key)+j*hash_2(key)]%M$$
四、散列碼轉換 hahsCode()
4.1 定義
散列碼(hash code):利用某一種散列碼轉換函數hashCode(),將關鍵碼key統一轉換為的一個整數。
4.2 為什么需要散列碼
因為詞條關鍵碼不一定天然支持大小比較,而且也並不一定是整數類型,因此需制定一個函數能將任意類型的關鍵碼先統一轉成散列碼,散列函數再由散列碼計算散列地址。
4.3 散列碼轉換函數設計原則
- 作為中間橋梁的散列碼,取值范圍應覆蓋系統所支持的最大整數范圍
- 各關鍵碼經hashCode()映射后的散列碼之間,也應盡可能減少沖突。否則在該階段的沖突,后續hash()必定無法消除。
- hashCode()應與判等器保持一致。即 判等器判斷相等的對象,其散列碼應該相等。
參考:
數據結構 鄧俊輝