行业知识
C++ 哈希表
���录
一、哈希概念
二、哈希冲突
三、哈希函数
四、哈希冲突解决
(一)闭散列
1、线性探测
2、实现线性探测
1、节点+哈希表定义
2、插入
3、查找
4、删除
3、线性探测总结
4、二次探测
(二)开散列
1、概念
2、节点
3、处理不同类型的哈希值
4、哈希表定义
5、插入
6、查找
7、删除
8、析构函数
9、质数列表(扩容)
10、寻找最大桶长(测试)
11、哈希表优化策略
代码完整版+测试
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
一、哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法是能够直接从表中获取要搜索的元素,而不需要进行比较。
哈希方法通过构造一种存储结构,并使用一个哈希函数(hashFunc)使元素的存储位置与其关键码之间建立一一映射的关系。这样,在查找时可以通过哈希函数快速找到元素。
在哈希方法中,当向结构中:
- 插入元素时,根据待插入元素的关键码,使用哈希函数计算出该元素的存储位置,并将其放置在该位置上。
- 搜索元素时,同样使用哈希函数计算元素的存储位置,并在结构中按照该位置取出元素进行比较。如果关键码相等,则搜索成功。
这种方式称为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)或散列表。
例如,对于数据集合{1, 7, 6, 4, 5, 9},可以设置哈希函数为hash(key) = key % capacity,其中capacity表示存储元素的底层空间总大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快.
二、哈希冲突
- 哈希冲突指的是不同的关键字 ki 和 kj(其中 i 不等于 j)通过哈希函数计算得到相同的哈希地址,即Hash(ki) == Hash(kj),尽管它们的关键字不同。这种现象被称为哈希冲突或哈希碰撞。
- 具有不同关键字但相同哈希地址的数据元素被称为"同义词"。
三、哈希函数
引起哈希冲突的一个原因可能是哈希函数设计不够合理。
哈希函数设计原则:
1. 哈希函数的定义域必须包括需要存储的全部关键码,而值域必须在0到m-1之间,其中m是散列表的地址数量。
2. 哈希函数计算出来的地址应该能够均匀分布在整个地址空间中。
3. 哈希函数应该比较简单。
常见的哈希函数包括:
1. 直接定址法(常用):使用关键字的某个线性函数作为散列地址,即 Hash(Key) = A*Key + B。
优点:简单且均匀。
缺点:需要事先知道关键字的分布情况。
使用场景:适合查找比较小且连续的情况,例如字符串中第一个只出现一次的字符。
2. 除留余数法(常用):选择一个不大于散列表地址数量m的质数p作为除数,通过哈希函数 Hash(key) = key % p (p = 0.7)if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;HashTable newht;newht._tables.resize(newsize);// 遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}size_t hashi = kv.first % _tables.size();size_t i = 1, index = hashi;while (_tables[index]._state == EXIST) {index = hashi + i;index %= _tables.size();i++;}_tables[index]._kv = kv;_tables[index]._state = EXIST;_n++;return true;}
- 首先会调用 Find 函数查找是否已存在相同的键,如果存在则返回 false,表示插入失败。
- 如果哈希表的负载因子超过了 0.7(即元素个数除以表的大小大于等于 0.7),则进行扩容操作。扩容后创建一个新的哈希表 newht,将旧表中的数据重新映射到新表中。
- 然后计算插入位置的哈希值 hashi,并使用线性探测法解决哈希冲突,找到合适的插入位置。
- 最后将键值对存储到该位置,并将状态设置为 EXIST,同时更新元素个数 _n。插入成功后返回 true。
3、查找
HashData* Find(const K& key){if (_tables.size() == 0)return nullptr;size_t hashi = key % _tables.size();size_t i = 1, index = hashi;while (_tables[index]._state != EMPTY) {if (_tables[index]._state == EXIST&& _tables[index]._kv.first == key) {return &_tables[index];}index = hashi + i++;index %= _tables.size();//如果已经查找一圈,则说明全是存在+删除if (index == hashi)break;}return nullptr;}- 首先判断哈希表是否为空,如果为空则返回 nullptr。
- 然后计算键的哈希值 hashi,并使用线性探测法查找对应的位置。
- 如果找到了状态为 EXIST 的元素且键匹配,则返回该元素的指针。
- 如果遍历一圈回到了起始位置,则表示没有找到匹配的元素,返回 nullptr。
4、删除
bool Erase(const K& key){HashData* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}- 调用 Find 函数查找要删除的元素,如果找到了则将其状态设置为 DELETE,同时更新元素个数 _n。删除成功返回 true,否则返回 false。
3、线性探测总结
线性探测优点:实现非常简单, 线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同 关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降 低。如何缓解呢?4、二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题, 找下一个空位置的方法为:
其中:i =1,2,3… ,H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表 的大小。对于2.1中如果要插入44,产生冲突,使用解决后的情况为:
研究表明: 当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5 ,如果超出必须考虑增容。
因此,哈希表的一个主要缺陷是空间利用率相对较低。
(二)开散列
1、概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链 接起来,各链表的头结点存储在哈希表中。
这是一个使用链地址法解决冲突的哈希表实现。链地址法是一种常见的哈希冲突解决方法,它将哈希到同一槽位的元素链接在一起,形成一个链表。
这个哈希表的主要组成部分有:
2、节点
HashNode:哈希节点,包含一个键值对和一个指向下一个节点的指针。
templatestruct HashNode{HashNode* _next;pair _kv;HashNode(const pair& kv):_next(nullptr),_kv(kv){}};3、处理不同类型的哈希值
HashFunc:哈希函数,用于计算键的哈希值。这里提供了一个默认的实现,直接返回键本身作为哈希值。另外,还提供了一个特化版本,用于处理字符串键,采用BKDR哈希算法。
templatestruct HashFunc{size_t operator()(const K& key){return key;}};templatestruct HashFunc{//BKDRsize_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}};4、哈希表定义
HashTable:哈希表,包含一个哈希节点的向量,每个元素是一个链表的头节点。哈希表还包含一些基本操作,如插入、查找、删除等。
templateclass HashTable{typedef HashNode Node;private:vector _tables;size_t _n = 0;public:~HashTable() Node* Find(const K& key) bool Erase(const K& key) size_t GetNextPrime(size_t prime) bool Insert(const pair& kv)5、插入
首先检查键是否已经存在,如果存在则插入失败。然后检查哈希表是否需要扩容,如果需要则进行扩容。最后,计算键的哈希值,找到对应的链表,然后在链表头部插入新的节点。
bool Insert(const pair& kv){if (Find(kv.first)){return false;}Hash hash;// 负载因因子==1时扩容if (_n == _tables.size()){size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;vector newtables(newsize, nullptr);//for (Node*& cur : _tables)for (auto& cur : _tables){while (cur){Node* next = cur->_next;size_t hashi = hash(cur->_kv.first) % newtables.size();// 头插到新表cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}}_tables.swap(newtables);}size_t hashi = hash(kv.first) % _tables.size();// 头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true;}-
首先,通过调用Find函数来检查待插入的键是否已经存在于哈希表中。如果存在,则直接返回false,表示插入失败。
-
接下来,创建一个哈希函数对象hash,用于计算键的哈希值。
-
然后,判断当前哈希表的负载因子是否达到了扩容的条件。负载因子是指哈希表中已存储元素的数量与哈希表大小的比值。在这段代码中,负载因子等于1时触发扩容操作。
-
如果需要进行扩容,首先计算新的哈希表大小newsize。如果当前哈希表大小为0,则将新的哈希表大小设置为10;否则,将新的哈希表大小设置为当前哈希表大小的两倍。
-
创建一个新的哈希表newtables,其大小为newsize,并初始化所有槽位为nullptr。
-
遍历旧的哈希表_tables,对每个槽位中的链表进行处理。通过循环遍历链表中的每个节点,将节点从旧表中取出,并根据节点的键重新计算哈希值,得到在新表中的槽位索引hashi。
-
将节点插入到新表的对应槽位中,采用头插法将节点插入链表中。
-
完成旧表中所有节点的处理后,使用swap函数将新表newtables和旧表_tables交换,使得新表成为当前的哈希表。
-
计算待插入键的哈希值,得到在当前哈希表中的槽位索引hashi。
-
创建一个新的节点newnode,将待插入的键值对kv存储在节点中。
-
将新节点插入到当前哈希表的槽位hashi的链表中,采用头插法将节点插入链表中。
-
增加哈希表中元素的数量_n。
-
返回true,表示插入成功。
6、查找
Find:查找操作,首先计算键的哈希值,然后对哈希表的大小取模,得到槽位索引。然后在对应的链表中查找键。
Node* Find(const K& key){if (_tables.size() == 0)return nullptr;Hash hash;size_t hashi = hash(key) % _tables.size();Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;}7、删除
Erase:删除操作,同样首先找到键对应的链表,然后在链表中查找并删除节点。
bool Erase(const K& key){Hash hash;size_t hashi = hash(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key){if(prev==nullptr)_tables[hashi] = cur->_next;elseprev->_next = cur->_next;delete cur;return true;}else {prev = cur;cur = cur->_next;}}return false;}8、析构函数
在类模板的析构函数中,首先遍历哈希表的每个桶(bucket),然后释放每个桶中的链表节点(HashNode),最后将桶指针置为空指针。这样可以确保在析构哈希表时,释放了所有的节点内存。
~HashTable(){for (auto& cur : _tables){while (cur){Node* next = cur->_next;delete cur;cur = next;}cur = nullptr;}}9、质数列表(扩容)
在哈希表的扩容过程中,通常会选择一个比当前容量更大的新容量。选择合适的新容量大小对于哈希表的性能和效率非常重要。这里使用了一个预定义的质数列表__stl_prime_list,其中包含了一系列递增的质数。
通过调用GetNextPrime函数,可以根据当前容量选择一个比它更大的质数作为新的容量。这样做的目的是为了减少哈希冲突的发生,提高哈希表的性能。质数的选择可以使得哈希函数的分布更加均匀,减少冲突的可能性。
// size_t newsize = GetNextPrime(_tables.size());size_t GetNextPrime(size_t prime){// SGIstatic 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};size_t i = 0;for (; i prime)return __stl_prime_list[i];}return __stl_prime_list[i];}10、寻找最大桶长(测试)
MaxBucketSize:返回哈希表中最长链表的长度,这可以用来评估哈希表的负载情况。如果某个链表过长,可能意味着哈希函数的分布性不好,或者哈希表的大小不够。
size_t MaxBucketSize(){size_t max = 0;for (size_t i = 0; i _next;}if (size > max)max = size;}return max;} void TestHashTableMax(){size_t N = 900000;HashTable ht;srand(time(0));for (size_t i = 0; i next; } // 更新桶中的数据为红黑树指针 bucket.data.redBlackTree = tree;}4. 在进行查找、插入或删除操作时,根据桶中的数据类型进行相应的操作。
if (bucket.data.linkedList != NULL) { // 链表操作 // ...} else if (bucket.data.redBlackTree != NULL) { // 红黑树操作 // ...}通过使用联合体,可以在同一内存空间中存储链表和红黑树,根据需要进行切换。这样可以实现将单个桶超过一定长度改为挂载红黑树的操作,并在操作时根据桶中的数据类型选择相应的操作方式。这种方式可以提高哈希表的性能和效率。
代码完整版+测试
#pragma once#include #include using namespace std;namespace openAddress{enum State{EMPTY,EXIST,DELETE};templatestruct HashData{pair _kv;State _state = EMPTY;};templateclass HashTable{public:bool Insert(const pair& kv){if (Find(kv.first))return false;// 负载因子超过0.7就扩容//if ((double)_n / (double)_tables.size() >= 0.7)if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;HashTable newht;newht._tables.resize(newsize);// 遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}size_t hashi = kv.first % _tables.size();size_t i = 1, index = hashi;while (_tables[index]._state == EXIST) {index = hashi + i;index %= _tables.size();i++;}_tables[index]._kv = kv;_tables[index]._state = EXIST;_n++;return true;}HashData* Find(const K& key){if (_tables.size() == 0)return nullptr;size_t hashi = key % _tables.size();size_t i = 1, index = hashi;while (_tables[index]._state != EMPTY) {if (_tables[index]._state == EXIST&& _tables[index]._kv.first == key) {return &_tables[index];}index = hashi + i++;index %= _tables.size();if (index == hashi)break;}return nullptr;}bool Erase(const K& key){HashData* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}private:vector _tables;size_t _n = 0;};void TestHashTable(){HashTable ht;// 插入键值对ht.Insert(std::make_pair(1, "One"));ht.Insert(std::make_pair(2, "Two"));ht.Insert(std::make_pair(3, "Three"));ht.Insert(std::make_pair(4, "Four"));ht.Insert(std::make_pair(5, "Five"));// 查找键值对HashData* data = ht.Find(3);if (data){std::cout _next;}if (size > max)max = size;}return max;}}; void TestHashTableMax(){size_t N = 900000;HashTable ht;srand(time(0));for (size_t i = 0; i -
- 调用 Find 函数查找要删除的元素,如果找到了则将其状态设置为 DELETE,同时更新元素个数 _n。删除成功返回 true,否则返回 false。
上一篇:推荐塔式服务器怎么选择?