1. unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到log_2 N
,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map
和unordered_set
进行介绍。
1.1 unordered_map
unordered_map
是存储<key, value>
键值对的关联式容器,其允许通过key
快速的索引到与其对应的value。- 在
unordered_map
中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。 - 在内部
,unordered_map
没有对<kye, value>
按照任何特定的顺序排序, 为了能在常数范围内找到key
所对应的value
,unordered_map
将相同哈希值的键值对放在相同的桶中。 unordered_map
容器通过key
访问单个元素要比map
快,但它通常在遍历元素子集的范围迭代方面效率较低。unordered_maps
实现了直接访问操作符operator[]
,它允许使用key
作为参数直接访问value
。- 它的迭代器至少是前向迭代器。
unordered_map的说明文档
1.2 unordered_map的使用
// unordered_map的类模板
// std::unordered_map
template < class Key, // unordered_map::key_type
class T, // unordered_map::mapped_type
class Hash = hash<Key>, // unordered_map::hasher
class Pred = equal_to<Key>, // unordered_map::key_equal
class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type
> class unordered_map;
- 演示1
#include<iostream>
#include<unordered_set>
using namespace std;
int main()
{
// 创建一个unordered_set对象,传入int对类模板进行实例化
unordered_set<int> us;
// 插入数据
us.insert(3);
us.insert(1);
us.insert(3);
us.insert(2);
us.insert(0);
// 使用迭代器遍历
unordered_set<int>::iterator it = us.begin();
while (it != us.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
// 打印结果:3 1 2 0
- 演示2
#include<iostream>
#include<unordered_map>
#include<string>
using namespace std;
int main()
{
string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
unordered_map<string, int> countmap;
for (auto& e : arr)
{
/*
// 使用auto,或者直接标明类型都是可以的
// find()的返回值是一个迭代器
// unordered_map<string, int>::iterator it = countmap.find(e);
auto it = countmap.find(e);
// 如果满足it == countmap.end(),那就是没有找到key,则我们插入key
if (it == countmap.end())
{
countmap.insert(make_pair(e, 1));
}
else
{
// 运行到这里说明,key已经存在了,那么我们对Value进行++
it->second++;
}
*/
// 直接使用operator[],和上面的代码的效果是一致的
countmap[e]++;
}
for (const auto& kv : countmap)
{
cout << kv.first << ":" << kv.second << endl;
}
return 0;
}
// 打印结果:
// 西瓜:3
// 苹果:6
// 香蕉:3
// 草莓:1
unordered_set/unordered_map的高效查找性能
#include<iostream>
#include<unordered_set>
#include<set>
#include<string>
using namespace std;
int main()
{
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
// 我们用vector将产生的随机数进行存储
vector<int> v;
// 提前开辟空间(防止不断扩容,提高性能)
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; ++i)
{
// case1:插入的都是随机数,但是重复的值比较多
// v.push_back(rand());
// case2:插入的都是随机数,但是重复的值比较少
v.push_back(rand()+i);
// case3:插入有序的数
// v.push_back(i);
}
// 1.set进行插入
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
// 2.unordered_set进行插入
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
// 3.set进行查找
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << endl;
// 4.unordered_set进行查找
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "unordered_set find:" << end4 - begin4 << endl;
// 用来查看产生了多少个随机数,插入进去了
cout << s.size() << endl;
cout << us.size() << endl;
// 5.set的删除
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
// 6.unordered_set的删除
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl;
return 0;
}
-
在vs2019,release版本下测试
-
case1:
插入的都是随机数,但是重复的值比较多
v.push_back(rand());
打印结果为:
set insert : 160
unordered_set insert : 17
set find : 0
unordered_set find : 0
32768
32768
set erase : 34
unordered_set erase : 4
可以看出,当重复值较多时,unordered_set不管是插入的性能还是删除的性能都高于set
- case2:
插入的都是随机数,但是重复的值比较少
v.push_back(rand()+i);
打印结果为:
set insert : 224
unordered_set insert : 168
set find : 0
unordered_set find : 0
635152
635152
set erase : 277
unordered_set erase : 90
可以看出,当重复值较少时,unordered_set不管是插入的性能还是删除的性能都高于set
- case3:
插入有序的数
v.push_back(i);
打印结果为:
set insert : 168
unordered_set insert : 228
set find : 0
unordered_set find : 0
1000000
1000000
set erase : 135
unordered_set erase : 134
可以看出,当是有序数列时,set不管是插入的性能还是删除的性能都高于unordered_set
注:vs2019并没有测试出find()的区别,但是其实unorder_set在任意情况下的查找效率都是大于或者等于set的查找效率的。
1.3 unordered_set
unordered_set
与unordered_map
类似,就不过多赘述了
unordered_set的在线说明文档
1.4 字符串中的第一个唯一字符
// 采用了hash映射的思想
// 方法一:
class Solution {
public:
int firstUniqChar(string s)
{
// 创建一个数组来记录每个字母出现的次数(一共有26个字母)
int count[26] = {0};
// 遍历一遍string,记录字母的出现的个数(使用范围for来进行遍历)
for(char ch : s)
{
//字符使用ASCLL值进行存储的,因此减去'a',就可以得到相应的下标
count[ch - 'a']++;
}
// 再找出第一个不重复的字符
for(size_t i = 0; i < s.size(); i++)
{
if(count[s[i] - 'a'] == 1)
return i;
}
// 运行到这里说明并没有找到
return -1;
}
};
// 方法二:
class Solution {
public:
int firstUniqChar(string s) {
// 创建一个哈希对象
std::unordered_map<char, int> count;
// 统计每个字符出现的次数
for (char c : s) {
count[c]++;
}
// 找到第一个出现次数为 1 的字符的索引
for (int i = 0; i < s.size(); ++i) {
if (count[s[i]] == 1) {
return i;
}
}
// 未找到符合条件的字符,返回 -1
return -1;
}
};
2. 底层结构
unordered
系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
2.1 哈希概念
-
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为
O(N)
,平衡树中为树的高度,即O(log_2 N)
,搜索的效率取决于搜索过程中元素的比较次数。 -
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数
hashFunc
使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
-
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
-
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
-
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
2.2 哈希冲突
对于两个数据元素的关键字k_i
和 k_j (i != j)
,有k_i
!= k_j
,但有:Hash(k_i)
==Hash(k_j
),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
2.3哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
2.3.1直接定址法–(常用的哈希函数)
**特点:**适用于整型并且范围相对集中的数
如:我们将{2,5,7,9,1}进行映射,或者将{22,25,27.29,21}进行映射,则映射关系如下
2.3.2除留余数法–(常用的哈希函数)
如果我们要进行映射的数据为{3,5,7,55,5,57,7,999999},这样的数据太过分散,如果按照直接定址法,则消耗的空间过大,因此我们采用除留余数法,具体如下:
但是,我们发现需要映射的数据的值不同,但是映射到哈希表的地址是相同的,这被我们成为哈希/冲突碰撞
**2.4 **哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
2.4.1 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
- 线性探测(寻找下一个空位置的方法)
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。如下图:我们插入44时
或者如下图所示:
- 删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索(我们查找是查找到空就结束)。比如删除元素27,如果直接删除掉,38查找起来可能会受影响(当线性探测到27时,如果直接删除了27,那么此时这个位置为空,则查找结束,则我们就找不到38了)。因此线性探测采用标记的伪删除法来删除一个元素。
- 因此:我们给哈希表每个空间给个标记状态
- EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
- enum State{EMPTY, EXIST, DELETE};
- 这样我们删除掉27,则这个空间就会被标记位DELETE,而我们是查到到空间的标记位EMPTY才会结束查找
哈希表的模拟实现(线性探测)
hash表插入的数据的类模板
// 枚举每块空间标记的状态
enum State
{
EMPTY,
EXIST,
DELETE,
};
// hash中存储的元素的类模板
template<class K, class V>
struct HashData
{
// 插入的元素是一个pair<K, V>类型的kv值
pair<K, V> _kv;
// 表示当前元素的状态
State _state = EMPTY;
};
HashTable的构造函数
HashTable()
:_n(0)
{
// 将_tables(哈希表)的大小初始化为10,避免除0错误(使用hash函数时,需要除以哈希表的大小)
// 如在insert中:if (_n * 10 / _tables.size() >= 7)
_tables.resize(10);
}
// hash类模板的私有成员变量
private:
vector<Data> _tables;
size_t _n = 0; // 表中存储的有效数据的个数
哈希函数(仿函数)
各种字符串转整型的hash函数
// 仿函数
// 可以将int、char、double、指针强转为size_t类型
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 将string转化为整型,并返回
// 这样我们才可以利用哈希函数来确定要插入的值的位置
// size_t hashi = hf(kv.first) % _tables.size();
// 取模得到hashi
// 特化模板
template<>
struct HashFunc<string>
{
// BKDR
// 本算法由于在Brian Kernighan与Dennis Ritchie的《The C Programming Language》一书被展示而得 名,是一种简单快捷的hash算法,也是Java目前采用的字符串的Hash算法(累乘因子为31)。
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
// 也可以乘以31、131、1313、13131、131313..
// 总之用这种方法之后,字符串运算最终得出的hashi基本不相同,则产生的哈希冲突就越少
hash *= 131;
hash += ch;
}
return hash;
}
};
插入(insert)
思考:哈希表什么情况下进行扩容呢?如何扩容?
对于扩容有如下的规则:
负载因子也叫作载荷因子
- 负载因子 = 表中有效数据的个数/表的大小
通过这个公式,我们可以判断出:
1.负载因子越小(表中有效数据的个数越少),则数据地址冲突概率越小,但是消耗的空间越大(表的大小越大)
2.负载因子越大(表中有效数据的个数越多),则数据地址冲突概率越大,但是哈希表空间的利用率越高
因此,规定当负载因子大于我们规定的大小时,哈希表就要进行扩容
bool Insert(const pair<K, V>& kv)
{
// 假如实现的哈希表中元素唯一,
// 即key相同的元素不再进行插入
if (Find(kv.first))
return false;
// 1.插入一个元素前,需要先判断哈希表的容量够不够,不够的话就需要进行扩容
// 大于标定负载因子,就需要扩容
// 我们规定的负载因子为0.7
// 但是两个整数相除得不到double类型的数据
// 因此我们对_n(表中有效数据的个数)和负载因子(0.7)同时扩大10倍
// 10*有效数据的个数/表的大小 >= 10*负载因子
if (_n * 10 / _tables.size() >= 7)
{
// 扩容之后
// 旧表数据,重新计算,映射到新表
// 创建一个新的哈希表(newHT)
HashTable<K, V, Hash> newHT;
// _tables.size()是旧表的vector的大小
// 因此新表的大小我们扩容为旧表的两倍
newHT._tables.resize(_tables.size() * 2);
for (auto& e : _tables)
{
// 当e._state == EXIST说明旧表的这个空间是存在数据的
// 那么我们将这个数据重新映射到新表中(标识状态)
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
// 我们将新表的vector的数据与旧表vector的数据再进行交换(旧表的容量被交换)
_tables.swap(newHT._tables);
// 新表的声明周期结束,新表会被系统回收
}
// 使用仿函数来将kv.first转化为整型
Hash hf;
// kv.first是拿到的是数据key
// _tables.size()是哈希表的大小
// hashi是数据key在哈希表中的下标
// 利用除留余数法计算出hashi
size_t hashi = hf(kv.first) % _tables.size();
// 如果_tables[hashi]._state == EXIST
// 说明hashi这个位置已经有数据了
// 按照线性探测的方法找下一个位置,并判断下一个位置是否存在数据
// 如果存在则继续探测下一个位置,如果不存在数据,则进行插入
while (_tables[hashi]._state == EXIST)
{
// 线性探测
++hashi;
// hashi %= _tables.size() 防止hashi越界
hashi %= _tables.size();
}
// 程序运行到这里说明hashi这个位置的状态不为EXIST,那么我们直接进行插入
_tables[hashi]._kv = kv;
// 并将这个位置的状态标记位EXIST
_tables[hashi]._state = EXIST;
// 且哈希表的有效数据个数n要++
++_n;
// 插入成功,返回真
return true;
}
查找(Find)
Data* Find(const K& key)
{
// 使用仿函数来将key转化为一个整型
Hash hf;
// 先找到key对应的hashi的位置
size_t hashi = hf(key) % _tables.size();
// 1.如果hashi的状态不为EMPTY,
// 2.再来判断这个位置的状态是否为EXIST
// 3.如果状态为EXIST,且数据为key,则hashi对应的数据就是我们要查找的
// 将初始hashi的值进行保留,防止查找哈希表死循环
size_t starti = hashi;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
// 代码运行到这里,则我们需要线性探测下一个位置
++hashi;
// hashi %= _tables.size()是为了防止hashi越界
hashi %= _tables.size();
// 极端场下没有空,全是存在或者删除状态
// 当hashi == starti时,我们已经将哈希表找完了,并回到了起始位置
// 但是此时,全是存在或者删除状态,如果按照状态不为空就接着循环
// 那么就会变为死循环
if (hashi == starti)
{
break;
}
}
return nullptr;
}
删除(Erase)
bool Erase(const K& key)
{
Data* ret = Find(key);
// 如果ret不为nullptr,说明这个数据存在
if (ret)
{
// 删除这个数据,只需要将这个数据的状态改为DELETE就可以了
ret->_state = DELETE;
// 再将哈希表的有效数据个数_n进行--就可以了
--_n;
// 成功删除,返回true
return true;
}
else
{
return false;
}
}
测试
void TestHT1()
{
// 因为仿函数我们使用了缺省值,因此不需要传递仿函数
// 且仿函数使用了特化,会自动匹配对应的仿函数
HashTable<int, int> ht;
int a[] = { 18, 8, 7, 27, 57, 3, 38, 18 };
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(17, 17));
ht.Insert(make_pair(5, 5));
cout << ht.Find(7) << endl;
cout << ht.Find(8) << endl;
ht.Erase(7);
cout << ht.Find(7) << endl;
cout << ht.Find(8) << endl;
}
void TestHT2()
{
string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
HashTable<string, int> countHT;
for (auto& e : arr)
{
HashData<string, int>* ret = countHT.Find(e);
if (ret)
{
ret->_kv.second++;
}
else
{
countHT.Insert(make_pair(e, 1));
}
}
HashFunc<string> hf;
cout << hf("abc") << endl;
cout << hf("bac") << endl;
cout << hf("cba") << endl;
cout << hf("aad") << endl;
}
哈希表的完整模拟实现
#include<vector>
// 仿函数的类模板(将key转为整型)
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 仿函数(将字符串转化为整型)
// 特化
template<>
struct HashFunc<string>
{
// BKDR
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
namespace closehash
{
// hash表元素的状态
enum State
{
EMPTY,
EXIST,
DELETE,
};
// hash表数据节点的类模板
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
// hash表的类模板
// 仿函数是缺省值,可以不进行传参(使用的类模板,不是特化的类模板)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
// 将数据节点的类类型 定义为Data
typedef HashData<K, V> Data;
public:
// 构造函数
HashTable()
:_n(0)
{
_tables.resize(10);
}
// 插入函数
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 大于标定负载因子,就需要扩容
if (_n * 10 / _tables.size() >= 7)
{
// 旧表数据,重新计算,映射到新表
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
// 线性探测
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
// 查找函数
Data* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
// 删除函数
bool Erase(const K& key)
{
Data* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<Data> _tables;
size_t _n = 0; // 表中存储的有效数据的个数
};
void TestHT1()
{
HashTable<int, int> ht;
int a[] = { 18, 8, 7, 27, 57, 3, 38, 18 };
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(17, 17));
ht.Insert(make_pair(5, 5));
cout << ht.Find(7) << endl;
cout << ht.Find(8) << endl;
ht.Erase(7);
cout << ht.Find(7) << endl;
cout << ht.Find(8) << endl;
}
void TestHT2()
{
string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
//HashTable<string, int, HashFuncString> countHT;
HashTable<string, int> countHT;
for (auto& e : arr)
{
HashData<string, int>* ret = countHT.Find(e);
if (ret)
{
ret->_kv.second++;
}
else
{
countHT.Insert(make_pair(e, 1));
}
}
HashFunc<string> hf;
cout << hf("abc") << endl;
cout << hf("bac") << endl;
cout << hf("cba") << endl;
cout << hf("aad") << endl;
}
}
二次探测(解决哈希冲突的方法)
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找 因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0+ i^2)% m
, 或者:H_i= (H_0 - i^2 )% m
。其中:i = 1,2,3…, H_0是通过散列函数 Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
2.4.2 开散列
- 开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
哈希桶插入节点的类
// 所谓的桶,其实就是一个单链表
// 数组中存放的是单链表头节点的地址
// 插入节点的类模板
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)
{}
};
// 将 插入节点的类模板类型定义为Node
typedef HashNode<K, V> Node;
// hash表的私有成员
private:
vector<Node*> _tables; // 指针数组(存放的就是hash表的头节点)
size_t _n = 0; // 桶的个数
插入(insert)
bool Insert(const pair<K, V>& kv)
{
// 同一个key不允许被插入两次
if (Find(kv.first))
return false;
// 哈希桶的负载因子,在c++中有定义,因此我们直接按大佬定义的负载因子来扩容
// 负载因子控制在1,超过就扩容(负载因子 = 有效数据的个数/表的大小 )
if (_tables.size() == _n)
{
/*
// 扩容
// 方法一:
// 创建一个新的哈希表,并将这个哈希表的大小调整为旧表的2倍
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
for (auto cur : _tables)
{
// cur为每个桶的头节点
// 如果cur不为空,则说明此处是有数据的,那么我们将这个数据插入到新表
while (cur)
{
newHT.Insert(cur->_kv);
// 每个桶都为单链表,要确定这个桶里面的数据都被插入到新表
cur = cur->_next;
}
}
// 我们将新表和旧表的数据进行交换
_tables.swap(newHT._tables);
// 当新表的生命周期结束,新表会自动销毁
// 方法一的缺点就是:
// 每次往新表中每插入一个数据,我们都需要new一个新的节点,这样的话就会影响效率
// 且新表的生命周期结束,新表虽然被销毁了,但是新表上面挂着的单链表的桶并没有被销毁
// vector会调用它自己的析构函数来回收资源,但是桶的,需要我们自己去实现资源的回收
// 如果我们可以重复利用旧表桶的节点,那么就不用开辟新的节点,这样就大大提高了扩容的效率
*/
// 扩容
// 方法二:
// 这种方法的优势在于,我们将旧表的key重新映射到新表时
// 我们是将旧表的节点重复利用,一个节点一个节点的重新链接到新表的vector中
// 不需要我们再去new新的节点,提高了映射的效率
// 第一步:创建一个新的vector,用来重新映射
// 这里并不需要创建一个哈希表,因为我们只是改变旧表vector的桶的节点的指向
// 只需要将旧表vector的桶的节点放入到新表的vector中就可以了
vector<Node*> newTables;
// 当新表的vector进行扩容时,并不是按旧表的2倍来扩容,而是扩容的大小最好是一个素数,
// 根据大佬的研究,如果表的大小为素数,那么得到的hashi的值将会越分散,这样哈希桶的效率将会得到提升
// size_t hashi = Hash()(cur->_kv.first) % newTables.size();
newTables.resize(__stl_next_prime(_tables.size()), nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
// 拿到旧表对应位置的头节点
Node* cur = _tables[i];
// 如果头节点不为空,那么说明这个头节点对应的桶存在数据
while (cur)
{
// 再将头节点插入新的vector之前,我们先记录下一个节点的位置
Node* next = cur->_next;
// 计算cur在新的vector中的hashi
// Hash()(cur->_kv.first) 是调用仿函数,强转其他类型的key为整型
size_t hashi = Hash()(cur->_kv.first) % newTables.size();
// 将cur头插到新表
// newTables[hashi] 上存放节点prev
// cur 为旧表的头节点
// 将 cur 头插到 prev 前面
// 1.cur->_next 指向prev
// 2.将cur放在数组newTables[hashi]位置上面
cur->_next = newTables[hashi];
newTables[hashi] = cur;
// 将next置为cur再次迭代
// 直到cur为空,说明已经将这个桶的所有节点进行了映射
cur = next;
}
// 最后再将旧表的vector的空间置空,防止从vector中找到桶的头节点地址,来访问桶(此时桶的所有节点都被重新映射,移动到了新表,因此是不允许旧表对其访问的,所以将旧表置空)
_tables[i] = nullptr;
}
// 将旧表和新表的vector数据进行交换
_tables.swap(newTables);
}
// 插入数据
// 先通过哈希函数找到key对应的hashi
size_t hashi = Hash()(kv.first) % _tables.size();
// new一个新节点,并将这个新节点的头插到对应的hashi的桶中
// 此时_tables[hashi] 存放着这个桶的头节点prev
// 头插的话,那么newnode->_next指向prev
// 再将新的头节点的地址放入到_tables[hashi]
// 这样我们就完成了头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
// 插入节点后,数据的个数增加
++_n;
return true;
}
返回扩容后的素数大小
// 内联函数(hash表扩容后,新表的大小)
inline unsigned long __stl_next_prime(unsigned long n)
{
// 初始化素数数组的下标为28
static const int __stl_num_primes = 28;
// 这是一个静态的素数数组,一共28个素数,对于32位的机器,42亿多已经是最大的素数了
static const unsigned long __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
};
for (int i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
// 返回比当前数组容量n更大的素数(这个素数近似是n的2倍)
return __stl_prime_list[i];
}
}
// 如果不满足上述条件,则返回素数数组中最大的元素
return __stl_prime_list[__stl_num_primes - 1];
}
哈希的析构函数
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
// 释放桶
// 依次拿到桶的头节点
Node* cur = _tables[i];
while (cur)
{
// 将每一个桶的所有节点都释放
Node* next = cur->_next;
delete cur;
cur = next;
}
// 将vector中存放桶的头节点的位置置空,防止桶已经被释放了,但是还可以通过头节点的地址来进行访问
_tables[i] = nullptr;
// 将桶释放完之后,会自动调用vector的析构函数释放vector占用的资源
}
}
哈希的构造函数
HashTable()
:_n(0)
{
// _tables.resize(10);
// 按照素数表来初始化vector的大小
_tables.resize(__stl_next_prime(0));
}
查找(Find)
Node* Find(const K& key)
{
// 通过hash函数确定key在哈希表中的下标hashi
size_t hashi = Hash()(key) % _tables.size();
// 通过hashi找到对应的桶的头节点
Node* cur = _tables[hashi];
while (cur)
{
// 判断这个单链表的桶中的节点的key是否是我们查找的key
// 迭代去查找
if (cur->_kv.first == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
删除(Erase)
bool Erase(const K& key)
{
// 根据哈希函数找到key的hashi
size_t hashi = Hash()(key) % _tables.size();
Node* prev = nullptr;
// 根据hashi找到存放key的桶的头节点
Node* cur = _tables[hashi];
// 依次遍历桶的每一个节点,直到找到对应的节点,或者遍历到空节点
// 则循环结束,查找不到这个节点,返回false
while (cur)
{
if (cur->_kv.first == key)
{
// 此时,找到了对应的节点
// 准备删除
if (cur == _tables[hashi])
{
// 如果我们要删除的节点就是头节点,这个需要我们单独处理
// 直接指定cur下一个节点成为新的头节点,则将新的头节点的地址放到_tables[hashi],完成连接
_tables[hashi] = cur->_next;
}
else
{
// 如果我们要删除的节点不是头节点,那么就是连接
// prev 和 cur->next 这两个节点
prev->_next = cur->_next;
}
// 当完成了连接之后,我们再释放cur节点就可以了
delete cur;
// 此时哈希桶中有效数据的个数要--
--_n;
return true;
}
else
{
// 如果当前cur节点不是我们要找的节点
// 则继续遍历下一个节点
// 则下一个节点变为新的cur节点
// cur变为新的prev节点
prev = cur;
cur = cur->_next;
}
}
return false;
}
哈希桶的完整模拟实现
namespace buckethash
{
// hash桶中节点的类模板
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)
{}
};
// hash表的类模板
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
:_n(0)
{
//_tables.resize(10);
_tables.resize(__stl_next_prime(0));
}
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
// 释放桶
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
// 插入函数
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子控制在1,超过就扩容
if (_tables.size() == _n)
{
vector<Node*> newTables;
newTables.resize(__stl_next_prime(_tables.size()), nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = Hash()(cur->_kv.first) % newTables.size();
// 头插到新表
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = Hash()(kv.first) % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
// 查找函数
Node* Find(const K& key)
{
size_t hashi = Hash()(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
// 删除函数
bool Erase(const K& key)
{
size_t hashi = Hash()(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 准备删除
if (cur == _tables[hashi])
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
// 素数表
inline unsigned long __stl_next_prime(unsigned long n)
{
static const int __stl_num_primes = 28;
static const unsigned long __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
};
for (int i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return __stl_prime_list[__stl_num_primes - 1];
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0;
};
void TestHT1()
{
HashTable<int, int> ht;
int a[] = { 18, 8, 7, 27, 57, 3, 38, 18,17,88,38,28};
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(5, 5));
ht.Erase(17);
ht.Erase(57);
}
void TestHT2()
{
string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
HashTable<string, int> countHT;
for (auto& e : arr)
{
auto ret = countHT.Find(e);
if (ret)
{
ret->_kv.second++;
}
else
{
countHT.Insert(make_pair(e, 1));
}
}
}
}