哈希表和哈希桶
- 一,什么是哈希
- 二,关联式容器unordered_map/set
- 1. unordered_map
- 2. unordered_set
- 三,哈希的结构
- 1. 哈希函数
- 2. 哈希冲突
- 四,哈希表(闭散列)及其模拟实现
- 五,哈希桶(开散列)及其模拟实现
- 六,封装unordered_set和unordered_map
学习C++的过程就像是打怪升级,我们上一节讲完了红黑树,这一节我们来看哈希。
一,什么是哈希
哈希我们不陌生,在数据结构的排序部分我们实现过基数排序,这其实就是一种哈希思想的实现。
哈希(也叫散列),是一种映射的思想(关键值和值建立一种关联关系)
哈希表是哈希思想的一种实现。
下面我们先来看一组关联式容器unordered_map/set
二,关联式容器unordered_map/set
unordered_map/set和map和set的用法类似,只是底层的实现是由哈希实现的
1. unordered_map
- unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_maps也实现了操作符operator[],它允许使用key作为参数直接访问value。
下面是unordered_map的一些接口,和map的用法类似
2. unordered_set
unordered_set和unordered_map类似,可以参考下面的文档链接: unordered_set文档
三,哈希的结构
哈希结构就是构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时又通过该函数可以很快找到该元素的结构
1. 哈希函数
哈希函数就是一种将关键码和存储位置进行一一映射的函数
这里介绍两种常见的函数:
直接定址法
适用于关键值比较集中,数据量不大的场景下。关键值和存储位置一一对应,不存在哈希冲突,但是空间浪费大。
除留余数法
可以用于分散的,数据量可以很大的场景。是多对一的关系,存在哈希冲突。
2. 哈希冲突
哈希冲突(也叫哈希碰撞)就是不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
出现哈希冲突的原因也有可能是哈希函数设计的不合理
解决哈希冲突的两种常见的方法是:
开放定址法(闭散列)
当前位置被占用时,在开放空间中按照某种规则去找没有被存储的地方
开放定址法又有两种方式:
1)线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。但是这又有一个问题是,一旦所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
2)二次探测
为了解决线探测的缺陷,所以有了二次探测,找下一个空位置的方法
为:
H
i
H_i
Hi = (
H
0
H_0
H0 +
i
2
i^2
i2 )% m, 或者:
H
i
H_i
Hi = (
H
0
H_0
H0 -
i
2
i^2
i2 )% m。其中:i =
1,2,3…,
H
0
H_0
H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表
的大小。
开散列(哈希桶/拉链法)
开散列法又叫链地址法(开链法),对关键码用哈希函数计算地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
知道了这两种方式的区别后我们就来实现一下这两种方式
四,哈希表(闭散列)及其模拟实现
先来实现闭散列,开放地址法实现哈希表
开放定址法要判断这个位置是否有值,我们可以用一个标记位来标记:
这里用枚举类型来表示某个位置的三种状态(空位置,已经存在值,这个位置的值被删除)
每个数据节点中有两个关键值,键值对pair和标记位:
enum Status{//表中的每个位置都要有状态
EMPTY,
EXIST,
DELETE
};
template<class K,class V>
struct HashDate {
pair<K, V> _kv;
Status _stat;
};
哈希表中既可以存储内置类型,也可以存储自定义类型,对于内置类型我们可以直接进行取模运算来进行哈希,但是自定义类型呢?
拿string为例,如果要将string映射在存储位上,无法直接进行取模运算,这里我们就要先将string映射为整型,再将这个整型经过哈希函数映射到存储的位置。
再这里我们借助仿函数来将内置类型和自定义类型转换为整型去进行哈希
//仿函数来将非整型来转换成整型去插入(线性探测要求用整型去求其所映射的位置)
template<class T>//这里只能将一般的类型强转成整型
struct Hashfunc {
size_t operator()(const T& t) {
return (size_t)t;
}
};
//为了支持将string也转换成整型,这里做了特化
template<>
struct Hashfunc<string> {
size_t operator()(const string& s) {//思路是将string的每个字符的ASCII相加来转换成整型
//KBDR
size_t hash = 0;
for (auto e : s) {
hash *= 31;//因为string对应的整型数量接近无限,而整型的数量只有2^32个,所以出现哈希冲突的可能性较高,为了降低冲突,这里做了特殊处理
hash += e;
}
return hash;
}
};
做好了这些准备工作,我们就可以用线性探测实现插入了
在这里提出一个负载因子
的概念,表示当前哈希表中被占用的位置多少
负载因子 = 数据个数 / 表的大小
线性探测法中负载因子是0.7时扩容,负载因子不能太大也不能太小,太大会出现哈希冲突,太小会造成空间浪费。
这里我们又提出扩容的问题,当负载因子达到0.7时进行扩容,扩容后在新表中重新建立映射关系
具体实现如下:
bool insert(const pair<K,V> &kv) {
if (find(kv.first)) {//不可以有重复元素
return false;
}
if (_n * 10 / _tables.size() == 7) {//_n和size都是无符号整型,这里给_n乘10可以防止出现小数
size_t newSize = _tables.size() * 2;
HashTable<K, V,Hash> newHT;//再开一个新表
newHT._tables.resize(newSize);//提前开二倍的size
//遍历旧表,将旧表的数据插入到新表,重新建立映射
for (size_t i = 0; i < _tables.size(); i++) {
if (_tables[i]._stat == EXIST) {
newHT.insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hf;//实例化仿函数
//线性探测
size_t hashi = hf(kv.first) % _tables.size();//这里除以size而不是capacity,如果模的是capacity的话,会插入在size之外
//而插入时[]只能访问size之内的
while (_tables[hashi]._stat == EXIST) {//当这个位置的状态表示有值时会往后找空闲位置
hashi++;
hashi %= _tables.size();//再环回回去继续找
}
_tables[hashi]._kv = kv;
_tables[hashi]._stat = EXIST;
++_n;
return true;
}
完整的代码可以进入我的gitee仓库进行参考: 哈希表的实现
五,哈希桶(开散列)及其模拟实现
现在我们来实现开散列,开散列我们在上面进行了讲解,就是将具有相同特征的数据元素放到一个桶中,所以也就不需要再判断这个位置的状态了。
这里的每个节点中出了要保存的数据外,还要定义一个next指针来链接该桶中这个节点的下一个节点。
template<class K,class V>
struct HashNode {
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K,V>& kv)
:_next(nullptr)
,_kv(kv)
{}
};
哈希桶的结构是由一个vector中存储一个个的桶
所以插入的操作也比较简单,将关键值经过哈希映射后找到对应的位置,直接将这个节点头插进所在的这个桶即可
但是哈希桶的平衡因子可以达到1再扩容,因为哈希桶解决了哈希冲突,在有些情况下,当一个桶的长度太大时可以将其放入红黑树中。
bool insert(const pair<K, V>& kv) {
if (find(kv.first)) {
return false;
}
//扩容
if (_n == _tables.size()) {//负载因子可以达到1再扩容
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHT;//定义一个新的哈希表
newHT._tables.resize(newSize);
//遍历旧表,将旧表中的数据放进新表
for (size_t i = 0; i < _tables.size(); i++) {
Node* cur = _tables[i];
while (cur) {
newHT.insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newHT._tables);
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
//在映射的位置直接头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;//新节点成为这个位置的第一个
++_n;
return true;
}
想看完整代码点击这里跳转: 哈希桶的实现
六,封装unordered_set和unordered_map
在简单实现了哈希桶之后,我们就可以来封装unordered_set和unordered_map了。这个部分的封装比较复杂,用了很多的模板参数,我们需要捋清各个函数间的关系再来看
感兴趣的朋友可以在我的gitee仓库中参考一下: 封装unordered_set和unordered_map
如果大家有什么疑问也欢迎提出问题。