总言
主要介绍哈希基本框架及其unordered系列容器简述。
文章目录
- 总言
- 0、思维导图
- 1、unordered系列介绍
- 2、底层:哈希
- 2.1、哈希概念介绍:哈希(散列)函数和哈希表(散列表)
- 2.2、映射关系建立与问题说明:除留余数法、哈希冲突
- 2.3、闭散列及其实现(开放定址法)
- 2.3.1、闭散列相关实现·线性探测版
- 2.3.1.1、如何创建一个哈希表
- 2.3.1.2、如何实现Insert插入
- 2.3.1.3、如何实现Find查找
- 2.3.1.4、如何实现Erase删除
- 2.3.1.5、仿函数引入
- 2.3.1.6、整体效果展示
- 2.3.2、闭散列·二次探测说明
- 2.4、开散列及其实现
- 2.4.1、哈希桶相关实现
- 2.4.1.1、如何如何定义一个哈希桶?
- 2.4.1.2、如何实现Insert插入
- 2.4.1.3、如何实现Find查找
- 2.4.1.4、如何实现Erase删除
- 2.4.1.5、默认成员函数:析构
- 2.4.1.6、仿函数引入:改进取模问题
- 2.4.1.7、素数引入:改进扩容问题
- 2.4.2、数量统计相关
- 2.4.3、整体效果
0、思维导图
1、unordered系列介绍
1)、unordered关联式容器
说明:在C++11中,STL提供了4个unordered系列
的关联式容器:unordered_map
、unordered_set
、unordered_multimap
、unordered_multiset
。这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。
2)、使用说明
对比map、set区别:
1、map、set遍历是有序的,unordered系列是无序的;
2、map、set是双向迭代器,unordered系列是单向迭代器;
问题:既然如此,为什么还要提供unordered系列容器?
回答:红黑树结构的一系列关联式容器,在查询时效率可达到
l
o
g
2
N
log_2 N
log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率不理想。我们期望在查询时,进行很少的比较次数,就能够将元素找到,因此在C++11中,STL又提供了 这4个unordered系列的关联式容器。
3)、关于查找效率演示
相关代码如下:
void test_op()
{
int n = 5000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
v.push_back(rand() + i);
}
//插入·测试set
size_t begin1 = clock();
set<int> s;
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
//插入·测试unordered_set
size_t begin2 = clock();
unordered_set<int> us;
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "numbersize:" << s.size() << endl;
cout << "set insert:" << end1 - begin1 << endl;
cout << "unordered_set insert:" << end2 - begin2 << endl;
cout << endl;
//查找·测试set
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
//查找·测试unordered_set
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "set find:" << end3 - begin3 << endl;
cout << "unordered_set find:" << end4 - begin4 << endl;
cout << endl;
//删除·测试set
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
//删除·测试unordered_set
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "set erase:" << end5 - begin5 << endl;
cout << "unordered_set erase:" << end6 - begin6 << endl;
cout << endl;
}
演示结果如下:可以看到,在面对大量数据时,unordered系列查找起来还是相对具有优势。相应的插入效率并无多大提高,在后续学习中我们将知道,这是红黑树的插入后续调整只需要旋转+变色,而哈希桶中插入涉及扩容重新定位地址。
2、底层:哈希
2.1、哈希概念介绍:哈希(散列)函数和哈希表(散列表)
1)、基本概念介绍
我们说过unordered系列的关联式容器,与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,即这里讲介绍的哈希结构。
哈希实际上是一种映射的结构关系。顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较,搜索的效率取决于搜索过程中元素的比较次数。
哈希提供了一种不经过任何比较,一次直接从表中得到要搜索的元素的存储结构。通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。我们将该方式称为哈希,也称为散列。
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
2.2、映射关系建立与问题说明:除留余数法、哈希冲突
2)、如何建立映射关系?(哈希函数)
说明: 映射关系有绝对映射和相对映射两种形式。
例如,在数组中
绝对映射:将每个数存入其数值对应的下标位置处。
相对映射:用最大减去最小值,获取数据间距,开辟间距大小的存储空间。存储时,将数据值减去该组最小值,获取的值即相对映射位置。
考虑到数据间距很大的情况,通常我们采取相对映射。这里我们使用除留余数法来达成:设哈希表中允许的存放的地址数量为
m
m
m,取一个不大于
m
m
m,但最接近或者等于
m
m
m的质数
p
p
p作为除数,按照哈希函数:Hash(key) = key % p
(
p
<
=
m
)
(p<=m)
(p<=m),将关键码转换成哈希地址。
举例演示:
数据集合a={1,5,7,9,12,14,18,20},则有:
1 % 10 = 1;
5 % 10 = 5;
7 % 10 = 7;
9 % 10 = 9;
12 % 10 = 2;
14 % 10 = 4;
18 % 10 = 8;
20 % 10 = 0;
3)、哈希冲突/哈希碰撞及其解决方法说明
基本介绍:
对于两个数据元素的关键字
k
i
k_i
ki和
k
j
k_j
kj(i != j),当遇到以下情况时:
k
i
k_i
ki !=
k
j
k_j
kj但Hash(
k
i
k_i
ki) == Hash(
k
j
k_j
kj)。即:不同关键字通过相同哈希哈数计算出相同的哈希地址,将这种现象称为哈希冲突或哈希碰撞。
PS:我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
以下为举例演示:
数据集合a={1,11,21,3,4,14,23,20,5,24},则有:
1 % 10 = 1;
11 % 10 = 1;
21 % 10 = 1;
3 % 10 = 3;
4 % 10 = 4;
14 % 10 = 4;
23 % 10 = 3;
20 % 10 = 0;
5 % 10 = 5;
24 % 10 = 4;
如果遇到哈希冲突,该如何解决呢?
总述:哈希冲突有两种常见的解决方法:闭散列和开散列
闭散列: 也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
开散列 :也叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
以下会对其分别讲解。
2.3、闭散列及其实现(开放定址法)
1)、哈希冲突解决方案一:闭散列法
闭散列: 当发生哈希冲突时,若哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
如何寻找下一个空位置?此处提供两种存放方法:线性探测和二次探测
2.3.1、闭散列相关实现·线性探测版
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
2.3.1.1、如何创建一个哈希表
1)、写法说明一
我们可以尽量复用曾经使用过的容器,此处要实现一个哈希表,vector即可提供需求。以下为一种实现方法:
template<class K,class V>
class HashTable
{
public:
private:
vector<pair<K, V>> _table;
};
问题说明: 直接创建一个元素类型为pair<K,V>的vector表是否可行?
回答: 在上述插入数据时,我们需要判断哈希表当前地址是否存在有效数据,比如上述数据集合a={1,11,21,3,4,14,23,20,5,24}
,假如我们不是一直插入,而是有插有删,那么在哈希冲突情况下,如何判断“下一个” 空位置将是一个问题。
解决方法: 这里给出一种解决方法,为哈希表中每个空间位置赋予状态标记。
enum State
{
EMPTY,//此位置空
EXIST,//此位置存在有效元素
DELETE//此位置元素已被删除
};
2)、写法说明二
根据上述,我们对哈希表做修改,结果如下:
enum State
{
EMPTY,//此位置空
EXIST,//此位置存在有效元素
DELETE//此位置元素已被删除
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;//当前哈希地址存储值
State _state;//该哈希地址值的状态
};
template<class K,class V>
class HashTable
{
public:
private:
vector<HashData<K, V>> _table;//哈希表
size_t _size = 0;//表中有效数据
};
2.3.1.2、如何实现Insert插入
1)、框架说明
1、闭散列线性探测下的哈希插入需要做什么?
①通过哈希函数获取待插入元素在哈希表中的位置。(此处采用除留余数法)
②若该位置中没有元素,则直接插入新元素;若该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
2、基本框架实现:
bool Insert(const pair<K, V> kv)
{
//1、找映射地址
size_t hashi = kv.first % _table.size();
//2、放值,若当前地址值存在,则线性探测向后寻找合适位置
while (_table[hashi]._state==EXIST)
{
++hashi;
hashi%= _table.size();//这里是为了防止++超过边界范围
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;//别忘了设置状态
++_size;
return true;
}
细节理解:
1、size_t hashi = kv.first % _table.size()
:哈希函数Hash(key) = key % p
,这里p选定的是vector::size()
。此处不能选取vector::capacity()
,原因是size()才是实际vector中有效数据,根据之前对vector的学习,vector::operator[]
、vector::end()
等各接口,都是以size()作为边界判断的。如果使用vector::capacity()
,除留后余数可能在[size,capacity]这个区间范围内,实际是越界行为。
2、hashi%= _table.size()
:这里是为了防止++超过边界范围,确保得到余数在[0,size)间。
2)、扩容与负载因子
问题引入: 插入数据并非能无线插入,哈希表的容量空间有限,因此此处有一个疑问:哈希表什么情况下进行扩容?如何扩容?
方法说明: 引入负载因子
散列表的载荷因子定义:
a = 填入表中的元素个数 / 散列表的长度
α是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,α越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,载荷因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
由上述可知,何时扩容取决于负载因子,定义一个基准值
a
标
a_标
a标,当负载因子到达该基准值时
a
>
=
a
标
a>=a_标
a>=a标,我们就扩容。
因此:
基准值越大,冲突越多,效率越低,但空间利用率越高;
基准值越小,冲突越少,效率越高,但空间利用率越低。
相关实现如下:
在Insert中,我们只需要加入该检查即可:
bool Insert(const pair<K, V> kv)
{
//0、检查扩容
CheckCapacity();
//1、找映射地址
size_t hashi = kv.first % _table.size();
//2、放值,若当前地址值存在,则线性探测向后寻找合适位置
while (_table[hashi]._state==EXIST)
{
++hashi;
hashi%= _table.size();//这里是为了防止++超过边界范围
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;//别忘了设置状态
++_size;
return true;
}
CheckCapacity实现如下:
void CheckCapacity()
{
//此处我们设置负载因子为0.7
if (_table.size() == 0 || 10 * _size / _table.size() >= 7)
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V> newHash;//创建一个新的哈希类
newHash._table.resize(newSize);//将新类中,哈希表容量空间设置为需要扩容后的容量空间
for (auto e : _table)
{
if (e._state == EXIST)//说明原表中该处位置值存在
{
newHash.Insert(e._kv);//复用Insert,将值放入新表中,构成新的哈希地址关系
}
}
_table.swap(newHash._table);
}
}
注意事项:
1、哈希表中,扩容是否是能直接使用vector::resize,将原先_table中数据拷贝到新容量空间中?
实际上这是有问题的。扩容后,根据哈希函数,原先存在冲突的数据,在新的容量空间中不一定存在哈希冲突。以下为举例演示:
2、HashTable<K, V> newHash
,为什么此处要创建一个新的哈希类?
根据1中阐述可知,由于扩容影响到原先的哈希地址,因此,在新的容量空间中,我们要重新使用哈希函数获取相应的映射关系。一种方法是我们手动对原先哈希表中数据的地址进行重计算:
if (_table.size() == 0 || 10 * _size / _table.size() >= 7) // 扩容
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
vector<HashData<K, V>> newTables;//创建一个新的哈希表
newTables.resize(newSize);
// 旧表的数据映射到新表
for ()
{
//……
//此处操作和我们之前的Insert中寻找哈希地址的操作相同
}
_table.swap(newTables);
}
但实际上我们可以复用写过的HashTable::Insert
来完成:当满足负载因子的扩容条件后,上述CheckCapacity根据原表容量大小,重新创建了新类newHash,在其中调用Insert。由于新类newHash对容量空间进行改写,此时CheckCapacity检查不满足扩容条件,故而在新的哈希表中,只进行了地址映射和放值。
3、_table.swap(newHash._table)
,这里完成2后,要注意将哈希表进行交换。类似于拷贝构造,相当于我们使用了一个现代写法。出了函数,局部定义的类HashTable<K, V> newHash
将会销毁,因此根据析构函数的性质,会对原先的哈希表进行空间释放(实则是vector的析构起作用)。
4、实际上,上述Inset中还存在去重问题。此处可结合Find函数来实现,在下述说明。
if (Find(kv.first))
return false;
2.3.1.3、如何实现Find查找
1)、框架说明
根据给定值,按照映射关系在哈希表中对应位置查找元素,此时会遇到两种情况:
1、经过哈希函数后得到的哈希地址存储的是目标值;
2、经过哈希函数后得到的哈希地址存储的是非目标;此时我们需要向后寻找,直到遍历完一回合哈希表。
2)、相关实现
HashData<K, V>* Find(const K& key)
{
if (_table.size() == 0)//哈希表中无值
{
return nullptr;
}
size_t hashi = key % _table.size();//判断目标值的哈希位置
size_t start = hashi;//用于标记开始遍历的位置
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == key)
{
return &_table[hashi];
}
hashi++;//向后寻找
hashi %= _table.size();
if (hashi == start)//一轮遍历结束
break;
}
return nullptr;//遍历到状态为空的位置处循环结束,说明表中无目标值
}
2.3.1.4、如何实现Erase删除
1)、相关实现
说明:删除某一值,需要先在哈希表中查找是否有该值。实际删除时我们只需要修改对应地址存储值的状态和哈希表有效元素大小即可。
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
_size--;
return true;
}
else
return false;
}
2)、验证
void test01()
{
//int a[] = { 1,11,21,3,4,14,23,20,5,24 };
int a[] = { 1,11,21,3,4,14};
//验证未扩容前
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Print();
cout << endl;
//验证哈希冲突
ht.Erase(4);
cout << ht.Find(14)->_kv.first << endl;
cout << ht.Find(4) << endl;
ht.Print();
cout << endl;
//验证插入负数
ht.Insert(make_pair(-2, -2));
ht.Print();
cout << ht.Find(-2)->_kv.first << endl;
cout << endl;
//验证扩容
ht.Insert(make_pair(23, 23));
ht.Insert(make_pair(20, 20));
ht.Insert(make_pair(5, 5));
ht.Print();
cout << endl;
}
2.3.1.5、仿函数引入
1)、问题引入:关于取模的类型支持
假如我们使用当前哈希表来统计查找下述天气出现次数,能够发现编译不过:
//次数统计
string arr[] = { "晴","多云","晴","阴","小雨","多云","多云","阴","晴","小雨","大雨","阴","多云","晴" };
HashTable<string, int> countHash;
for (string& str : arr)
{
HashData<string, int>* p = countHash.Find(str);
if (p)//找到
{
p->_kv.second++;//次数增加
}
else//找不到
countHash.Insert(make_pair(str,1));//插入
}
报错原因在于哈希函数:size_t hashi = key % _table.size();
。
原因说明:我们使用的是除留余数法,其涉及运算符%,该运算符两边数值只能是整形,而此处哈希表中,Key类型为string,故而出错。
那么我们面临一个问题,当前实现的哈希函数,只适用于整形,而实际数据类型像string、char等很常见,因此我们需要对原先的哈希表做一定修改。
观察库中实现,其提供了一个仿函数接口,我们实现时也可照做。
2)、相关实现1.0
常规情况处理:
class Hash=HashFunc<K>
:增加一个仿函数,用于除留余数时能够转换为整形。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;//能够强制类型转换为size_t的。
}
};
template<class K,class V,class Hash=HashFunc<K>>
class HashTable
{
//……
bool Insert(const pair<K, V> kv)
{
//……
//1、找映射地址
Hash HsTrans;
//size_t hashi = kv.first % _table.size();
size_t hashi = HsTrans(kv.first) % _table.size();
//……
}
HashData<K, V>* Find(const K& key)
{
//……
//size_t hashi = key % _table.size();//判断目标值的哈希位置
size_t hashi = HsTrans(key) % _table.size();//判断目标值的哈希位置
//……
}
}
针对特别类型:
size_t hashi = key % _table.size()
1、对于int、size_t等类型,能够直接使用其自身数值进行取模,对于string类型,要能实现%,一种方法是使用其ASCII码值。但是如果直接使用首字符的ASCII码,由于只有26个字母,除留余数后获取到的哈希地址冲突性很大,因此我们可以将字符串的ASCII值进行加和,将其结果用于取模。
2、那么,如何实现它呢?一种方法是我们自己手动写一个仿函数作为HashTable
的第三参数传入,这样class Hash=HashFunc<K>
就不会调用给定的缺省参数。另一种方法是模板特化。(实际中,SQL采用的是后者,因此我们使用unordered系列时,能直接将类型定义为string等字符串而又不需要显示传入第三参数)
struct HashFunctostring //写法一:显示写一个仿函数
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)//取当前string中的每个字符
{
val += ch;//将其Ascii码做加和
}
return val;//获取结果返回用于取模
}
};
void test()
{
string arr[] = { "晴","多云","晴","阴","小雨","多云","多云","阴","晴","小雨","大雨","阴","多云","晴" };
HashTable<string, int, HashFunctostring> countHash;//次数统计时显示传入
//……
}
template<> //写法二:使用模板特化
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += ch;
}
return val;
}
};
3)、改进2.0
上述字符加和虽然解决了字符串取模的问题,但对于一些场景,如下,这些字符串加和后的值相同,那么引起哈希冲突的概率较大:
abcd = 394
aadd = 394
acbd = 394
因此,对于字符串的哈希函数,可做一定修改,以减少其哈希冲突。
相关链接:各种字符串Hash函数
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
2.3.1.6、整体效果展示
#pragma once
#include<iostream>
#include<vector>
using namespace std;
enum State
{
EMPTY,//此位置空
EXIST,//此位置存在有效元素
DELETE//此位置元素已被删除
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;//能够强制类型转换为size_t的。
}
};
template<>
struct HashFunc<string>//方法二
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
//struct HashFunctostring//方法一
//{
// size_t operator()(const string& key)
// {
// size_t val = 0;
// for (auto ch : key)
// {
// val += ch;
// }
// return val;
// }
//};
template<class K,class V,class Hash=HashFunc<K>>
class HashTable
{
void CheckCapacity()
{
//此处我们设置负载因子为0.7
if (_table.size() == 0 || 10 * _size / _table.size() >= 7)
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V, Hash> newHash;//创建一个新的哈希类
newHash._table.resize(newSize);//让新的哈希表容量空间为重定义后的容量空间
for (auto e : _table)
{
if (e._state == EXIST)//说明原表中该处位置值存在
{
newHash.Insert(e._kv);//复用Insert,将值放入新表中,构成新的哈希地址关系
}
}
_table.swap(newHash._table);
}
//if (_table.size() == 0 || 10 * _size / _table.size() >= 7) // 扩容
//{
// size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
// vector<HashData<K, V>> newTables;//创建一个新的哈希表
// newTables.resize(newSize);
// // 旧表的数据映射到新表
// for ()
// {
// }
// //_table.swap(newTables);
//}
}
public:
bool Insert(const pair<K, V> kv)
{
if (Find(kv.first))
return false;
//0、检查扩容
CheckCapacity();
//1、找映射地址
Hash HsTrans;
//size_t hashi = kv.first % _table.size();
size_t hashi = HsTrans(kv.first) % _table.size();
//2、放值,若当前地址值存在,则线性探测向后寻找合适位置
while (_table[hashi]._state==EXIST)
{
++hashi;
hashi%= _table.size();//这里是为了防止++超过边界范围
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;//别忘了设置状态
++_size;
return true;
}
HashData<K, V>* Find(const K& key)
{
if (_table.size() == 0)//哈希表中无值
{
return nullptr;
}
Hash HsTrans;
//size_t hashi = key % _table.size();//判断目标值的哈希位置
size_t hashi = HsTrans(key) % _table.size();//判断目标值的哈希位置
size_t start = hashi;//用于标记开始遍历的位置
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == key)
{
return &_table[hashi];
}
hashi++;
hashi %= _table.size();
if (hashi == start)//一轮遍历结束
break;
}
return nullptr;//,遍历到状态为空的位置处循环结束,说明表中无目标值
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
_size--;
return true;
}
else
return false;
}
//void Print()
//{
// for (size_t i = 0; i < _table.size(); ++i)
// {
// if (_table[i]._state == EXIST)
// {
// printf("[%d:%d] ", i, _table[i]._kv.first);
// }
// else
// {
// printf("[%d:null] ", i);
// }
// }
// cout << endl;
//}
private:
vector<HashData<K, V>> _table;//哈希表
size_t _size = 0;//表中有效数据
};
2.3.2、闭散列·二次探测说明
1)、相关讲解
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: 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是表的大小。
以插入举例:
线性探测版本:
bool Insert(const pair<K, V> kv)
{
if (Find(kv.first))
return false;
//0、检查扩容
CheckCapacity();
//1、找映射地址
Hash HsTrans;
size_t hashi = HsTrans(kv.first) % _table.size();
//2、放值,若当前地址值存在,则线性探测向后寻找合适位置
while (_table[hashi]._state==EXIST)
{
++hashi;
hashi%= _table.size();//这里是为了防止++超过边界范围
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;//别忘了设置状态
++_size;
return true;
}
二次探测版:
bool Insert(const pair<K, V> kv)//二次探测版
{
if (Find(kv.first))
return false;
//0、检查扩容
CheckCapacity();
//1、找映射地址
Hash HsTrans;
size_t hashi = HsTrans(kv.first) % _table.size();
size_t i = 0; size_t start = hashi;
//2、放值,若当前地址值存在,则线性探测向后寻找合适位置
while (_table[hashi]._state == EXIST)
{
++i;
hashi = start + i * i;//存储地址变为当前起始地址往后走i^2
hashi %= _table.size();//这里是为了防止++超过边界范围
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;//别忘了设置状态
++_size;
return true;
}
2.4、开散列及其实现
1)、哈希冲突解决方案二:开散列法
开散列法又叫链地址法(开链法)。首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
问题1:哈希桶为什么使用单链表结构?
回答:实际上并未规定需要什么结构的链表,此处我们也可以使用带头双向循环链表完成,出于数据需求,有时也会在哈希桶中挂红黑树。单看此处,使用单链表实际上是因为一个桶中的数据存放可以头插也可以尾插(方式一/方式二),所以不存在找尾问题。而使用双链表尾插也行得通,在遍历一遍链表的同时可以检查待插入值是否存在链表中(相当于解决了去重问题)。
2.4.1、哈希桶相关实现
2.4.1.1、如何如何定义一个哈希桶?
和闭散列类似,只不过这里我们需要将哈希表实现为链表结构。
template<class K,class V>
struct HashNode
{
pair<K, V> _kv;//当前结点存储值
HashNode<K, V>* _next;//指向下一个结点的指针
HashNode(const pair<K, V>& kv)//构造
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
private:
vector<Node*> _table;
size_t _size = 0;//表中有效数据
};
细节理解:
vector<Node*> _Table
:实际上这是一个指针数组,vector中存储的是每个链表的头结点。
2.4.1.2、如何实现Insert插入
1)、如何插入数据?
问题:哈希桶中,数据插入需要做些什么?
回答:
①扩容检查(后续说明);
②除留余数法寻找哈希地址;
③类似于链表,插入数值,修改链接关系。(选择尾插:找尾可以解决检查数据重复问题;选择头插:方便省事,但需要注意链表为空和链表有头结点时)
相关实现如下:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//0、扩容检查
CheckCapacity();
//1、计算哈希地址
size_t hashi = kv.first % _table.size();
//2、插入值,修改链接关系
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_size;
return true;
}
2)、扩容问题说明
在闭散列中,随着哈希地址不断冲突占用,最终vector空间将被使用耗尽,因此再次插入新元素时,需要考虑扩容问题。在开散列中也一样,哈希桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容。
闭散列扩容中我们引入了负载因子,此处开散列中,负载因子的条件如何确认?
开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突。因此,可设置负载因子为1,即当前元素个数刚好等于桶的个数时,需要增容。
相关实现如下:
void CheckCapacity()
{
if (_size == _table.capacity())
{
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;//确定新的vector大小
vector<Node*> newtable;//新建一个哈希表
newtable.resize(newSize, nullptr);//将新的哈希表容量设置为上述newSize容量,注意此处我们先将每个Node*初始化为nullptr
for (size_t i = 0; i < _table.size(); ++i)//遍历原表,在新表中重新建立映射关系:此处我们直接使用原表已经存在的结点,修改链接关系即可
{
Node* cur = _table[i];
while (cur)//若当前桶中存在有效结点:依次取当前桶中每个结点,让其重新链接到新表中
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newtable.size();//获取结点在新表中的哈希地址
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;//在原哈希桶迭代依次修改节点,直到当前桶为空
}
_table[i] = nullptr;
}
//交换
_table.swap(newtable);
}
}
细节理解:
1、vector<Node*> newtable;//新建一个哈希表
。在闭散列中,扩容我们直接重新创建了一个类HashTable<K, V> newHash
,而在此处的哈希桶中,我们只是重新创建Hashtable
中的成员变量vector<Node*> newtable
,为什么会存在这样的区别?
回答:取决于开散列和闭散列中vector数据处理方式。在闭散列中,扩容后需要重新计算每个数据的哈希地址再放值,这步操作实际Insert可以解决,因此我们选择创建了一个类复用Insert。而在开散列中,除了要处理哈希地址,由于数据存储在节点中,还需要处理对应的链接关系。若直接复用Insert,则每次都要开辟新节点Node* newnode = new Node(kv)
,那么为何不直接对原链表中已经存在的节点做修改呢,这样还能省去new新节点带来的消耗。
2、for (size_t i = 0; i < _table.size(); ++i)
和while (cur)
:链接关系的修改。需要注意此处_table[i] = nullptr;
将原链表置空,是为了后续_table.swap(newtable);
交换后,调用析构函数解决我们局部创建的类。
2.4.1.3、如何实现Find查找
与闭散列不同,哈希桶中冲突的数据仍旧处于当前地址处,因此查找时只需要计算出相应的映射地址,然后在链表结构中查询有无该值即可。
Node* Find(const K& key)
{
if (_table.size() == 0)//表中无元素
return nullptr;
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;//找不到的情况
}
2.4.1.4、如何实现Erase删除
1)、相关实现
删除某一数据,首先要在哈希桶中寻找该数据是否存在,若存在,则将对应元素存储空间释放。注意:①区分头删和中间位置删除;②除了要删除目标结点本身, 还要修改其前后结点的链接关系。
bool Erase(const K& key)
{
Node* ret = Find(key);
if (ret)//该目标值存在
{
size_t hashi = key % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur != ret)
{
prev = cur;
cur = cur->_next;
}
if (prev)//非头删
prev->_next = cur->_next;
else//头删
_table[hashi] = cur->_next;
delete cur;
--_size;
return true;
}
return false;
}
事实上两种写法无太大区别,只是上面复用了Find查找元素是否存在,而下面则是自己查找相关值。
bool Erase(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
size_t hashi = key % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 1、头删
// 2、中间删
if (prev == nullptr)
{
_table[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
2)、代码验证
void test04()
{
HashBucket::HashTable<int, int> ht;
int a[] = { 1,11,21,3,4,14,23,20,5,24 };
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
typedef HashBucket::HashNode<int, int> Node;
cout << (ht.Find(11))->_kv.first << endl;
cout << ht.Erase(11) << endl;
cout << ht.Erase(222) << endl;
}
2.4.1.5、默认成员函数:析构
1)、相关实现
说明:对应HashTable,其两个成员变量分别为vector<Node*> _table;
、size_t _size = 0;
。析构函数对内置类型不做处理,对自定义类型会去调用它的析构,因此此处_table会调用vector的析构。但这里实际是一个指针数组,vector只释放其本身的空间,不会对哈希桶中每个结点做处理,因此我们需要在析构时手动处理。
~HashTable()
{
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
Node* next = nullptr;
while (cur)
{
next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
2.4.1.6、仿函数引入:改进取模问题
和闭散列一致,为了解决string等类型取模问题,我们引入了仿函数:实际上这里开散列和闭散列实现一致,可共用一个仿函数。
修改后的整体效果展示在后续小节。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;//能够强制类型转换为size_t的。
}
};
template<>
struct HashFunc<string>//方法二
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
2.4.1.7、素数引入:改进扩容问题
1)、问题说明
在哈希表中,对应扩容问题还可以做一点小改进:尽量让表的大小是一个素数,这种模式下哈希冲突能得到一定优化。
以下为相关源码解析:
实现如下:
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
//遍历上述的素数集合,设当前_table中元素个数为n,
//则下次resize从新规定空间时,我们只需要找首个大于n的素数即可。
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
实际其运用在Insert新增数据,进行扩容检查时:
void CheckCapacity()
{
if (_size == _table.capacity())
{
vector<Node*> newtable;//新建一个哈希表
//原先写法:二倍扩容
//size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;//确定新的vector大小
//newtable.resize(newSize, nullptr);//resize重新设置容量空间
newtable.resize(__stl_next_prime(_table.size()), nullptr);//resize重新设置容量空间
Hash HsTrans;//取模改进:使用仿函数
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = HsTrans(cur->_kv.first) % newtable.size();
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
//交换
_table.swap(newtable);
}
}
2.4.2、数量统计相关
在上述实现的基础上,我们来测试以下哈希表的性能。此处需要涉及一些长度统计。
//统计哈希表中数据个数
size_t Size()
{
return _size;
}
//统计哈希表中表的长度
size_t TableSize()
{
return _table.size();
}
//统计哈希表中桶的个数
size_t BucketNum()
{
size_t count = 0;
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i])
count++;
}
return count;
}
//寻找最长桶的长度
size_t MaxBucketLength()
{
size_t length = 0;
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
size_t curlen = 0;
while (cur)
{
++curlen;
cur = cur->_next;
}
if (curlen > 0)//用于查看有数据的桶其上挂有多少个结点
printf("[%d]号桶长度:%d\n", i, curlen);
if (curlen > length)
length = curlen;
}
return length;
}
相关测试如下:
void test05()
{
int n = 100000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
HashBucket::HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert(make_pair(e, e));
}
size_t end1 = clock();
cout << "最长的桶的长度:" << ht.MaxBucketLength() << endl;
cout << "数据个数:" << ht.Size() << endl;
cout << "表的长度:" << ht.TableSize() << endl;
cout << "桶的个数:" << ht.BucketNum() << endl;
cout << "负载因子:" << (double)ht.Size() / (double)ht.TableSize() << endl;
cout << "平均每个桶的长度:" << (double)ht.Size() / (double)ht.BucketNum() << endl;
}
演示结果如下:可看到平均下来哈希表中每个桶大致挂有一个结点。
2.4.3、整体效果
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace HashBucket
{
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;//能够强制类型转换为size_t的。
}
};
template<>
struct HashFunc<string>//方法二
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
template<class K,class V>
struct HashNode
{
pair<K, V> _kv;//当前结点存储值
HashNode<K, V>* _next;//指向下一个结点的指针
HashNode(const pair<K, V>& kv)//构造
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V,class Hash= HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
Node* next = nullptr;
while (cur)
{
next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
void CheckCapacity()
{
if (_size == _table.capacity())
{
vector<Node*> newtable;//新建一个哈希表
//size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;//确定新的vector大小
//newtable.resize(newSize, nullptr);//resize重新设置容量空间
newtable.resize(__stl_next_prime(_table.size()), nullptr);//resize重新设置容量空间
Hash HsTrans;
for (size_t i = 0; i < _table.size(); ++i)//遍历原表,在新表中重新建立映射关系:此处我们直接使用原表已经存在的结点,修改链接关系即可
{
Node* cur = _table[i];
while (cur)//若当前桶中存在有效结点:依次取当前桶中每个结点,让其重新链接到新表中
{
Node* next = cur->_next;
size_t hashi = HsTrans(cur->_kv.first) % newtable.size();//获取结点在新表中的哈希地址
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;//在原哈希桶迭代依次修改节点,直到当前桶为空
}
_table[i] = nullptr;
}
//交换
_table.swap(newtable);
}
}
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
//遍历上述的素数集合,设当前_table中元素个数为n,
//则下次resize从新规定空间时,我们只需要找首个大于n的素数即可。
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//0、扩容检查
CheckCapacity();
//1、计算哈希地址
Hash HsTrans;
size_t hashi = HsTrans(kv.first) % _table.size();
//2、插入值,修改链接关系
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_size;
return true;
}
Node* Find(const K& key)
{
if (_table.size() == 0)//表中无元素
return nullptr;
Hash HsTrans;
size_t hashi = HsTrans(key) % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;//找不到的情况
}
bool Erase(const K& key)
{
Node* ret = Find(key);
if (ret)//该目标值存在
{
Hash HsTrans;
size_t hashi = HsTrans(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur != ret)
{
prev = cur;
cur = cur->_next;
}
if (prev)//非头删
prev->_next = cur->_next;
else//头删
_table[hashi] = cur->_next;
delete cur;
--_size;
return true;
}
return false;
}
//统计哈希表中数据个数
size_t Size()
{
return _size;
}
//统计哈希表中表的长度
size_t TableSize()
{
return _table.size();
}
//统计哈希表中桶的个数
size_t BucketNum()
{
size_t count = 0;
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i])
count++;
}
return count;
}
//寻找最长桶的长度
size_t MaxBucketLength()
{
size_t length = 0;
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
size_t curlen = 0;
while (cur)
{
++curlen;
cur = cur->_next;
}
if (curlen > 0)//用于查看有数据的桶其上挂有多少个结点
printf("[%d]号桶长度:%d\n", i, curlen);
if (curlen > length)
length = curlen;
}
return length;
}
private:
vector<Node*> _table;
size_t _size = 0;
};
}