聲明:本文為學習資料結構與演算法分析(第三版) Clifford A.Shaffer 著的學習筆記,代碼有參考該書的範例程式碼。 散列方法
把關鍵碼值對應到數組中的位置來訪問記錄這個過程稱為散列(hashing)
把關鍵碼值對應到位置的函數稱為散列函數(hashing function),通常用 h 表示。
存放記錄的數組稱為散列表(hash table),用 HT 表示。
散列表中的一個位置稱為一個槽(slot)。
散列表 HT 中的數目用變數 M 表示,槽從 0 到 M-1標號。
散列方法通常不適用於允許多條記錄有相同關鍵碼值的應用程式。散列方法一般不適用於範圍檢索。
對於一個散列函數 h 和兩個關鍵碼值k1 、 k2,如果 h(k1) =B= h(k2) ,其中 B 為表中的一個槽,那麼就說 k1 和 k2 對於 B 在散列函數 h 下有衝突。
對於衝突,有開散列和閉散列的解決方式。 散列函數
從技術上來說,任何能把所有可能關鍵碼值對應到散列表槽中的函數都是散列函數。
下面介紹一個常用的散列函數 平方取中方法
平方取中方法是一個用於數值的散列方法。對關鍵碼計算平方,取中間的幾位,散列到相應的位置。
這樣計算的原因是因為關鍵碼的大多數位或所有位對結果都有貢獻。
比如,基數為 10 的四位關鍵碼,散列到一個長度為 100 的散列表中。假如關鍵碼為 4567 ,則
4567 * 4567 = 20857489
取的中間兩位是 57 。(位元等於 ln100 ) 摺疊方法
這是一個用於字串的散列方法,計算的方法是將字串的 ASCII 值累加起來,對 M 求模。
int h(char* str){ int sum, i; for(sum = i = 0; str[i] != '\0'; ++i) sum += static_cast<int>(str[i]); return sum % M;}
這個方法有一個不好的地方就是,假如 sum 的值比 M 小,則會產生比較差的分布。 將字串解釋為無符號整型和的散列方法
由於找不到這個方法的名字,就這麼叫吧。
這種散列方法的計算方式是,將字串解釋為一個個大小為 4 的無符號整型,求和,然後求模。
int sfold(char* str){ unsigned int sum = 0; unsigned int* intKey = static_cast<decltype(intKey)>(str); int len = strlen(str)/4; for(int i =0;i<len;++i) sum += intKey[i]; int extra = strlen(str)-len*4; char temp[4]; intKey = static_cast<decltype(intKey)>(temp);//or reinterpret_cast? intKey[0] = 0; for(int i =0;i<extra;++i) temp[i] = str[len*4+i]; sum += intKey[0]; return sum%M;}
使用不帶正負號的整數的目的是為了避免求模時結果會是負數。
在就算的過程中,如果數字過大,則會發生溢出。但是作為散列函數,並沒有關係。(意思就是,讓它溢出吧) 開散列方法
儘管散列函數的目的是盡量減少衝突,但是有些衝突是不可避免的。
解決衝突的技術可以分為兩類:開散列方法(open hashing,也稱單鏈方法,separate chaining)和閉散列方法(closed hashing,也稱開地址方法,open addressing)
開散列方法解決衝突是將衝突記錄在表外,而閉散列方法是將衝突記錄在表內的另一個空槽。
看起來就好像很好實現的樣子,於是我就是實現了一下。
在我的實現中 M 為11,散列函數如下:
int h(const Key& k) const { return k*7%M; }
實話說,這並不是一個好的散列函數。 閉散列方法
閉散列方法將所有記錄都直接儲存在散列表中。
閉散列方法有集中散列方式:桶式散列、線性探查、二次探查、雙散列方法。 桶式散列
桶式散列是把散列的槽分成多個桶(bucket)。
把散列表中的 M 個槽分成 B 個桶,每個桶中包含 M/B 個槽。
散列函數把每一條記錄分配到某個桶的第一個槽中。如果這個槽已經被佔用,那麼就順序地沿著桶尋找,直到找到一個空槽。
如果沒有空槽了,那麼就將該條記錄分配到一個具有無限容量的溢出桶中。
插入順序是
9 30 27 4 8
有6個槽,3個桶的散列表,散列函數是
int h(int i){ return i%B;}
我想這樣應該表達明確了吧。
桶式散列的一個簡單變體是,先把關鍵碼散列到槽中。當該槽滿時,再把關鍵碼散列到同一個桶的其他槽中。如果還沒有空槽,就散列到溢出桶中。
這時散列函數是:
int h(int i){ return i%M;}線性探查
線性探查是比較常用的散列函數,它不使用桶式散列的方法,而是允許記錄儲存在散列表中的任何一個空槽中。
衝突解決方案策略是產生一組有可能放置該記錄的槽,第一個槽就是該關鍵碼的基槽。如果基槽被佔用了,那麼就會尋找下一個槽,直到記錄被存放。
這組槽就稱為衝突解決方案策略產生的探查序列(probe sequence)。
探查序列是由探查函數(probe function)的 p 函數產生的。
注意,探查函數返回的是相對於初始位置的位移量,而不是散列表的一個槽。
線性探查的函數類似如下:
int p(int k, int i){ return a*i+b;}
其中 i 是第幾次的探查參數, k 是關鍵碼,a 和 b 是常數。
使用的時候如下:
return (h(k)+p(k, i))%M;
為了是探查列走遍所有的槽,a 必須與 M互素。
線性探查會導致:基本聚集(primary clustering)
考慮順序插入:
9 30 27 4 8
加入使用最基本的探查函數 return i; M為6
那麼 9 的探查序列:
3 4 5 0 1 2
而 27 的探查序列也是如此。
到插入的記錄多了,就是大部分地聚集到一起。
把記錄聚集到一起的傾向就是基本聚集了。
好的探查函數應該是使得它們的探查序列岔開。
而解決基本聚集問題就是使用二次探查或偽隨機探查。 二次探查
二次探查的函數如下:
int p(int k, int i){ return a*i*i+b*i+c;}
其中 a, b, c 為常數。
二次探查的缺陷在於,在某些特定的情況下,只有特定的槽被探查到。
考慮 M 為 3,p(k, i) = i*i;
那麼散列到槽 0 的只會探查到 0, 1 ,而不會探查到 2。
然而,這樣的情況是可以在低開銷的基礎上探查得到較好的結果。
當散列表長度為素數,以及探查函數為 p(k, i) = i*i 時,至少能夠訪問到表中一半的槽。
如果散列表長為 2 的指數,並且探查函數為 p(k, i) = (i*i+i)/2 , 那麼表中所有槽都能被探查序列訪問到。 偽隨機探查
在偽隨機探查中,探查序列中的第 i 個槽是 (h(k) + ri) mod M ,ri是 1 到 M-1 之間的數的隨機序列。
所有的插入和檢索都使用相同的偽隨機序列。
儘管二次探查和偽隨機探查能夠解決基本聚集問題,然而如果散列函數在某個基槽聚集,依然會保持聚集。這個問題稱為二次聚集(secondary clustering)
解決二次聚集問題可以使用雙散列方法 雙散列方法
雙散列方法的形式:
int p(int k, int i){ return i*h2(k);}
h2 是第二個散列函數
好的雙散列實現方法應當保證所有探查序列常數都與表 M 長度互素。
其中一種方法是設定 M 為素數,而h2 返回 1<=h2<=M-1 之間的值。
另外一種方法是給定一個 m 值,設定 M = 2m ,然後讓 h2 返回 1 到 2m 之間的一個奇數值。 閉散列方法結論
每個新插入操作產生的額外尋找代價將在散列表接近半滿時急劇增加。
如果還考慮到訪問模式,在理想情況下,記錄應當沿著探查序列按照訪問頻率排序。 刪除
刪除記錄的時候,要考慮到兩點: 刪除不應當影響後面的檢索。 刪除的槽應當可以為後來的插入操作所使用。
通過在刪除的槽中放置一個特殊的標記就可以解決這個問題。這個標記稱為墓碑(tombstone)。
如果不想使用墓碑的標記,還可以在刪除的時候進行一次局部的重組,或者定期重組散列表。
本文實現了開散列方法和閉散列方法中的一種。
代碼在本人的 github 上可以找到。
我的github
–END–