哈希原理
哈希原理
C++11提供的unordered系列的容器之所以在查找方面能夠達到O(1)O(1)O(1)的復雜度,是因為其底層使用了哈希的結構
一、哈希的概念:
順序結構以及平衡樹中,元素關鍵碼與其存儲位置之間沒有對應的關系,因此在查找一個元素時,必須要經過關鍵碼的多次比較。順序查找時間復雜度為O(N)O(N)O(N),平衡樹中為樹的高度,即O(log2N)O(log~2 N)O(log?2N),搜索的效率取決于搜索過程中元素的比較次數。
理想的搜索方法:可以不經過任何比較,一次直接從表中得到要搜索的元素。 如果構造一種存儲結構,通過某種函數(hashFunc)使元素的存儲位置與它的關鍵碼之間能夠建立一一映射的關系,那么在查找時通過該函數可以很快找到該元素
當向該結構中:
插入元素
根據待插入元素的關鍵碼,以此函數計算出該元素的存儲位置并按此位置進行存放
搜索元素
對元素的關鍵碼進行同樣的計算,把求得的函數值當做元素的存儲位置,在結構中按此位置取元素比較,若關鍵碼相等,則搜索成功
該方式即為哈希(散列)方法,哈希方法中使用的轉換函數稱為哈希(散列)函數,構造出來的結構稱為哈希表(Hash Table)(或者稱散列表)
用該方法進行搜索不必進行多次關鍵碼的比較,因此搜索的速度比較快
問題:按照上述哈希方式,向集合中插入元素44,會出現什么問題?
二、哈希沖突
對于兩個數據元素的關鍵字ki 和kj (i != j),有 ki != kj ,但有:Hash( ki) == Hash(kj),即:不同關鍵字通過 相同哈希哈數計算出相同的哈希地址,該種現象稱為哈希沖突或哈希碰撞。
把具有不同關鍵碼而具有相同哈希地址的數據元素稱為“同義詞”。 發生哈希沖突該如何處理呢?
三、哈希函數
引起哈希沖突的一個原因可能是:哈希函數設計不夠合理。 哈希函數設計原則:
- 哈希函數的定義域必須包括需要存儲的全部關鍵碼,而如果散列表允許有m個地址時,其值域必須在0 到m-1之間
- 哈希函數計算出來的地址能均勻分布在整個空間中
- 哈希函數應該比較簡單
常見哈希函數
直接定制法–(常用)
取關鍵字的某個線性函數為散列地址:Hash(Key)= A*Key + B
優點:簡單、均勻 。
缺點:需要事先 知道關鍵字的分布情況
使用場景:適合查找比較小且連續的情況
除留余數法--(常用)
設散列表中允許的地址數為m,取一個不大于m,但最接近或者等于m的質數p作為除數,按照哈希函 數:Hash(key) = key% p(p<=m),將關鍵碼轉換成哈希地址
平方取中法
假設關鍵字為1234,對它平方就是1522756,抽取中間的3位227作為哈希地址; 再比如關鍵字為4321,對它平方就是18671041,抽取中間的3位671(或710)作為哈希地址 平方取中法比較適合:不知道關鍵字的分布,而位數又不是很大的情況
折疊法
折疊法是將關鍵字從左到右分割成位數相等的幾部分(最后一部分位數可以短些),然后將這幾部分疊加 求和,并按散列表表長,取后幾位作為散列地址。
折疊法適合事先不需要知道關鍵字的分布,適合關鍵字位數比較多的情況
隨機數法
選擇一個隨機函數,取關鍵字的隨機函數值為它的哈希地址,即H(key) = random(key),其中random為 隨機數函數。
通常應用于關鍵字長度不等時采用此法
數學分析法
四、解決哈希沖突的方法
1. 閉散列
閉散列:也叫開放定址法,當發生哈希沖突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那 么可以把key存放到沖突位置中的“下一個” 空位置中去。
那如何尋找下一個空位置呢?
比如前面的場景,現在需要插入元素44,先通過哈希函數計算哈希地址,hashAddr為4,因此44理論 上應該插在該位置,但是該位置已經放了值為4的元素,即發生哈希沖突。
線性探測:從發生沖突的位置開始,依次向后探測,直到尋找到下一個空位置為止
- 插入
a.通過哈希函數獲取待插入元素在哈希表中的位置
b.如果該位置中沒有元素則直接插入新元素,如果該位置中有元素發生哈希沖突,使用線性探 測找到下一個空位置,插入新元素
- 刪除
采用閉散列處理哈希沖突時,不能隨便物理刪除哈希表中已有的元素,若直接刪除元素會影響其他 元素的搜索。比如刪除元素4,如果直接刪除掉,44查找起來可能會受影響。因此線性探測采用標 記的偽刪除法來刪除一個元素
線性探測的實現
// 注意:假如實現的哈希表中元素唯一,即key相同的元素不再進行插入 // 為了實現簡單,此哈希表中我們將比較直接與元素綁定在一起 template<class K, class V> class HashTable {struct Elem{pair<K, V> _val;State _state;};public:HashTable(size_t capacity = 3): _ht(capacity), _size(0){for(size_t i = 0; i < capacity; ++i)_ht[i]._state = EMPTY;}bool Insert(const pair<K, V>& val){// 檢測哈希表底層空間是否充足// _CheckCapacity();size_t hashAddr = HashFunc(key);// size_t startAddr = hashAddr;while(_ht[hashAddr]._state != EMPTY){if(_ht[hashAddr]._state == EXIST &&_ht[hashAddr]._val.first == key)return false;hashAddr++;if(hashAddr == _ht.capacity())hashAddr = 0;/*// 轉一圈也沒有找到,注意:動態哈希表,該種情況可以不用考 慮,哈希表中元素個數到達一定的數量,哈希沖突概率會增大,需要擴容來降低哈希沖突, 因此哈希表中元素是不會存滿的if(hashAddr == startAddr)return false;*/}// 插入元素_ht[hashAddr]._state = EXIST;_ht[hashAddr]._val = val;_size++;return true;}int Find(const K& key){size_t hashAddr = HashFunc(key);while(_ht[hashAddr]._state != EMPTY){if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)return hashAddr;hashAddr++;}return hashAddr;}bool Erase(const K& key){int index = Find(key);if(-1 != index){_ht[index]._state = DELETE;_size++;return true;}return false;}size_t Size()const;bool Empty() const;void Swap(HashTable<K, V, HF>& ht);private:size_t HashFunc(const K& key){return key % _ht.capacity();}private:vector<Elem> _ht;size_t _size; };思考:哈希表什么情況下進行擴容?如何擴容?
void CheckCapacity() {if(_size * 10 / _ht.capacity() >= 7){HashTable<K, V, HF> newHt(GetNextPrime(ht.capacity));for(size_t i = 0; i < _ht.capacity(); ++i){if(_ht[i]._state == EXIST)newHt.Insert(_ht[i]._val);}Swap(newHt);} }線性探測優點:實現非常簡單,
線性探測缺點:一旦發生哈希沖突,所有的沖突連在一起,容易產生數據“堆積”,即:不同關鍵碼占據 了可利用的空位置,使得尋找某關鍵碼的位置需要許多次比較,導致搜索效率降低。如何緩解呢?
2.二次探測
線性探測的缺陷是產生沖突的數據堆積在一塊,這與其找下一個空位置有關系,因為找空位置的方式就 是挨著往后逐個去找,因此二次探測為了避免該問題,找下一個空位置的方法為:Hi = (H0 + i^2)%m, 或者: Hi = (H0 + i^2)% m,H(i + 1) = (H0 + (i + 1)^2)% m,所以H(i + 1) = Hi + 2i + 1。其中:i = 1,2,3…, 是通過散列函數Hash(x)對元素的關鍵碼 key 進行 計算得到的位置,m是表的大小。
對于前面如果要插入44,產生沖突,使用解決后的情況為:
研究表明:當表的長度為質數且表裝載因子a不超過0.5時,新的表項一定能夠插入,而且任何一個位置 都不會被探查兩次。因此只要表中有一半的空位置,就不會存在表滿的問題。在搜索時可以不考慮表裝 滿的情況,但在插入時必須確保表的裝載因子a不超過0.5,如果超出必須考慮增容。
因此:閉散列最大的缺陷就是空間利用率比較低,這也是哈希的缺陷
2. 開散列
開散列法又叫鏈地址法(開鏈法),首先對關鍵碼集合用散列函數計算散列地址,具有相同地址的關鍵碼 歸于同一子集合,每一個子集合稱為一個桶,各個桶中的元素通過一個單鏈表鏈接起來,各鏈表的頭結 點存儲在哈希表中。
從上圖可以看出,開散列中每個桶中放的都是發生哈希沖突的元素。
桶的個數是一定的,隨著元素的不斷插入,每個桶中元素的個數不斷增多,極端情況下,可能會導致一個桶中鏈表節點非常多,會影響的哈希表的性能,因此在一定條件下需要對哈希表進行增容,那該條件
怎么確認呢?開散列最好的情況是:每個哈希桶中剛好掛一個節點,再繼續插入元素時,每一次都會發生哈希沖突,因此,在元素個數剛好等于桶的個數時,可以給哈希表增容
void _CheckCapacity() {size_t bucketCount = BucketCount();if(_size == bucketCount){HashBucket<V, HF> newHt(bucketCount);for(size_t bucketIdx = 0; bucketIdx < bucketCount; ++bucketIdx){PNode pCur = _ht[bucketIdx];while(pCur){// 將該節點從原哈希表中拆出來_ht[bucketIdx] = pCur->_pNext;// 將該節點插入到新哈希表中size_t bucketNo = newHt.HashFunc(pCur->_data);pCur->_pNext = newHt._ht[bucketNo];newHt._ht[bucketNo] = pCur;pCur = _ht[bucketIdx];}}newHt._size = _size;this->Swap(newHt);} }只能存儲key為整形的元素,其他類型怎么解決?
// 哈希函數采用處理余數法,被模的key必須要為整形才可以處理,此處提供將key轉化為整形的方法 // 整形數據不需要轉化 template<class T> class DefHashF { public:size_t operator()(const T& val){return val;} };// key為字符串類型,需要將其轉化為整形 class Str2Int { public:size_t operator()(const string& s){const char* str = s.c_str();unsigned int seed = 131; // 31 131 1313 13131 131313unsigned int hash = 0;while (*str){hash = hash * seed + (*str++);}return (hash & 0x7FFFFFFF);} };// 為了實現簡單,此哈希表中我們將比較直接與元素綁定在一起 template<class V, class HF> class HashBucket {// …… private:size_t HashFunc(const V& data){return HF()(data.first)%_ht.capacity();} };除留余數法,最好模一個素數,如何每次快速取一個類似兩倍關系的素數?
const int PRIMECOUNT = 28; const size_t primeList[PRIMECOUNT] = {53ul, 97ul, 193ul, 389ul, 769ul,1543ul, 3079ul, 6151ul, 12289ul, 24593ul,49157ul, 98317ul, 196613ul, 393241ul, 786433ul,1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,1610612741ul, 3221225473ul, 4294967291ul };size_t GetNextPrime(size_t prime) {size_t i = 0;for(; i < PRIMECOUNT; ++i){if(primeList[i] > prime)return primeList[i];}return primeList[PRIMECOUNT - 1]; }開散列與閉散列比較
應用鏈地址法處理溢出,需要增設鏈接指針,似乎增加了存儲開銷。事實上: 由于開地址法必須保持大量的空閑空間以確保搜索效率,如二次探查法要求裝載因子a <= 0.7,而表項所占空間又比指針大的多,所以使用鏈地址法反而比開地址法節省存儲空間
最后最后再說一下開散列和閉散列能否用vector自定義的擴容方式?
答案是不可以。
- vector底層的擴容方式為:申請新空間–>拷貝元素—>釋放舊空間,
- 在閉散列和開散列中共同的問題是:在計算哈希地址的時候用到data%capacity,而vector是直接拷貝過來,在完成擴容后,capacity已經改變,再去用data%capacity就無法正確找到元素。
- 而在開散列中,vector每個元素存放的是每個哈希鏈的首地址,在釋放舊空間的時候就已經把哈希鏈釋放掉了,在新空間中卻仍然在使用,就會發生錯誤
五、模擬實現哈希
github源碼
總結
- 上一篇: 基于Huffman算法的文件解压缩
- 下一篇: 基于LZ77算法的文件压缩铺垫