Hash
它是什麼?
哈希表是又稱散列表,一種以 “key-value” 形式存儲數據的數據結構。所謂以 “key-value” 形式存儲數據,是指任意的 key 都唯一對應到內存中的某個位置。只需要輸入查找的值 key,就可以快速地找到其對應的 value。可以把哈希表理解爲一種高級的數組,這種數組的下標可以是很大的整數,浮點數,字符串甚至結構體。
爲什麼存在?
有時關鍵碼空間的數量級可能遠遠大於實際問題的空間,造成了巨大的浪費,我們使用桶(bucket)直接存放或間接指向一個詞條。
優缺點?
優點:
>>>空間利用率 :
問題空間N,關鍵碼空間R,桶數組(bucket array)或散列表(hash table),容量爲M,則:
N < M << R
空間 = O( N + M )=O(N)
M儘可能與N同階,所以至少與使用關鍵碼空間相比空間利用率大大的提高了。
>>>常數級的查找時間 :
因爲哈希表遵循的是循值訪問,所以查找時間只需要O(1),這是再好不過的了。
缺點:
>>>衝突:
hash(key) = key % M
衝突是無法避免的,但是我們可以儘量減少衝突,並從以下兩個方向入手:
- 精心設計散列表及散列函數,以儘可能降低衝突的概率;
- 制定可行的預案,以便在發生衝突時,能夠儘快予以排解。
怎麼用?
>>>循值訪問
>>>散列函數的設計:
- 除餘法 :hash(key) = key % M
M應當選取素數。步長爲step,gcd(step,M)=G,當且僅當 G == 1時,足跡能夠遍佈整個散列表。又由於step不能確定,所以M應是一個素數。
- MAD法 (multiply-add-divide) :hash(key) = ( a * key + b ) % M
除餘法有兩個缺陷。
一:它有不動點。無論表長M取值如何,總有hash(0) ≡ 0
二:零階均勻。[ 0 ,R)的關鍵碼,平均分配至M個桶;但相鄰的關鍵碼的散列地址也比相鄰。
取M爲素數,a > 0,b > 0,a % M != 0。hash(key) = ( a * key + b ) % M
- 平方取中 (mid-square) :取 key^2的中間若干位,構成地址
原理:將平方運算分解爲一系列的左移操作,以及若干次加法,思想類似於快速冪,如13^2=13 + (13)<<2 + (13)<<3。如果忽略進位,每個數位都是由原關鍵碼若干次求和得到的,因此兩側的數位是由更少的原數位累積而得,而越是居中的數位是由更多的原數位累積,截取居中的若干位,可以使得原關鍵碼各數位對最終地址的影響彼此更爲接近。
-
多項式法 :
與Karp-Rabin算法:串即是數
的思想吻合。 -
更多散列函數 :數字分析法(selecting digits),摺疊法(folding),位異或法(XOR),僞隨機數法(#!慎用此法)法等
>>>排解衝突 :
- 獨立鏈(linker-list chaining / separate chaining)
每個桶存放一個指針,衝突的詞條,組成列表。封閉定址(closed addressing)策略,每個桶只能存放與這個桶單元的地址相沖突的詞條。
優點 :
1. 無需爲每個桶預留多個槽位
2. 任意多次的衝突都可解決
3. 刪除操作實現簡單、統一
但是 :
1.指針需要額外空間
2. 節點需要動態
申請(時間成本要比常規操作高出兩個數量級)
3. 更重要的是系統緩存幾乎失效!每個桶內部的查找都是沿着對應的列表順序進行的,在此之前各節點的插入和刪除順序是隨機的,對於任何一個列表而言,其中的節點在物理空間上往往不是連續分佈的,無法利用有效的緩存加速查找,當散列表的長度非常大,要使用到IO時,這種問題會更加明顯。
- 開放定址(open addressing ~ closed hashing) :
散列表所佔用的空間在物理上始終是與地址一致的,無需申請額外的空間。每一個詞條都可以存放在任何一個桶中。爲每個桶事先約定若干備用桶,它們構成一個查找鏈(probing sequence/chain)。
沿查找鏈查找,逐個轉向下一桶單元,直到
命中成功
,或者抵達一個空桶失敗
線性探索(Linear probing) 一旦衝突,則試探後一緊鄰桶單元;
[ hash(key) + 1 ] % M
[ hash(key) + 2 ] % M
[ hash(key) + 3 ] % M
...
優點:
1. 無需附加的空間
2. 查找鏈具有局部性,可充分利用系統緩存,有效減少I/O
但是:
1. 操作時間大於O(1)
2. 衝突增多,以往的衝突,會導致後續的的衝突clustering。
懶惰刪除: 使用時需要特別注意刪除,如果直接刪除,後續詞條將丟失—明明存在卻訪問不到。這時需要進行懶惰刪除,對需要刪除的某一詞條進行標記,查找操作遇到標記轉向下一個繼續查找,插入操作遇到標記則直接將詞條插入在此處。
- 平方試探(Quadratic probing) :
open addressing和closed addressing都屬於線性試探,而線性試探有一個問題就是試探位置間距太小,大部分的試探的位置都集中於某一個相對很小的局部。因此不妨適當的拉開各次試探的間距,平方試探就是這一思路的具體體現。
以平方數爲距離,確定下一試探桶單元
[ hash(key) + 1^2 ] % M
[ hash(key) + 2^2 ] % M
[ hash(key) + 3^2 ] % M
[ hash(key) + 4^2 ] % M
優點 :
數據聚集現象有所緩解,在查找鏈上,各桶間距線性遞增,一旦衝突,可聰明地跳離。
缺點 :
1. 若設計外存,I/O將激增。平方試探策略將在一定程度上破壞數據訪問的局部性,甚至系統緩存的功能會失效,不過通常情況下這個問題還不算很嚴重。不失一般性,取系統緩存頁面的大小爲1KB,如果桶單元只記錄相應的引用,大致需要4字節,每一個緩存頁面都可以容納至少256個桶單元,,也就是說要做一次額外的I/O對換,必須連續的發生16次衝突,機率其實是非常小的。
2. 可能會出現空桶。
如 { 0, 1, 2, 3, 4, 5, … }^2 % 12 = {0, 1, 4, 9}
只會涉及到其中的4個單元。沒有辦法找到剩下2/3的空桶。這裏M選的12是一個合數,借組數論的知識不難證明,只要表長M是合數,這種情況必然發生,因爲 n^2 % M 可能的取值必然少於[M/2]([]爲向上取整,()爲向下取整,之後不再說明)
種。
將表長變爲素數,如 { 0, 1, 2, 3, 4, 5, … }^2 % 11 = {0, 1, 4, 9, 5, 3}。
M若爲素數: n^2 % M 可能的取值恰好會有 [M/2]種—此前,恰由查找鏈的前[M/2],因爲一般的素數M都是奇數,所以這個比例剛剛超過50%,這是情況可能到的最壞程度。
關於這一點的正面結論是:若M是素數,但裝填因子< 0.5,就一定能找出;否則,不一定。
反證法證明這個結論:
假設存在 0 <= a < b < [M/2] ,使得沿着查找鏈,第a項和第b項彼此衝突。
即
然而
得
得到(b+a)竟然是M是一個非平凡因子,這與M是素數矛盾!
- 雙向平方試探 :
一旦發生衝突,則交替的向前向後以遞增的平方數爲間隔逐一試探。
[ hash(key) + 1^2 ] % M
[ hash(key) - 1^2 ] % M
[ hash(key) + 2^2 ] % M
[ hash(key) - 2^2 ] % M
[ hash(key) + 3^2 ] % M
[ hash(key) - 3^2 ] % M
...
正向和逆向的子查找鏈,各包含[m/2]個互異的桶,但是有些素數會讓這兩個序列存在除0以外公共的桶。
結論:若表長取做素數 M = 4 * K +3,必然可以保證查找鏈的前M項均互異。
反證法證明:
M=4*K+3
設正向試探序列的第a步與逆向試探序列的第b步衝突,
而且應當是1<= b , a <= (M/2)
衝突即 -b^2 和 a^2 是一個同餘類
-b^2 ≡ a ^2 (mod M)
設a^2 + b^2 =n
0 ≡ a^2 + b^2 = n (mod M)
所以M是n的一個素因子,
根據費馬雙平方定理的推論,
n不僅能被M整除,也能被M^2整除
所以M ^2 <= a^2 + b^2
但是與b , a <= (M/2)矛盾,即不可能成立。
費馬雙平方定理---任一素數p可表示爲一對整數的平方和,當且僅當p%4=1
費馬雙平方定理的推論---任一自然數n可表示爲一對正數的平方和,當且僅當在其素分解中,
形如 M = 4*K+3的每一素因子均爲偶數次方。
extend
>>>什麼樣的哈希函數纔是更好的?
- 確定determinism : 同一關鍵碼總是被映射至同一單元。
- 快速efficiency :expected-O(1)
- 滿射surjection : 儘可能充分地覆蓋整個散列空間
- 均勻uniformity : 關鍵碼映射到散列表各位置的概率儘量接近(可有效避免聚集clostering現象)
>>> 關於哈希的題
哈希表的查找只需要O(1),我覺得強大的不只是哈希表,更主要是hash帶給我們的這種思想,比如說Karp-Rabin算法,而且我覺得狀壓也是有這種思想的,這些都是一種把信息壓縮的思想,充分利用信息。附上兩道這兩天的leetcode的打卡題,我覺得還是很不錯的。
內容是對鄧俊輝鄧老師學堂在線上的數據結構(下)(2020春)第九章詞典的總結。(鄧老師的課真的太好了)