【c++篇】:探索哈希表--数据结构中的独特存在,打开数据组织与查找的新视界
✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:c++篇–CSDN博客
前言
哈希表(
Hash Table
)是一种非常重要的数据结构,它利用哈希函数将键值key
映射到表中的一个位置,从而实现快速的1插入,删除和查找操作。哈希表的核心思想是通过空间换时间,即通过开辟一定的空间资源来实现接近O(1)
的平均时间复杂度操作。下面将会详细讲解哈希表的概念,原理以及代码实现。
一.哈希表的基本概念
- 1.哈希函数
(Hash Function)
- 哈希函数h(key)将键值key映射到哈希表的某一个位置,通常是一个数组,存放到数组的某个索引位置。
- 理想状态下,哈希函数应该能将不同的键均匀的映射到哈希表的各个位置,以减少冲突。
- 对于如何获取映射位置,有两种方法可以实现:第一种就是类似于计数排序的直接定址法(通常用于值的分布范围集中);第二种就是除留余数法(通常用于值的范围比较分散)
-
2.哈希表
(Hash Table)
- 哈希是一个数组,数组的每一个元素都是一个桶(Bucket),桶中可以存储一个或多个键值对
(pair<key,value)
- 每个桶的位置有哈希函数确定
- 哈希是一个数组,数组的每一个元素都是一个桶(Bucket),桶中可以存储一个或多个键值对
-
3.哈希冲突
(Collision)
- 当两个或者多个值映射到同一个位置时,就发生了哈希冲突
- 解决哈希冲突有两种方法:开放定址法
(Open Addressing)
和链式定址法(Separate Chaining)
下面将分别讲解这两种方法的原理和代码实现
二.哈希表(开放定址法)原理及实现
1.开放定址法的原理
**1.哈希函数计算:**首先通过哈希函数对元素的值进行计算,得到一个初始的哈希表中映射的位置索引。
**2.地址检查:**检查该索引位置是否已经存在值,也就是已经被占用,如果没有被占用就存储到该位置;如果已经被占用,则进入哈希冲突解决过程。
**3.哈希冲突解决:**当发生冲突时,开放定址法会通过某种规则(比如线性探测,二次探测或双重哈希)来寻找下一个可用的索引位置,这个规则可以确保每次冲突时都能找到一个新的,未被占用的地址
**4.存储元素:**将发生冲突的元素存储在新的索引位置
2.代码实现
基本框架
我们首先来实现一下简单的基本框架:
-
代码实现:
#include<iostream> #include<algorithm> #include<vector> #include<utility> using namespace std; //仿函数用来实现除整形外其它类型的存储,将数据类型强制转换成无符号整型 template<class K> class DefaultHashFunc{ public: size_t operator()(const K& key){ return (size_t)key; } }; //这里针对字符串类型的可以使用模板的特化 template<> class DefaultHashFunc<string>{ public: size_t operator()(const string& str){ size_t hash=0; for(auto e : str){ hash*=131; hash+=e; } return hash; } }; namespace open_address{ //枚举位置状态 enum STATE{ EXIST, EMPTY, DELETE }; //哈希数据封装 template<class K,class V> class HashData{ public: pair<K,V> _kv; STATE _state=EMPTY; }; //哈希表封装 template<class K,class V,class HashFunc=DefaultHashFunc<K>> class HashTable{ public; //构造函数 HashTable{ _table.resize(10); } //其他成员函数... private: vector<HashData<K,V>> _table; size_t _n=0; } };
-
实现原理:
- 首先因为我们要判断一个位置是否已经被占用,所以可以通过枚举来列举三种位置状态,
EMPTY
表示空;EXIST
表示存在,也就是已经被占用;DELETE
表示已经被删除。 - 接着就是将哈希表中的数据进行封装,哈希数据中需要存储数据
pair<key,value>
,也要有一个表示当前的位置状态STATE
,因此需要通过类来实现封装。 - 然后就是实现哈希表的封装,通过动态数组
vector
来实现哈希表,每个数组中存储的是一个哈希数据HashData<K,V>
,然后再设置一个成员标量表示哈希表的有效元素个数。 - 这里有一个注意点就是哈希表封装的第三个模板参数是用来实现其他类型到无符号整型的转换,专门用来处理处理除整形以外的其它类型的存储。
- 首先因为我们要判断一个位置是否已经被占用,所以可以通过枚举来列举三种位置状态,
查找函数
-
代码实现:
HashData<K,V>* Find(const K& key){ //先获取对应下标 HashFunc hf; size_t hashi=hf(key)%_table.size(); //如果当前位置不为空,不为空检查存放的值是否是查找的值,不是就循环查找下一个位置,遇到空停止 while(_table[hashi]._state==EXIST){ if(_table[hashi]._kv.first==Key){ return &_table[hashi]; } else{ hashi++; //当达到数组最大容量时,从零开始查找 if(hashi==_table.size()){ hashi=0; } } } //没有找到返回空 return nullptr; }
-
实现原理:
查找函数的实现简单,首先就是获取映射的下标,如果当前位置不为空,就检查存放的值是否是查找的值,不是就循环查找下一个位置的值,直到遇到空为止。这里需要注意的是,当下标值达到数组容量的最大值时,从零开始从新查找,知道遇到空结束。
删除函数
-
代码实现:
bool erase(const K& key){ HashData<K,V>* ret=Find(key); if(ret){ ret->_state=DELETE; --_n; return true; } return false; }
-
实现原理:
删除函数实现原理也比较简单,通过查找函数判断是否已经存在,如果不存在直接返回false;如果存在就直接将当前位置状态标记为删除
DELETE
即可,然后个数减一,返回true。
插入函数
在讲解插入函数之前先讲解一下什么是负载因子?
**哈希表中的负载因子
(Load Factor)
是指哈希表中已存储元素数量和哈希表总容量之间的比率。**通常以符号λ表示负载因子,计算公式如下:λ=(已存储元素数量)/(哈希表总容量)。
负载因子用来衡量哈希表的填充程度,即哈希表中已存储元素占用程度。当负载因子较低时,哈希表相对空闲,有较多的空位置可以使用;而当负载因子较高时,哈希表的位置大部分被占用,可能会导致哈希冲突的增加,进而影响哈希表的性能。
一般来说,适度的负载因子可以提高哈希表的效率。常见的负载因子的阈值通常在0.7到0.8之间。当负载因子超过这个阈值时,就要考虑进行扩容,以减少哈希冲突的概率,进而保持较高的性能。
当理解了负载因子后,我们就知道为什么要对哈希表进行扩容,接下来我们再来了解一下如何进行插入。
- 实现原理:
-
代码实现:
bool insert(const pair<K,V>& kv){ //检查过程 //如果插入的值已经存在就返回false if(Find(kv.first)){ return false; } //扩容过程 //判断是否需要扩容,当负载因子大于0.7时扩容 if((double)_n/(double)_table.size()>=0.7){ //扩容大小为原来大小的二倍 size_t newSize=_table.size()*2; //创建一个新的哈希表并扩容 HashTable<k,V> newHT; newHT._table.resize(newSize); //遍历旧的哈希表,将数据从新映射到新的哈希表中 for(size_t i=0;i<_table.size();i++){ if(_table[i]._state==EXIST){ newHT.insert(_table[i]._kv); } } //交换完数据后交换两个表,将新的哈希表作为_table _table.swap(newHT._table); } //插入过程 //先获取映射位置 HashFunc hf; size_t hashi=hf(kv.first)%_table.size(); //如果当前位置已经存在值,就找到下一个空位置存放 while(_table[hashi]._state==EXIST){ hashi++; //当下表hashi增加到数组最大长度时,从零开始 if(hashi==_table.size()){ hashi=0; } } //找到对应位置后存放插入的数据,同时将该位置状态标记为存在 _table[hashi]._kv=kv; _table[hashi]._state=EXIST; ++_n; return true; }
测试
-
测试代码:
void test1(){ open_address::HashTable<int,int> hash; //依次插入十个数据,当插入到第七个也就是插入10时会进行扩容 hash.insert(make_pair(2,2)); hash.insert(make_pair(12,12)); hash.insert(make_pair(4,4)); hash.insert(make_pair(1,1)); hash.insert(make_pair(7,7)); hash.insert(make_pair(3,3)); hash.insert(make_pair(5,5)); hash.insert(make_pair(10,10)); hash.insert(make_pair(23,23)); hash.insert(make_pair(21,21)); //插入已经存在的数据10,输出0 cout<<hash.insert(make_pair(10,10))<<endl; //删除数据2 hash.erase(2); //删除数据100,100不存在,输出结果为0 cout<<hash.erase(100)<<endl; //查找已经删除的值2,输出结果为0 cout<<hash.Find(2)<<endl; }
扩容前:
扩容后:
输出结果:
三.哈希表(链式定址法)原理及实现
1.链式定址法的原理
- 哈希函数:首先,使用哈希函数对元素的键值key进行计算,得到一个初始的哈希地址。这个地址决定了元素应该被存储在哪个桶中。
- 桶与链表:哈希表由多个桶组成,每个桶内部维护一个链表。当多个元素映射到同一个桶时,这些元素会被依次添加到该桶对应的链表中。
- 冲突解决:由于链表的特性,即使多个元素映射到同一个桶,也不会发生直接的冲突。它们只是被顺序地存储在链表中,因此可以通过遍历链表来查找特定的元素。
相较于开放定址法的优点:
- 简单易实现:链式定址法的实现较为简单,只需为每个桶维护一个链表即可。
- 动态扩展:链表可以动态扩展,无需预先确定大小,因此可以适应键值对数量的变化。
- 高效删除:链表中的元素删除操作较为高效,只需调整指针即可。
相对的链式定址法也有自身的缺点:
- 额外的空间开销:每个桶需要额外的空间来存储链表的指针,链表中的每个节点也需要额外的指针来连接。
- 性能退化:当哈希表的负载因子较高时(即桶中元素较多),链表长度增加,操作时间复杂度可能退化为O(n)。这会导致哈希表的性能下降。
图片
2.代码实现
基本框架
- 代码实现:
namespace Hash_bucket{
//节点数据封装
template<class K,class V>
class HashNode{
public:
//构造函数
HashNode(const pair<K,V>& kv)
:_kv(kv)
,_next(nullptr)
{}
pair<K,V> _kv;
HashNode<K,V>* _next;
};
//哈希表封装
template<class K,class V,class HashFunc=DefaultHashFunc<K>>
class HashTable{
typedef HashNode<K,V> Node;
public:
//构造函数
HashTable(){
//开辟数组空间并初始化为空指针
_table.resize(10,nullptr);
}
//其他成员函数...
private:
//哈希表中每个数据的是一个链表,相当于一个同一样将数据挂起来
vector<Node*> _table;
size_t _n=0;
};
};
-
实现原理:
- 链式定址法中哈希表存储每个数据是一个链表,因此要将每个数据进行封装,封装成节点类,数据域中存储
pair<key,value>
,指针域存放下一个节点的指针。 - 接着就是对哈希表进行封装,还是使用动态数组来实现。只不过是一个指针数组,存放的是每个哈希数据的结点指针,因此在初始化时要将数组都初始化为空指针
- 链式定址法中哈希表存储每个数据是一个链表,因此要将每个数据进行封装,封装成节点类,数据域中存储
查找函数
-
代码实现:
Node* Find(const K& key){ HashFunc hf; //先获取查找值在表中的映射位置 size_t hashi=hf(key)%_table.size(); //从当前位置的链表中查找对应节点 Node* cur=_table[hashi]; while(cur){ //检查节点是否是查找值,是返回对应节点 if(cur->_kv.first==key){ return cur; } //不是继续沿着链表查找 else{ cur=cur->_next; } } //没有找到返回空 return nullptr; }
-
实现原理:
- 先获取查找值在表中的映射位置,从当前位置的链表中查找对应节点
- 检查节点是否是查找值,是返回对应节点,不是继续沿着链表查找,没有找到返回空
删除函数
-
代码实现:
bool erase(const K& key){ HashFunc hf; //先获取删除值在表中的映射位置 size_t hashi=hf(key)%_table.size(); //找到当前值对应位置的链表头节点,依次往后查找,同时设置一个父节点 Node* parent=nullptr; Node* cur=_table[hashi]; while(cur){ //如果找到删除节点,将父节点的下一个节点连接成删除节点的下一个 if(cur->_kv.first==key){ //如果删除的是头节点 if(parent==nullptr){ _table[hashi]=cur->_next; } else{ parent->_next=cur->_next; } //释放删除节点 delete cur; cur=nullptr; return=true; } //没有找到沿着链表继续查找 else{ parent=cur; cur=cur->_next; } } //直到找到空节点为止 return false; }
-
实现原理:
- 先获取删除值在表中的映射位置,找到当前值对应位置的链表头节点,依次往后查找,同时设置一个父节点
- 如果找到删除节点,将父节点的下一个节点连接成删除节点的下一个。注意如果删除的是头节点,删除结点的下一个变为头节点。释放删除节点
- 没有找到沿着链表继续查找,直到找到空节点为止
插入函数
-
代码实现:
bool Insert(const pair<K,V>& kv){ //检查过程 //检查插入的值是否已经存在 if(Find(kv.first)){ return false; } HashFunc hf; //扩容过程 if(_n==_table.size()){ //新的容量大小是原来容量的二倍 size_t newSize=_table.size()*2; //创建一个新的哈希表并初始化 vector<Node*> newHT; newHT.resize(newSize,nullptr); //遍历旧表,将旧表中的节点存放到新的哈希表中 for(size_t i=0;i<_table.size();i++){ Node* cur=_table[i]; while(cur){ Node* next=cur->_next; size_t hashi=hf(cur->_kv.first)%newHT.size(); //头插到新表中 cur->_next=newHT[hashi]; newHT[hashi]=cur; cur=next; } //最后交换两个哈希表 _table.swap(newHT); } //插入过程 //获取哈希表中的映射位置 size_t hashi=hf(kv.first)%_table.size(); //头插 Node* newnode=new Node(kv); newnode->_next=_table[hashi]; _table[hashi]=newnode; ++_n; return true } }
测试
-
测试代码:
void test2(){ Hash_bucket::HashTable<int,int> hash; hash.Insert(make_pair(2,2)); hash.Insert(make_pair(12,12)); hash.Insert(make_pair(4,4)); hash.Insert(make_pair(1,1)); hash.Insert(make_pair(7,7)); hash.Insert(make_pair(3,3)); hash.Insert(make_pair(5,5)); hash.Insert(make_pair(10,10)); hash.Insert(make_pair(23,23)); hash.Insert(make_pair(21,21)); cout << "Before Expansion:" << endl; hash.Print(); hash.Insert(make_pair(32,32)); hash.Insert(make_pair(25,25)); cout << "After Expansion:" << endl; hash.Print(); cout<<hash.Insert(make_pair(10,10))<<endl; hash.Erase(2); hash.Erase(12); hash.Erase(32); cout<<hash.Erase(100)<<endl; cout<<hash.Find(2)<<endl; cout << "After Erase:" << endl; hash.Print(); }
-
测试结果:
四.完整代码文件
-
HashTable.h
文件完整代码:#include<iostream> #include<algorithm> #include<vector> #include<utility> using namespace std; template<class K> class DefaultHashFunc{ public: size_t operator()(const K& key){ return (size_t)key; } }; //对于字符串类型专门使用特化 template<> class DefaultHashFunc<string>{ public: size_t operator()(const string& str){ size_t hash=0; for(auto e : str){ hash*=131; hash+=e; } return hash; } }; //闭散列--开放定址法 namespace open_address{ //枚举位置状态 enum STATE{ EXIST, EMPTY, DELETE }; //哈希数据封装 template<class K,class V> class HashData{ public: pair<K,V> _kv; STATE _state=EMPTY; }; //哈希表封装 template<class K,class V,class HashFunc=DefaultHashFunc<K>> class HashTable{ public: HashTable(){ _table.resize(10); } bool insert(const pair<K,V>& kv){ //检查一下插入的值是否已经存在 if(Find(kv.first)){ return false; } //判断是否需要扩容 //当负载因子大于0.7时扩容 if((double)_n/(double)_table.size()>=0.7){ //扩容大小为原来大小的二倍 size_t newSize=_table.size()*2; //创建一个新的哈希表并扩容 HashTable<K,V> newHT; newHT._table.resize(newSize); //遍历旧的哈希表,将数据从新映射到新的哈希表中的对应位置 for(size_t i=0;i<_table.size();i++){ if(_table[i]._state==EXIST){ newHT.insert(_table[i]._kv); } } //交换完数据后一定要交换两个表,将新表作为_table _table.swap(newHT._table); } //插入 HashFunc hf; size_t hashi=hf(kv.first)%_table.size(); //如果当前位置已经存在值,就到下一个位置中存放 while(_table[hashi]._state==EXIST){ hashi++; //当下标hashi增加到数组最大长度时,从新置为0继续查找空位置存放 if(hashi==_table.size()){ hashi=0; } } //找到对应位置后存放插入的数据,同时将位置状态标价为存在 _table[hashi]._kv=kv; _table[hashi]._state=EXIST; ++_n; return true; } HashData<K,V>* Find(const K& key){ //先获取对应下标 HashFunc hf; size_t hashi=hf(key)%_table.size(); //如果位置状态不为空,就循环查找,遇到空就结束 while(_table[hashi]._state==EXIST){ if(_table[hashi]._kv.first==key){ return &_table[hashi]; } else{ hashi++; if(hashi==_table.size()){ hashi=0; } } } return nullptr; } bool erase(const K& key){ HashData<K,V>* ret=Find(key); //查找不为空将当前数据位置状态修改为删除,数据个数减一 if(ret){ ret->_state=DELETE; --_n; return true; } return false; } private: vector<HashData<K,V>> _table; size_t _n=0; }; }; //哈希桶 namespace Hash_bucket{ //节点数据封装 template<class K,class V> class HashNode{ public: //构造函数 HashNode(const pair<K,V>& kv) :_kv(kv) ,_next(nullptr) {} pair<K,V> _kv; HashNode<K,V>* _next; }; //哈希表封装 template<class K,class V,class HashFunc=DefaultHashFunc<K>> class HashTable{ typedef HashNode<K,V> Node; public: //构造函数 HashTable() { //开辟数组空间并初始化为空指针 _table.resize(10,nullptr); } //析构函数 ~HashTable(){ for(size_t i=0;i<_table.size();i++){ Node* cur=_table[i]; while(cur){ Node* next=cur->_next; delete cur; cur=next; } _table[i]=nullptr; } } //插入函数 bool Insert(const pair<K,V>& kv){ //检查插入的值是否已经存在 if(Find(kv.first)){ return false; } HashFunc hf; //判断是否需要扩容 if(_n==_table.size()){ //新的容量大小是原来容量的二倍 size_t newSize=_table.size()*2; //创建一个新的哈希表并初始化 vector<Node*> newHT; newHT.resize(newSize,nullptr); //遍历旧表,将旧表中的节点存放到新的哈希表中映射的位置 for(size_t i=0;i<_table.size();i++){ Node* cur=_table[i]; while(cur){ Node* next=cur->_next; size_t hashi=hf(cur->_kv.first)%newHT.size(); //头插到新表中 cur->_next=newHT[hashi]; newHT[hashi]=cur; cur=next; } //将旧表对应的位置置为空指针 _table[i]=nullptr; } //最后交换两个哈希表 _table.swap(newHT); } //获取哈希表中映射的位置下标 size_t hashi=hf(kv.first)%_table.size(); //头插 Node* newnode=new Node(kv); newnode->_next=_table[hashi]; _table[hashi]=newnode; ++_n; return true; } //查找函数 Node* Find(const K& key){ HashFunc hf; //先获取查找值在表中的映射位置 size_t hashi=hf(key)%_table.size(); //从当前位置的链表中查找对应节点 Node* cur=_table[hashi]; while(cur){ //找到返回对应节点 if(cur->_kv.first==key){ return cur; } //没找到沿着链表继续查找 else{ cur=cur->_next; } } //没有找到返回空 return nullptr; } //删除函数 bool Erase(const K& key){ HashFunc hf; //先获取删除值在表中的映射位置 size_t hashi=hf(key)%_table.size(); //找到当前值对应位置的链表头节点,依次往后查找,同时设置一个父节点 Node* parent=nullptr; Node* cur=_table[hashi]; while(cur){ //如果找到删除节点,将父节点的下一个节点连接成删除节点的下一个 if(cur->_kv.first==key){ //如果删除节点是头节点 if(parent==nullptr){ _table[hashi]=cur->_next; } else{ parent->_next=cur->_next; } //释放删除节点 delete cur; cur=nullptr; return true; } //没有找到沿着链表继续查找 else{ parent=cur; cur=cur->_next; } } //直到找到空节点结束 return false; } void Print(){ for(size_t i=0;i<_table.size();i++){ printf("[%d]",i); Node* cur=_table[i]; while(cur){ cout<<cur->_kv.first<<":"<<cur->_kv.second<<"->"; cur=cur->_next; } cout<<"NULL"<<endl; } cout<<endl; } private: //哈希表中每个数据存储的是节点,节点之间可以相互连接,构成链表,相当于哈希桶 vector<Node*> _table; size_t _n=0; }; };
以上就是关于哈希表的概念以及两种方法实现哈希表的原理和代码讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!