文章目录
- 👾 哈希表概念
- 👾 常见哈希函数
- 🎏 直接定址法
- 🎏 除留余数法
- 👾 哈希冲突的解决方案
- 🎏 闭散列与闭散列哈希表的实现
- 🎐 闭散列哈希表的节点设置与基本架构
- 🎐 闭散列哈希表的插入逻辑及实现
- 🎐 闭散列哈希表的扩容
- 🎐 闭散列哈希表的查找
- 🎐 闭散列哈希表的数据删除
- 🎐 闭散列哈希表整体代码(供参考)
- 🎏 开散列与开散列哈希表的实现
- 🎐 开散列哈希表的节点设置与基本架构
- 🎐 开散列哈希表的插入逻辑及实现
- 🎐 开散列哈希表的扩容
- 🦠 哈希表扩容的优化
- 🎐 确保哈希表的泛型特性
- 🎐 开散列哈希表的查找
- 🎐 开散列哈希表的数据删除
- 🎐 开散列哈希表整体代码(供参考)
👾 哈希表概念
哈希表是一种常用的数据结构,该数据结构往往能存储大量的数据,在C++当中,底层为哈希表的容器最常见的为unordered_xxx
系列,例如unordered_map
与unordered_set
,这两个容器是在C++当中以哈希表为底层的关联式容器,具体的关联式容器的特点参照上篇;
哈希表通过一个叫做哈希函数(Hash Function)的算法,将存储的每个数据项与一个唯一的键值(key)进行绑定;这个函数会将每个键值映射到哈希表中的一个位置,以便对数据进行快速访问;
哈希表的高效决定了这个数据结构在计算机中的地位:
-
高效增删查改
在理想情况下,哈希表在进行这些操作的时间复杂度一般可以达到接近O(1);
由于哈希表中的数据是以元素的存储位置与关键码的绑定映射的关系,那么在对数据进行查找的时候只需要通过所谓的关键码即能找到该元素;
本文主要实现对K,V键值对数据插入的哈希表模型;
该篇博客进行对哈希表中迭代器的封装 |
👾 常见哈希函数
假设存在一组数据;
{13,10,7,4,8,9};
要将数据进行存储,即可以使用一种简单的哈希对数据进行存储;
以该图为例即为一种简单的哈希;
该方法也被称为直接定址法,即将数据以绝对映射的方式放置在顺序容器当中(例如数组),从而达到存储数据的目的;
🎏 直接定址法
在上面的例子当中即为直接定址法;
取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B
;
以绝对映射的关系将数据进行存储,该方法方便快捷;
-
代码(简单实现,供参考):
#include <iostream> #include <vector> using namespace std; class HashTable { private: vector<int> table; public: HashTable(int size) { table.resize(size, -1); // 初始化哈希表,将所有元素设为-1 } void insert(int key, int value) { if (key >= 0 && key < table.size()) { table[key] = value; // 将值插入哈希表中对应的位置 } else { cout << "Key out of range!" << endl; } } }; int main() { HashTable hashtable(100); // 创建一个大小为100的哈希表 hashtable.insert(42, 10); hashtable.insert(15, 20); hashtable.insert(67, 30); return 0; }
当然其有一个较大的缺点,该方法只支持数据量差值较小,或是数据相对集中的场景;
-
若是数据间差值较大,则可能导致空间浪费的情况;
存在一组数据为
{3,2,5,7,1001}
;在这种场景中若是使用直接定址法则至少需要开最大值
1001+1
个空间对数据进行存储;而实际数据量仅仅为
5
个,这将大大浪费空间;
🎏 除留余数法
除留余数法能够大大降低直接定址法所出现的极端场景;
同样以上面直接定址法的极端场景为例:
-
存在一组数据为
{3,2,5,7,1001}
;使用除留余数法对数据进行存储:
将上述的数据取余表的大小,并将数据存储至余数部分;
{ 3 % 10 = 3 , 2 % 10 = 2 ,5 % 10 = 5 , 7 % 10 = 7 ,1001 % 10 = 1 }
即设散列表中允许的地址数为m
,取一个不大于m
但最接近或者等于m
的质数P
作为除数;
按照哈希函数:Hash(key) = key%p(p<=m)
将关键码转换成对应的哈希地址(一般除数P
为表的大小);
-
代码(简单实现,供参考):
#include <iostream> #include <vector> using namespace std; class HashTable { private: vector<vector<int>> table; int size; public: HashTable(int tableSize) { size = tableSize; table.resize(size); } int hash(int key) { return key % size; // 使用除留余数法计算哈希值 } void insert(int key, int value) { int index = hash(key); table[index].push_back(value); // 插入值到哈希表的对应位置 } }; int main() { HashTable hashtable(10); // 创建一个大小为10的哈希表 hashtable.insert(42, 10); hashtable.insert(15, 20); hashtable.insert(67, 30); return 0; }
这种方法可以有效的解决极端情况当中对于两数之间差值较大的情况;
但是随之而来的又有另外的问题:
-
若是两个数据的取余余数相等应该如何进行解决?
如该问题所述,若是出现这种情况仍然将数据放置进对应的位置时,新数据将会把原来的数据进行覆盖,从而出现数据丢失的情况;
这种情况也被称为
哈希冲突
;
本文将着重以除留余数法为主题;
👾 哈希冲突的解决方案
在上文当中,在使用除留余数法对数据进行存储时将会造成哈希冲突;
若是出现哈希冲突的情况下一样强行将数据进行存储那么将可能出现数据覆盖的问题;
上文中出现的两段代码,尤其是对于除留余数法都是简单的哈希表实现,而在实际的使用当中,在使用除留余数法对数据进行存储时应该注意哈希冲突的问题;
在一般情况下处理哈希冲突有两种方案:
- 闭散列
- 开散列
🎏 闭散列与闭散列哈希表的实现
闭散列(Closed Hashing)也被称作开放定址法;
当数据在插入时发生哈希冲突时,如果哈希表未满,说明在哈希表当中还有可以存放数据的位置;
而闭散列则为当哈希未满时将数据插入至其他非冲突位置;
而所谓的其他非冲突位置即为空位置;
至于空位置的位置决定于插入时的探测方式,一般的探测方式为以下两种:
-
线性探测
线性探测即为将数据插入至冲突位置的下一个空位;
-
二次探测
二次探测为将数据插入至下一个位置的乘方倍的位置,即 i2 位置;
🎐 闭散列哈希表的节点设置与基本架构
当数据插入时若是出现哈希冲突,则需要采用线性探测或者二次探测的方式对数据进行插入;
在删除时只需要使用插入时相同的哈希规则(映射关系)对数据进行查找并删除即可;
而实际当中,在数组中对数据直接进行删除其效率会变得尤其低效;
且若是对数据直接删除则会出现一种情况:
- 不能很好的在线性探测或者二次探测中探测到冲突数据应该插入的位置;
那么在这种情况下不能对数据直接进行删除,对应的应该采用一种伪删除法;
即表中所存储的数据也不能是所需要的数据,相对的应该存储一个原生结构,这个结构内应存放数据以及其存在状态;
-
代码(供参考):
enum State{ //设置枚举类型 /* 分别为 1.空 2.存在 3.删除 三个状态 */ EMPTY, EXIST, DELETE }; template<class K,class V> struct HashiData{ //设置节点 std::pair<K, V> _kv;//Key Value模型 State _state = EMPTY;//默认情况下节点为空 }; template<class K, class V> class HashiTable{ //整体构架 public: typedef HashiData<K, V> Data;//使用typedef进行重命名方便后序的调用 HashiTable():_tables(0),_n(0){}//构造函数使得初始的Vector容器的大小为0,_n表示当前存在的有效数据 //其他成员函数... private: std::vector<Data> _tables;//利用vector容器实现闭散列的哈希表 size_t _n;//存储数据个数 };
在该段代码中,使用了枚举的方式设置了数据存在状态的可能性(空,存在,删除);
并设置了一个节点,这个节点主要存储了数据及其存在的情况;
这段代码中整体结构主要存在两个成员:
-
_table
该容器即为本质哈希表的结构,为一个
vector
容器; -
_n
这个成员表示了目前数据的存储个数,当然它表示的是表中状态为
EXIST
状态的数据个数;
🎐 闭散列哈希表的插入逻辑及实现
插入逻辑即为上文提到的除留余数法,当发生哈希冲突的时候使用线性探测或者二次探测的方式来解决哈希冲突的问题;
-
代码(供参考):
bool Insert(const std::pair<K, const V> &kv) { size_t hashi = kv.first % _tables.size();//除留余数法确定数据需要插入的位置 size_t index = hashi; size_t i = 0; while (_tables[index]._state == EXIST) { /* 线性探测: 当该数据所需要存储的位置发生哈希冲突时则将数据插入至发生冲突的后一个位置; 若是后一个位置也存在数据则继续向后遍历; */ index = (hashi + i)%_tables.size(); ++i; } _tables[index]._kv = kv; _tables[index]._state = EXIST; ++_n; return true; }
在该段代码当中,使用了除留余数法对数据进行插入;
并在发生哈希冲突时使用线性探测对数据进行插入;
🎐 闭散列哈希表的扩容
在哈希表中,在使用除留余数法对数据进行插入时若是数据量到一定程度时则需要对表进行扩容;
-
那么当数据量到什么程度时可以对表进行扩容?
在哈希表当中,扩容的需求是在插入过程中进行的,所以只需要在插入中的对应环境进行扩容即可;
针对提出的问题可以引入一个新的概念,即负载因子;
-
什么是负载因子?
负载因子实际上是哈希表中现存的有效数据占表大小的一个因子;
其的计算即为表中存在有效数据/表的大小;
在该哈希表的实现当中可以看作
_n/_tables
;负载因子的大小取决于用户需要表的能力以及实现,一般控制在
0.7
或是0.8
;当负载因子在
0.7
或是0.8
时即可以对表进行扩容操作;
那么关键的问题是:
-
如何对表进行扩容?
从上面的大致结构可以看出,实际上闭散列的哈希表本质上是一个数组容器
vector
,那么是否可以直接对容器进行扩容?实际上,在扩容时有一个非常重要的思想,即为哈希表的规则;
哈希表的规则自始至终都贯穿在整个结构当中,无论是增删查改;
那么如果忽略哈希表的哈希规则而直接采用
vector
中的resize()
接口对齐进行扩容将会导致哈希规则错乱;简而言之即为若是直接使用
resize()
接口对vector
容器进行扩容时,其对应的映射关系将会打乱;以该图为例,若是直接将
vector
容器进行扩容时将破坏原有的映射关系;那么实际上在哈希表需要进行扩容时只需要另开一个新表并将原有的数据重新以新的哈希规则进行插入即可(可直接开辟新的哈希表遍历原哈希表并调用
Insert
接口进行插入);
而在插入过程当中还需要处理对初始插入时表大小为0的状态;
-
代码(供参考):
bool Insert(const std::pair<K, const V> &kv) { //-------扩容------- if(_tables.size() == 0 || (_n*100) / _tables.size() >=75){ /* 哈希表中存在一个概念为负载因子 负载因子即为数据中存在的数据/表的大小 负载因子越大时越容易造成哈希冲突 [ps:哈希冲突指的是一个值占了另一个值的位置]; 当负载因子到达一定大小时需要扩大表的大小从而降低负载因子 开空间时不能直接在原地开空间,若是使用在原地开空间则将会破坏原来的哈希规则 使得在查找以及下次插入时都存在问题; 开空间时应该重新开辟一块空间 并且以新的表的大小来确定新的哈希规则重新对数据进行插入 同时新的哈希规则制定完毕以后 旧的哈希规则则可以摒弃遗忘 对应的空间也应该释放 (因为闭散列中使用的哈希结构只采用了vector容器,故不需要再节点中指定对应的析构函数) */ size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2; HashiTable<K, V> newtables; newtables._tables.resize(newsize); for(auto &it : _tables){ newtables.Insert(it._kv); } _tables.swap(newtables._tables); //-------正常插入------- size_t hashi = kv.first % _tables.size(); size_t index = hashi; size_t i = 0; // if (_tables[index]._kv.first == kv.first) return false; while (_tables[index]._state == EXIST) { index = (hashi + i)%_tables.size(); ++i; } _tables[index]._kv = kv; _tables[index]._state = EXIST; ++_n; return true; }
当然,在哈希表中的负载因子并不是越小越好;
-
负载因子太小则,哈希冲突概率低,空间利用率低,查找效率高;
-
负载因子太大则,哈希冲突概率高,空间使用率高,查找效率低;
🎐 闭散列哈希表的查找
哈希表的查找只需要遵从当前的哈希规则进行查找即可;
即按照插入的逻辑思路进行查找;
-
代码(供参考):
Data* Find(const K& key){ //与插入函数的逻辑相同 if(_tables.size() == 0){ //如果是空表则返不进行查找 return nullptr; } size_t hashi = key % _tables.size(); size_t index = hashi; size_t i = 0; while (_tables[index]._state != EMPTY) { //如果不为空则循环继续找数据 if (_tables[index]._state == EXIST &&//条件为数据存在在表中且状态为存在 _tables[index]._kv.first == key) return &_tables[index]; index = (hashi + i) % _tables.size(); ++i; if(index == hashi) break; //在查找过程中如果没找到数据的前提下index又回到了hashi的位置则代表已经找了一圈了 说明不存在数据 可以跳出循环(极端情况) } return nullptr; }
由于在哈希表的逻辑当中需要用到取模运算%
,所以若是表的大小为0
时将会引发除零错误;
为了避免除零错误的发生,应该在查找之前判断表的大小是否为0
,若是表为空则停止查找;
当然,在实际的使用当中,哈希表不能插入表中已经存在的数据,所以在插入函数Insert()
的插入之前可以调用Find()
接口进行检查;
若是表中存在相同数据则不再进行插入;
🎐 闭散列哈希表的数据删除
闭散列哈希表的删除采用的是一个伪删除法,即将对应数据中的状态进行修改即可;
-
代码(供参考):
bool Erase(const K& key){ //采用伪删除法 /* 伪删除法的思路只要改变节点中的状态即可 */ Data *to_del = Find(key); if (to_del) { to_del->_state = DELETE; --_n; return true; } return false; }
🎐 闭散列哈希表整体代码(供参考)
#pragma once
//***************************************
//**********闭散列哈希表的实现***********
//***************************************
#include<iostream>
#include<vector>
enum State{
//设置枚举类型
/*
分别为 ( 1.空 2.存在 3.删除 ) 三个状态
*/
EMPTY,
EXIST,
DELETE
};
template<class K,class V>
struct HashiData{
//设置节点
std::pair<K, V> _kv;//Key Value模型
State _state = EMPTY;//默认情况下节点为空
};
template<class K, class V>
class HashiTable{
//整体模型
public:
typedef HashiData<K, V> Data;//使用typedef进行重命名方便后序的调用
HashiTable():_tables(0),_n(0){}//构造函数使得初始的Vector容器的大小为0,_n表示当前存在的有效数据
bool Insert(const std::pair<K, const V> &kv) {
Data *to_find = Find(kv.first);
if(to_find) return false;
if(_tables.size() == 0 || (_n*100) / _tables.size() >=75){
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashiTable<K, V> newtables;
newtables._tables.resize(newsize);
for(auto &it : _tables){
newtables.Insert(it._kv);
}
_tables.swap(newtables._tables);
size_t hashi = kv.first % _tables.size();
size_t index = hashi;
size_t i = 0;
// if (_tables[index]._kv.first == kv.first) return false;
while (_tables[index]._state == EXIST) {
index = (hashi + i)%_tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
++_n;
return true;
}
Data* Find(const K& key){
//与插入函数的逻辑相同
if(_tables.size() == 0){
//如果是空表则返不进行查找
return nullptr;
}
size_t hashi = key % _tables.size();
size_t index = hashi;
size_t i = 0;
while (_tables[index]._state != EMPTY) { //如果不为空则循环继续找数据
if (_tables[index]._state == EXIST &&//条件为数据存在在表中且状态为存在
_tables[index]._kv.first == key)
return &_tables[index];
index = (hashi + i) % _tables.size();
++i;
if(index == hashi) break;
//在查找过程中如果没找到数据的前提下index又回到了hashi的位置则代表已经找了一圈了 说明不存在数据 可以跳出循环(极端情况)
}
return nullptr;
}
bool Erase(const K& key){
//采用伪删除法
/*
伪删除法的思路只要改变节点中的状态即可
*/
Data *to_del = Find(key);
if (to_del) {
to_del->_state = DELETE;
--_n;
return true;
}
return false;
}
protected:
private:
std::vector<Data> _tables;//利用vector容器实现闭散列的哈希表
size_t _n;//存储数据个数
};
虽然闭散列的方式可以很好的对数据进行插入并且解决除留余数法中的哈希冲突的问题;
但虽然解决了哈希冲突的问题后又引发了一个问题:
-
即多组数据引发哈希冲突将会发生踩踏
当一组数据因为哈希冲突而使用线性探测的方式使得数据插入到了冲突位置的下一个位置时;
这个数据所插入的数据可能是后面数据所要插入的位置,这个问题即被称为哈希碰撞(Hash Collision);
当发生哈希碰撞时可以使用二次探测来减少哈希碰撞的问题;
二次探测的说明参照上文;
虽然二次探测能够减少哈希碰撞的频率,但是无法完全避免;
所以为了解决该问题可以使用开散列的方式来实现哈希表;
🎏 开散列与开散列哈希表的实现
开散列(Open Hashing)也被称作拉链法;
当数据在插入时发生哈希冲突时,可以采用链式结构的方式对数据进行存储;
即本质上也是利用一个vector
容器,只不过对应的容器内并不存放其他数据,而是存放一个节点的指针;
通过链式结构的方式将发生哈希冲突的数据链接在一起;
在开散列当中,哈希表的每个桶通常是一个链表(或是其他的动态数据结构),用于存储映射到同一哈希值的所有元素;
因此,当发生哈希冲突时,元素会被添加到对应的哈希值的链表当中;
这种方式能够很好的解决使用除留余数法的哈希冲突;
🎐 开散列哈希表的节点设置与基本架构
开散列的哈希表的节点设置并不像闭散列哈希表需要定义节点的状态;
对于开散列而言,vector
容器中所存储的并不是数据本身,而是一个节点,所以对于开散列而言若是在数据删除时可以直接对数据进行delete
操作,不需要定义其节点状态;
-
代码(供参考);
template <class K, class V> struct HashNode { // 哈希表的节点设置 typedef HashNode<K, V> Node; Node* _next = nullptr; std::pair<K, V> _kv; HashNode(const std::pair<K, V> kv) : _kv(kv) {} }; template <class K, class V, class Hash = HashFunc<K>> class HashTable { public: typedef HashNode<K, V> Node; //成员函数 private: std::vector<Node*> _hashtable; // 哈希表整体构造 size_t _n = 0; // 负载因子 };
在开散列的哈希表中也需要一个负载因子来判断其是否需要进行扩容操作;
- 那么为什么开散列的哈希表也需要进行扩容操作?
实际上虽然开散列使用了拉链法的方式对数据进行插入,但若是一个桶下挂了过多的数据也会降低哈希表的整体效率;
所以在使用开链法实现哈希表时同样也要对哈希表进行扩容操作;
🎐 开散列哈希表的插入逻辑及实现
开散列哈希表的插入操作与闭散列哈希表的插入逻辑大部相同;
-
唯一不同的是
对于闭散列哈希表来说,当出现哈希冲突的问题时闭散列采用的是线性探测或是二次探测的解决方式;
而对于开散列而言,开散列只需要对数据进行头插即可,其并不需要担心发生的哈希冲突问题;
-
代码(供参考):
bool Insert(const std::pair<K, V> kv) { /* 扩容... */ // 正常插入 Node* newnode = new Node(kv); size_t hashi = to_int(kv.first) % _hashtable.size(); // std::cout << kv.first <<std::endl; newnode->_next = _hashtable[hashi]; _hashtable[hashi] = newnode; ++_n; return true; }
插入时只需要进行链表的头插即可;
🎐 开散列哈希表的扩容
在上文中提到,对于开散列而言也需要判断其是否需要进行扩容;
-
那么开散列哈希表在哪种情况中需要对表进行扩容操作?
在开散列的哈希表中,扩容操作一般取决于其最坏的情况;
即假设存在一个哈希表,它的大小为
m
,而最坏的情况即为表中各个桶下都挂上一个数据;实际上只要数据的个数
_n
与表的大小相同,即负载因子为1
时进行扩容;当然,扩容的情况也可根据需要来进行变化;
在上文当中关于闭散列哈希表的扩容中提到在进行扩容时不能直接在原地扩容;
相同,由于开散列与闭散列的插入规则采用的都是哈希中的除留余数法进行操作,所以相对的开散列的哈希表也需要像闭散列一样新开一块空间并将原来的数据以新的哈希规则进行插入以免破坏对应的映射关系;
在扩容时也可按照闭散列中的方式,扩容之后重新建立哈希表并对数据进行重新插入;
但当数据重新插入后原有的数据将会被释放,但本质上并不会释放链表中的元素数据,所以应该在哈希表中进行析构函数的写入;
-
析构函数(供参考):
~HashTable() { Node* cur = nullptr; for (size_t i = 0; i < _hashtable.size(); ++i) { if (_hashtable[i]) { cur = _hashtable[i]; Node* next = cur->_next; while (cur) { delete cur; cur = next; } } } }
该方法的扩容不进行赘述,参照闭散列中的扩容逻辑即可;
而使用上述这种方法虽然可以简短对于扩容操作的代码,但实际上这样将原来的哈希表即表中的数据全部进行删除使得整体在扩容中的效率变慢且进行了冗余的操作(对数据节点重新进行构造);
在不使用该方法的前提可以使用其他的方式将其进行扩容;
最简单的方式即为构建一个新的vector
容器遍历原来的vector
容器将对应的节点以新的哈希规则插入至新的vector
容器当中;
-
代码(供参考);
bool Insert(const std::pair<K, V> kv) { Hash to_int;//仿函数 // 使用Find函数进行判断是否需要进行插入(需要预防除零错误) if (_n == _hashtable.size()) { // 判断负载因子是否为1 负载因子若是为1则进行扩容 // size_t newsize = _hashtable.size() == 0 ? 10 : _hashtable.size() * 2; size_t newsize = GetNextPrime(_hashtable.size()); std::vector<Node*> newTable; newTable.resize(newsize); // for(Node *&cur : _hashtable) 遍历Node*指针数组 for (auto& cur : _hashtable) { while (cur) { Node* next = cur->_next; size_t hashi = to_int(cur->_kv.first) % newTable.size(); cur->_next = newTable[hashi]; newTable[hashi] = cur; cur = next; } } _hashtable.swap(newTable); } // 正常插入 Node* newnode = new Node(kv); size_t hashi = to_int(kv.first) % _hashtable.size(); // std::cout << kv.first <<std::endl; newnode->_next = _hashtable[hashi]; _hashtable[hashi] = newnode; ++_n; return true; }
该种方式所实现的扩容在代码层面中要比上一方法复杂;
但不可否认的是该方法实际上在效率当中要高于上一方法,因为该方法避免了同一节重复进行构造的冗余以及将原有节点进行释放的浪费操作;
在该段代码中存在一个仿函数为
Hash
,该仿函数具体的作用为将类型转化为可以在哈希表中实现的类型,具体实现将在下文中提到;
🦠 哈希表扩容的优化
在上文的代码中存在一个函数为GetNextPrime();
这个函数的功能是哈希表中在扩容中的一个优化;
其本身并不复杂,即设置一个全部为素数的扩容指数,当需要扩容时则去该函数中取下一个素数作为需要扩容的新大小;
-
代码(供参考):
size_t GetNextPrime(size_t prime) { 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}; size_t i = 0; for (; i < __stl_num_primes; ++i) { if (__stl_prime_list[i] > prime) return __stl_prime_list[i]; } return __stl_prime_list[i]; }
-
那么为什么需要采取该操作?或者说使用该种方法进行扩容的优势是什么?
-
减少哈希冲突
当哈希函数产生的地址分布不均时,会导致哈希冲突的概率增加,从而影响哈希表的性能;
使用素数作为容量因子可以使得哈希值分布更加均匀。这是因为素数不能被除以其他较小的数(除了
1
和它自身
),这有助于减少在哈希表中的“分组”现象,从而减少冲突。 -
优化哈希函数
如果容量因子是一个素数,它可以帮助哈希函数更有效地分散键值,尤其是在键值本身具有一定的规律性时。
如果键值倾向于在某些特定的数值范围内聚集,使用素数作为容量因子可以帮助打破这种规律性,使得哈希值更加分散。
-
提高计算效率
在某些情况下,使用素数作为容量因子可以简化哈希函数的计算,提高效率。例如,在一些模运算中,使用素数作为模可以减少计算量。
-
可以发现,上述代码的容量因子实际是在素数的二倍附近进行取值;
🎐 确保哈希表的泛型特性
这个泛型特性实际上不只针对开散列哈希表,这是大部分哈希表中都需要的;
尤其是在使用除留余数法时,需要将数据进行取模操作,而进行取模操作的只能是整型数据;
而若是需要存储其他类型的数据时,本身并不支持;
最简单的方式即为使用一个仿函数将数据转换为整型数据;
-
代码(供参考):
template <class K> struct HashFunc { size_t operator()(const K& key) { // std::cout << key << std::endl; return (size_t)key; } };
使用该代码能够解决部分类型不匹配的问题;
那么为什么这里提到的是部分而不是所有;
在存储数据当中,可能遇到需要将字符串string
类型数据进行存储;
而实际上string
无法有效的转化为size_t
整型数据;
所以需要对string
数据在仿函数当中使其变为size_t
类型;
最普遍的使用即为使用相加的操作将string
字符串中的所有字符的ASCII码值进行相加;
那么使用该种方法时还会遇到另一个问题:
-
若是字符串中的字符相同而字符顺序不同该如何进行比较?
如该问题而言,若是使用普通的将字符串的
ASCII
码值进行相加的话将会出现顺序不同但其ASCII
值相同;例:
abcd,adbc,bcad,dcba
等;
而解决方法也很简单,在各大语言的对应的哈希表实现中都引用了一个概念,即乘数因子;
使用乘数因子的方法可以很好的避免string
类型中对于字符串中字符(或是总ASCII码之和)相同但顺序不同的数据的插入;
-
即使用一个数作为乘数对当前遍历到的
string
中的字符的ASCII
码值进行相乘;即
sum *= 乘数因子;
后sum += ch
(ch为当前字符的ASCII值); -
那么这个乘数因子应该取什么值?
实际上这个乘数因子取谁都行,大多数乘数因子所取值都为素数,最常见用来作为乘数因子的值实际上是
31
;具体原因
31
是一个小的素数,其能够快速计算(即左移5位后减去原值),且在实践中表现良好;在
Java
中的String.hashCode()
所使用的乘数因子即为31; -
代码(供参考):
template <> struct HashFunc<std::string> { size_t operator()(const std::string& key) { size_t hash = 0; for (auto it : key) { hash += it; hash *= 31; } // std::cout << hash << std::endl; return hash; } };
实际上在对于string
的处理可以使用模板中的特化
进行处理;
编译器在处理时若是遇到该类型则走模板特化,否则走原仿函数即可("特化"
参考 『C++ - 模板』之模板进阶 中对于特化
的理解;
🎐 开散列哈希表的查找
开散列哈希表的查找与闭散列哈希表的查找的思路相同,即在查找过程当中复用插入函数中的思路即可;
相比于闭散列哈希表而言,开散列哈希表的查找相对闭散列要简单,具体原因为开散列哈希表无需像闭散列哈希表那样再次进行线性探测;
对于开散列的哈希表的查找而言,只需要找到对应的哈希位置,并判断该位置是否存有指针:
-
存在指针
若是对应的哈希位置存在指针则要查找的数据可能存在该位置的链表当中,需要对链表进行遍历查找;
-
不存在指针(为空)
若是不存在指针(为空)时则表示该数据不存在;
当然上面两点的前提是你总体的哈希规则(映射关系)没有错乱;
-
代码(供参考):
Node* Find(const K& key) { Hash to_int; if (_hashtable.size() == 0) return nullptr; // 防止除零错误 size_t hashi = to_int(key) % _hashtable.size(); Node* cur = _hashtable[hashi]; while (cur) { if (cur->_kv.first == key) { return cur; } cur = cur->_next; } return nullptr; }
当然,当Find()
接口被实现后应该在插入时调用该接口判断所插入的数据是否存在,若是存在则不进行插入;
接下来的Erase()
删除接口也可按照插入的判断来判断数据是否存在,当然这个并不是特别必要,因为删除的时间复杂度与查找的时间复杂度的差距不大,若是数据存在则还需要再次进行遍历,且再删除接口时也可判断该条件(按照具体需求适合进行调用);
在查找时也需要判断当前vector
容器的大小是否为0
,从而避免出现除零错误
的问题;
🎐 开散列哈希表的数据删除
开散列哈希表的数据删除与查找的思路类似,即判断数据是否存在,若是存在则删除,若是不存在则删除失败;
开散列哈希表的删除与闭散列哈希表的删除并不相同,闭散列哈希表的删除采用的是伪删除法
进行删除;
而开散列哈希表的数据删除为确确实实的删除,将节点进行释放;
当然开散列哈希表的数据删除也该分为两种情况:
-
删除数据的节点指针存在于链表当中
当删除数据的节点指针存在链表当中时,只需要进行普通的链表删除即可;
-
删除数据的节点指针存在于表(
vector
容器)中当删除数据的节点指针存在于表(
vector
容器)当中时,在删除之后需要将删除后的节点的下一个节点赋值给表中对应的位置; -
代码(供参考):
bool Erase(const K& key) { Hash to_int; if (_hashtable.size() == 0) return false; // 防止空的情况继续删除 size_t hashi = to_int(key) % _hashtable.size(); Node* cur = _hashtable[hashi]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { if (prev) { prev->_next = cur->_next; } else { _hashtable[hashi] = cur->_next; } delete cur; return true; } else { prev = cur; cur = cur->_next; } } return false; }
🎐 开散列哈希表整体代码(供参考)
#include <iostream>
#include <string>
#include <vector>
template <class K, class V>
struct HashNode {
// 哈希表的节点设置
typedef HashNode<K, V> Node;
Node* _next = nullptr;
std::pair<K, V> _kv;
HashNode(const std::pair<K, V> kv) : _kv(kv) {}
};
template <class K>
struct HashFunc {
size_t operator()(const K& key) {
// std::cout << key << std::endl;
return (size_t)key;
}
};
template <>
struct HashFunc<std::string> {
size_t operator()(const std::string& key) {
size_t hash = 0;
for (auto it : key) {
hash += it;
hash *= 31;
}
// std::cout << hash << std::endl;
return hash;
}
};
template <class K, class V, class Hash = HashFunc<K>>
class HashTable {
public:
typedef HashNode<K, V> Node;
~HashTable() {
Node* cur = nullptr;
for (size_t i = 0; i < _hashtable.size(); ++i) {
if (_hashtable[i]) {
cur = _hashtable[i];
Node* next = cur->_next;
while (cur) {
delete cur;
cur = next;
}
}
}
}
bool Insert(const std::pair<K, V> kv) {
Hash to_int;
// 使用Find函数进行判断是否需要进行插入(需要预防除零错误)
if (Find(kv.first)) {
// 找到该数据说明该数据存在不予继续插入
return false;
}
if (_n == _hashtable.size()) {
// 判断负载因子是否为1 负载因子若是为1则进行扩容
// size_t newsize = _hashtable.size() == 0 ? 10 : _hashtable.size() * 2;
size_t newsize = GetNextPrime(_hashtable.size());
std::vector<Node*> newTable;
newTable.resize(newsize);
// for(Node *&cur : _hashtable) 遍历Node*指针数组
for (auto& cur : _hashtable) {
while (cur) {
Node* next = cur->_next;
size_t hashi = to_int(cur->_kv.first) % newTable.size();
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
}
_hashtable.swap(newTable);
}
// 正常插入
Node* newnode = new Node(kv);
size_t hashi = to_int(kv.first) % _hashtable.size();
// std::cout << kv.first <<std::endl;
newnode->_next = _hashtable[hashi];
_hashtable[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key) {
Hash to_int;
if (_hashtable.size() == 0) return nullptr; // 防止除零错误
size_t hashi = to_int(key) % _hashtable.size();
Node* cur = _hashtable[hashi];
while (cur) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key) {
Hash to_int;
if (_hashtable.size() == 0) return false; // 防止空的情况继续删除
size_t hashi = to_int(key) % _hashtable.size();
Node* cur = _hashtable[hashi];
Node* prev = nullptr;
while (cur) {
if (cur->_kv.first == key) {
if (prev) {
prev->_next = cur->_next;
} else {
_hashtable[hashi] = cur->_next;
}
delete cur;
return true;
} else {
prev = cur;
cur = cur->_next;
}
}
return false;
}
void Check() {
// 检查函数 没有太重要的意义
int i = 0;
for (auto cur : _hashtable) {
std::cout << "(" << i << ")"
<< " == ";
if (cur) {
while (cur) {
std::cout << cur->_kv.first << " : " << cur->_kv.second << " || ";
cur = cur->_next;
}
std::cout << std::endl;
} else {
std::cout << "nullptr" << std::endl;
}
++i;
}
}
protected:
size_t GetNextPrime(size_t prime) {
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};
size_t i = 0;
for (; i < __stl_num_primes; ++i) {
if (__stl_prime_list[i] > prime) return __stl_prime_list[i];
}
return __stl_prime_list[i];
}
private:
std::vector<Node*> _hashtable; // 哈希表整体构造
size_t _n = 0; // 负载因子
};