C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)
Posted 呆呆兽学编程
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)相关的知识,希望对你有一定的参考价值。
⭐️今天我要和大家介绍一种新的算法思想——哈希,其中哈希中会用到的转换函数称为哈希函数,构造出来的结构叫哈希表(散列表)
⭐️博客哈希表和哈希桶完整代码已上传至gitee:https://gitee.com/byte-binxin/cpp-class-code
目录
🌏概念
概念: 不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(HashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素,其中哈希方法中用到的转换函数称为哈希函数,构造出来的结构叫哈希表(散列表)
下面是该结构中插入元素和搜索元素的方法(时间复杂度都可以达到O(1)):
- 插入元素: 根据待插入元素的关键码,通过哈希函数计算出该元素的存储位置,并按此位置进行存放
- 查找元素: 对要查找的元素的关键码用样的计算方法得出该元素的存储位置,然后与该位置的元素进行比较,相同就表示查找成功
其实之前数据结构中的计数排序也用到了哈希的思想。
🌏哈希函数
常见的有以下几种:
- 直接定制法: 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B,其中A和B为常数
优点: 简单,均匀
缺点: 需要事先知道关键字的分布情况,如果关键字分布很散(范围很大),就需要浪费很多的空间
使用范围: 关键字分布范围小且最好连续的情况 - 除留余数法: 取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key % p,p<=m(p的选择很重要,一般取素数或m)
优点: 可以将范围很大的关键字都模到一个范围内
缺点: 对p的选择很重要
使用范围: 关键字分布不均匀 - 平方取中法(不常用): 取关键字平方后的中间几位作为散列地址
- 随机数法(不常用): 选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合
- 折叠法(不常用): 将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址
总结: 前面两种方法是用的比较多的,后面的几种方法了解即可。但是这些函数都无法避免的就是哈希冲突(下面介绍)的问题,如果哈希函数设计越精妙,那么哈希冲突的概率就会越低
🌏哈希冲突
看下面一个例子:
有一组元素0,1,3,15,9用哈希的方式存放,其中哈希函数是Hash(key)=key%10 (存放后的结果如下)
用这种方式存储和查找数据显然很快,但是如果此时插入一个元素5,它应该放在那个位置?
Hash(5) = 5%10 = 5,但是3这个位置中已经有元素5,难道我们要选择覆盖元素9吗?
显然这样是不妥的。(后面有解决的方法)
总结: 不同关键字通过相同的哈希函数计算出相同的哈希地址, 这里的这种现象称为哈希冲突或哈希碰撞
🌏哈希冲突的解决
🌲闭散列
🍯概念
闭散列: 也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去(下面介绍两种寻找空位置的方式
)。
两种寻找空位置的方法:
- 线性探测: 从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。在上面哈希冲突的场景中,插入元素3时,因为此时的位置被占了,所以元素3选择下一个空位置,就是下标为4的位置。
看下面几个问题:
-
如何实现插入元素?
先通过哈希函数确定待插入元素的位置,如果该位置为空,直接插入,如果不为空就需要通过线性探测寻找下一个位置,如下面动图所示:
-
如何实现删除元素?
先通过哈希函数确定待删除元素的起始位置,然后线性探测往后找到要删除元素,此时不可以直接把这个元素删除,否则会影响到其它元素的搜索。所以这里对每个位置状态进行了标记,EMPTY(空) 、EXITS(存在) 和 DELETE(删除) 三种状态,用DELETE标记删除的位置(这是一种伪删除的方式)
下面用图解的方式解释为什么不能直接删除:
显然,这种删除方式会影响后期元素的查找,所以我们采用三种状态记录每个位置的状态,只有为空才结束元素的查找,具体操作如下。 -
如何查找元素?
先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测。
-
何时增容?
要注意的是,哈希表不能满了才增容,这样会导致哈希冲突的概率增大。哈希表中有一个衡量哈希表负载的量,叫负载因子。负载因子(Load Factor) = 数据个数/哈希表大小 。
一般我们选择负载因子为0.7-0.8的时候开始增容,如果这个值选取太小,会导致空间浪费;如果这个值选取太大,会导致哈希冲突的概率变大
- 二次探测: 线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H(i) = H(0) + i^2。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
增容问题: 当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
总结: 线性探测的优点是实现起来很简单,缺点就是会有累加效应
(我的位置如果被占了,那么我就占别人的位置);二次探测的优点减轻了累加效应
,因为哈希冲突的时候抢占的位置会在相对远一点的地方,这样元素排列就相对稀疏了。闭散列最大的缺陷就是空间利用率不高
,这同样也是哈希的缺陷。
🍯哈希表闭散列的实现(采用线性探测)
🐚哈希表整体框架
概述: 这里采用线性探测的方式构建哈希表,下面是整体框架,其中模板参数第一个是key关键字,第二个是哈希表存储的元素的数据类型,可以是K,也可以是pair<K,V>类型,主要就是为了同时实现K模型和KV模型。第三个参数就是一个仿函数,为了获取T中K的值,这里要实现两个仿函数,一个是对K模型,一个是对KV模型。这里其实和上一篇博客中通过改造红黑树同时实现map和set容器的方式是一样的。哈希表底层我们借用vector容器来实现。
哈希表数据存什么?
用一个类组织起来,里面有每个位置的状态和每个位置存放的元素
template<class K, class V>
struct KeyOfValue
const K& operator()(const K& key)
return key;
const K& operator()(const pair<K, V>& kv)
return kv.first;
;
enum State
EMPTY,
EXITS,
DELETE
;
template<class T>
struct HashData
T _data;
State _state;
;
template<class K, class T, class KOFV>
class HashTable
typedef HashData<T> HashData;
private:
vector<HashData> _tables;
size_t _num = 0;// 记录已经存放了多少个数据
;
🐚插入元素
有以下几个步骤:
- 先判断负载因子是否大于0.7,如果大于0.7,就要考虑增容(下面详细介绍);否则就直接插入
- 用哈希函数计算出要插入的元素的起始位置,然后找空位置(状态为EMPTY和DELETE)。然后进行插入,并把状态改为EXITS(这里不用担心没有空位置,因为哈希表不可能满,他不是满了才增容的)
- 如果此过程中发现要插入的元素存在,则返回FALSE代表元素插入失败;否则返回TRUE
增容问题: 我们需要把原来空间中的元素全部转移到新的空间中,此过程相当于往新空间重新插入元素,且要对它们进行重新定位
一般有两种方法:
-
直接开一个新的vector(大小为增容后空间的大小),然后一个元素一个元素地进行转移,最后把哈希表中的vector和性的vector进行交换,让这个新的vector带走旧空间,并清理资源
-
创建一个临时的哈希表,然后把vector成员的空间设置为增容后空间的大小,然后复用insert函数方法,对旧表中元素进行转移,最后新表和旧表的vector进行交换。(这里其实和上面的方法区别就在这里对insert进行了复用,且都用到了利用临时对象的析构函数清理旧空间的资源)
代码实现如下:
bool Insert(const T& data)
KOFV kofv;
// 哈希表不能满了在增容,这样会导致哈希冲突的概率增大
// 不能太小,太小会导致空间浪费;也不能太大,太大会导致哈希冲突的概率很大
// 负载因子(Load Factor)等于0.7就增容 _num/_tables.size()>=0.7
// 负载因子 = 数据个数/哈希表大小
if (_tables.size() == 0 || 10 * _num / _tables.size() >= 7)
vector<HashData> newtables;
size_t newsz = _tables.size() == 0 ? 10 : _tables.size() * 2;
newtables.resize(newsz);
// 先把旧表的数据重新放到新表中
// 因为表的大小发生变化,所以数据在旧表中的位置和新表的位置不一样,需要重新调整
// 写法1
for (size_t i = 0; i < _tables.size(); ++i)
if (_tables[i]._state == EXITS)
int index = kofv(_tables[i]._data) % newsz;
while (newtables[index]._state == EXITS)
// 不会存在重复数据,因为旧表中不可能右重复的数据
++index;
if (index == newsz)
index = 0;
newtables[index] = _tables[i];
_tables.swap(newtables);// 把临时空间和旧空间进行交换,交换后,旧空间的将由临时对象的析构函数来释放
//
// 写法2
/*HashTable<K, T, KOFV> newht;
size_t newsz = _tables.size() == 0 ? 10 : _tables.size() * 2;
newht._tables.resize(newsz);
for (size_t i = 0; i < _tables.size(); ++i)
if (_tables[i]._state == EXITS)
newht.Insert(_tables[i]._data);
_tables.swap(newht._tables);*/
int index = kofv(data) % _tables.size();
//int start = index;
//int i = 1;
while (_tables[index]._state == EXITS)
if (_tables[index]._data == data)
return false;
// 二次探测
/*index = start + pow(i, 2);
index %= _tables.size();
++i;*/
++index;
// 走到末尾置0
if (index == _tables.size())
index = 0;
// DELETE和EMPTY的位置都可以插入数据
_tables[index]._data = data;
_tables[index]._state = EXITS;
++_num;
return true;
🐚查找元素
前面介绍过了,先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测。
代码实现如下:
HashData* Find(const K& key)
KOFV kofv;
int index = key % _tables.size();
// int start = index;// 不用标记,因为表不会满,所以一定会遇到空
while (_tables[index]._state != EMPTY)
if (kofv(_tables[index]._data) == key)
if (_tables[index]._state == EXITS)
return &_tables[index];
else// _tables[index]._state == DELETE
return nullptr;
++index;
if (index == _tables.size())
index = 0;
// 找完一遍没有就退出 这里其实是不必要的,这里面一定有空的位置,所以一定会退出
/*if (index == start)
return nullptr;*/
return nullptr;
🐚删除元素
前面介绍过了,这里不多说,比较简单。
代码实现如下:
bool Erase(const K& key)
HashData* ret = Find(key);
if (ret != nullptr)
ret->_state = DELETE;
--_num;
return true;
else
return false;
🌲开散列
🍯概念
开散列法: 又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。(如下图)
注意: 开散列中每个桶放的都是哈希冲突的元素。哈希桶下面挂着的是一个一个的节点(一条链表),如果该位置哈希冲突的元素过多时,我们会选择在这里挂一颗红黑树(Java中是这样实现的)
🍯哈希表开散列实现(哈希桶)
🍯整体框架
哈希桶下面挂着的是一个一个的节点(一条链表),也就是每个位置存放链表头节点的地址。这里和开散列一样,我们还是用vector来存放元素。模板参数列表中前三个就不过多介绍,和闭散列是一样的,第四个参数后面介绍。
template<class T>
struct HashNode
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
,_next(nullptr)
;
template<class K, class T, class KOFV, class Hash>
class HashBucket
typedef HashNode<T> Node;
private:
vector<Node*> _tables;
int _num = 0;// 记录表中的数据个数
;
🐚插入元素
有以下几个步骤:
- 先根据元素个数考虑增容问题(下面详细介绍)
- 再通过哈希函数确定关键字的位置,然后把节点挂到这个桶下面(可以是链表的头,也可以是链表的尾部)
增容问题: 当哈希桶中元素个数打的一定个数时,就要增容,否则哈希冲突的概率会变得,且时间复杂度会下降的很快。所以,哈希桶一般是在元素个数等于桶的大小,也就是负载因子为1时,就开始增容。
- 先遍历一遍哈希桶的每个位置,然后对旧桶上的元素节点进行转移
- 最后插入新节点
代码实现如下:(这里的哈希函数后面介绍)
bool Insert(const T& data)
KOFV kofv;
// 负载因子为1时就增容
if (_num == _tables.size())
vector<Node*> newtables;
size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
newtables.resize(newsize);
for (size_t i = 0; i < _tables.size(); ++i)
Node* prev = nullptr;
Node* cur = _tables[i];
// 把一个位置的所有节点转移,然后换下一个位置
while (cur)
// 记录下一个节点的位置
Node* next = cur->_next;
int index = HashFunc(kofv(cur->_data)) % newtables.size();
// 把cur连接到新的表上
cur->_next = newtables[index];
newtables[index] = cur;
cur = next;// cur会发生变化,需要提前记录next
_tables.swap(newtables);
int index = HashFunc(kofv(data)) % _tables.size();
// 先查找该条链表上是否有要插入的元素
Node* cur = _tables[index];
while (cur)
if (kofv(cur->_data) == kofv(data))
return false;
cur = cur->_next;
// 插入数据,选择头插(也可以尾插)
Node* newnode = new Node(data);
newnode->_next = _tables[index];
_tables[index] = newnode;
++_num;
return true;
🐚查找元素
步骤:
- 先确定要查找的元素在哪个桶
- 然后在该桶下的链表对元素进行查找
代码实现如下:
HashNode*Find(const K& key)
KOFV kofv;
int index = HashFunc(key) % _tables.size();
Node* cur = _tables[index];
while (cur)
if (key == kofv(cur->_data))
return cur;
cur = cur->_next;
return nullptr;
🐚删除元素
步骤:
- 先找到元素
- 然后对元素节点进行删除,没找到就删除失败
代码实现如下:
bool Erase(const K& key)
KOFV kofv;
int index = HashFunc(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[index];
while (cur)
if (key == kofv(cur->_data))
// 删第一个节点时
if (prev == nullptr)
_tables[index] = cur->_next;
else
prev->_next = cur->_next;
--_num;
delete cur;
return true;
prev = cur;
cur = cur->_next;
return false;
🐚字符串哈希
在上面的哈希桶中,只能存放key为整形的元素,这个问题应该如何解决呢?
我们上面哈希函数采用除留余数法,key必须为整形才可以进行处理。所以我们需要采取一些措施,将这些key转为整形。
下面是一些字符串转换整数的Hash函数的比较: 戳这里
我们选择上面的一种,来进行使用。
实现如下: 因为较多情况下,key都是可以取模的,所以哈希桶的模板参数列表中选择直接返回key的函数作为缺省参数。有因为字符串哈希用的也比较多,所以这里对key为string类型进行一个特化
template<class K>
struct _Hash
// 大多树的类型就是是什么类型就返回什么类型
const K& operator()(const K& key)
return key;
;
// 特化string
template<>
struct _Hash<string>
size_t operator()(const string& key)
size_t hash = 0;
// 把字符串的所有字母加起来 hash = hash*131 + key[i]
for (size_t i = 0; i < key.size(); ++i)
hash *= 131;
hash += key[i];
return hash;
;
我们再实现一个哈希函数,里面是对key进行对应地转换,然后返回整形。
实现如下:
size_t HashFunc(const K& key)
Hash hash;
return hash(key);
🐚构建一个扩容数组
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。所以除留余数法最好模上一个素数。
代码实现如下:
const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
;
size_t GetNextPrime(size_t prime)
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
if (primeList[i] > prime)
return primeList[i];
return primeList[i];
改造增容部分代码:
//改造前
//size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
//改造后
size_t newsize = GetNextPrime(_tables.size());
🌐总结
哈希算法是一种将一个数据转换为一个关键值,这个关键值和原数据具有映射关系,这样数据的增删查改的时间复杂度就都可以达到O(1)。闭散列和开散列 是解决哈希冲突的两个方法,其中通过哈希方法可以构造哈希表和哈希桶两种结构。今天就先介绍到这里了,后面还会用哈希表来实现unordered_map和unorded_set两种容器,喜欢的话,欢迎点赞、支持和收藏~
以上是关于C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)的主要内容,如果未能解决你的问题,请参考以下文章