C++哈希表的實現(xiàn)思路剖析講解
前言
哈希又稱散列,是一種組織數(shù)據(jù)的方式。從譯名來看,有散亂排列的意思。本質(zhì)就是通過哈希函數(shù)把關(guān)鍵字Key跟存儲位置建立一個哈希映射關(guān)系,查找時通過這個哈希函數(shù)計算出Key存儲的位置,進行快速查找。
1.直接定址法
當(dāng)關(guān)鍵字的范圍比較集中時,直接定址法就是非常簡單高效的方法,如一組關(guān)鍵字都在[0,99]之間,那么我們開一個100個數(shù)的數(shù)組,每個關(guān)鍵字的值直接就是存儲位置的下標(biāo)。再比如一組關(guān)鍵字值都在[a,z]的小寫字母,那么我們開一個26個數(shù)的數(shù)組,每個關(guān)鍵字ascii碼-'a'就是存儲位置的下標(biāo),也就是說直接定址法本質(zhì)就是用關(guān)鍵字計算出一個絕對位置或者相對位置。
class Solution {
public:
int firstUniqChar(string s) {
//桶思想
vector<int> cnt(26,0);
//統(tǒng)計次數(shù)
for(auto i:s) cnt[i-'a']++;
for(size_t i=0;i<s.size();i++) if(cnt[s[i]-'a']==1) return i;
return -1;
}
};2.哈希沖突
直接定址法的缺點也非常明顯,當(dāng)關(guān)鍵字的范圍比較分散時,就很浪費內(nèi)存甚至內(nèi)存不夠用。假設(shè)只有數(shù)據(jù)范圍是[0,9999]的N個值,要映射到一個M個空間的數(shù)組中(一般情況下M>=N),那么就要借助哈希函數(shù)(hash function)hf,關(guān)鍵字key被放在數(shù)組的h(key)位置,要注意的是h(key)計算出的值必須在[0,M)之間。
這里存在的一個問題就是,兩個不同的key可能會映射到同一個位置,這種問題叫哈希沖突,或哈希碰撞。理想情況是找出一個好的哈希函數(shù)避免沖突,但是實際場景中,沖突是不可避免的,所以盡可能設(shè)計出優(yōu)秀的哈希函數(shù),減少沖突的次數(shù),同時也要去設(shè)計出解決沖突的方案。
3.負(fù)載因子
假設(shè)哈希表中已經(jīng)映射存儲了N個值,哈希表的大小為M,那么負(fù)載因子=N/M,負(fù)載因子有些地方也翻譯為荷載因子/裝載因子,英文為load factor。負(fù)載因子越大,哈希沖突的概率越高,空間利用率越高;負(fù)載因子越小,哈希沖突的概率越低,空間利用率越低。
4.將關(guān)鍵字轉(zhuǎn)成整數(shù)
將關(guān)鍵字映射到數(shù)組中位置,一般是整數(shù)好做映射計算,若不是整數(shù),要想辦法轉(zhuǎn)換成整數(shù)。
5.哈希函數(shù)
一個好的哈希函數(shù)應(yīng)該讓N個關(guān)鍵字被等概率的均勻的散列分布到哈希表的M個空間中,但是實際中卻很難做到,但是要盡量往這個方向去考量設(shè)計。
5.1除法散列法/除留余數(shù)法
●除法散列法也叫除留余數(shù)法,顧名思義,假設(shè)哈希表的大小為M,那么通過key除以M的余數(shù)作為映射位置的下標(biāo),也就是哈希函數(shù)為:h(key)=key%M。
●當(dāng)使用除法散列法時,要盡量避免M為某些值,如2的冪,10的冪等。若是2^x,那么key%2^x本質(zhì)相當(dāng)于保留key的后x位,那么后x位相同的值,計算出的哈希值都是一樣的,就沖突了。如{63,31}看起來沒有關(guān)聯(lián)的值,若M是16,也就是2^4,那么計算出的哈希值都是15,因為63的二進制后8位是00111111,31的二進制后8位是00111111。若是10^x,就更明顯了,保留的都是10進制的后x位,如:{112,12312},若M是100,也就是10^2,那么計算出的哈希值都是12。
●當(dāng)使用除法散列法時,建議M取不太接近2的整數(shù)次冪的一個質(zhì)數(shù)(素數(shù))。
●需要說明的是,Java的HashMap采用除法散列法時就是2的整數(shù)次冪做哈希表的大小M,這樣的話,就不用取模,而可以直接位運算,相對而言位運算比模更高效一些。但它不是單純的去取模,比如M是2^16次方,本質(zhì)是取后16位,那么用key`=key>>16,然后把key和key`異或的結(jié)果作為哈希值。也就是說我們映射出的值還是在[0,M)范圍內(nèi),但是盡量讓key所有的位都參與計算,這樣映射出的哈希值更均勻一些。所以建議M取不太接近2的整數(shù)次冪的一個質(zhì)數(shù)的理論是大多數(shù)數(shù)據(jù)結(jié)構(gòu)書籍中寫的理論,但實踐中需靈活運用。
5.2乘法散列法(了解)
●乘法散列法對哈希表大小M沒有要求,它的大思路第一步:用關(guān)鍵字K乘上常數(shù)A(0<A<1),并抽出K*A的小數(shù)部分。第二步:后再用M乘以K*A的小數(shù)部分,在向下取整。
●h(key)=floor(Mx((Axkey)%1.0)),其中floor表示對表達式進行向下取整,A∈(0,1),這里最重要的是A的值應(yīng)該如何設(shè)定,Knuth認(rèn)為A=(√5-1)/2=0.6180339887……(黃金分割點)比較好。
●乘法散列法對哈希表大小M是沒有要求的,假設(shè)M位1024,key為1234,A=0.6180339887,A*key=762.6539420558,取小數(shù)部分為0.6539420558,Mx((Axkey)%1.0)=0.6539420558*1024=669.6366651392,那么h(1234)=669。
5.3全域散列法(了解)
●若存在一個惡意的對手,他針對我們提供的散列函數(shù),特意構(gòu)造出一個發(fā)生嚴(yán)重沖突的數(shù)據(jù)集,比如,讓所有關(guān)鍵字全部落入同一個位置中。這種情況是可以存在的,只要散列函數(shù)是公開且確定的,就可以實現(xiàn)此攻擊。解決方法就是給散列函數(shù)增加隨機性,攻擊者就無法找出確定可以導(dǎo)致最壞情況的數(shù)據(jù)。這種方法叫做全域散列。
●h(key)=((axkey+b)%P)%M,P需要選一個足夠大的質(zhì)數(shù),a可以隨機選[1,P-1]之間的任意整數(shù),b可以隨機選[0,P-1]之間的任意整數(shù),這些函數(shù)構(gòu)成了一個P*(P-1)組全域散列函數(shù)組。假設(shè)P=17,M=6,a=3,b=4,則h(8)=((3x8+4)%17)%6=5。
●需要注意每次初始化哈希表時,隨機選取全域散列函數(shù)組中的一個散列函數(shù)使用,后續(xù)增刪查改都固定使用這個散列函數(shù),否則每次哈希都是隨機選一個散列函數(shù),那么插入是一個散列函數(shù),查找又是另一個散列函數(shù),就會導(dǎo)致找不到插入的key了。
5.4其它方法(了解)
●上面的幾種方法是《算法導(dǎo)論》書籍中講解的方法。
●《殷人昆 數(shù)據(jù)結(jié)構(gòu):用面向?qū)ο蠓椒ㄅcC++語言描述(第二版)》和《數(shù)據(jù)結(jié)構(gòu)(C語言版) 嚴(yán)蔚敏_吳偉民》等教材型書籍上面還給出平方取中法、折疊法、隨機數(shù)法、數(shù)學(xué)分析法等,這些方法相對更適用于一些局限的特定場景。
6.處理哈希沖突
實際中哈希表一般還是選擇除法散列法作為哈希函數(shù),當(dāng)然哈希表無論選擇什么哈希函數(shù)也避免不了沖突,那么插入數(shù)據(jù)時,如何解決沖突?主要有兩種方法,開放定址法和鏈地址法。
6.1開放定址法
在開放定址法中所有的元素都放在哈希表中,當(dāng)一個關(guān)鍵字key用哈希函數(shù)計算出的位置沖突了,則按照某種規(guī)則找到一個沒有存儲數(shù)據(jù)的位置進行存儲,開放定址法中負(fù)載因子一定是小于1的。這里的規(guī)則有三種:線性探測、二次探測、雙重探測。
線性探測
●從發(fā)生沖突的位置開始,依次線性向后探測,直到尋找到下一個沒有存儲數(shù)據(jù)的位置為止,若走到哈希表尾,則繞回到哈希表頭的位置。
●h(key)=hash0=key%M,hash0位置沖突了,則線性探測公式為:hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,…,M-1},因為負(fù)載因子小于1,則最多探測M-1次,一定能找到一個存儲key的位置。
●線性探測的比較簡單且容易實現(xiàn),線性探測的問題假設(shè),hash0位置連續(xù)沖突,hash0,hash1,hash2位置已經(jīng)存儲數(shù)據(jù)了,后續(xù)映射到hash0,hash1,hash2,hash3的值都會爭奪hash3位置,這種現(xiàn)象叫做群集/堆積。下面的二次探測可以一定程度改善這個問題。
●下面演示{19,30,5,36,13,20,21,12}等這一組值映射到M=11的表中。

h(19)=8,h(30)=8,h(5)=5,h(36)=3,h(13)=2,h(20)=9,h(21)=10,h(12)=1

二次探測
●從發(fā)生沖突的位置開始,依次左右二次方跳躍式探測,直到尋找到下一個沒有存儲數(shù)據(jù)的位置為止,若往右走到哈希表尾,則回繞到哈希表頭的位置;若往左走到哈希表頭,則繞回到哈希表尾的位置;
●h(key)=hash0=key%M,hash0位置沖突了,則二次探測公式為:hc(key,i)=hashi=(hash0?i^2)%M,i={1,2,3…,M/2}
●二次探測當(dāng)hashi=(hash0-i^2)%M時,當(dāng)hashi<0時,需要hashi+=M
●下面演示{19,30,52,63,11,22}等這一組值映射到M=11的表中。

h(19)=8,h(30)=8,h(52)=8,h(63)=8,h(11)=0,h(22)=0

雙重散列(了解)
●第一個哈希函數(shù)計算出的值發(fā)生沖突,使用第二個哈希函數(shù)計算出一個跟key相關(guān)的偏移量值,不斷往后探測,直到尋找到下一個沒有存儲數(shù)據(jù)的位置為止。
●h1(key)=hash0=key%M,hash0位置沖突了,則雙重探測公式為:hc(key,i)=hashi=(hash0+i*h2(key))%M,i={1,2,3,…,M}。
●要求h2(key)<M且h2(key)和M互為質(zhì)數(shù),有兩種簡單的取值方法:1、當(dāng)M為2整數(shù)冪時,h2(key)從[0,M-1]任選一個奇數(shù);2、當(dāng)M為質(zhì)數(shù)時,h2(key)=key%(M-1)+1
●保證h2(key)與M互質(zhì)是因為根據(jù)固定的偏移量所尋址的所有位置將形成一個群,若最大公約數(shù)p=g c d(M,h1(key))>1,那么所能尋址的位置的個數(shù)為M/P<M,使得對于一個關(guān)鍵字來說無法充分領(lǐng)用整個散列表。例:若初始探測位置為1,偏移量為3,整個散列表大小為12,那么所能尋址的位置為{1,4,7,10},尋址個數(shù)為12/g c d(12,3)=4
●下面演示{19,30,52,74}等這一組值映射到M=11的表中,設(shè)h2(key)=key%10+1

6.2開放定址法代碼實現(xiàn)
開放地址法在實踐中,不如下面鏈地址法,因為開發(fā)定址法解決沖突不管使用哪種方法,占用的都是哈希表中的空間,始終存在互相影響的問題。所以開放定址法,簡單選擇線性探測實現(xiàn)。
開發(fā)定址法的哈希表結(jié)構(gòu)
enum State{
EXIST,EMPTY,DELETE
};
template<class K,class V>
struct HashData{
pair<K,V> _kv;
State _state=EMPTY;
};
template<class K,class V>
class HashTable{
private:
vector<HashData<K,V>> _tables;
int _n;
};要注意這里需要給每個存儲值的位置加一個狀態(tài)標(biāo)識,否則刪除一些值后,回影響后面沖突的值的查找。如下圖,刪除30,回導(dǎo)致查找20失敗,當(dāng)我們給每個位置加一個狀態(tài)標(biāo)識{EXIST,EMPTY,DELETE},刪除30就可以不用刪除值,而是把狀態(tài)改成DELETE,那么查找20是時遇到EMPTY才能停止,就可以找到20.
h(19)=8,h(30)=8,h(5)=5,h(36)=3,h(13)=2,h(20)=9,h(21)=10,h(12)=1


擴容
哈希表負(fù)載因子控制在0.7,當(dāng)負(fù)載因子到0.7以后就需要擴容,按照2倍擴容,但是同時要保持哈希表是一個質(zhì)數(shù),第一個是質(zhì)數(shù),2倍后就不是質(zhì)數(shù)了。那么如何解決?一種方案就是上面1.4.1除法散列中的Java HashMap的使用2的整數(shù)冪,但是計算時不能直接取模的改進方法。另一種方案時sgi版本的哈希表使用的方法,給了一個近似2倍的質(zhì)數(shù)表,每次取質(zhì)數(shù)表獲取擴容后的大小。
inline unsigned long __stl_next_prime(unsigned long n){
//Note:assumes long is at least 32 bits
static const int __stl_num_primes=28;
static const unsigned long __stl_prime_list[__stl_num_primes]={
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first=__stl_prime_list;
const unsigned long* last=__stl_prime_list+__stl_num_primes;
const unsigned long* pos=lower_bound(first,last,n);
return pos==last?*(last-1):*pos;
}key不能取模的問題
當(dāng)key是string/Date等類型是,key不能取模,那么需要給HashTable增加一個仿函數(shù),這個仿函數(shù)支持把key轉(zhuǎn)換成一個可以取模的整型,若key可以轉(zhuǎn)換為整型并且不容易沖突,那么這個仿函數(shù)就用默認(rèn)參數(shù)即可,若這個key不能轉(zhuǎn)換成整型,就需要自己實現(xiàn)一個仿函數(shù)傳給這個參數(shù),實現(xiàn)這個仿函數(shù)的要求就是盡量key的每個值都參與到計算中,讓不同的key轉(zhuǎn)換出的整型值不同。string做哈希表的key非常常見,所以可以把sring特化一下。
template<class K>
struct HashFunc{
size_t operator()(const K& key){
return (size_t)key;
}
};
template<>
struct HashFunc<string>{
size_t operator()(const string& s){
//BKDR
size_t hash=0;
for(auto ch:s){
hash+=ch;
hash*=131;
}
return hash;
}
};
template<class K,class V,class Hash=HashFunc<K>>
class HashTable{
private:
vector<HashData<K,V>> _tables;
int _n;
};完整代碼示例
namespace open_address{
template<class K,class V,class Hash=HashFunc<K>>
class HashTable{
public:
HashTable()
:_tables(__stl_next_prime(0))
,_n(0)
{}
bool Insert(const pair<K,V>& kv){
if(Find(kv.first)){
return false;
}
//負(fù)載因子>=0.7,擴容
if(_n*10/_tables.size()>=0.7){
//舊法,等于說把Insert接口的邏輯又實現(xiàn)了一遍
// vector<HashData<K,V> nextables(_tables.size()*2);
// for(auto& data:_tables){
// //舊表的數(shù)據(jù)映射到新表
// if(data._state==EXIST){
// size_t hash0=data._kv.first%newtables.size();
// //……
// }
// }
//現(xiàn)代寫法
HashTable<K,V> newht;
//newht._tables.resize(_tables.size()*2);
newht._tables.resize(__stl_next_prime(_tables.size()+1));
for(auto& data:_tables){
//舊表映射到新表
if(data._state==EXIST)
newht.Insert(data._kv);
}
_tables.swap(newht._tables);
}
size_t hash0=kv.first%_tables.size();
size_t hash1=hash0;
int i=1;
int flag=1;
while(_tables[hash1]._state==EXIST){
//線性探測
hash1=(hash0+i)%_tables.size();
++i;
//二次探測
/*hash1=(hash0+(i*i*flag))%_tables.size();
if(hash1<_tables.size()){
hash1+=_tables.size();
}
if(flag==1)
flag=-1;
else{
++i;
flag=-1;
}*/
}
_tables[hash1]._kv=kv;
_tables[hash1]._state=EXIST;
_n++;
return false;
}
HashData<K,V>* Find(const K& key){
size_t hash0=key%_tables.size();
size_t hash1=hash0;
int i=1;
while(_tables[hash1]._state!=EMPTY){
if(_tables[hash1]._kv.first==key&&_tables[hash1]._state==EXIST)
return &_tables[hash1];
hash1=(hash0+i)%_tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key){
HashData<K,V>* ret=Find(key);
if(ret){
ret->_state=DELETE;
--_n;
return true;
}
else return false;
}
private:
vector<HashData<K,V>> _tables;
int _n;
};
}6.3鏈地址法
解決沖突的思路
開放定址法中所有的元素都放在哈希表里,鏈地址法中所有的數(shù)據(jù)不再直接存儲在哈希表中,哈希表中存儲一個指針,沒有數(shù)據(jù)映射這個位置時,這個指針為空,有多個數(shù)據(jù)映射到這個位置時,我們把這些沖突的數(shù)據(jù)鏈接成一個鏈表,掛在哈希表這個位置下面,鏈地址法也叫拉鏈法或哈希桶。
●下面演示{19,30,5,36,13,20,21,12,24,96}等這一組值映射到M=11的表中。

h(19)=8,h(30)=8,h(5)=5,h(36)=3,h(13)=2,h(20)=9,h(21)=10,h(12)=1,h(24)=2,h(96)=88

擴容
開放定址法負(fù)載因子必須小于1,鏈地址法的負(fù)載因子就沒有限制了,可以大于1。負(fù)載因子越大,哈希沖突的概率越高,空間利用率越高;負(fù)載因子越小,哈希沖突的概率越低,空間利用率越低;stl中unordered_xxx的最大負(fù)載因子基本控制在1,大于1就擴容。
極端場景
若極端場景下,某個桶特別長怎么辦?可以考慮使用全域散列法,這樣就不容易被針對。但是假設(shè)不是被針對了,用來全域散列法,但是偶然情況下,某個桶很長,查找效率很低怎么辦?在Java8的HashMap中當(dāng)桶的長度超過一定閾值(8)時就把鏈表轉(zhuǎn)換成紅黑樹。一般情況下,不斷擴容,單個桶很長的場景還是比較少的。
6.4鏈地址法代碼實現(xiàn)
namespace hash_bukect{
template<class K,class V>
struct HashNode{
pair<K,V> _kv;
HashNode<K,V>* _next;
HashNode(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V,class Hash=HashFunc<K>>
class HashTable{
typedef HashNode<K,V> Node;
public:
HashTable()
:_tables(11)
,_n(0)
{}
inline unsigned long __stl_next_prime(unsigned long n){
//Note:assumes long is at least 32 bits
static const int __stl_num_primes=28;
static const unsigned long __stl_prime_list[__stl_num_primes]={
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first=__stl_prime_list;
const unsigned long* last=__stl_prime_list+__stl_num_primes;
const unsigned long* pos=lower_bound(first,last,n);
return pos==last?*(last-1):*pos;
}
bool Insert(const pair<K,V>& kv){
//當(dāng)負(fù)載因子為1時,擴容
if(_n==_tables.size()){
//直接復(fù)用Insert,不好
/*HashTable<K,V> newht;
newht._tables.resize(_tables.size()*2);
for(int i=0;i<_tables.size();i++){
Node* cur=_tables[i];
while(cur){
newht.Insert(cur);
cur=cur->_next;
}
}
_tables.swap(newht);*/
HashTable<K,V> newht;
newht._tables.resize(_tables.size()*2);
//newht._tables.resize(__stl_next_prime(_tables.size()+1));
for(int i=0;i<_tables.size();i++){
Node* cur=_tables[i];
while(cur){
Node* next=cur->_next;
size_t hash1=cur->_kv.first%newht._tables.size();
//頭插
cur->_next=newht[hash1];
newht[hash1]=cur;
cur=next;
}
_tables[i]=nullptr;
}
_tables.swap(newht);
}
//頭插
size_t hash1=kv.first%_tables.size();
Node* newNode=new Node(kv);
newNode->next=_tables[hash1];
_tables[hash1]=newNode;
++_n;
return true;
}
Node* Find(const K& key){
size_t hash0=key%_tables.size();
Node* cur=_tables[hash0];
while(cur){
if(cur->_kv.first==key)
return cur;
cur=cur->_next;
}
return nullptr;
}
bool Erase(const K& key){
//沒有這個值
if(!Find(key)) return false;
size_t hash0=key%_tables.size();
Node* cur=_tables[hash0];
Node* prev=nullptr;
while(cur){
if(cur->_kv==key){
//刪除的節(jié)點是鏈表的頭
if(!prev){
_tables[hash0]=cur->_next;
}
else{
prev->_next=cur->_next;
}
delete cur;
--_n;
return true;
}
prev=cur;
cur=cur->_next;
}
return false;
}
private:
vector<HashNode<K,V>*> _tables;
size_t _n;
};
}以上就是C++哈希表的實現(xiàn)思路剖析講解的詳細(xì)內(nèi)容,更多關(guān)于C++哈希表的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
C++ new與malloc和delete及free動態(tài)內(nèi)存管理及區(qū)別介紹
這篇文章主要介紹了深入理解C++中的new/delete和malloc/free動態(tài)內(nèi)存管理,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-12-12
C++常用函數(shù)總結(jié)(algorithm 頭文件)
本文給大家詳細(xì)介紹了algorithm 頭文件中最常用的函數(shù)及其使用方法,當(dāng)然這只是其中的一部分,algorithm 頭文件中還有很多其他的函數(shù),感興趣的朋友一起看看吧2023-12-12
C++實現(xiàn)LeetCode(21.混合插入有序鏈表)
這篇文章主要介紹了C++實現(xiàn)LeetCode(21.混合插入有序鏈表),本篇文章通過簡要的案例,講解了該項技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-07-07

