目录
哈希表(散列表)的概念
哈希冲突
发生了哈希冲突该怎么办
闭散列(开放定址法)
在闭散列的方式下,如何从代码层面上判断是否发生了哈希冲突呢?
通过闭散列的方式实现的哈希表的基础框架
1. 线性探测
哈希表的Insert的整体代码(通过闭散列的线性探测方法实现)
哈希表的Find和Erase的整体代码(通过闭散列的线性探测方法实现)
闭散列的线性探测版本下的哈希表的整体代码
闭散列的线性探测版本下的Insert、Find、Erase的测试
线性探测的优缺点
2. 二次探测
哈希表的Insert的整体代码(通过闭散列的二次探测方法实现)
二次探测的优缺点
开散列(链地址法、开链法)
在开散列的方式下,如何从代码层面上判断是否发生了哈希冲突呢?
通过开散列的方式实现的哈希表的基础框架
通过开散列的方式实现的哈希表的Insert
哈希表的Insert的整体代码(通过开散列的方式实现)
开散列版本下的Insert的测试
通过开散列的方式实现的哈希表的Erase和Find
哈希表的Erase和Find的整体代码(通过开散列的方式实现)
开散列版本下的Erase和Find的测试
通过开散列的方式实现的哈希表的析构函数
通过开散列的方式实现的哈希表的拷贝构造
开散列的哈希表的拷贝构造的测试
对开散列的哈希表的性能的测试
开散列的哈希表的迭代器
迭代器类的基础框架(包含operator*()等等接口)
迭代器类的前后置的operator++函数
哈希表类的begin()、end()函数
和哈希表的迭代器相关的整体代码
开散列版本的哈希表的整体代码
通过开散列方式实现的哈希表优点
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较,比如在搜索树中,如果比当前节点大,就往右边走,小就往左边走。顺序结构的查找的时间复杂度为O(N);而搜索树中的查找的时间复杂度为O(h),h表示树的高度,因为平衡树也是搜索树,所以平衡树中的查找的时间复杂度为O(log_2N),查找的效率取决于查找过程中元素的比较次数。理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立【一对一】的映射的关系,那么在查找时通过该函数可以很快找到该元素,其时间复杂度可以达到O(1)。
在哈希(散列)结构中插入元素就是根据待插入元素的关键码,以hashFunc函数计算出该元素的存储位置并按此位置进行存放。
在哈希(散列)结构中搜索元素就是对元素的关键码通过hashFunc函数进行计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功,反之则失败。
上面所描述的方式即为哈希(散列)方法,哈希方法中使用的hashFunc转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)或者散列表。为了加深理解,简单上一个实例,如下图。
用上面的哈希方法进行搜索的确不必进行多次关键码的比较,的确搜索的速度比较快,但在上图中,按照上面所说的哈希方式向集合中再插入元素44时就会出现问题:按照哈希函数44%10==4,但问题是下标为4的空间上已经存储了元素4,所以元素44就无法插入,这就是哈希冲突问题。
哈希冲突的标准定义是:不同关键码通过相同的哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
这里顺便说一下另一个概念:把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
有两种应对方案,第一种叫闭散列,第二种叫开散列。
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那如何寻找下一个空位置呢?有两种方式,第一种叫线性探测,第二种叫二次探测。
有人看到这个问题,肯定直接就会说:【这还不简单,拿上图举例,我插入元素44时,根据哈希函数算出的哈希地址hashAddr上已经存在了元素4,这不就是哈希冲突嘛?】
这里我想说的是:问题在于该怎么判断一个哈希地址上是否存在元素,难道根据元素不是0就判断为当前位置上存在元素,根据元素是0就判断为当前位置上不存在元素吗?答案:当然不行,举个例子,现在假设根据元素是0就可以判断为当前位置上不存在元素,那如果我要插入的数据是0,在某个位置A上插入0后,之后如果有元素根据哈希函数算出的哈希地址就是位置A,此时根据这个假设,就会覆盖掉之前插入的元素0,这不是我们想要看到的。所以综上所述,我们需要思考出一种方案让一个哈希地址能表示出当前的位置是空(无效),还是非空(有效),这样一来,就有办法判断是否发生了哈希冲突了。比如拿插入元素x举例,根据哈希函数算出元素x的哈希地址HashAddr后,发现下标为HashAddr的位置上不为空,则说明发生了哈希冲突;反之如果为空,则没有发生哈希冲突。
那如何判断一个哈希地址上当前是否为空呢?
1.如果哈希表中的每个数据不是自定义类,则常见的方法有以下两种:
1.1:使用其他特殊值表示空或无效的状态:可以选择一个与哈希表中可能的有效值不同的特殊值,用来表示空或无效的状态。例如,可以选择一个负数或其他非常规的值来表示空位置。
1.2:使用额外的标记数组:可以使用一个额外的布尔数组或位图来标记哈希表中的每个位置是否已经被占用。这样,即使哈希表中的某个位置存储了0,你仍然可以通过标记数组来判断该位置是否已经被占用。
2.如果哈希表中的每个数据是自定义类,则就简单了,为每个数据类定义两个类成员,一个表示数据的值,一个表示数据的状态,数据的状态是空,则当前哈希地址上就是空;数据的状态不是空,则当前哈希地址上就不是空。
选择上面哪种方法取决于你的具体需求和哈希表的实现方式。在实际应用中,需要根据具体情况选择最适合的方法来处理特殊值的情况。接下来有两种解决哈希冲突的方案,分别叫【闭散列的线性探测】和【闭散列的二次探测】,并且因为存在一些需求,所以我们要为这两种方法而把数据定义成自定义类型,数据类里就有两个成员,一个表示数据的值,一个表示数据的状态。所以通过闭散列的方式实现的哈希表的基础框架如下。
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
class HashTable
{
public:
private:
vector> _v;
size_t _size;
};
拿上面讲解哈希冲突时所使用的场景举例,如上图。现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突,线性探测就是从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止,也就是上图中hashAddr为8的地方,在这个位置上插入元素44。
所以结合闭散列的线性探测的思想,在哈希(散列)结构中插入元素就是:先通过哈希函数获取待插入元素在哈希表中的位置,然后如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
结合上面插入的理论,咱们可以编写出第一阶段的Insert的代码。(注意下面代码未编写完毕)
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
class HashTable
{
public:
bool Insert(const T& x)
{
//在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
//因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
//所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
//问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
int hashaddr = x % _v.size();
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else//走到这里说明发生了哈希冲突,开始线性探测
{
int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
hashaddr++;
while (hashaddr != temp)
{
//这里模%的意义是:有可能hashaddr的值在数组的靠后位置,后面的位置都满了,但数组的前几个位置是空的,所以如果超出数组范围了,我们应该重新从数组的首部开始找空位置
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else
{
hashaddr++;
}
}
}
}
private:
vector> _v;
size_t _size;
};
仔细观察代码,如果这样写,则还有一个问题没有解决,那就是可能哈希表的capacity已经满了(或者是正在进行第一次插入,哈希表没开空间),就是说:在哈希表【0,哈希表的capacity】这个区间的位置上都已经存在了数据,此时数据就插入不进去了,此时哈希表就需要扩容。说一下,因为上面代码的注释中说过【vector的size就是哈希表的capacity】,所以哈希表的capacity已经满了也等价于是vector的size满了,就是说是在vector中的【0,vector的size】这个区间的位置上,都已经存在了数据,此时数据就插入不进去了,此时vector不一定需要扩容,但一定得把size变大。那么综上所述,问题就转化成了:如何给哈希表扩容(或者说如何把vector的size变大)?答案:很简单,通过vector的resize函数即可给哈希表扩容(或者说把vector的size变大)。
问题1:那什么时候扩容呢?有人肯定会说:【上一段不是说过了吗,在哈希表插满了就扩容呗】,这里我想说的是:【实际上不会这么做,因为在哈希表只剩下1个空位置没有被插入数据时,再去插入数据则极大概率会不断地向后继续探测找空位置,则导致插入的效率非常低效】,那到底什么时候扩容呢?
给出答案前,首先需要知道几个知识点:
1.散列表(即哈希表)的载荷因子(也叫负载因子)定义为:α =填入散列表中的元素个数/散列表的长度(长度就是哈希表的capacity)。
2.α是散列表(即哈希表)装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,表明填入表中的元素越多,产生冲突的可能性就越大,插入删除的效率就越低(但空间利用率会变高);反之,α越小,说明填入表中的元素越少,产生冲突的可能性就越小,插入删除的效率就越高(但空间利用率会变低)。
3.实际上,散列表(即哈希表)的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
答案1:结合上面的几个知识点可知,让哈希表的负载因子为0.7后就扩容是若干个合适的选择中的一个,所以我们就按照0.7为标准,只要在插入某个数据时,发现当前的负载因子是0.7,就需要让哈希表扩容。
(说一下:因为在插入时只要发现当前的负载因子是0.7,就需要让哈希表扩容,这就意味着哈希表永远不可能被插满,所以上面代码中的temp变量就失去了意义,可以注释掉了)
问题2:那扩容扩多大呢?或者说怎么扩容呢?
答案2:扩容的大小一般和vector一样,也是扩2倍,但注意,哈希表的扩容不能像vector一样直接扩2倍后拷贝原数据到新空间上,什么意思呢?比如说在哈希表扩容前,假如有数据13,则在经过哈希函数int hashaddr = x % _v.size()的计算后,数据应插入下标为3的位置上;但在哈希表扩容后,哈希表的capacity变大后(也就是vector的size变大后),此时数据13就不应该还放到下标为3的位置上,因为数据13此时再经过哈希函数int hashaddr = x % _v.size()的计算,数据按理应插入下标为13的位置上。但如果哈希表的扩容像vector一样直接扩2倍后拷贝原数据到新空间上,则数据13在哈希表扩容后,依然位于下标为3的位置上,这就导致了哈希函数的映射关系乱套了,所以哈希表的扩容不能像vector一样直接扩2倍后拷贝原数据到新空间上。所以综上所述,可以发现哈希表的扩容是比vector的扩容有更多的消耗的,因为哈希表在扩容后,需要根据哈希函数重新映射哈希表中所有的元素。
结合上面理论,可以得到第二阶段的Insert的代码,如下。(注意下面代码未编写完毕)
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
class HashTable
{
public:
bool Insert(const T& x)
{
//如果哈希表的capacity为0(即vector的size为0),或者是负载因子(负载因子=哈希表的size/哈希表的capacity)达到了0.7,则需要扩容
if (_v.size() == 0 || (double)_size / (double)_v.size() == 0.7)//因为_size和_v.size()都是size_t类型,如果不强转则相除后的结果为0,所以要强转一下产生double类型的临时变量后再相除
{
size_t newSize = (_v.size() == 0 ? 10 : 2 * _v.size());
HashTabletemp;
temp._v.resize(newSize);
//扩容完毕后将原vector中的数据全部重新映射到新的vector中
for (int i = 0; i < _v.size(); i++)
{
if (_v[i]._s == EXIST)
{
temp.Insert(_v[i]._data);//这边递归复用了HashTable的Insert函数,这里最多只会递归一次,因为在进入递归函数前我们把_v的size变成了10或者原来的2倍,进不了最外面的if分支,所以也就走不到这里的if分支来
}
}
//映射完毕后,交换新旧哈希表中的vector成员,这样一来旧哈希表就窃取了新哈希表的资源,就完成了扩容
swap(_v, temp._v);
}
//在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
//因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
//所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
//问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
int hashaddr = x % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else//走到这里说明发生了哈希冲突,开始线性探测
{
//文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以temp就可以被注释掉了
//int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
hashaddr++;
//temp被注释掉后,自然就不能作为循环条件了,因为文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以下面不可能死循环,一定会在中途return true,所以循环条件就写while(1)即可
//while (hashaddr != temp)
while (1)
{
//这里模%的意义是:有可能hashaddr的值在数组的靠后位置,后面的位置都满了,但数组的前几个位置是空的,所以如果超出数组范围了,我们应该重新从数组的首部开始找空位置
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else
{
hashaddr++;
}
}
}
}
private:
vector> _v;
size_t _size;
};
说一下,插入或者删除的元素如果是负数,上面的代码依然是正确的,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,虽然x和hashaddr都是int类型,但v.size()的返回值类型是size_t,所以这里x%_v.size()后会整形提升,比如说,如果相模之后的结果为-1,则结果整形提升成size_t类型后,结果会变成1,所以不必担心插入或者删除负数时会出错。
上面Insert的代码已经基本上算是把所有需要的逻辑都编写完毕了,只剩下最后的一个问题,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,所以如果要插入的元素x不是int类型,而是string类型,那么x%_v.size()就会出错,因为string类没有operator%这个成员函数,而且我们没法对库中的string的源代码进行修改,没法给string类增加operator%成员函数,所以就还剩下该问题需要解决。
咱们看看库中是如何解决这个问题的,如下图红框处,库中是为unordered_map提供了一个类模板参数Hash,Hash是一种可调用对象的类型(就等价于Compare,只不过用途不同),比如可以是仿函数的类型,函数指针的类型,函数的类型,下图红框处的缺省参数hash
所以我们也要为咱们模拟实现的HashTable类模板增加一个模板参数Hash,然后为Hash这个可调用对象的类型编写可调用的类,比如仿函数类。加完仿函数后,还需要在所有通过哈希函数【int hashaddr = x % _v.size()】计算哈希地址的地方,把哈希函数修改成【int hashaddr = hf(x) % _v.size()】,hf就是仿函数类的对象。说一下仿函数类的编写思路:
情况1:如果x的类型T是指针类型,或者是char类型,int类型,double类型,float类型等等能直接强转成size_t类型的类型,则通过operator()函数直接将T类型的x强转成size_t类型并return这个强转生成的size_t类型的临时对象即可;
情况2:如果是x的类型T是string类型,则可以通过类模板的特化,重新设计operator()函数,思路为设置一个size_t val=0,让val分别与string中的每一个char都相加,并且每在相加之前都把val*=131。所有的char都被相加过后,最后return size_t类型的val即可。这样的字符串哈希方法被命名为BKDR方法,是通过两位大佬的名字命名的。
所以综上所述,不管是情况1还是情况2,通过hf(x),或者说通过operator()函数就能让无法被%的x转化成一种能被%的size_t类型。
问题1:为什么在上面的情况2中val每在和char相加前都要把val*=131呢?
答案1:这是发明C语言的大佬们设计出的一种字符串哈希算法,名叫BKDR字符串哈希算法,可以更加有效的避免哈希冲突,为什么更有效呢?举个例子,如果val每在和char相加前都不把val*=131,而是直接和char相加,如下图1所示,则会导致通过仿函数计算不同的字符串时所得到的size_t类型的数据相同的可能性变大,进而导致不同的字符串对象通过哈希函数【int hashaddr = hf(x) % _v.size()】计算出的哈希地址相同的可能性变大,导致产生哈希冲突的可能性变大;而如果val每在和char相加前都把val*=131,则通过不同的字符串所计算出的size_t类型的数据相等的可能性就会变小,如下图2,也就不会出现后序的问题了。至于为什么是131,笔者也没有深入了解,估计是结合数学上的一些理论并且经过多次的测试所得到的结果。
问题2:为什么情况2中size_t类型的val可以和char类型的数据相加呢?
答案2:当size_t类型与char类型相加时,C++会进行隐式类型转换。根据C++的整数提升规则,较小的整数类型(如char)会被提升为较大的整数类型(如size_t),以便进行运算。因此,当size_t类型与char类型相加时,char类型会被提升为size_t类型,结果也将是size_t类型。这是因为C++倾向于保持较大的类型,以确保不会发生数据丢失或截断。需要注意的是,由于size_t是无符号整数类型,与有符号整数类型(如char)相加时,可能会导致一些意外的结果。因此,在进行这样的操作时,应该谨慎处理,并确保理解类型转换的规则和可能的影响。
结合上面的思路,最终阶段的Insert的代码如下。
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it=s.begin();it!=s.end();it++)
{
val *= 131;
val+=(*it);
}
return val;
}
};
template>
class HashTable
{
public:
bool Insert(const T& x)
{
//如果哈希表的capacity为0(即vector的size为0),或者是负载因子(负载因子=哈希表的size/哈希表的capacity)达到了0.7,则需要扩容
if (_v.size() == 0 || (double)_size / (double)_v.size() == 0.7)//因为_size和_v.size()都是size_t类型,如果不强转则相除后的结果为0,所以要强转一下产生double类型的临时变量后再相除
{
size_t newSize = (_v.size() == 0 ? 10 : 2 * _v.size());
HashTabletemp;
temp._v.resize(newSize);
//扩容完毕后将原vector中的数据全部重新映射到新的vector中
for (int i = 0; i < _v.size(); i++)
{
if (_v[i]._s == EXIST)
{
temp.Insert(_v[i]._data);//这边递归复用了HashTable的Insert函数,这里最多只会递归一次,因为在进入递归函数前我们把_v的size变成了10或者原来的2倍,进不了最外面的if分支,所以也就走不到这里的if分支来
}
}
//映射完毕后,交换新旧哈希表中的vector成员,这样一来旧哈希表就窃取了新哈希表的资源,就完成了扩容
swap(_v, temp._v);
}
hashfunc hf;
//在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
//因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
//所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
//问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else//走到这里说明发生了哈希冲突,开始线性探测
{
//文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以temp就可以被注释掉了
//int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
hashaddr++;
//temp被注释掉后,自然就不能作为循环条件了,因为文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以下面不可能死循环,一定会在中途return true,所以循环条件就写while(1)即可
//while (hashaddr != temp)
while (1)
{
//这里模%的意义是:有可能hashaddr的值在数组的靠后位置,后面的位置都满了,但数组的前几个位置是空的,所以如果超出数组范围了,我们应该重新从数组的首部开始找空位置
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else
{
hashaddr++;
}
}
}
}
private:
vector> _v;
size_t _size;
};
————分割线————
注意结合闭散列的线性探测的思想处理哈希冲突时,在删除元素时有一些注意事项,比如删除哈希表中已有的元素后,我们不能把该位置上的状态设置成空(即无效),而需要把该位置上的状态设置成已被删除。有人说:【你逗我玩呢?这不是一个意思吗?】
注意这里还真不是一个意思,因为结合闭散列的线性探测的思想,在插入某元素时如果发生了哈希冲突,该元素会挨个向后找空位置并完成插入,这就意味着在删除某元素A时,根据元素A的值算出哈希地址A后,如果哈希地址A的状态是空(无效),那我们就不知道要删除的元素A曾经是否在哈希表中存在过,也就不知道此时应不应该继续从哈希位置A开始挨个向后找目标元素A,如果说每次遇到这样的情况时都找吧,又显得太笨了,这样哈希表不就和顺序表的效率一样了,干嘛不直接用顺序表呢;如果说不找吧,要删除的目标元素A可能在哈希地址A的后若干个位置(因为曾经在插入目标元素A时可能发生了哈希冲突,导致目标元素A在哈希地址A的后面若干个位置),此时不找就完成不了删除任务。
总而言之,就很难办,所以就有人想出了一种方法,就是在删除某元素后,把该元素所在的位置的状态设置成已删除,这样一来,如果有哈希地址上的状态是空(无效),说明从未有节点插入到这个位置上过,那在删除某元素A时,根据元素A的值算出哈希地址A后,如果哈希地址A的状态是空(无效),则一定代表了要删除的目标元素A在哈希表中从未存在过,此时一定就不需要从哈希地址A开始挨个向后找了。只有在删除某元素A时,根据元素A的值算出哈希地址A后,哈希地址A的状态是已删除,此时才有可能因为曾经在插入目标元素A时发生了哈希冲突,导致目标元素A在哈希地址A的后面若干个位置,才有从哈希位置A开始挨个向后找目标元素A的必要。因此,删除哈希表中已有的元素后,我们不能把该位置上的状态设置成空(即无效),而需要把该位置上的状态设置成已被删除。
结合上面理论,Find的思路为:先根据哈希函数计算出哈希地址HashAddr,如果下标为HashAddr的位置上的元素的状态为EXIST,并且元素的值也和需要查找的元素相等,则直接返回该元素的地址;如果下标为HashAddr的位置上的元素的状态为DELETE或者是【元素状态为EXIST,但元素的值不等于需要查找的元素】,则此时需要继续向后探测,因为说不定曾经在插入目标元素时发生了哈希冲突,导致目标元素在哈希地址HashAddr的后面若干个位置;如果下标为HashAddr的位置上的元素的状态为EMPTY,则此时一定不用向后继续探测,哈希表中一定不存在正在查找的元素。
然后删除的思路就简单了,如果找到了目标元素x,就删除,注意删除是不需要释放空间或者修改元素的值的,只需要把元素的状态设置成DELETE(即已删除)即可;反之如果没找到,则退出Erase函数即可。
结合上面的理论,可以先后编写出Find和Erase的第一阶段的代码,如下。(注意下面代码未编写完毕)
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
class HashTable
{
public:
HashDate* Find(const T& x)
{
if (_v.size() == 0)
return nullptr;
int hashaddr = x % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else if (_v[hashaddr]._s == DELETE || (_v[hashaddr]._s == EXIST && _v[hashaddr]._data != x))
{
int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经找过了,此时就不应该继续找了
hashaddr++;
while (hashaddr != temp)
{
//这里模%的意义是:因为在插入某个值时,线性探测有向后继续找空位置的特性,所以元素x有可能在下标为hashaddr的元素的前面,所以这里%运算后可以从vector的首元素开始找x
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else
{
hashaddr++;
}
}
//走到这里就出了while循环,说明把vector中的所有位置都找过了,但没找到,所以直接return nullptr即可
return nullptr;
}
else//(_v[hashaddr]._s == EMPTY),文中说过,这种情况下没有必要继续往后探测了,所以直接return nullptr即可
{
return nullptr;
}
}
bool Erase(const T& x)
{
if (_v.size() == 0)
{
cout << "哈希表已为空,无法删除" << endl;
return false;
}
HashDate* p = Find(x);
if (p == nullptr)
{
cout << "哈希表中不存在" << x << ",无法删除。" << endl;
return false;
}
else
{
p->_s = DELETE;
--_size;
return true;
}
}
private:
vector> _v;
size_t _size;
};
说一下,插入或者删除的元素如果是负数,上面的代码依然是正确的,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,虽然x和hashaddr都是int类型,但v.size()的返回值类型是size_t,所以这里x%_v.size()后会整形提升,比如说,如果相模之后的结果为-1,则结果整形提升成size_t类型后,结果会变成1,所以不必担心插入或者删除负数时会出错。
上面Find和Erase的代码已经基本上算是把所有需要的逻辑都编写完毕了,只剩下最后的一个问题,在上面讲解Insert时我们说过, 因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,所以如果要插入的元素x不是int类型,而是string类型,那么x%_v.size()就会出错,在Find函数和Erase函数中,我们也需要解决这个问题,如何解决已经在Insert的部分全部讲过了,这里不再说明,直接上代码。
结合上面的思路,最终阶段的Find和Erase的代码如下。
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it=s.begin();it!=s.end();it++)
{
val *= 131;
val+=(*it);
}
return val;
}
};
template>
class HashTable
{
public:
HashDate* Find(const T& x)
{
if (_v.size() == 0)
return nullptr;
hashfunc hf;
int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else if (_v[hashaddr]._s == DELETE || (_v[hashaddr]._s == EXIST && _v[hashaddr]._data != x))
{
int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经找过了,此时就不应该继续找了
hashaddr++;
while (hashaddr != temp)
{
//这里模%的意义是:因为在插入某个值时,线性探测有向后继续找空位置的特性,所以元素x有可能在下标为hashaddr的元素的前面,所以这里%运算后可以从vector的首元素开始找x
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else
{
hashaddr++;
}
}
//走到这里就出了while循环,说明把vector中的所有位置都找过了,但没找到,所以直接return nullptr即可
return nullptr;
}
else//(_v[hashaddr]._s == EMPTY),文中说过,这种情况下没有必要继续往后探测了,所以直接return nullptr即可
{
return nullptr;
}
}
bool Erase(const T& x)
{
if (_v.size() == 0)
{
cout << "哈希表已为空,无法删除" << endl;
return false;
}
HashDate* p = Find(x);
if (p == nullptr)
{
cout << "哈希表中不存在" << x << ",无法删除。" << endl;
return false;
}
else
{
p->_s = DELETE;
--_size;
return true;
}
}
private:
vector> _v;
size_t _size;
};
文件Hash.h的代码如下。
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
template>
class HashTable
{
public:
//线性探测版本的Insert
bool Insert(const T& x)
{
//如果哈希表的capacity为0(即vector的size为0),或者是负载因子(负载因子=哈希表的size/哈希表的capacity)达到了0.7,则需要扩容
if (_v.size() == 0 || (double)_size / (double)_v.size() == 0.7)//因为_size和_v.size()都是size_t类型,如果不强转则相除后的结果为0,所以要强转一下产生double类型的临时变量后再相除
{
size_t newSize = (_v.size() == 0 ? 10 : 2 * _v.size());
HashTabletemp;
temp._v.resize(newSize);
//扩容完毕后将原vector中的数据全部重新映射到新的vector中
for (int i = 0; i < _v.size(); i++)
{
if (_v[i]._s == EXIST)
{
temp.Insert(_v[i]._data);//这边递归复用了HashTable的Insert函数,这里最多只会递归一次,因为在进入递归函数前我们把_v的size变成了10或者原来的2倍,进不了最外面的if分支,所以也就走不到这里的if分支来
}
}
//映射完毕后,交换新旧哈希表中的vector成员,这样一来旧哈希表就窃取了新哈希表的资源,就完成了扩容
swap(_v, temp._v);
}
hashfunc hf;
//在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
//因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
//所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
//问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else//走到这里说明发生了哈希冲突,开始线性探测
{
//文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以temp就可以被注释掉了
//int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
hashaddr++;
//temp被注释掉后,自然就不能作为循环条件了,因为文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以下面不可能死循环,一定会在中途return true,所以循环条件就写while(1)即可
//while (hashaddr != temp)
while (1)
{
//这里模%的意义是:有可能hashaddr的值在数组的靠后位置,后面的位置都满了,但数组的前几个位置是空的,所以如果超出数组范围了,我们应该重新从数组的首部开始找空位置
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else
{
hashaddr++;
}
}
}
}
HashDate* Find(const T& x)
{
if (_v.size() == 0)
return nullptr;
hashfunc hf;
int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else if (_v[hashaddr]._s == DELETE || (_v[hashaddr]._s == EXIST && _v[hashaddr]._data != x))
{
int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经找过了,此时就不应该继续找了
hashaddr++;
while (hashaddr != temp)
{
//这里模%的意义是:因为在插入某个值时,线性探测有向后继续找空位置的特性,所以元素x有可能在下标为hashaddr的元素的前面,所以这里%运算后可以从vector的首元素开始找x
if (hashaddr == _v.size())
{
hashaddr %= _v.size();
}
if (_v[hashaddr]._s == EXIST && _v[hashaddr]._data == x)
{
return &_v[hashaddr];
}
else
{
hashaddr++;
}
}
//走到这里就出了while循环,说明把vector中的所有位置都找过了,但没找到,所以直接return nullptr即可
return nullptr;
}
else//(_v[hashaddr]._s == EMPTY),文中说过,这种情况下没有必要继续往后探测了,所以直接return nullptr即可
{
return nullptr;
}
}
bool Erase(const T& x)
{
if (_v.size() == 0)
{
cout << "哈希表已为空,无法删除" << endl;
return false;
}
HashDate* p = Find(x);
if (p == nullptr)
{
cout << "哈希表中不存在" << x << ",无法删除。" << endl;
return false;
}
else
{
p->_s = DELETE;
--_size;
return true;
}
}
void print()
{
cout << "为:";
for (HashDate& e : _v)
{
if (e._s == EXIST)
cout << e._data << ' ';
}
cout << endl;
}
private:
vector> _v;
size_t _size;
};
测试图如下,可以看到结果是符合预期的。注意Erase内部是调用了Find函数的,Erase没有出错说明Find也没有出错,所以Find也是被检查过的。如果光看结果让您感觉有点不真实,请拷贝上面闭散列的线性探测版本下的哈希表的整体代码,然后拷贝下面的测试代码,然后去调试一下。
上图代码如下。
#include
using namespace std;
#include
#include
#include
线性探测优点:实现非常简单。
线性探测缺点:(结合下图思考)一旦发生哈希冲突,并且所有的冲突还连在一起的话,就容易产生数据堆积(即数据都紧挨在一起),而一旦数据都紧挨在一起,则就更加可能导致哈希冲突,就像滚雪球一样,发生的哈希冲突越多,就越容易发生哈希冲突。而发生的哈希冲突越多,哈希表的增删查改的效率就越低。
说一下闭散列的通病就是空间利用率低,因为要求负载因子<=0.7。
如何缓解呢?这就是接下来咱们要说的二次探测所要解决的问题了。
什么是二次探测呢?
先拿线性探测举例,然后通过对比线性探测就能知道什么是二次探测了。
拿线性探测的方式举例:如下图1,插入21时根据哈希函数计算出的哈希地址为1,下标为1的地方已经插入了元素1,此时就会发生哈希冲突,按照线性探测的方式,则会向后探测一次,发现下标为2的位置是空,则21就插入到下标为2的位置;插入31时,根据哈希函数计算出的哈希地址也为1,而下标为1的地方已经插入了元素1,此时也会发生哈希冲突,则会向后探测一次,但发现下标为2的位置也被占用了,则会继续向后探测一次,终于发现下标为3的位置是空,则31插入到该位置上。综上所述,可以看出对于线性探测而言,如果在插入一个元素时以根据哈希函数计算出的哈希地址为基准地址,则发生了几次哈希冲突,下一次探测的位置就要在基准地址上加几,比如插入21时会发生1次哈希冲突,则21的位置就要在基准地址上加1;插入31时会发生2次哈希冲突,则31的位置就要在基准地址上加2。
二次探测和线性探测的唯一区别就是,如果在插入一个元素时以根据哈希函数计算出的哈希地址为基准地址,则发生了几次哈希冲突,下一次探测的位置就要在基准地址上加几的平方(结合下图思考),比如通过二次探测的方式插入21时,根据哈希函数算出的哈希地址为1,但下标为1的位置已经插入了元素1,所以发生了第1次哈希冲突,则下一次探测的位置要在基准地址1上加1的平方,算出来也就是2,然后发现下标为2的位置上是空,所以直接插入21;插入31时,根据哈希函数算出的哈希地址为1,但下标为1的位置已经插入了元素1,所以发生了第1次哈希冲突,下一次探测的位置则要在基准地址1上加1的平方,算出来也就是2,然后发现下标为2的位置上已经插入了元素21,所以发生了第2次哈希冲突,则下一次探测的位置要在基准地址1上加2的平方,算出来也就是5,然后发现下标为5的位置上是空,所以直接插入31。
图1如下。
图2如下 。
结合上文,可以看到二次探测相对于线性探测的区别是非常小的,所以编写通过闭散列的二次探测的方式实现的Insert、Find、Erase的代码时,也只需要在线性探测版本的Insert、Find、Erase的代码的基础上进行稍微的修改,结合上面二次探测的理论知识,代码如下。
这里笔者偷个懒,就只编写二次探测版本的Insert了,剩下的靠各位脑补。
#pragma once
#include
#include
using namespace std;
enum State
{
EXIST,
DELETE,
EMPTY
};
template
struct HashDate
{
HashDate()
:_data()
, _s(EMPTY)
{}
HashDate(const T& x)
:_data(x)
, _s(EXIST)
{}
T _data;
State _s;
};
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
template>
class HashTable
{
public:
线性探测版本的Insert
//bool Insert(const T& x)
//{
// //如果哈希表的capacity为0(即vector的size为0),或者是负载因子(负载因子=哈希表的size/哈希表的capacity)达到了0.7,则需要扩容
// if (_v.size() == 0 || (double)_size / (double)_v.size() == 0.7)//因为_size和_v.size()都是size_t类型,如果不强转则相除后的结果为0,所以要强转一下产生double类型的临时变量后再相除
// {
// size_t newSize = (_v.size() == 0 ? 10 : 2 * _v.size());
// HashTabletemp;
// temp._v.resize(newSize);
// //扩容完毕后将原vector中的数据全部重新映射到新的vector中
// for (int i = 0; i < _v.size(); i++)
// {
// if (_v[i]._s == EXIST)
// {
// temp.Insert(_v[i]._data);//这边递归复用了HashTable的Insert函数,这里最多只会递归一次,因为在进入递归函数前我们把_v的size变成了10或者原来的2倍,进不了最外面的if分支,所以也就走不到这里的if分支来
// }
// }
// //映射完毕后,交换新旧哈希表中的vector成员,这样一来旧哈希表就窃取了新哈希表的资源,就完成了扩容
// swap(_v, temp._v);
// }
// hashfunc hf;
// //在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
// //因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
// //所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
// //问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
// int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
// if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
// {
// _v[hashaddr]._data = x;
// _v[hashaddr]._s = EXIST;
// _size++;
// return true;
// }
// else//走到这里说明发生了哈希冲突,开始线性探测
// {
// //文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以temp就可以被注释掉了
// //int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
// hashaddr++;
// //temp被注释掉后,自然就不能作为循环条件了,因为文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以下面不可能死循环,一定会在中途return true,所以循环条件就写while(1)即可
// //while (hashaddr != temp)
// while (1)
// {
// //这里模%的意义是:有可能hashaddr的值在数组的靠后位置,后面的位置都满了,但数组的前几个位置是空的,所以如果超出数组范围了,我们应该重新从数组的首部开始找空位置
// if (hashaddr == _v.size())
// {
// hashaddr %= _v.size();
// }
// if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
// {
// _v[hashaddr]._data = x;
// _v[hashaddr]._s = EXIST;
// _size++;
// return true;
// }
// else
// {
// hashaddr++;
// }
// }
// }
//}
//二次探测版本的Insert
bool Insert(const T& x)
{
//如果哈希表的capacity为0(即vector的size为0),或者是负载因子(负载因子=哈希表的size/哈希表的capacity)达到了0.7,则需要扩容
if (_v.size() == 0 || (double)_size / (double)_v.size() == 0.7)//因为_size和_v.size()都是size_t类型,如果不强转则相除后的结果为0,所以要强转一下产生double类型的临时变量后再相除
{
size_t newSize = (_v.size() == 0 ? 10 : 2 * _v.size());
HashTabletemp;
temp._v.resize(newSize);
//扩容完毕后将原vector中的数据全部重新映射到新的vector中
for (int i = 0; i < _v.size(); i++)
{
if (_v[i]._s == EXIST)
{
temp.Insert(_v[i]._data);//这边递归复用了HashTable的Insert函数,这里最多只会递归一次,因为在进入递归函数前我们把_v的size变成了10或者原来的2倍,进不了最外面的if分支,所以也就走不到这里的if分支来
}
}
//映射完毕后,交换新旧哈希表中的vector成员,这样一来旧哈希表就窃取了新哈希表的资源,就完成了扩容
swap(_v, temp._v);
}
hashfunc hf;
//在文中的图示中,我们计算HashAddr的值都是让数据去模哈希表的capacity,那这里的代码计算HashAddr的值时为什么是%size而不是%capacity呢?
//因为哈希表的底层容器是vector,而对于vector来说,就算你capacity是100,但如果size是0,那在前100个数据里,我们无法通过operator【】访问任意一个,会断言报错,
//所以这里我想说的是:vector的size就是哈希表的capacity,所以本质上这里计算HashAddr的值依然是让数据去模哈希表的capacity,只不过vector的size就是哈希表的capacity
//问题:如何通过代码体现出【vector的size就是哈希表的capacity】这一点呢?答案:给哈希表扩容时,也就是让哈希表的capacity变大时,要通过vector的resize函数给vector的size变大,vector的size变大后,哈希表的capacity也就变大了
int hashaddr = hf(x) % _v.size();//不管x是正的还是负的,都不会出错,因为_v.size()的返回值是size_t类型,这里会整形提升
int i = 0;//用于记录发生了几次哈希冲突,进而可以根据i和文中所说的基准地址算出下一次向后探测的位置
int hashi = 0;//表示下一次向后探测的位置
if (_v[hashaddr]._s == EMPTY || _v[hashaddr]._s == DELETE)
{
_v[hashaddr]._data = x;
_v[hashaddr]._s = EXIST;
_size++;
return true;
}
else//走到这里说明发生了哈希冲突,开始二次探测
{
//文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以temp就可以被注释掉了
//int temp = hashaddr;//temp用于防止在下面死循环,比如在哈希表中每一个位置都已经插入了数据时再去插入新数据,此时就会出现死循环。
i++;
hashi = hashaddr + (i * i);
//temp被注释掉后,自然就不能作为循环条件了,因为文中说过,加入了负载因子这个概念后,哈希表中不可能被插满,所以下面不可能死循环,一定会在中途return true,所以循环条件就写while(1)即可
//while (hashaddr != temp)
while (1)
{
//这里模%的意义是:有可能hashi的值在经过 +=(i * i)后变得非常大,此时为了提高效率和防止hashi越界,直接取%后的值
//为什么上面说能提高效率呢?举个例子,假如哈希表的capacity,也就是vector的size为10时,则从哈希表上的任意一个位置开始向后走10或者向前走10,都会回到原来的位置,
//所以这里如果hashi的值在经过 +=(i * i)后变得非常大,我们可以直接通过%10来快速的计算出下一次需要探测的位置
if (hashi >= _v.size())
{
hashi %= _v.size();
}
if (_v[hashi]._s == EMPTY || _v[hashi]._s == DELETE)
{
_v[hashi]._data = x;
_v[hashi]._s = EXIST;
_size++;
return true;
}
else
{
i++;
hashi = hashaddr + (i * i);
}
}
}
}
private:
vector> _v;
size_t _size;
};
以上就是二次探测的全部内容,虽然二次探测在一定程度上缓解了线性探测的滚雪球问题(即上文中所说的线性探测的缺点),也就是在一定程度上降低了发生哈希冲突的可能性,但二次探测依然没有解决【发生哈希冲突后需要向后探测找空位置占据其他位置】的缺陷,为什么说它是缺陷呢?因为只要你非法占据其他人的位置,那别人就只能也非法占据其他人的位置,就有可能增加发生哈希冲突的可能性,也就是说二次探测即使比线性探测好一点,但也有很大的可能性会发生哈希冲突。
说一下闭散列的通病就是空间利用率低,因为要求负载因子<=0.7。
那有没有更加优秀的方法解决哈希冲突呢?答案是有的,这就是咱们接下来要说的开散列(也叫链地址法、开链法)。
开散列法又叫链地址法(开链法),首先对各个数据用哈希函数计算哈希地址,具有相同地址的数据归于同一子集合,也就是插入到同一个链表里,每一个子集合(即链表)被称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点的地址存储在哈希表中,如下图演示。至于为什么是单链表,原因1:因为双向链表的意义不大(在当前情景下设置成双向链表的意义就是能够从后向前找数据,但因为哈希桶中的数据是无序无规律的,所以从后向前找数据没有意义)。原因2:并且双向链表的节点类还多了一个指针成员,占用了更多的内存。
拿哈希表的插入举例:上文中说过各链表的头结点的地址存储在哈希表中,那么根据哈希函数算出的哈希地址hashAddr上如果是nullptr,则说明没有发生哈希冲突;如果不为nullptr,则发生了哈希冲突。
因为通过开散列的方式实现的哈希表中的数据类是一个个节点,所以这里对比闭散列的方式,数据类的名字从HashDate变成了HashNode,并且数据类的成员也发生了变化,从成员State _s变成了HashNode* _next,为什么不需要表示状态的State成员了呢?因为对于EXIST和EMPTY这两种状态而言,非nullptr和nullptr就可以表示;而对于DELETE这种状态,在开散列中是不需要这种状态的,因为没有用途,为什么呢?在开散列的哈希表中,我们将哈希地址相同的元素都放到了同一个哈希桶中,并不需要经过探测寻找所谓的“下一个位置”,就是说根据哈希函数算出元素所在的哈希地址hashAddr后,如果一个元素不在下标为hashAddr的链表上,则说明该元素一定不存在。所以综上所述,在实现开散列的哈希表时,我们不用为哈希表中的每个节点设置一个State成员。
代码如下。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
private:
vector_v;
size_t _size;
};
}
结合上面的理论部分与上图,我们能写出开散列的Insert的一个大概的框架,第一阶段的代码如下。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
bool Insert(const T& x)
{
//不管是否发生哈希冲突,插入元素x都需要执行以下逻辑
int hashaddr = x % _v.size();
Node* cur = _v[hashaddr];//cur是哈希表中下标为hashaddr位置上的链表
Node* temp = new Node(x);//temp是需要新插入的节点
temp->_next = cur;
_v[hashaddr] = temp;
_size++;
}
private:
vector_v;
size_t _size;
};
}
仔细观察上面代码,如果这样写,可以发现还有一个问题没有解决,那就是哈希表的扩容问题,如果哈希表的capacity为0,也就是vector的size为0,则肯定需要扩容;还有什么情况需要扩容呢?
因为桶的个数是一定的,所以随着元素(即链表节点)的不断插入是一定会发生哈希冲突的,而发生的哈希冲突越多,则某些桶中元素(即链表节点)的个数就越多,而某些桶中的元素越多,查找该桶里的元素时效率就越低,甚至在极端情况下可能所有的元素(即链表节点)都挂在了一个桶中,这就会大大降低的哈希表的性能。所以综上所述,哈希表的性能降低的根本原因是因为发生的哈希冲突太多了,而前面说过在不断向哈希表中插入元素是一定会发生哈希冲突的,因此如果想让哈希表的性能变高就得尽可能的减少发生哈希冲突的次数,而想要尽可能的减少发生哈希冲突的次数,唯一的方式就是扩容,尽可能为哈希表预留更多的空闲空间,也就是说要在负载因子比较小的时候就扩容,这样一来负载因子就会一直处于较小的状态,负载因子越小,就越不容易发生哈希冲突,闭散列就是这样做的。但注意,开散列作为闭散列的升级版,是需要弥补闭散列的短板的,上文中说过闭散列的缺点就是空间利用率太低,所以这里开散列不能尽可能地为哈希表预留更多的空闲空间,也就是说不能在负载因子比较小的时候就扩容,要尽可能的在负载因子比较大的时候再扩容,但为了提高哈希表的效率,又不能让负载因子太大,最后经过取舍,经过大量的实验,发现这样做比较好:在开散列的哈希表最理想的情况下是每个哈希桶中刚好挂一个节点,也就是说到目前为止一次哈希冲突都没有发生,但下一次插入数据时则一定会发生哈希冲突,此时已经避无可避,所以这时就扩容,即当负载因子达到1时就需要扩容(STL源码就是这么设计的)。别忘了哈希表的负载因子定义为:α =填入哈希表中的元素个数/哈希表的长度(长度就是哈希表的capacity)。
如何扩容呢?
1.若哈希表的大小为0,则将哈希表的初始大小设置为10。
2.若哈希表的负载因子已经等于1了,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据拷贝到新哈希表,注意和闭散列一样,开散列这里将原数据拷贝到新哈希表的过程中也是需要重新计算哈希地址HashAddr的,最后将原哈希表与新哈希表所管理的数据交换即可。
注意在上一段中将原哈希表的数据拷贝到新哈希表的过程中,不能和闭散列的方式一样,即不能通过复用上面哈希表的插入函数将原哈希表中的数据插入到新哈希表,因为观察上面代码可以发现在这个过程中我们会new创建相同数据的结点插入到新哈希表,这样一来,在插入完毕后就需要将原哈希表中的结点进行delete释放(否则就空间泄漏),这就有点多此一举了,这是无谓的消耗。拷贝数据的正确方式为:遍历原哈希表的vector中的每个哈希桶(即每个链表),将每个哈希桶(即每个链表)中的所有节点都通过哈希函数定位出该节点需要挂到新哈希表的vector中的哪个桶的位置,然后将该节点从旧vector的桶中拆下来,并挂到(头插到)新vector上的之前算出的桶的位置即可,不用进行结点的创建与释放。注意这里头插或者尾插是无所谓的,只不过头插比较方便,因为尾插要找尾。
其实上一段中开散列之所以和闭散列的扩容方式不同、不需要像闭散列一样复用哈希表的插入函数的本质原因,或者说是不用创建节点的原因是:因为闭散列的哈希表中的数据HashDate是靠vector所new出的一片连续的空间,无法将单个HashDate的空间拆给别人;而开散列中的数据HashNode并不是vector所new出的一片连续的空间,vector所new出的一片连续的空间都是给HashNode*类型的指针对象用的,HashNode是靠HashTable所new出的非连续的一个个独立的空间(每个独立空间都是一个HashNode),既然是非连续,也就可以将这一个个HashNode的空间交给任意一个指针打理,所以HashNode就可以从原哈希表的vector的桶中拆下来,然后交给另一个vector的桶管理,不必再拷贝一份。如果还是不明白,请根据下面代码自行分析您的疑点。
结合上面的理论,我们能写出开散列的Insert的第二阶段的代码,如下。
#pragma once
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
bool Insert(const T& x)
{
//一石二鸟,哈希表为空时走这里扩容;哈希表的负载因子达到1时也走这里扩容
if (_size == _v.size())
{
int newSize = _v.size() == 0 ? 10 : 2 * (_v.size());
vectorv1;
v1.resize(newSize);
//将旧空间_v的数据都移到新空间v1上
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur1 = _v[i];
while (cur1 != nullptr)
{
int hashaddr = cur1->_data % v1.size();//hashaddr表示新vector上的下标,注意因为发生了扩容,所以哈希函数这里是模v1的size,而不是模_v的size
Node* cur2 = v1[hashaddr];//cur2表示新vector上第hashaddr个链表的头节点的地址
Node* cur3 = cur1->_next;
cur1->_next = cur2;
v1[hashaddr] = cur1;
cur1 = cur3;
}
_v[i] = nullptr;
}
}
//走到这里就出了for循环,表明已经把数据都挪动完毕了,将新vector交给哈希表管理即可,出了最外层的if分支后,旧vector中的节点的内存不会被释放,释放的是旧vector中的指针所占的8字节空间
_v.swap(v1);
}
//不管是否发生哈希冲突,插入元素x都需要执行以下逻辑
int hashaddr = x % _v.size();
Node* cur = _v[hashaddr];//cur是哈希表中下标为hashaddr位置上的链表
Node* temp = new Node(x);//temp是需要新插入的节点
temp->_next = cur;
_v[hashaddr] = temp;
_size++;
return true;
}
private:
vector_v;
size_t _size;
};
}
说一下,插入或者删除的元素如果是负数,上面的代码依然是正确的,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,虽然x和hashaddr都是int类型,但v.size()的返回值类型是size_t,所以这里x%_v.size()后会整形提升,比如说,如果相模之后的结果为-1,则结果整形提升成size_t类型后,结果会变成1,所以不必担心插入或者删除负数时会出错。
上面Insert的代码已经基本上算是把所有需要的逻辑都编写完毕了,只剩下最后的一个问题,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,所以如果要插入的元素x不是int类型,而是string类型,那么x%_v.size()就会出错,因为string类没有operator%这个成员函数,而且我们没法对库中的string的源代码进行修改,没法给string类增加operator%成员函数,所以就还剩下该问题需要解决。如何解决已经在闭散列部分全部说明过了,不再赘述,直接上代码。
结合上面的理论,我们能写出开散列的Insert的最终阶段的代码,如下。注意下面包含了用于打印vector中每个哈希桶的print函数,该函数用于在下文中测试Insert的逻辑是否正确。
#pragma once
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
bool Insert(const T& x)
{
hashfunc hf;
//一石二鸟,哈希表为空时走这里扩容;哈希表的负载因子达到1时也走这里扩容
if (_size == _v.size())
{
int newSize = _v.size() == 0 ? 10 : 2 * (_v.size());
vectorv1;
v1.resize(newSize);
//将旧空间_v的数据都移到新空间v1上
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur1 = _v[i];
while (cur1 != nullptr)
{
int hashaddr = hf(cur1->_data) % v1.size();//hashaddr表示新vector上的下标,注意因为发生了扩容,所以哈希函数这里是模v1的size,而不是模_v的size
Node* cur2 = v1[hashaddr];//cur2表示新vector上第hashaddr个链表的头节点的地址
Node* cur3 = cur1->_next;
cur1->_next = cur2;
v1[hashaddr] = cur1;
cur1 = cur3;
}
_v[i] = nullptr;
}
}
//走到这里就出了for循环,表明已经把数据都挪动完毕了,将新vector交给哈希表管理即可,出了最外层的if分支后,旧vector中的节点的内存不会被释放,释放的是旧vector中的指针所占的8字节空间
_v.swap(v1);
}
//不管是否发生哈希冲突,插入元素x都需要执行以下逻辑
int hashaddr = hf(x) % _v.size();
Node* cur = _v[hashaddr];//cur是哈希表中下标为hashaddr位置上的链表
Node* temp = new Node(x);//temp是需要新插入的节点
temp->_next = cur;
_v[hashaddr] = temp;
_size++;
return true;
}
void print()
{
for (int i=0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur = _v[i];
cout << "哈希桶" << i << "为:";
while (cur != nullptr)
{
cout << cur->_data << ' ';
cur = cur->_next;
}
cout << endl;
}
}
cout << endl;
}
private:
vector_v;
size_t _size;
};
}
(下图所用的print函数在上面开散列的Insert的整体代码中,该函数用于打印vector中每个哈希桶)
如下图,代码的逻辑是每插入一个数据,都把vector中所有存在数据的桶都打印出来,共有11个待插入的数据,咱们直接看插入第10和第11个数据后所打印出的结果(也就是下图红框处的打印结果)。插入第11个数据时,此时的负载因子已经达到了1,根据上面的理论,此时就应该扩容,扩2倍,然后从原vector的1号桶开始,到原vector的10号桶结束,将挂在原vector桶中的节点全部拆下来,然后根据哈希函数算出的哈希地址再挂到新vector对应的桶中,可以看到插入第11个数据时,既完成了扩容,又完成了节点的迁移,符合预期。
上图的代码如下。
#include
using namespace std;
#include
#include
#include
Find的思路为:说白了就是对单链表的查找,比如根据哈希函数int hashaddr = x % _v.size()算出哈希地址后,就在vector的第hashaddr号哈希桶(即链表)上依次向后找目标元素即可,找到就返回目标元素的地址,没找到就返回nullptr。
Erase的思路为:说白了就是对单链表的Erase,比如根据哈希函数int hashaddr = x % _v.size()算出哈希地址后,就在vector的第hashaddr号哈希桶(即链表)上依次向后找目标元素和目标元素的前驱元素,如果找到了目标元素,则让前驱元素的next指针指向目标元素的后继元素,以此断开和目标元素的连接,最后delete掉目标元素。有一种特殊情况就是目标元素没有前驱元素,目标元素是vector的哈希桶(即链表)中的头节点,这种情况直接更换哈希桶的头节点,最后delete掉目标元素即可;如果没有找到目标元素,直接return false。
根据上面的思路,Erase和Find的第一阶段的代码如下。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
Node* Find(const T& x)
{
//防止计算哈希地址时,int hashaddr = x % _v.size()的时候去模0
if (_v.size() == 0)
return nullptr;
int hashaddr = x % _v.size();
Node* cur = _v[hashaddr];
while (cur != nullptr)
{
if (cur->_data == x)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const T& x)
{
//防止计算哈希地址时,int hashaddr = x % _v.size()的时候去模0
if (_v.size() == 0)
return false;
int hashaddr = x % _v.size();
Node* cur1 = nullptr;
Node* cur2 = _v[hashaddr];
while (cur2 != nullptr)
{
if (cur2->_data == x)
{
if (cur1 != nullptr)
{
cur1->_next = cur2->_next;
delete cur2;
_size--;
return true;
}
else
{
_v[hashaddr] = cur2->_next;
delete cur2;
_size--;
return true;
}
}
else
{
cur1 = cur2;
cur2 = cur2->_next;
}
}
//走到这里就出了循环,说明vector中的hashaddr号哈希桶上不存在目标元素,那哈希表中也就不存在目标元素,直接return false
return false;
}
private:
vector_v;
size_t _size;
};
}
说一下,插入或者删除的元素如果是负数,上面的代码依然是正确的,因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,虽然x和hashaddr都是int类型,但v.size()的返回值类型是size_t,所以这里x%_v.size()后会整形提升,比如说,如果相模之后的结果为-1,则结果整形提升成size_t类型后,结果会变成1,所以不必担心插入或者删除负数时会出错。
上面Find和Erase的代码已经基本上算是把所有需要的逻辑都编写完毕了,只剩下最后的一个问题,在上面讲解Insert时我们说过, 因为咱们计算哈希地址HashAddr的哈希函数是【int hashaddr = x % _v.size()】,所以如果要插入的元素x不是int类型,而是string类型,那么x%_v.size()就会出错,在Find函数和Erase函数中,我们也需要解决这个问题,如何解决已经在闭散列的部分全部讲过了,这里不再说明,直接上代码。
结合上面的理论,我们能写出开散列的Erase和Find的最终阶段的代码,如下。注意下面包含了用于打印vector中每个哈希桶的print函数,该函数用于在下文中测试Erase和Find的逻辑是否正确。
namespace OpenHash//表示开散列的意思
{
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
Node* Find(const T& x)
{
hashfunc hf;
//防止计算哈希地址时,int hashaddr = hf(x) % _v.size()的时候去模0
if (_v.size() == 0)
return nullptr;
int hashaddr = hf(x) % _v.size();
Node* cur = _v[hashaddr];
while (cur != nullptr)
{
if (cur->_data == x)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const T& x)
{
hashfunc hf;
//防止计算哈希地址时,int hashaddr = hf(x) % _v.size()的时候去模0
if (_v.size() == 0)
return false;
int hashaddr = hf(x) % _v.size();
Node* cur1 = nullptr;
Node* cur2 = _v[hashaddr];
while (cur2 != nullptr)
{
if (cur2->_data == x)
{
if (cur1 != nullptr)
{
cur1->_next = cur2->_next;
delete cur2;
_size--;
return true;
}
else
{
_v[hashaddr] = cur2->_next;
delete cur2;
_size--;
return true;
}
}
else
{
cur1 = cur2;
cur2 = cur2->_next;
}
}
//走到这里就出了循环,说明vector中的hashaddr号哈希桶上不存在目标元素,那哈希表中也就不存在目标元素,直接return false
return false;
}
void print()
{
for (int i=0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur = _v[i];
cout << "哈希桶" << i << "为:";
while (cur != nullptr)
{
cout << cur->_data << ' ';
cur = cur->_next;
}
cout << endl;
}
}
cout << endl;
}
private:
vector_v;
size_t _size;
};
}
(下图所用的print函数在上面开散列的Erase和Find的整体代码中,该函数用于打印vector中每个哈希桶)
可以发现下图是符合我们的预期的。
上图的代码如下。
#include
using namespace std;
#include
#include
#include
HashNode是HashTable所new出来的,所以理应由HashTable去释放,所以需要编写HashTable的析构函数。析构函数是在类的任意一个成员被销毁前自动被调用的特殊成员函数。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
//HashNode是HashTable所new出来的,所以理应由HashTable去释放, 析构函数是在类的成员被销毁前调用的特殊成员函数
~HashTable()
{
for (int i = 0; i < _v.size(); i++)
{
Node* cur = _v[i];
while (cur != nullptr)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
}
}
private:
vector_v;
size_t _size;
};
}
哈希表的拷贝构造也是深拷贝,思路很简单:HashTable有两个成员,vector
注意下面包含了用于打印vector中每个哈希桶的print函数,该函数用于在下文中测试拷贝构造的逻辑是否正确。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
//拷贝构造
HashTable(const HashTable& ht)
:_v(ht._v)
,_size(ht._size)
{
for (int i = 0; i < ht._v.size(); i++)
{
if (ht._v[i] != nullptr)
{
_v[i] = new Node((ht._v[i])->_data);
Node* cur1 = _v[i];//cur1指针用于操作正在构造的哈希桶中的HashNode
Node* cur2 = ht._v[i];//cur2指针用于操作被拷贝的哈希桶中的HashNode
cur2 = cur2->_next;
while (cur2 != nullptr)
{
cur1->_next = new Node(cur2->_data);
cur1 = cur1->_next;
cur2 = cur2->_next;
}
}
}
}
void print()
{
for (int i=0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur = _v[i];
cout << "哈希桶" << i << "为:";
while (cur != nullptr)
{
cout << cur->_data << ' ';
cur = cur->_next;
}
cout << endl;
}
}
cout << endl;
}
private:
vector_v;
size_t _size;
};
}
(下图所用的print函数在上面开散列的拷贝构造的整体代码中,该函数用于打印vector中每个哈希桶)
可以看到通过ht1拷贝构造ht2后,我们删除哈希表ht2中的数据(即HashNode节点)是不会影响哈希表ht1中的数据的,这说明咱们编写的拷贝构造的确是深拷贝,逻辑是正确的。
上图的代码如下。
#include
using namespace std;
#include
#include
#include
为了测试通过开散列方式实现的哈希表,我们需要给OpenHash命名空间下的HashTable增加函数size、CapacityOfHashTable、HashBucketNum、MaxBucketLenth,如下代码所示,这些函数的用途已经在注释中说明了,不再赘述。
namespace OpenHash//表示开散列的意思
{
template
struct HashNode
{
HashNode()
:_data()
,_next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
template>
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
//计算哈希表中有多少个节点(即有效数据),或者说计算vector的所有哈希桶中的节点个数之和
//用于测试性能
size_t size()const
{
return _size;
}
//计算哈希表的长度(即capacity),也就是计算vector的size
//用于测试性能
size_t CapacityOfHashTable()const
{
return _v.size();
}
//计算有多少个哈希桶上挂着节点
//用于测试性能
size_t HashBucketNum()const
{
size_t val = 0;
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
val++;
}
}
return val;
}
//计算vector中最长的哈希桶的长度
//用于测试性能
size_t MaxBucketLenth()const
{
size_t maxLenth = 0;
for (int i = 0; i < _v.size(); i++)
{
Node* cur = _v[i];
size_t lenth = 0;
while (cur != nullptr)
{
lenth++;
cur = cur->_next;
}
if (lenth > maxLenth)
{
maxLenth = lenth;
}
}
return maxLenth;
}
private:
vector_v;
size_t _size;
};
}
测试情况如下图1所示。
可以看到无论数据量怎么变化,即无论vector的所有哈希桶中的节点个数之和怎么变化,vector中挂有节点的哈希桶的平均长度数都是不超过2的(注意是挂有节点的哈希桶,没有把空桶算进去),即vector中挂有节点的链表的平均长度数都是不超过2的。这也就是说,无论有多少个数据,只要哈希桶中存在需要查找的目标元素,算出其哈希地址后,找目标元素平均只需要2次或者说是常数次;而如果哈希桶中不存在需要查找的目标元素,在查找时算出其哈希地址后就能立马知道它不存在于哈希表中,一次都不用找。综上所述,这就证明了哈希表的平均查找的时间复杂度是O(1),那哈希表的在最坏的情况下查找的时间复杂度是多少呢?
如下图1所示,可以看到随着数据量变大,vector中长度最长的哈希桶的长度也在不断变大,这时可能会有人说【既然vector中长度最长的哈希桶的长度在不断变大,那哈希表的在最坏的情况下查找的时间复杂度总不能是O(1)了吧?】这里我想说的是:的确不是严格意义上的O(1),但效率上也不会比O(1)要差多少,比如你看在10w个数字里最长的哈希桶也只不过是8个元素,查找的消耗只不过是九牛一毛而已。并且咱们模拟实现的哈希表在插入时是没有去重逻辑的,又因为通过rand()函数生成的数据会有重复值,所以在这种情况下vector中长度最长的哈希桶基本都是带有重复的元素的,如下图2的打印结果(说一下图2中的MaxBucketLenth函数相比于图1的MaxBucketLenth函数是被稍微修改过的,比如返回值类型从size_t变成了pair,而且图2中对比图1还增加了一个oneprint函数,用于打印单个桶的所有节点,这里我想说:只看测试的打印结果就好,不用在意实现的细节),所以如果在Insert函数中把去重的逻辑加上,那么vector中长度最长的哈希桶的长度是一定会减少的,那么在最坏情况下的查找的效率也就会进一步提高。因为本章讲解的是纯粹的哈希表,所以就不实现去重逻辑了,但说一下,以后通过哈希表去模拟实现的unordered系列的容器中,有些是需要去重逻辑的,比如unordered系列的map和set,所以这些容器的查找效率也就会进一步提高。去重的逻辑也非常简单,咱们在上文中已经实现过FInd函数了,那么调用Find函数查找要插入的目标元素,如果返回值不是nullptr,说明此时哈希表中已经存在了该元素,就不继续Inset插入它了,反之则继续插入。
所以总结:综上所述,哈希表的查找的时间复杂度虽然不是严格的O(1),但咱们可以认为它约等于是O(1),因此哈希表的查找的效率是远比红黑树要高的。
图1如下。
图2如下。
上面图1的代码如下。
void test8()
{
srand(time(0));
OpenHash::HashTableht;
for (int i=0;i<100000;i++)
{
ht.Insert(rand()+i);
}
//ht.print();
cout << endl << endl;
cout << "vector的所有哈希桶中的节点个数之和为:" << ht.size() << endl;
cout << "vector的所有哈希桶的个数(包含空桶)为" << ht.CapacityOfHashTable() << endl;
cout << "vector中挂有节点的哈希桶的个数为:" << ht.HashBucketNum() << endl;
cout << "vector中挂有节点的哈希桶的平均长度数为:" << (double)ht.size() / (double)ht.HashBucketNum() << endl;
cout << "vector中长度最长的哈希桶的长度为:" << ht.MaxBucketLenth() << endl;
cout << "哈希表的负载因子为:" << (double)ht.size()/ (double)ht.CapacityOfHashTable() << endl;
}
void main()
{
test8();
}
如下代码所示。一些简单的接口比如operator*等的实现就不再说明,因为很简单,所以把它们都归为了基础框架中。然后要说的是:迭代器类有一个HashTable* _ht成员是因为在实现前后置的operator++函数时,需要在函数体内计算当前节点所在的桶是哈希表的哪一个桶,计算完毕后还需要遍历哈希表中的桶,因此需要哈希表的指针成员_ht。剩下要说的话都在注释中。
#pragma once
#include
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
//HashTable类模板的定义在HashIterator的定义的下方,而HashIterator类内有HashTable的成员对象,因此这里需要前置声明,如果对前置声明不太熟悉,请看<<模板的进阶(包括模板的分离编译问题、前置声明问题)>>一文
template
class HashTable;
//因为存在const_iterator这种东西,于是设置T2专门用于控制operator*和operator->的返回值;而T1就用于控制哈希表中节点Node的类型了
//因为在operator++中有需要用到计算哈希地址的哈希函数,所以需要Hash这个仿函数类去将任意类型转化成size_t类型,辅助哈希函数计算哈希地址
template>
class HashIterator
{
typedef HashNode Node;
typedef HashIterator iterator;
typedef HashTable HT;
public:
HashIterator()
:_n(nullptr)
, _ht(nullptr)
{}
HashIterator(Node* p, const HT* ht)//注意这里的ht,和HashIterator类的指针成员_ht,都必须加上const,否则哈希表的成员函数const_iterator begin()const就编不过,原因是const_iterator构造不出来
:_n(p)
, _ht(ht)
{}
T2& operator*()
{
assert(_n != nullptr);
return _n->_data;
}
T2* operator->()
{
assert(_n != nullptr);
return &(_n->_data);
}
bool operator==(iterator it)const
{
return _n == it._n;
}
bool operator!=(iterator it)const
{
return _n != it._n;
}
private:
Node* _n;
const HashTable* _ht;
};
template>
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
private:
vector_v;
size_t _size;
};
}
思路也很简单,先判断调用前后置operator++函数的迭代器指向的节点是否为nullptr,如果是,则不能++了,因为当前迭代器指向最后一个元素的后一个位置。最后如果是前置operator++,此时return *this即可;如果是后置operator++,则要返回一个和【调用operator++函数的迭代器】指向相同的临时迭代器,即return iterator(nullptr, _ht)即可。
如果不是,则如果当前桶中还有下一个节点,就让迭代器类中的指针成员_n指向下一个节点;如果没有下一个节点,就需要通过哈希函数计算出【调用前后置operator++函数的迭代器指向的节点】在vector的第几号哈希桶中,假如为n,则然后需要在vector中从下标n开始不断向后遍历,找出vector中第一个不是空桶的桶,然后让迭代器类中的指针成员_n指向这个桶顶的节点。最后如果是前置operator++,则return *this即可;如果是后置operator++,则要返回一个和【调用operator++函数的迭代器】在调用operator++函数之前指向相同的临时迭代器,即return iterator(temp, _ht)即可,temp是一个指针,记录了_n指针在发生变化之前的值。
注意因为需要在迭代器类中通过类的指针成员HashTable*_ht访问哈希表类的私有成员vector _v,因此需要让迭代器类成为哈希表类的友元类。
#pragma once
#include
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
//HashTable类模板的定义在HashIterator的定义的下方,而HashIterator类内有HashTable的成员对象,因此这里需要前置声明,如果对前置声明不太熟悉,请看<<模板的进阶(包括模板的分离编译问题、前置声明问题)>>一文
template
class HashTable;
//因为存在const_iterator这种东西,于是设置T2专门用于控制operator*和operator->的返回值;而T1就用于控制哈希表中节点Node的类型了
//因为在operator++中有需要用到计算哈希地址的哈希函数,所以需要Hash这个仿函数类去将任意类型转化成size_t类型,辅助哈希函数计算哈希地址
template>
class HashIterator
{
typedef HashNode Node;
typedef HashIterator iterator;
typedef HashTable HT;
public:
HashIterator()
:_n(nullptr)
, _ht(nullptr)
{}
HashIterator(Node* p, const HT* ht)//注意这里的ht,和HashIterator类的指针成员_ht,都必须加上const,否则哈希表的成员函数const_iterator begin()const就编不过,原因是const_iterator构造不出来
:_n(p)
, _ht(ht)
{}
//后置++
iterator operator++(int)
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return iterator(temp, _ht);
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if (_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return iterator(temp, _ht);
}
}
//走到这里,说明出了循环,说明所有桶都已经找完了,目前迭代器就指向最后一个元素,后面没有元素了
_n = nullptr;
return iterator(temp, _ht);
}
}
else
{
return iterator(nullptr, _ht);
}
}
//前置++
iterator& operator++()
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return *this;
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if(_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return *this;
}
}
//走到这里,说明出了循环,说明目前迭代器就指向最后一个元素,后面没有元素了,让_n等于空后返回*this即可。
_n = nullptr;
return *this;
}
}
else
{
return *this;
}
}
/*
开散列的哈希表的迭代器是单项迭代器,不支持前后置的operator--,其原因是通过开散列方式实现的哈希表的哈希桶是单链表,不支持向桶(即链表)的上方寻找。
*/
private:
Node* _n;
const HashTable* _ht;
};
template>
class HashTable
{
typedef HashNode Node;
public:
HashTable()
:_size(0)
, _v()
{}
private:
vector_v;
size_t _size;
};
}
上文中把迭代器类的接口都编写完毕后,接下来咱们就开始编写哈希表类的begin()、end()函数。begin()思路很简单,从vector的第一个哈希桶开始访问、然后依次访问后面的哈希桶,哪个桶不是空桶,则begin()就返回指向该桶桶顶节点的迭代器。
end()的思路更简单,我们把指向nullptr的迭代器当作指向哈希表中最后一个元素的后一个位置的迭代器,也就是end()函数返回的迭代器。
#pragma once
#include
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
//HashTable类模板的定义在HashIterator的定义的下方,而HashIterator类内有HashTable的成员对象,因此这里需要前置声明,如果对前置声明不太熟悉,请看<<模板的进阶(包括模板的分离编译问题、前置声明问题)>>一文
template
class HashTable;
//因为存在const_iterator这种东西,于是设置T2专门用于控制operator*和operator->的返回值;而T1就用于控制哈希表中节点Node的类型了
//因为在operator++中有需要用到计算哈希地址的哈希函数,所以需要Hash这个仿函数类去将任意类型转化成size_t类型,辅助哈希函数计算哈希地址
template>
class HashIterator
{
typedef HashNode Node;
typedef HashIterator iterator;
typedef HashTable HT;
public:
HashIterator()
:_n(nullptr)
, _ht(nullptr)
{}
HashIterator(Node* p, const HT* ht)//注意这里的ht,和HashIterator类的指针成员_ht,都必须加上const,否则哈希表的成员函数const_iterator begin()const就编不过,原因是const_iterator构造不出来
:_n(p)
, _ht(ht)
{}
private:
Node* _n;
const HashTable* _ht;
};
template>
class HashTable
{
friend class HashIterator;
friend class HashIterator;
typedef HashNode Node;
public:
typedef HashIterator iterator;
typedef HashIterator const_iterator;
HashTable()
:_size(0)
, _v()
{}
iterator begin()
{
int i = 0;
while (i < _v.size())
{
if(_v[i] == nullptr)
i++;
else
return iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return iterator(nullptr, this);
}
const_iterator begin()const
{
int i = 0;
while (i < _v.size())
{
if (_v[i] == nullptr)
i++;
else
return const_iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return const_iterator(nullptr, this);
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator end()const
{
return const_iterator(nullptr, this);
}
private:
vector_v;
size_t _size;
};
}
#pragma once
#include
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
//HashTable类模板的定义在HashIterator的定义的下方,而HashIterator类内有HashTable的成员对象,因此这里需要前置声明,如果对前置声明不太熟悉,请看<<模板的进阶(包括模板的分离编译问题、前置声明问题)>>一文
template
class HashTable;
//因为存在const_iterator这种东西,于是设置T2专门用于控制operator*和operator->的返回值;而T1就用于控制哈希表中节点Node的类型了
//因为在operator++中有需要用到计算哈希地址的哈希函数,所以需要Hash这个仿函数类去将任意类型转化成size_t类型,辅助哈希函数计算哈希地址
template>
class HashIterator
{
typedef HashNode Node;
typedef HashIterator iterator;
typedef HashTable HT;
public:
HashIterator()
:_n(nullptr)
, _ht(nullptr)
{}
HashIterator(Node* p, const HT* ht)//注意这里的ht,和HashIterator类的指针成员_ht,都必须加上const,否则哈希表的成员函数const_iterator begin()const就编不过,原因是const_iterator构造不出来
:_n(p)
, _ht(ht)
{}
T2& operator*()
{
assert(_n != nullptr);
return _n->_data;
}
T2* operator->()
{
assert(_n != nullptr);
return &(_n->_data);
}
//后置++
iterator operator++(int)
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return iterator(temp, _ht);
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if (_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return iterator(temp, _ht);
}
}
//走到这里,说明出了循环,说明所有桶都已经找完了,目前迭代器就指向最后一个元素,后面没有元素了
_n = nullptr;
return iterator(temp, _ht);
}
}
else
{
return iterator(nullptr, _ht);
}
}
//前置++
iterator& operator++()
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return *this;
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if(_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return *this;
}
}
//走到这里,说明出了循环,说明目前迭代器就指向最后一个元素,后面没有元素了,让_n等于空后返回*this即可。
_n = nullptr;
return *this;
}
}
else
{
return *this;
}
}
/*
开散列的哈希表的迭代器是单项迭代器,不支持前后置的operator--,其原因是通过开散列方式实现的哈希表的哈希桶是单链表,不支持向桶(即链表)的上方寻找。
*/
bool operator==(iterator it)const
{
return _n == it._n;
}
bool operator!=(iterator it)const
{
return _n != it._n;
}
private:
Node* _n;
const HashTable* _ht;
};
template>
class HashTable
{
friend class HashIterator;
friend class HashIterator;
typedef HashNode Node;
public:
typedef HashIterator iterator;
typedef HashIterator const_iterator;
HashTable()
:_size(0)
, _v()
{}
iterator begin()
{
int i = 0;
while (i < _v.size())
{
if(_v[i] == nullptr)
i++;
else
return iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return iterator(nullptr, this);
}
const_iterator begin()const
{
int i = 0;
while (i < _v.size())
{
if (_v[i] == nullptr)
i++;
else
return const_iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return const_iterator(nullptr, this);
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator end()const
{
return const_iterator(nullptr, this);
}
private:
vector_v;
size_t _size;
};
}
代码如下。
#pragma once
#include
#include
#include
using namespace std;
namespace OpenHash//表示开散列的意思
{
template
struct hashfunc
{
size_t operator()(const T& x)
{
return (size_t)x;//如果T是指针类型,或者是char类型,int类型,double类型,float类型等能直接强转成size_t类型的类型,则通过该函数直接转即可
}
};
template<>
struct hashfunc
{
//BKDR字符串哈希方法
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
template
struct HashNode
{
HashNode()
:_data()
, _next()
{}
HashNode(const T& x, HashNode* p = nullptr)
:_data(x)
, _next(p)
{}
T _data;
HashNode* _next;
};
//HashTable类模板的定义在HashIterator的定义的下方,而HashIterator类内有HashTable的成员对象,因此这里需要前置声明,如果对前置声明不太熟悉,请看<<模板的进阶(包括模板的分离编译问题、前置声明问题)>>一文
template
class HashTable;
//因为存在const_iterator这种东西,于是设置T2专门用于控制operator*和operator->的返回值;而T1就用于控制哈希表中节点Node的类型了
//因为在operator++中有需要用到计算哈希地址的哈希函数,所以需要Hash这个仿函数类去将任意类型转化成size_t类型,辅助哈希函数计算哈希地址
template>
class HashIterator
{
typedef HashNode Node;
typedef HashIterator iterator;
typedef HashTable HT;
public:
HashIterator()
:_n(nullptr)
, _ht(nullptr)
{}
HashIterator(Node* p, const HT* ht)//注意这里的ht,和HashIterator类的指针成员_ht,都必须加上const,否则哈希表的成员函数const_iterator begin()const就编不过,原因是const_iterator构造不出来
:_n(p)
, _ht(ht)
{}
T2& operator*()
{
assert(_n != nullptr);
return _n->_data;
}
T2* operator->()
{
assert(_n != nullptr);
return &(_n->_data);
}
//后置++
iterator operator++(int)
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return iterator(temp, _ht);
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if (_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return iterator(temp, _ht);
}
}
//走到这里,说明出了循环,说明所有桶都已经找完了,目前迭代器就指向最后一个元素,后面没有元素了
_n = nullptr;
return iterator(temp, _ht);
}
}
else
{
return iterator(nullptr, _ht);
}
}
//前置++
iterator& operator++()
{
if (_n != nullptr)
{
Node* temp = _n;
if (_n->_next != nullptr)
{
_n = _n->_next;
return *this;
}
else
{
Hash hf;
int hashaddr = hf(_n->_data) % _ht->_v.size();//已经把迭代器类设置成哈希表的友元类了,因此可以访问哈希表的private成员_v
hashaddr++;
while (hashaddr < _ht->_v.size())
{
if(_ht->_v[hashaddr] == nullptr)
hashaddr++;
else
{
_n = _ht->_v[hashaddr];
return *this;
}
}
//走到这里,说明出了循环,说明目前迭代器就指向最后一个元素,后面没有元素了,让_n等于空后返回*this即可。
_n = nullptr;
return *this;
}
}
else
{
return *this;
}
}
/*
开散列的哈希表的迭代器是单项迭代器,不支持前后置的operator--,其原因是通过开散列方式实现的哈希表的哈希桶是单链表,不支持向桶(即链表)的上方寻找。
*/
bool operator==(iterator it)const
{
return _n == it._n;
}
bool operator!=(iterator it)const
{
return _n != it._n;
}
private:
Node* _n;
const HashTable* _ht;
};
template>
class HashTable
{
friend class HashIterator;
friend class HashIterator;
typedef HashNode Node;
public:
typedef HashIterator iterator;
typedef HashIterator const_iterator;
HashTable()
:_size(0)
, _v()
{}
//HashNode是HashTable所new出来的,所以理应由HashTable去释放, 析构函数是在类的成员被销毁前调用的特殊成员函数
~HashTable()
{
for (int i = 0; i < _v.size(); i++)
{
Node* cur = _v[i];
while (cur != nullptr)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
}
}
//拷贝构造
HashTable(const HashTable& ht)
:_v(ht._v)
, _size(ht._size)
{
for (int i = 0; i < ht._v.size(); i++)
{
if (ht._v[i] != nullptr)
{
_v[i] = new Node((ht._v[i])->_data);
Node* cur1 = _v[i];//cur1指针用于操作正在构造的哈希桶中的HashNode
Node* cur2 = ht._v[i];//cur2指针用于操作被拷贝的哈希桶中的HashNode
cur2 = cur2->_next;
while (cur2 != nullptr)
{
cur1->_next = new Node(cur2->_data);
cur1 = cur1->_next;
cur2 = cur2->_next;
}
}
}
}
iterator begin()
{
int i = 0;
while (i < _v.size())
{
if(_v[i] == nullptr)
i++;
else
return iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return iterator(nullptr, this);
}
const_iterator begin()const
{
int i = 0;
while (i < _v.size())
{
if (_v[i] == nullptr)
i++;
else
return const_iterator(_v[i], this);
}
//如果走出了循环,说明哈希表中一个元素也没有
return const_iterator(nullptr, this);
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator end()const
{
return const_iterator(nullptr, this);
}
bool Insert(const T& x)
{
hashfunc hf;
//一石二鸟,哈希表为空时走这里扩容;哈希表的负载因子达到1时也走这里扩容
if (_size == _v.size())
{
int newSize = _v.size() == 0 ? 10 : 2 * (_v.size());
vectorv1;
v1.resize(newSize);
//将旧空间_v的数据都移到新空间v1上
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur1 = _v[i];
while (cur1 != nullptr)
{
int hashaddr = hf(cur1->_data) % v1.size();//hashaddr表示新vector上的下标,注意因为发生了扩容,所以哈希函数这里是模v1的size,而不是模_v的size
Node* cur2 = v1[hashaddr];//cur2表示新vector上第hashaddr个链表的头节点的地址
Node* cur3 = cur1->_next;
cur1->_next = cur2;
v1[hashaddr] = cur1;
cur1 = cur3;
}
_v[i] = nullptr;
}
}
//走到这里就出了for循环,表明已经把数据都挪动完毕了,将新vector交给哈希表管理即可,出了最外层的if分支后,旧vector中的节点的内存不会被释放,释放的是旧vector中的指针所占的8字节空间
_v.swap(v1);
}
//不管是否发生哈希冲突,插入元素x都需要执行以下逻辑
int hashaddr = hf(x) % _v.size();
Node* cur = _v[hashaddr];//cur是哈希表中下标为hashaddr位置上的链表
Node* temp = new Node(x);//temp是需要新插入的节点
temp->_next = cur;
_v[hashaddr] = temp;
_size++;
return true;
}
Node* Find(const T& x)
{
hashfunc hf;
//防止计算哈希地址时,int hashaddr = hf(x) % _v.size()的时候去模0
if (_v.size() == 0)
return nullptr;
int hashaddr = hf(x) % _v.size();
Node* cur = _v[hashaddr];
while (cur != nullptr)
{
if (cur->_data == x)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const T& x)
{
hashfunc hf;
//防止计算哈希地址时,int hashaddr = hf(x) % _v.size()的时候去模0
if (_v.size() == 0)
return false;
int hashaddr = hf(x) % _v.size();
Node* cur1 = nullptr;
Node* cur2 = _v[hashaddr];
while (cur2 != nullptr)
{
if (cur2->_data == x)
{
if (cur1 != nullptr)
{
cur1->_next = cur2->_next;
delete cur2;
_size--;
return true;
}
else
{
_v[hashaddr] = cur2->_next;
delete cur2;
_size--;
return true;
}
}
else
{
cur1 = cur2;
cur2 = cur2->_next;
}
}
//走到这里就出了循环,说明vector中的hashaddr号哈希桶上不存在目标元素,那哈希表中也就不存在目标元素,直接return false
return false;
}
void print()
{
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
Node* cur = _v[i];
cout << "哈希桶" << i << "为:";
while (cur != nullptr)
{
cout << cur->_data << ' ';
cur = cur->_next;
}
cout << endl;
}
}
cout << endl;
}
//计算哈希表中有多少个节点(即有效数据),或者说计算vector的所有哈希桶中的节点个数之和
//用于测试性能
size_t size()const
{
return _size;
}
//计算哈希表的长度(即capacity),也就是计算vector的size
//用于测试性能
size_t CapacityOfHashTable()const
{
return _v.size();
}
//计算有多少个哈希桶上挂着节点
//用于测试性能
size_t HashBucketNum()const
{
size_t val = 0;
for (int i = 0; i < _v.size(); i++)
{
if (_v[i] != nullptr)
{
val++;
}
}
return val;
}
//计算vector中最长的哈希桶的长度
//用于测试性能
size_t MaxBucketLenth()const
{
size_t maxLenth = 0;
for (int i = 0; i < _v.size(); i++)
{
Node* cur = _v[i];
size_t lenth = 0;
while (cur != nullptr)
{
lenth++;
cur = cur->_next;
}
if (lenth > maxLenth)
{
maxLenth = lenth;
}
}
return maxLenth;
}
private:
vector_v;
size_t _size;
};
}
开散列的哈希桶结构比闭散列更实用,主要原因有两点:
1,哈希桶的负载因子可以更大,空间利用率高。使用开散列的方式处理哈希冲突时,需要增设链接指针,似乎增加了存储开销,但事实上由于闭散列的方式必须保持大量的空闲空间以确保搜索效率,如二次探测法要求负载因子α <= 0.7且最好是α <= 0.5,而sizeof(HashDate)大小的空闲空间又比指针所占的空间大的多,所以使用开散列的方式反而比闭散列的方式节省存储空间。
2,哈希桶在极端情况下还有可用的解决方案。
如下图,哈希桶的极端情况是所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O ( N ) 。
这时我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中,如下图所示。在这种情况下,就算有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的“桶里种树”。
为了避免出现这种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构,比如在JAVA中比较新一点的版本中,当桶当中的数据个数超过8时,就会将该桶当中的单链表结构换成红黑树结构,而当该桶当中的数据个数减少到8或8以下时,又会将该桶当中的红黑树结构换回单链表结构。
但有些地方也会选择不做此处理,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。