C++數(shù)據(jù)結(jié)構(gòu)之哈希表的實(shí)現(xiàn)
哈希表概念
二叉搜索樹(shù)具有對(duì)數(shù)時(shí)間的表現(xiàn),但這樣的表現(xiàn)建立在一個(gè)假設(shè)上:輸入的數(shù)據(jù)有足夠的隨機(jī)性。哈希表又名散列表,在插入、刪除、搜索等操作上具有「常數(shù)平均時(shí)間」的表現(xiàn),而且這種表現(xiàn)是以統(tǒng)計(jì)為基礎(chǔ),不需依賴(lài)輸入元素的隨機(jī)性。
聽(tīng)起來(lái)似乎不可能,倒也不是,例如:
假設(shè)所有元素都是 8-bits 的正整數(shù),范圍 0~255,那么簡(jiǎn)單得使用一個(gè)數(shù)組就可以滿足上述要求。首先配置一個(gè)數(shù)組 Q,擁有 256 個(gè)元素,索引號(hào)碼 0~255,初始值全部為 0。每一個(gè)元素值代表相應(yīng)的元素的出現(xiàn)次數(shù)。如果插入元素 i,就執(zhí)行 Q[i]++,如果刪除元素 i,就執(zhí)行 Q[i]--,如果查找元素 i,就看 Q[i] 是否為 0。

這個(gè)方法有兩個(gè)很?chē)?yán)重的問(wèn)題。
- 如果元素是 32-bits,數(shù)組的大小就是232=4GB,這就太大了,更不用說(shuō) 64-bits 的數(shù)了
- 如果元素類(lèi)型是字符串而非整數(shù),就需要某種方法,使其可用作數(shù)組的索引
散列函數(shù)
如何避免使用一個(gè)太大的數(shù)組,以及如何將字符串轉(zhuǎn)化為數(shù)組的索引呢?一種常見(jiàn)的方法就是使用某種映射函數(shù),將某一元素映射為一個(gè)「大小可接受的索引」,這樣的函數(shù)稱(chēng)為散列函數(shù)。
散列函數(shù)應(yīng)有以下特性:
- 函數(shù)的定義域必須包含需要存儲(chǔ)的全部關(guān)鍵字,當(dāng)散列表有 m 個(gè)地址時(shí),其值域在 0 到 m - 1 之間
- 函數(shù)計(jì)算出來(lái)的地址能均勻分布在整個(gè)空間
直接定址法
取關(guān)鍵字的某個(gè)線性函數(shù)為散列地址:Hash(Key)=A∗Key+B
優(yōu)點(diǎn):簡(jiǎn)單、均勻
缺點(diǎn):需要事先知道關(guān)鍵字的分布情況
使用場(chǎng)景:數(shù)據(jù)范圍比較集中的情況
除留余數(shù)法
設(shè)散列表的索引個(gè)數(shù)為 m,取一個(gè)不大于 m,但最接近 m 的質(zhì)數(shù) p 最為除數(shù),按照散列函數(shù):Hash(Key)=key,將關(guān)鍵字轉(zhuǎn)化為哈希地址
平方取中法
假設(shè)關(guān)鍵字為 1230,它的平方是 1512900,取中間的 3 位 129 作為哈希地址;
再比如關(guān)鍵字為 321,它的平方是 103041,取中間的 3 位 304(或 30)作為哈希地址。
哈希沖突
使用散列函數(shù)會(huì)帶來(lái)一個(gè)問(wèn)題:可能有不同的元素被映射到相同的位置。這無(wú)法避免,因?yàn)樵貍€(gè)數(shù)大于數(shù)組的容量,這便是「哈希沖突」。解決沖突問(wèn)題的方法有很有,包括線性探測(cè)、二次探測(cè)、開(kāi)散列等。
線性探測(cè)
當(dāng)散列函數(shù)計(jì)算出某個(gè)元素的插入位置,而該位置上已有其他元素了。最簡(jiǎn)單的方法就是向下一一尋找(到達(dá)尾端,就從頭開(kāi)始找),直到找到一個(gè)可用位置。
進(jìn)行元素搜索時(shí)同理,如果散列函數(shù)計(jì)算出來(lái)的位置上的元素值與目標(biāo)不符,就向下一一尋找,直到找到目標(biāo)值或遇到空。
至于元素的刪除,必須采用偽刪除,即只標(biāo)記刪除記號(hào),實(shí)際刪除操作在哈希表重新整理時(shí)再進(jìn)行。這是因?yàn)楣1碇械拿恳粋€(gè)元素不僅表示它自己,也影響到其他元素的位置。

從上述插入過(guò)程我們可以看出,當(dāng)哈希表中元素變多時(shí),發(fā)生沖突的概率也變大了。由此,我們引出哈希表一個(gè)重要概念:負(fù)載因子。
負(fù)載因子定義為:Q = 表中元素個(gè)數(shù) / 哈希表的長(zhǎng)度
- 負(fù)載因子越大,剩余可用空間越少,發(fā)生沖突可能越大
- 負(fù)載因子越小,剩余可用空間越多,發(fā)生沖突可能越小,同時(shí)空間浪費(fèi)更多
因此,控制負(fù)載因子是個(gè)非常重要的事。對(duì)于開(kāi)放定址法(發(fā)生了沖突,就找下一個(gè)可用位置),負(fù)載因子應(yīng)控制在 0.7~0.8 以下。超過(guò) 0.8,查找時(shí)的 CPU 緩存不命中按照指數(shù)曲線上升。
二次探測(cè)
線性探測(cè)的缺陷是產(chǎn)生沖突的數(shù)據(jù)會(huì)堆在一起,這與其找下一個(gè)空位置的方式有關(guān),它找空位置的方式是挨著往后逐個(gè)去找。二次探測(cè)主要用來(lái)解決數(shù)據(jù)堆積的問(wèn)題,其命名由來(lái)是因?yàn)榻鉀Q碰撞問(wèn)題的方程式F(i)=i2是個(gè)二次方程式。
更具體地說(shuō),如果散列函數(shù)計(jì)算出新元素的位置為 H,而該位置實(shí)際已被使用,那么將嘗試H+12,H+22,H+32,...,H+i2,而不是像線性探測(cè)那樣依次嘗試H+1,H+2,H+3,...,H+i。

大量實(shí)驗(yàn)表明:當(dāng)表格大小為質(zhì)數(shù),而且保持負(fù)載因子在 0.5 以下(超過(guò) 0.5 就重新配置),那么就可以確定每插入一個(gè)新元素所需要的探測(cè)次數(shù)不超過(guò) 2。
鏈地址法
這種方法是在每一個(gè)表格元素中維護(hù)一個(gè)鏈表,在呢個(gè)鏈表上執(zhí)行元素的插入、查詢、刪除等操作。這時(shí)表格內(nèi)的每個(gè)單元不再只有一個(gè)節(jié)點(diǎn),而可能有多個(gè)節(jié)點(diǎn)。

節(jié)點(diǎn)的定義:
template <class Value>
struct __hashtable_node {
__hashtable_node* next;
Value val;
};
哈希表的實(shí)現(xiàn)
閉散列
接口總覽
template <class K, class V>
class HashTable {
struct Elem {
pair<K, V> _kv;
State _state = EMPTY;
};
public:
Elem* Find(const K& key);
bool Insert(const pair<K, V>& kv);
bool Erase(const K& key);
private:
vector<Elem> _table;
size_t _n = 0;
};
節(jié)點(diǎn)的結(jié)構(gòu)
因?yàn)樵陂]散列的哈希表中的每一個(gè)元素不僅表示它自己,也影響到其他元素的位置。所以要使用偽刪除,我們使用一個(gè)變量來(lái)表示。
/// @brief 標(biāo)記每個(gè)位置狀態(tài)
enum State {
EMPTY, // 空
EXIST, // 有數(shù)據(jù)
DELETE // 有數(shù)據(jù),但已被刪除
};
哈希表的節(jié)點(diǎn)結(jié)構(gòu),不僅存儲(chǔ)數(shù)據(jù),還存儲(chǔ)狀態(tài)。
/// @brief 哈希表的節(jié)點(diǎn)
struct Elem {
pair<K, V> _kv; // 存儲(chǔ)數(shù)據(jù)
State _state; // 存儲(chǔ)狀態(tài)
};
查找
查找的思路比較簡(jiǎn)單:
- 利用散列函數(shù)獲取映射后的索引
- 遍歷數(shù)組看是否存在,直到遇到空表示查找失敗
/// @brief 查找指定 key
/// @param key 待查找節(jié)點(diǎn)的 key 值
/// @return 找到返回節(jié)點(diǎn)的指針,沒(méi)找到返回空指針
Elem* Find(const K& key) {
if (_table.empty()) {
return nullptr;
}
// 使用除留余數(shù)法的簡(jiǎn)化版本,并沒(méi)有尋找質(zhì)數(shù)
// 同時(shí),該版本只能用于正整數(shù),對(duì)于字符串等需使用其他散列函數(shù)
size_t start = key % _table.size();
size_t index = start;
size_t i = 1;
// 直到找到空位置停止
while (_table[index]._state != EMPTY) {
if (_table[index]._state == EXIST && _table[index]._kv.first == key) {
return &_table[index];
}
index = start + i;
index %= _table.size();
++i;
// 判斷是否重復(fù)查找
if (index == start) {
return nullptr;
}
}
return nullptr;
}
在上面代碼的查找過(guò)程中,加了句用于判斷是否重復(fù)查找的代碼。理論上上述代碼不會(huì)出現(xiàn)所有的位置都有數(shù)據(jù),查找不存在的數(shù)據(jù)陷入死循環(huán)的情況,因?yàn)楣1頃?huì)擴(kuò)容,閉散列下負(fù)載因子不會(huì)到 1。
但假如,我們插入了 5 個(gè)數(shù)據(jù),又刪除了它們,之后又插入了 5 個(gè)數(shù)據(jù),將 10 個(gè)初始位置都變?yōu)榉?EMPTY。此時(shí)我們查找的值不存在的話,是會(huì)陷入死循環(huán)的。
插入
插入的過(guò)程稍微復(fù)雜一些:
1.首先檢查待插入的 key 值是否存在
2.其次需要檢查是否需要擴(kuò)容
3.使用線性探測(cè)方式將節(jié)點(diǎn)插入
/// @brief 插入節(jié)點(diǎn)
/// @param kv 待插入的節(jié)點(diǎn)
/// @return 插入成功返回 true,失敗返回 false
bool Insert(const pair<K, V>& kv) {
// 檢查是否已經(jīng)存在
Elem* res = Find(kv.first);
if (res != nullptr) {
return false;
}
// 看是否需要擴(kuò)容
if (_table.empty()) {
_table.resize(10);
} else if (_n > 0.7 * _table.size()) { // 變化一下負(fù)載因子計(jì)算,可以避免使用除法
HashTable backUp;
backUp._table.resize(2 * _table.size());
for (auto& [k, s] : _table) {
// C++ 17 的結(jié)構(gòu)化綁定
// k 綁定 _kv,s 綁定 _state
if (s == EXIST) {
backUp.Insert(k);
}
}
// 交換這兩個(gè)哈希表,現(xiàn)代寫(xiě)法
_table.swap(backUp._table);
}
// 將數(shù)據(jù)插入
size_t start = kv.first % _table.size();
size_t index = start;
size_t i = 1;
// 找一個(gè)可以插入的位置
while (_table[index]._state == EXIST) {
index = start + i;
index %= _table.size();
++i;
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
++_n;
return true;
}
刪除
刪除的過(guò)程非常簡(jiǎn)單:
1.查找指定 key
2.找到了就將其狀態(tài)設(shè)為 DELETE,并減少表中元素個(gè)數(shù)
/// @brief 刪除指定 key 值
/// @param key 待刪除節(jié)點(diǎn)的 key
/// @return 刪除成功返回 true,失敗返回 false
bool Erase(const K& key) {
Elem* res = Find(key);
if (res != nullptr) {
res->_state = DELETE;
--_n;
return true;
}
return false;
}
開(kāi)散列
接口總覽
template <class K, class V>
class HashTable {
struct Elem {
Elem(const pair<K, V>& kv)
: _kv(kv)
, _next(nullptr)
{}
pair<K, V> _kv;
Elem* _next;
};
public:
Elem* Find(const K& key);
bool Insert(const pair<K, V>& kv);
bool Erase(const K& key);
private:
vector<Elem*> _table;
size_t _n = 0;
};
節(jié)點(diǎn)的結(jié)構(gòu)
使用鏈地址法解決哈希沖突就不再需要偽刪除了,但需要一個(gè)指針,指向相同索引的下一個(gè)節(jié)點(diǎn)。
/// @brief 哈希表的節(jié)點(diǎn)
struct Elem {
Elem(const pair<K, V>& kv)
: _kv(kv)
, _next(nullptr)
{}
??????? pair<K, V> _kv; // 存儲(chǔ)數(shù)據(jù)
Elem* _next; // 存在下一節(jié)點(diǎn)地址
};
查找
查找的實(shí)現(xiàn)比較簡(jiǎn)單:
1.利用散列函數(shù)獲取映射后的索引
2.遍歷該索引位置的鏈表
/// @brief 查找指定 key
/// @param key 待查找節(jié)點(diǎn)的 key 值
/// @return 找到返回節(jié)點(diǎn)的指針,沒(méi)找到返回空指針
Elem* Find(const K& key) {
if (_table.empty()) {
return nullptr;
}
size_t index = key % _table.size();
Elem* cur = _table[index];
// 遍歷該位置鏈表
while (cur != nullptr) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
插入
開(kāi)散列下的插入比閉散列簡(jiǎn)單:
1.首先檢查待插入的 key 值是否存在
2.其次需要檢查是否需要擴(kuò)容
3.將新節(jié)點(diǎn)以頭插方式插入
/// @brief 插入節(jié)點(diǎn)
/// @param kv 待插入的節(jié)點(diǎn)
/// @return 插入成功返回 true,失敗返回 false
bool Insert(const pair<K, V>& kv) {
// 檢查是否已經(jīng)存在
Elem* res = Find(kv.first);
if (res != nullptr) {
return false;
}
// 檢查是否需要擴(kuò)容
if (_table.size() == _n) {
vector<Elem*> backUp;
size_t newSize = _table.size() == 0 ? 10 : 2 * _table.size();
backUp.resize(newSize);
// 遍歷原哈希表,將所有節(jié)點(diǎn)插入新表
for (int i = 0; i < _table.size(); ++i) {
Elem* cur = _table[i];
while (cur != nullptr) {
// 取原哈希表的節(jié)點(diǎn)放在新表上,不用重新申請(qǐng)節(jié)點(diǎn)
Elem* tmp = cur->_next;
size_t index = cur->_kv.first % backUp.size();
cur->_next = backUp[index];
backUp[index] = cur;
cur = tmp;
}
_table[i] = nullptr;
}
_table.swap(backUp);
}
// 將新節(jié)點(diǎn)以頭插的方式插入
size_t index = kv.first % _table.size();
Elem* newElem = new Elem(kv);
newElem->_next = _table[index];
_table[index] = newElem;
++_n;
return true;
}
刪除
開(kāi)散列的刪除與閉散列有些許不同:
1.獲取 key 對(duì)應(yīng)的索引
2.遍歷該位置鏈表,找到就刪除
/// @brief 刪除指定 key 值
/// @param key 待刪除節(jié)點(diǎn)的 key
/// @return 刪除成功返回 true,失敗返回 false
bool Erase(const K& key) {
size_t index = key % _table.size();
Elem* prev = nullptr;
Elem* cur = _table[index];
while (cur != nullptr) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
// 是該位置第一個(gè)節(jié)點(diǎn)
_table[index] = cur->_next;
} else {
prev->_next = cur->_next;
}
delete cur; // 釋放該節(jié)點(diǎn)
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
到此這篇關(guān)于C++數(shù)據(jù)結(jié)構(gòu)之哈希表的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)C++哈希表內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++學(xué)習(xí)進(jìn)階之Makefile基礎(chǔ)用法詳解
Makefile 通常指的是一個(gè)含有一系列命令(directive)的,通過(guò) Make自動(dòng)化編譯工具,幫助 C/C++ 程序?qū)崿F(xiàn)自動(dòng)編譯目標(biāo)文件的文件,這篇文章主要給大家介紹了關(guān)于C++學(xué)習(xí)進(jìn)階之Makefile基礎(chǔ)用法的相關(guān)資料,需要的朋友可以參考下2021-07-07
opencv 做人臉識(shí)別 opencv 人臉匹配分析
opencv 人臉識(shí)別通過(guò)級(jí)聯(lián)分類(lèi)器對(duì)特征的分級(jí)篩選來(lái)確定是否是人臉,每個(gè)節(jié)點(diǎn)的正確識(shí)別率很高,但正確拒絕率很低,任一節(jié)點(diǎn)判斷沒(méi)有人臉特征則結(jié)束運(yùn)算,宣布不是人臉2012-11-11
C語(yǔ)言實(shí)現(xiàn)排序算法之歸并排序詳解
這篇文章主要介紹了C語(yǔ)言實(shí)現(xiàn)排序算法之歸并排序,對(duì)歸并排序的原理及實(shí)現(xiàn)過(guò)程做了非常詳細(xì)的解讀,需要的朋友可以參考下2014-07-07
C++示例講解friend static const關(guān)鍵字的用法
靜態(tài)成員static是解決同一個(gè)類(lèi)的不同對(duì)象之間數(shù)據(jù)和函數(shù)共享問(wèn)題。區(qū)分全局變量,全局變量也能實(shí)現(xiàn)數(shù)據(jù)共享,但安全性和封裝性被破壞了,友元提供了不同類(lèi)或?qū)ο蟮某蓡T函數(shù)之間、類(lèi)的成員函數(shù)與一般函數(shù)之間進(jìn)行數(shù)據(jù)共享的機(jī)制,const常引用-被引用的對(duì)象不能被更新2022-06-06
C語(yǔ)言結(jié)構(gòu)體數(shù)組同時(shí)賦值的另類(lèi)用法
今天小編就為大家分享一篇關(guān)于C語(yǔ)言結(jié)構(gòu)體數(shù)組同時(shí)賦值的另類(lèi)用法,小編覺(jué)得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來(lái)看看吧2018-12-12
C語(yǔ)言統(tǒng)計(jì)輸入字符各個(gè)字母出現(xiàn)頻率的解題思路
這篇文章主要介紹了C語(yǔ)言統(tǒng)計(jì)輸入字符各個(gè)字母出現(xiàn)頻率的解題思路,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2015-08-08

