一:哈希表
1.1:哈希概念
1.2:哈希函数
1.3:哈希冲突
1.4:闭散列
二:哈希桶
2.1:开散列
2.2:闭散列和开散列的比较
三:总结
////
一:哈希表:
1.1:哈希概念:
哈希表(Hash Table),也称为散列表,是一种使用哈希函数实现的数据结构,用于实现关联数组和集合。哈希函数(hashFunc)将输入的键值映射到哈希表中的槽位索引,使得查找、插入和删除操作具有常数时间复杂度。
在之前学习的多种顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。在顺序结构中查找时间复杂度为O(N),在平衡树中查找时间复杂度为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
虽然像红黑树,AVL树这样高效的树形结构可以将搜索效率提高到O(log_2 N),但这并不是我们最理想的状态,
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
此时,哈希表这种通过键值和存贮位置构成映射关系的数据结构则是我们的不二之选!通过键值和存储位置的映射关系,我们就可以快速的通过键值找到它映射的存储位置,此时,搜索效率高达O(1);
///
1.2:哈希函数:
哈希函数是哈希表的关键,它承担着将key值和存储位置建立映射关系,就是上面说到的hashFunc。
1.2.1:直接定址法:
直接定址法是一种哈希函数的实现方法,其基本思想是按照某个固定的规则,将关键字的某个线性函数值作为哈希地址。具体地,对于给定的哈希表大小为m和关键字key,直接定址法哈希函数的定义为:h(key) = (A* key + B) % m(其中A,B为常数),下面给出一个具体示例演示一下:
如图所示,其中,数组arr是要存储或者插入的值,即是Key,下面的数组是用来存储arr数组中的值;
直接定址法就是将数组arr中的值对应到要存储的数组的下标位置,具体来说,就是arr中的1这个元素存储在下面存储数组的1号下标位置,arr中的3这个元素就存储在存储数组的3号下标位置,依次类推,此时,哈希函数中:h(key) = (A* key + B) % m(其中A,B为常数),那么根据上面的示例,A=1,B=0;m=99;
通过这个示例我们可以得出以下的结论:
优点:简单、均匀,就是关键值对应存储位置的下标,一目了然,简单直白;
缺点:需要事先知道关键字的分布情况,需要统计要存储的值中最大与最小的差值,方便开存储空间,
使用场景:适合查找比较小且连续的情况,因为当数组中有极大或者极小的数时,就会开辟大量不用的空间,浪费空间;
例如上面的arr,明明只有6个值,却因为有较大值99和较小值1,开辟了99个空间,导致99-6=93个空间被浪费,当然现在感觉93个空间不大,但是万一极大和极小之间的差值成千上万,大量的空间被浪费,那就不能容忍了;
///
1.2.2:除留余数法:
除留余数法,也被称为取余法,是一种常用的哈希函数实现方法。其基本思想是将关键字key除以一个不大于哈希表大小m的数p,然后取其余数作为哈希地址。具体地,对于给定的哈希表大小m和关键字key,除留余数法哈希函数的定义为:h(key) = key % m
除留余数法是另一种常用的哈希函数,相较于直接定址法,通过除留余数,取模的操作将数据的存储范围缩小的一个固定的区间,使存储区间的浪费程度减小,提高空间利用率;下面给出一个具体示例演示一下:
如图所示,其中,数组arr是要存储或者插入的值,即是Key,下面的数组是用来存储arr数组中的值;与直接定址法要存储的数据一样;
除留余数法将要插入的值,即arr数组里面的每一个值都去模除你所开辟的数组大小,不同于直接定址法,直接地址法中,存储空间需要计算准确后开辟,而除留余数法则直接开辟空间,无需计算,然后将arr数组中的每一个元素都模除你自己开辟的存储空间的大小,所得的余数,就是存储到存储数组的下标,
具体来说就是:arr数组中的1元素模除存储数组的大小(这里存储数组的大小为10),即1%10=1;那么1元素就存储在下面存储数组下标为1的位置,3%10=3,那么3元素就存储在下面存储数组下标为3的位置,以此类推,99%10=9,那么9元素就存储在下面存储数组下标为9的位置,
通过这个示例我们可以得出以下的结论:
优点:解决了直接定址法有可能要开辟大量空间,浪费空间的问题;
缺点:当插入的值模除后有大量相等的值时,会引发哈希冲突(后面再说);
同时,除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数;比如STL库中就有类似的实现方式,具体如下所示:
上面数组中虽然只有20个素数,但是完全够用了,最大的素数是3221225473,当下一个二倍的素数就已经越过整形的最大值了,就意味着哈希表有4GB个数据,同样在哈希桶中,每个数据是一个指针(32位),也就是4B大小,这样来看已经有16GB的数据量了,再考虑上挂的桶中的数据,数据量是非常大,正常情况下根本没有这么大量的数据。
///
1.3:哈希冲突;
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
如上面的除留余数法示例那样,当我再插入199,299,399,499时,模除存储空间大小10后,得出的余数都是9,一个空间怎么可能存的下5个数据呢,此时,就引发了哈希冲突;
///
1.4:闭散列:
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
这里给出一个示例,方便理解:
如上图所示, 数组arr中有7个待插入的元素,下面的存储数组大小为10,通过除留余数法来进行映射关系的建立;我们可以看到,3,5,6这三个元素按照上述除留余数法的方式插入到下面的存储数组中,此时,下一个元素13通过除留余数法算得余数为3,当想下面插入时,下标为3的位置已经被占用了,那么此时该如何插入?
解决方法:1. 线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为空,然后将其插入;
如图所示,当13存储时和3有冲突,存储数组下标为3的地方已经被占用,那么13开始在存储数组中向后探测,找到第一个为空的地方,就是13的存储位置
同时,我们接着将剩下的值插入:
当15这个元素模除后想放入时,下标为5的存储位置已经被占用,向后探测,下标为6的地方已经被占用,向后探测,下标为7的位置没有被占用,将15放入;
当66这个元素模除后想放入时,下标为6的存储位置已经被占用,向后探测,下标为7的地方已经被占用,向后探测,下标为8的位置没有被占用,将66放入;
由这种线性探测的方式可以看出,虽然可以一定程度上解决哈希冲突,但是这种方式有那种走别人的路让别人无路可走的感觉,这样一来,存放的数据越多,发生的哈希冲突就越多,就会越混乱。下面,我将闭散列先稍微实现一下;
///
哈希表的大致结构:
enum State//表中的状态
{
EMPTY,//空
EXIST,//存在
DELETE//删除
};
template<class K, class V>//哈希表/闭散列
struct HashData
{
pair<K, V>_kv;//存储的数据
State _state = EMPTY;//存储位置的状态,初始为空
//HashData(const pair<K, V>& kv)
// :_kv(kv)
// ,_state(EMPTY)
//{
//}
};
template<class K,class V>
class HashTable
{
private:
vector<HashData<K,V>> _tables;//有现成的顺序表容器,就不需要自己再去构建顺序表
size_t _n=0;//记录存储数组中的数据
};
这里一定要注意,我们必须要考虑删除时的情况,该怎么删除,直接删除,然后挪动存储数组吗?
显然,这样做有两个问题,一是效率变低了,当挪动存储数组时,那么时间复杂度能到到O(N),二是挪动之后,可能会使得映射关系发生变换!
所以,我们不能真给某一个数据删除掉,既然不能真删,我们可以伪删除,将其表示为删除就行了,但是这里新的问题是,该怎么表示成删除?是给空?还是赋标志值(如0,-1表示)表示为空呢?这里即不能给空表示删除,也不能赋标志值表示空,因为:
当删除某一个数据时,将其置空:万一后面还有数据呢,顺序表是按照顺序依次读取遍历的,当给空时,读到空直接返回,后面的数据将无法读取;
当删除某一个数据时,将其赋一个标志值表示空:万一表中存的就是你赋的标志值呢,这样的话,将其识别成空,那么将会数据丢失等问题;
所以这里吸取前面红黑树的经验,用枚举的方式将存储的状态表示出来,而存储数据的位置无非就是三种状态,是已经被人占用,还是为空,还是已经被删除,所以我们用EXIST表示已经被占用,EMPTY表示为空,DELETE表示被删除;
///
哈希表的插入:
如上所示,插入通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,
使用线性探测找到下一个空位置,插入新元素,同时改变插入位置的状态;
但是此时,如果创建这个存储数组时没有开辟空间,那么这个数组的大小为0,进行除留余数找映射关系时,可能会发生除0错误;所以在此之前需要先判断一下;
同时,我们还需要考虑一下扩容,当哈希表满的时候,没有地方插入了,此时需要扩容;
哈希表的扩容是根据荷载因子来决定的,对于开放定址法,荷载因子是特别重要的因素,应严格控制在0.7~0.8以下,当超过0.8时,查表时的CPU缓存命中率低,一般情况下,当负载因子大于0.7进行扩容。(荷载因子 = 哈希表中有效数据的个数 / 哈希表的大小)
之所以按照负载因子来扩容,是为了减轻哈希碰撞。
当负载因子越小,冲突概率越小,消耗空间越多。
当负载因子越大,冲突概率越大,空间利用率越高。
所以在插入的时候,我们需要注意到除0错误和扩容的问题;
通过代码,我们可以发现,插入时的逻辑和扩容后将旧表重新映射插入到新表的逻辑相同,代码复用率不高,此时,我们右两种方法提高代码的复用率,
第一种,就是把插入逻辑时的相同代码封装成一个函数,进行调用,通过调用函数通过代码的复用率;
第二种,创建一个新的对象,调用对象中的插入接口进项插入,实现复用;
这里实现第二种方法,通过调用对象接口提高代码复用率:
至此插入的整个逻辑就如上所示,但是,这个插入还存在着一个局限的问题,
映射关系是通过除留余数法得到的,只有整形家族中的类型才可以进行去模,但是其他的类型的数据怎么除留余数吗?如string,自定义类型该如果去除留余数?
此时就使用仿函数的方法,“将自定义类型转换成整形”,这里的转换只是为了除留余数法能够使用。这里以string为例
上图所示代码是一个仿函数,用来将string类型转换成整形,方便去进行除留余数法进行映射关系的建立。至于为什么要这样的计算,可能得去问问哪些大佬了,可以看一下别人的这篇博客:https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html
这样,通过仿函数就可以将一个字符串转换为一个唯一的整形,通过这个整形去除留余数,至此,这是插入的全部逻辑,具体代码如下所示:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first) != nullptr)
{
cout << "已有该元素,不能插入重复的值" << endl;
return false;
}
//if (_n == 0 || _n / _tables.size() >= 0.7)
//{
// //如果存储数组大小为0,则开辟10个空间,如果是扩容,就扩二倍;
// size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
// //因为扩容,那么映射关系就会发生变化
// vector<HashData<K, V>> newtables;//创建一个新的哈希表;
// newtables.resize(newsize);//开扩容后的空间;
// //将旧表中的值重新通过除留余数法映射到新表中,重新插入;
// for (auto& e : _tables)
// {
// if (e._state == EXIST)
// {
// size_t hashi = e._kv.first % _tables.size();
// size_t i = 1;
// size_t index = hashi;
// while (newtables[index]._state != EXIST)
// {
// index = hashi + i;
// index %= _tables.size();
// ++i;
// }
// newtables[index]._kv = e._kv;
// newtables[index]._state = EXIST;
// ++_n;
// }
// }
// //将旧表中的值重新插完到新表后,旧表与新表进行交换
// _tables.swap(newtables);
//}
if (_tables.size()==0 || _n*10 / _tables.size() >=7)
{
//如果存储数组大小为0,则开辟10个空间,如果是扩容,就扩二倍;
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V>newt;//创建一个哈希对象;
newt._tables.resize(newsize);//将哈希对象中的哈希表开扩容后的空间;
//将旧表中的值重新通过除留余数法映射到新表中,重新插入;
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newt.Insert(e._kv);
}
}
_tables.swap(newt._tables);//交换旧表与新表
}
size_t hashi = kv.first % _tables.size();
size_t i = 1;
size_t 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;
}
///
哈希表的查找:
查找就相对比较简单,就是拿着要寻找的Key值,通过除留余数法找到它在表中映射的位置hashi,而这一步,正是它实现查找时的时间复杂度为O(1)的关键,因为像AVL树,红黑树的查找是通过节点一个一个比较,比较耗时,而在哈希表的查找中,只需要拿映射的位置hashi,直接去表中寻找,整形的寻找比较对于CPU来说非常快;具体如下所示:
///
哈希表的删除:
删除更为简单,直接通过查找来实现,如果表中能查找到想要删除的值,直接通过伪删除法,将其状态改为DELETE;具体如下所示:
至此,这是整个哈希闭散列的插入,删除和插入,看起来简单,但是也有很多值得我们学习的地方,如:
1:给哈希表每个位置状态标识。
2:使用仿函数将不同类型的K转换成整数,这一点与红黑树封装set,map时获取key值的进行比较的仿函数(keyOFT)有异曲同工之妙;不得不说,仿函数还是非常便利和好用的!
同时,整个闭散列的主体思路还是线性探测,但是线性探测有着一些不可避免的问题:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
二次探测:
与线性探测不同的是,二次探测法的探测间隔是以平方的方式递增的,即第i次探测会跳过i^2个位置。例如,在哈希表中,如果第一次探测散列得到的位置是k,那么第二次探测将从k+1^2的位置开始,第三次探测将从k+2^2的位置开始,以此类推。这样可以避免哈希表中出现大量的聚集现象,从而提高哈希表的性能。具体如下所示:
45通过除留余数法得到余数5,
第一次探测的位置是K,也就是5,但是下标为5的位置已经被占用;
第二次探测的位置是K+1^2,也就是6,但是下标为6的位置已经被占用;
第三次探测的位置是K+2^2,也就是9,下标为9的位置没有被占用,所以插入到这里;
使用二次探测,查找时也是按照二次探测的方式去查找,二次探测其实也是探测,只不过在线性探测的基础上优化了一点点而已,实际我们也不经常使用,我们主要实现哈希桶;
同时,研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
////
二:哈希桶:
2.1:开散列
1. 开散列概念:
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。具体结构如下所示:
如上图所示,我们还是通过要插入的值与存储位置的下标建立映射关系,不同的是,之前的哈希表存储数组存储的是一种类型,而此时的存储数组是指针数组,里面存储的都是一个一个链表的头节点,那么,当一个存储位置的下标被多个元素映射时,不需要在线性探测,而是直接将发生哈希冲突的元素挂在链表上,这样,就完美的解决了哈希冲突以及线性探测的弊端!
例如上图所示,当2,12,1002发生哈希冲突时,直接将其挂到一个链表上,同时,我们需要注意链接的方式,我们是头插链接呢,还是尾插链接?
其实都可以,但是如果尾插链接,可能需要遍历一下链表,可能会降低效率,所以我们采取头插(我上面画的图没注意,画成尾插了,请多包涵!)
开散列的方法,通常被称为哈希桶,使用的也最广泛,能够解决闭散列中空间利用率不高的问题。
哈希表的大致结构:
没有了哈希表的状态表示,但是存储数组的每一个位置存的都是链表的头节点,当然,此时哈希表里初始化全是nullptr;同时有一些和哈希表相同的结构,比如说仿函数,哈希表需要有把不同类型转成整形去除留余数的方法, 那么哈希桶同样需要;
同时,哈希桶需要有自己的析构函数,
因为它是一个vector+链表,当析构时,vector只会析构vector里面的数据,但是它不会析构挂在vector下的链表,为了防止内存泄漏,我们需要自己写析构函数,析构函数如下所示:
~HashBucker()
{
for (auto& cur : _tables)//遍历vector
{
while (cur)//拿到vector中每一个链表的头节点
{
Node* next = cur->_next;//先保存头节点的下一个节点
delete cur;//释放头结点
cur = next;//把保存的下一个节点赋给头节点,如此向后遍历
}
cur = nullptr;//将vector中的每一个链表删除干净的同时,将vector里面的数据也置空
}
}
///
哈希桶的插入:
哈希桶的插入逻辑基本就是这样,找到映射到的下标,然后头插,同样,我们也需要像哈希表一样考虑一下扩容的问题;
哈希桶的扩容:桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?
开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
这样写可以很好的复用代码,不用在写一遍插入的逻辑,直接调用插入接口就能搞定,但是这样重新在新的哈希桶映射效率会大打折扣,为什么呢?
因为当你通过旧桶的链表节点去在新表中重新映射,他们你需要重新构建链表节点,同时当新桶完全链接映射好后,旧桶被析构,析构的时候又需要将旧桶中的链表节点一起析构,一来一回,重新构建链表节点,又析构链表节点,这样会造成很大的消耗,使得效率变低,那么不如直接将旧桶上面的链表节点直接拿过来,在新桶重新映射,这样即不用构建新的链表节点,也不用费劲的去析构旧的链表节点,具体如下所示:
那么这样,将节省很多的空间,提高效率,下面给一个示例验证一下:
通过内存窗口我们可以清晰的查看到,旧桶中的节点被插入到新桶中使用,节点的地址没有改变!
///
哈希桶的查找:
直接通过映射找到挂在桶上的链表,然后开始查找,哈希桶结构,查找的效率高就高在这里,可以直接根据key值定位哈希表,时间复杂度是O(1)。
///
哈希桶的删除:
哈希桶的删除不能像哈希表那样直接通过查找删除,因为哈希桶的查找直接返回的是链表的节点,如果直接将节点删除,那么链表就断了,所以我们在删除的时候,需要先将前一个节点记录下来,然后在将要删除的节点删除;具体操作如下所示:
由于哈希映射的存在,在寻找key时的时间复杂度同样是O(1),所以删除的效率也很高。
///
2.2:闭散列和开散列的比较:
5. 开散列与闭散列比较
应用链地址法,也就是开散列处理溢出,需要增设链接指针,似乎比闭散列多了一个指针,增加了存储开销。
但是事实上:由于开放地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开放地址法节省存储空间。
这只是两种哈希结构的比较,但因为都是哈希结构,所以它都会通过哈希函数的映射关系找到存储的位置,然后在查询常数次,所以这两种方式的时间复杂度都是O(1)。相对于其他的数据结构,无论是闭散列,还是开散列,性能还是比其他的数据结构略胜一筹的;
同时,哈希桶还有优化的空间,为了防止有极端的情况出现,比如所以的节点都挂在一个位置下面,那么此时,哈希桶的结构优势就荡然无存了,就只变成了链表的增删查改,性能大大降低;
为了防止此类情况的出现,可以控制扩容,通过扩容重新映射就解决的七七八八了,还可以加上限制每一个位置下面挂的节点个数,甚至可以把挂在vector下面的链表换成AVL树,红黑树这样的树形结构,提供效率;
////
三:总结:
哈希表/哈希桶是较为重要的数据结构,通过对他们的学习,使我们对容器的搭配使用更加了解,比如哈希桶的vector+List,同时哈希结构的映射思想非常的有用,给人一定的启发,
同时,unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构,虽然哈希结构有开散列和闭散列之分,但是还是哈希桶的应用比较多,unordered系列的关联式容器就是通过哈希桶封装的,所以,我们的重点还是在哈希桶上面!