目录
Unordered系列关联式容器
什么是哈希
哈希表
闭散列
载荷因子α
扩容
查找
删除
字符串哈希算法
最终代码
开散列
插入
查找
删除
最终代码
完整代码
Unordered系列关联式容器
C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可以达到log_2 N,即最差情况下要比较红黑树的高度次(树中的结点特别多且有序):
但又因为键值对与其存储位置之间没有对应的关系,查找一个元素时必须要多次比较键值对的键,而我们理想中的搜索方法应该是“可以不经过任何比较,一次直接从表中得到要搜索的元素”,而实现这一搜索方法应该需要构造一种存储结构,通过某种函数使元素的存储位置与它的键值对的键之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素,所以C++11中又提供了4个unordered系列的关联式容器unordered_map、unordered_set、unordered_multimap、unordered_multiset,它们与之前的map、set等容器的使用方式基本类似,只是底层结构不同,unordered序列的关联式容器的底层结构是哈希表,这使得它们在查询时的时间复杂度可以达到O(1)
什么是哈希
基本概念:哈希是一种将任意大小的数据映射到固定大小的值(通常为整数)的思想(建立值和值存储间的映射关系)
注意事项:哈希是一种思想,哈希表是基于哈希表的实现的一种数据结构
哈希表
基本概念:本质是一个数组。这个数组的每个位置(称为桶或槽)用于存储键值对,哈希表需要使用哈希函数依据键值对中key将该键值对映射到数组的某个位置,因为如果直接映射会出现“值不易映射”和“值分散”的问题
- 值不易映射:键的类型是string、结构体类型的对象
- 值分散:多个值之间的差值过大导致的空间浪费(存放并建立10001、11、55、24、19与存放位置间的映射关系,如果不做特殊处理就需要开辟很大的数组来存放这些数
“除留余数法”可以解决值分散的问题(使得值的大小和空间无关),但会出现哈希冲突问题(此外引起哈希冲突的一个原因可能是:哈希函数设计不够合理):
哈希函数: 将key转换为数组(哈希表)的索引(可能是一行代码,也可能是一个函数)
哈希函数的设计理念:
均匀性:一个好的哈希函数应该能够尽可能地将不同的键映射到不同的哈希值,以确保数据在哈希表中分布均匀。这样可以减少哈希冲突的可能性,提高哈希表的效率
确定性:哈希函数应该是确定性的,即对于相同的输入键,哈希函数应该始终返回相同的哈希值。这是保证哈希表中数据一致性的重要特性
简单性:尽量设计简单的哈希函数,以降低计算成本和实现复杂度,计算哈希值所需的时间应该是常量级的,这样可以确保哈希表的操作效率高,不会成为性能瓶颈
有效性:如果散列表有 m 个地址(桶),则哈希函数的值域(输出范围)必须在 0 到 m-1 之间,以确保哈希值可以映射到散列表的有效地址范围内
常见的哈希函数:
- 除留余数法:hash = key % table_size
- 直接定址法:
- Multiplicative Hash Function:hash = floor(table_size * (key * A % 1))
- 平方取中法:
key = 1234
,key^2 = 1522756
,取中间的227
作为哈希值- FNV Hash (Fowler-Noll-Vo):
uint32_t fnv1a_hash(const std::string& key) { uint32_t hash = 2166136261u; // FNV offset basis for (char c : key) { hash ^= c; hash *= 16777619; } return hash; }
- DJB2 Hash:
uint32_t djb2_hash(const std::string& key) { uint32_t hash = 5381; for (char c : key) { hash = ((hash << 5) + hash) + c; // hash * 33 + c } return hash; }
注意事项:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突:当不同的键被哈希函数映射到相同的索引时,称为哈希冲突,哈希表需要处理这些冲突,常见的方法有链地址法(开散列)和开放地址法(闭散列)
闭散列
基本概念:发生冲突时,通过探测(如线性探测、二次探测或双重哈希等方法)来寻找下一个键可用的位置
优点:不需要额外的存储空间,访问速度快
缺点:探测过程中可能出现“聚集”现象使得存放效率降低,且它的删除操作复杂
删除操作复杂的原因:
1、寻找一个数字时,先去该数字的映射位置寻找,找不到就继续向后寻找,如果遇到空时还没找到就停止寻找(因为如果映射位置没有,那么该数字一定是在映射位置后的某个位置插入了,插入时应该是数字、新数字 ...而不是空 新数字 ...)
2、如果按照上述的方式寻找一个数字,那么当删除目标数字前的某个数字时就会导致虽然目标数字存在但是找不到目标数字的问题
删除操作:
1、映射位置除了要存放数值外,还要存放一个标志位,用于标识当前映射位置的状态
enum State { EMPTY,//为空 EXIST,//存在 DELETE//删除 };
- 当55被删除后,它的状态被设为删除而不是空,这样就可以接着继续向后寻找
进行插入操作前先进行依据上面的内容写出插入操作(负载因子后面有描述):
bool Insert(const pair<K, V>& kv) { //防止冗余 if (Find(kv.first)) return false; //当负载因子大于等于0.7时进行扩容,以空间换时间 if (_n * 10 / _table.size() >= 0.7)//采用/取整得不到小数,所以直接令_n * 10 { //新建一个哈希表 HashTable<K,V, Hash> newHT; //确定新哈希表的大小 newHT._table.resize(_table.size() * 2); //将旧表中的键重新映射到新表中 for (size_t i = 0; i < _table.size(); i++) { if (_table[i]._state == EXIST)//原表中某下标的状态为存在时,将原表某下标中存放的键值对插入新表中 { newHT.Insert(_table[i]._kv);//直接复用Insert操作 } } _table.swap(newHT._table); } size_t hashi = kv.first % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数 //线性探测 while (_table[hashi]._state == EXIST)//探查到目标映射位置存在数字时就进行探测 { ++hashi; hashi %= _table.size();//取模的方式防止hashi越界 } //确定好位置后进行插入 _table[hashi]._kv = kv;//在合适的映射位置插入 _table[hashi]._state = EXIST;//然后后将该位置的状态标识为EXIST ++_n;//哈希表中有效数据元素+1 return true; }
注意事项:哈希表的大小(
table_size
)和底层数组的容量(capacity
)是两个不同的概念, 假设我们有一个哈希表,初始大小为 7(即table_size = 7
),底层数组的容量为 10(即capacity = 10
)。我们有一组键需要插入到哈希表中:{10, 20, 30, 40, 50}:
哈希函数为
hash = key % table_size
:
10 % 7 = 3
,键10
存储在桶3
20 % 7 = 6
,键20
存储在桶6
30 % 7 = 2
,键30
存储在桶2
40 % 7 = 5
,键40
存储在桶5
50 % 7 = 1
,键50
存储在桶1
哈希函数为
hash = key % capacity
:
10 % 10 = 0
,键10
存储在桶0
20 % 10 = 0
,键20
存储在桶0
(冲突)30 % 10 = 0
,键30
存储在桶0
(冲突)40 % 10 = 0
,键40
存储在桶0
(冲突)50 % 10 = 0
,键50
存储在桶0
(冲突)可以看出,使用
capacity
作为模数会导致所有键都存储在桶0
,导致严重的冲突和性能下降。而使用table_size
作为模数可以确保键均匀地分布在不同的桶中,从而提高查找效率,此外,如果table_size
满了也会出现陷入死循环问题,为此引入了闭散列的另一个重要概念:载荷因子α(一定要分清底层容器大小capacity、哈希表的大小hash_table.size、哈希表中存放的有效数据个数n,一般情况下哈希表的大小和底层容器大小的设定是分开的,但也可以让它们两个相等)
载荷因子α
基本概念:载荷因子α表示哈希表中已存储元素数量与哈希表容量的比值,即哈希表的填充程度,载荷因子是开放定址法中十分重要的因素
计算公式:α = 填入表中的元素个数 / 哈希表的长度
α 越高,冲突率越高,插入等操作的效率越低,空间利用率越高
α 越低,冲突率越低,插入等操作的效率越高,空间利用率越低
α 的取值应该严格控制在0.7~0.8之间
扩容
中心思想:扩容时需要将原表中参与映射的键在新表中重新映射一次
vector的swap方法:vector::swap - C++ 参考 (cplusplus.com)
问题:_table.swap(newHT._table)时都做了什么?
- 交换容量:当前可以存储的元素数量被交换
- 交换大小:当前已存储的元素数量被交换
- 结论:交换了双方的数据结构,
_table
现在持有新表的数据结构,而newHT
持有旧表的数据结构,函数结束后newHT
会被自动销毁,从而实现了高效的扩容操作交换不会改变新旧表的地址:
cout <<"交换前旧表的地址为:" << & _table << endl; cout <<"交换前新表的地址为:" << &newTable << endl; _table.swap(newTable);//交换 cout << "交换后旧表的地址为:" << &_table << endl; cout << "交换后新表的地址为:" << &newTable << endl;
查找
HashDate<K, V>* Find(const K& key)
{
//计算要查早的键的映射位置
size_t hashi = key % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数
//线性探测
while (_table[hashi]._state != EMPTY)//到映射位置上去查找,如果该位置上不为空,
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
//错误的:if (_table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
{
return &_table[hashi];//返回该位置的地址
}
++hashi;
hashi %= _table.size();//取模的方式防止hashi越界
}
return nullptr;//找不到就返回空
}
删除
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;//这里只是将删除键所处位置的状态标识符改为了DELETE
--_n;
return true;
}
}
字符串哈希算法
基本概念:key不支持强转为整型后取模,需要自己提供转换为整型的仿函数,仿函数中的()重载一般为将字符串映射成固定长度的哈希值的算法(stoi只能处理"1112"类型的字符串,但是除了不了"2115few"甚至是由汉字组成的字符串"样非分"),常见的字符串哈希算法如下:
1、DJB2
unsigned long djb2(const std::string& str) {
unsigned long hash = 5381;
for (char c : str) {
hash = ((hash << 5) + hash) + c; // hash * 33 + c
}
return hash;
}
2、SDBM
unsigned long sdbm(const std::string& str) {
unsigned long hash = 0;
for (char c : str) {
hash = c + (hash << 6) + (hash << 16) - hash;
}
return hash;
}
3、BKDR Hash
unsigned long bkdrHash(const std::string& str) {
unsigned long seed = 131; // 31, 131, 1313, 13131, 131313, etc.
unsigned long hash = 0;
for (char c : str) {
hash = hash * seed + c;
}
return hash;
}
4、ELF Hash
unsigned long elfHash(const std::string& str) {
unsigned long hash = 0;
unsigned long x = 0;
for (char c : str) {
hash = (hash << 4) + c;
if ((x = hash & 0xF0000000L) != 0) {
hash ^= (x >> 24);
}
hash &= ~x;
}
return hash;
}
5、FNV-1a Hash
unsigned long fnv1aHash(const std::string& str) {
const unsigned long fnv_prime = 0x100000001b3;
const unsigned long offset_basis = 0xcbf29ce484222325;
unsigned long hash = offset_basis;
for (char c : str) {
hash ^= c;
hash *= fnv_prime;
}
return hash;
}
6、MD5
#include <openssl/md5.h>
std::string md5Hash(const std::string& str) {
unsigned char digest[MD5_DIGEST_LENGTH];
MD5((unsigned char*)str.c_str(), str.size(), (unsigned char*)&digest);
char mdString[33];
for(int i = 0; i < 16; i++)
sprintf(&mdString[i*2], "%02x", (unsigned int)digest[i]);
return std::string(mdString);
}
7. SHA-1
#include <openssl/sha.h>
std::string sha1Hash(const std::string& str) {
unsigned char hash[SHA_DIGEST_LENGTH];
SHA1((unsigned char*)str.c_str(), str.size(), hash);
char buf[SHA_DIGEST_LENGTH*2];
for (int i = 0; i < SHA_DIGEST_LENGTH; i++)
sprintf((char*)&(buf[i*2]), "%02x", hash[i]);
return std::string(buf);
}
参考网址:各种字符串Hash函数 - clq - 博客园 (cnblogs.com)
BKDR Hash的综合评分是最高的,ELF Hash的综合评分是最低的
为了泛型编程一般需要两种仿函数:处理可以强转为int的仿函数 和 处理不能强转为int的仿函数
//可以直接强转的类型,将它们强转为size_t类型,因为即使是整数也可能为负
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//这里采用的是BKDR Hash字符串哈希算法写的仿函数
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
最终代码
#pragma once
#include <iostream>
#include <vector>
#include <utility>
using namespace std;
//将该仿函数放在了命名空间外,使得开散列和闭散列都可以使用该仿函数
//可以直接强转的类型,将它们强转为size_t类型,因为即使是整数也可能为负
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//对HashFunc采用特化处理,如果是int等就会用上面的仿函数,如果是string就会用该特化版本的HashFunc
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
namespace open_address
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
State _state = EMPTY;//默认情况下为EMPTY
};
//处理字符串类型的强制类型转换
struct StringHashFunc
{
size_t operator()(const string& key)
{
size_t hashi = 0;
for (auto ch : key)
{
hashi = hashi * 131 + ch;
}
return hashi;
}
};
template <class K, class V, class Hash = HashFunc<K>>//强转方式的缺省值是强转为整型
class HashTable
{
public:
//实例化哈希表时,将哈希表的大小和底层数组的容量均调整为10
HashTable()
{
_table.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
//防止冗余
if (Find(kv.first))
return false;
//当负载因子大于等于0.7时进行扩容,以空间换时间
if (_n * 10 / _table.size() >= 0.7)//采用/取整得不到小数,所以直接令_n * 10
{
//新建一个哈希表
HashTable<K,V, Hash> newHT;
//确定新哈希表的大小
newHT._table.resize(_table.size() * 2);
//将旧表中的键重新映射到新表中
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)//原表中某下标的状态为存在时,将原表某下标中存放的键值对插入新表中
{
newHT.Insert(_table[i]._kv);//直接复用Insert操作
}
}
_table.swap(newHT._table);
}
Hash sh;//处理强转为整型的仿函数
size_t hashi = sh(kv.first) % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数
//线性探测
while (_table[hashi]._state == EXIST)//探查到目标映射位置存在数字时就进行探测
{
++hashi;
hashi %= _table.size();//取模的方式防止hashi越界
}
//确定好位置后进行插入
_table[hashi]._kv = kv;//在合适的映射位置插入
_table[hashi]._state = EXIST;//然后后将该位置的状态标识为EXIST
++_n;//哈希表中有效数据元素+1
return true;
}
HashDate<K, V>* Find(const K& key)
{
Hash hs;//处理强转为整型的仿函数
//计算要查早的键的映射位置
size_t hashi = hs(key) % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数
//线性探测
while (_table[hashi]._state != EMPTY)//到映射位置上去查找,如果该位置上不为空,
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
//错误的:if (_table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
{
return &_table[hashi];//返回该位置的地址
}
++hashi;
hashi %= _table.size();//取模的方式防止hashi越界
}
return nullptr;//找不到就返回空
}
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;//删除时只是将该位置的标识符置为了DELETE该位置上还存放着一个键,只不过该键可以被随时替换
--_n;
return true;
}
}
private:
vector<HashDate<K, V>> _table;//哈希表的大小和底层数组的容量都是通过 _table来管理的,正常情况下哈希表的大小_table.size和底层数组的容量是要分开的
size_t _n;//哈希表中的有效数据个数
};
void TeshHT1()
{
int a[] = { 10001,11,55,24,19,12,31 };
HashTable<int, int> ht;//使用缺省的强转方式
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
cout << ht.Find(55) << endl;
cout << ht.Find(31) << endl;
ht.Erase(55);
cout << ht.Find(55) << endl;
cout << ht.Find(31) << endl;
}
void TeshHT2()
{
使用StringHashFunc
//HashTable<string, int, StringHashFunc> ht;
//ht.Insert(make_pair("sort", 1));
//ht.Insert(make_pair("left", 1));
//ht.Insert(make_pair("insert", 1));
//查看是否可以用仿函数StringHashFunc将string转换为整型(与上面的内容无关)
StringHashFunc()实例化一个匿名的仿函数类对象,然后("bacd")向该仿函数传参
//cout << StringHashFunc()("bacd") << endl;
//cout << StringHashFunc()("abcd") << endl;
//cout << StringHashFunc()("aadd") << endl;
//使用特化版本
HashTable<string, int> ht;
ht.Insert(make_pair("sort", 1));
ht.Insert(make_pair("left", 1));
ht.Insert(make_pair("insert", 1));
//查看是否可以用仿函数的特化版本HashFunc<string>将string转换为整型
cout << HashFunc<string>()("bacd") << endl;
cout << HashFunc<string>()("abcd") << endl;
cout << HashFunc<string>()("aadd") << endl;
}
}
开散列
基本概念:将所有映射到相同哈希值的元素存储在一个链表或其他结构中,冲突发生时就将冲突位置的链表结点++,这样每个哈希表的桶实际上是一个指向链表头部的指针
优点:冲突处理简单,扩展容易,删除操作高效
缺点:需要额外的存储空间来存储链表节点,可能导致内存碎片
- 可以是尾插但是尾插还要去寻找尾,而且发生冲突存入同一位置的结点也是有顺序的双向循环差入也行,但是没必要
插入
//采用头插的方式
bool Insert(const pair<K, V>& kv)
{
//防止冗余
if(Find(kv.first))
return false;
//如果还是重新建立映射关系,如果结点个数过多,在新表创建结点和移动后删除旧表的结点需要很多的资源
//因为是队列是一个个挂在数组上的,所以当存放的有效元素个数 == 数组大小时才需要扩容
//if (_n == _table.size())
//{
// //新建一个哈希表
// HashTable<K, V> newHT;
// //确定新哈希表的大小
// newHT._table.resize(_table.size() * 2);
// //将旧表中的键重新映射到新表中
// for (size_t i = 0; i < _table.size(); i++)//每一次的for循环都是将某一个数组下标中的结点都链接到新表中
// {
// Node* cur = _table[i];
// while (cur)
// {
// newHT.Insert(_table[i]._kv);//直接复用Insert操作
// cur = cur->_next;
// }
// }
// _table.swap(newHT._table);//使用交换新旧表名中代表的地址信息
//}
//应该在确认了映射关系后,直接将旧表中的所有结点移动到新表中,这样移动后旧表中只用析构一个vector,且不用创建新的结点
if (_n == _table.size())
{
vector<Node*> newTable(_table.size() * 2, nullptr);//设定新表大小及初始化各个位置
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];//cur指向旧表的下标为i位置的结点(该结点是一条链表的头结点)
//遍历一条链表
while (cur)
{
Node* next = cur->_next;//next存放cur的下一个结点的位置
//确定将旧表cur指向的结点要头插新表的哪个位置
size_t hashi = cur->_kv.first % newTable.size();
cur->_next = newTable[hashi];//头插(忘了回去看外面的注释,这点就是有点会让人发懵)
newTable[hashi] = cur;
cur = next;//cur指向自己的下一个结点
}
_table[i] = nullptr;//置空
}
/*cout <<"交换前旧表的地址为:" << & _table << endl;
cout <<"交换前新表的地址为:" << &newTable << endl;*/
_table.swap(newTable);//交换
/* cout << "交换后旧表的地址为:" << &_table << endl;
cout << "交换后新表的地址为:" << &newTable << endl;*/
}
size_t hashi = kv.first % _table.size();
Node* newnode = new Node(kv);//匿名结点对象
//头插(数组中某个位置没有结点插入时_table[hashi] == nullptr)
newnode->_next = _table[hashi];//_新结点的next结点指向当前链表的头指针指向的结点
_table[hashi] = newnode;//令链表头指针指向新结点
++_n;
return true;
//哈希桶的头插类似于:
//Node* head = nullptr; // 初始化一个空的链表,现在数组中每个位置都是一个空指针
//void insertAtHead(int value) {
// Node* newNode = new Node(value); // 创建一个新节点
// newNode->next = head; // 将新节点的后继节点指向当前的头节点
// head = newNode; // 更新头节点指针,使其指向新节点
//}
}
假设我们有以下插入过程:
初始状态:
_tables
被初始化为大小为 10 的向量,每个元素都是nullptr
。- 这意味着
_tables[0]
到_tables[9]
都是nullptr
。第一次插入:
- 插入键值对
(key1, value1)
。- 计算哈希值
hashi = key1 % 10
,假设hashi
为 2。- 创建新节点
newnode
,其_kv
为(key1, value1)
,且_next
初始化为nullptr
。newnode->_next = _tables[hashi];
此时_tables[2]
是nullptr
,因此newnode->_next
也指向nullptr
。- 将
newnode
赋值给_tables[hashi]
,即_tables[2] = newnode
。第二次插入:
- 插入键值对
(key2, value2)
。- 计算哈希值
hashi = key2 % 10
,假设hashi
仍然为 2。- 创建新节点
newnode
,其_kv
为(key2, value2)
,且_next
初始化为nullptr
。newnode->_next = _tables[hashi];
此时_tables[2]
已经指向第一个节点(即存储(key1, value1)
的节点),所以newnode->_next
指向这个第一个节点。- 将
newnode
赋值给_tables[hashi]
,即_tables[2] = newnode
。通过这种方式,链表中的节点按插入顺序逆序排列,因为每次新节点都插入到链表的头部。
查看扩容过程的视频:20240526_213552-CSDN直播
结论:交换过程中结点的地址不发生改变,存放结点存放的位置一直在改变,且重新插入后一条链表中结点的先后顺序发生改变,原本的头变成了尾;交换结束后新旧表的地址不变使用&新表对象得到的依然是原地址,而不是旧表的地址,实际交换的是两个表的各个数据结构
查找
Node* Find(const K& key)
{
size_t hashi = kv.first % _tables.size();
Node* cur = _tables[hashi];//直接去映射位置上查找
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
删除
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];//直接去映射位置上查找
while (cur)
{
if (cur->_kv.first == key)
{
delete cur;
}
else
{
prev = cur->_next;
cur = prev;
}
cur = cur->_next;
}
return nullptr;
}
最终代码
namespace hash_bucket
{
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 T>
//struct HashNode
//{
// T _data;
// HashNode<T>* _next;
// HashNode(const T& data)
// :_data(data)
// , _next(nullptr)
// {}
//};
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_table.resize(10, nullptr);//初始化时,数组中有十个位置,每个位置都存放的是空指针
_n = 0;
}
//哈希表的底层容器vector函数结束后自动析构,但是vector中各个位置存放的都是自定义类型Node,自定义类型的析构时会调用它们的析构函数,所以还要重新写一个析构函数
~HashTable()
{
//遍历删除
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;//删除一个位置上的一整条链表就将该位置存放的指针置空
}
}
//采用头插的方式
bool Insert(const pair<K, V>& kv)
{
//防止冗余
if (Find(kv.first))
return false;
Hash hs;
//如果还是重新建立映射关系,如果结点个数过多,在新表创建结点和移动后删除旧表的结点需要很多的资源
//因为是队列是一个个挂在数组上的,所以当存放的有效元素个数 == 数组大小时才需要扩容
//if (_n == _table.size())
//{
// //新建一个哈希表
// HashTable<K, V> newHT;
// //确定新哈希表的大小
// newHT._table.resize(_table.size() * 2);
// //将旧表中的键重新映射到新表中
// for (size_t i = 0; i < _table.size(); i++)//每一次的for循环都是将某一个数组下标中的结点都链接到新表中
// {
// Node* cur = _table[i];
// while (cur)
// {
// newHT.Insert(_table[i]._kv);//直接复用Insert操作
// cur = cur->_next;
// }
// }
// _table.swap(newHT._table);//使用交换新旧表名中代表的地址信息
//}
//应该在确认了映射关系后,直接将旧表中的所有结点移动到新表中,这样移动后旧表中只用析构一个vector,且不用创建新的结点
if (_n == _table.size())
{
vector<Node*> newTable(_table.size() * 2, nullptr);//设定新表大小及初始化各个位置
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];//cur指向旧表的下标为i位置的结点(该结点是一条链表的头结点)
//遍历一条链表
while (cur)
{
Node* next = cur->_next;//next存放cur的下一个结点的位置
//确定将旧表cur指向的结点要头插新表的哪个位置
size_t hashi = hs(cur->_kv.first) % newTable.size();
cur->_next = newTable[hashi];//头插(忘了回去看外面的注释,这点就是有点会让人发懵)
newTable[hashi] = cur;
cur = next;//cur指向自己的下一个结点
}
_table[i] = nullptr;//置空
}
/*cout <<"交换前旧表的地址为:" << & _table << endl;
cout <<"交换前新表的地址为:" << &newTable << endl;*/
_table.swap(newTable);//交换
/* cout << "交换后旧表的地址为:" << &_table << endl;
cout << "交换后新表的地址为:" << &newTable << endl;*/
}
size_t hashi = hs(kv.first) % _table.size();
Node* newnode = new Node(kv);//匿名结点对象
//头插(数组中某个位置没有结点插入时_table[hashi] == nullptr)
newnode->_next = _table[hashi];//_新结点的next结点指向当前链表的头指针指向的结点
_table[hashi] = newnode;//令链表头指针指向新结点
++_n;
return true;
//哈希桶的头插类似于:
//Node* head = nullptr; // 初始化一个空的链表,现在数组中每个位置都是一个空指针
//void insertAtHead(int value) {
// Node* newNode = new Node(value); // 创建一个新节点
// newNode->next = head; // 将新节点的后继节点指向当前的头节点
// head = newNode; // 更新头节点指针,使其指向新节点
//}
}
//链表的遍历
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(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)
{
Hash hs;
size_t hashi = hs(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];//直接去映射位置上查找
while (cur)
{
if (cur->_kv.first == key)
{
delete cur;
}
else
{
prev = cur->_next;
cur = prev;
}
cur = cur->_next;
}
return nullptr;
}
private:
vector<Node*> _table;//数组中的每个位置存放的都是结点的指针
size_t _n;
};
void HashTest1()
{
int a[] = { 10001,11,55,24,19,12,31,4,34,44 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
//ht.Insert(make_pair(32, 32));
//ht.Insert(make_pair(32, 32));
}
void HashTest2()
{
HashTable<string, int> ht;
ht.Insert(make_pair("sort", 1));
ht.Insert(make_pair("left", 1));
ht.Insert(make_pair("insert", 1));
}
}
完整代码
#pragma once
#include <iostream>
#include <vector>
#include <utility>
using namespace std;
//将该仿函数放在了命名空间外,使得开散列和闭散列都可以使用该仿函数
//可以直接强转的类型,将它们强转为size_t类型,因为即使是整数也可能为负
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//对HashFunc采用特化处理,如果是int等就会用上面的仿函数,如果是string就会用该特化版本的HashFunc
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
namespace open_address
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
State _state = EMPTY;//默认情况下为EMPTY
};
//处理字符串类型的强制类型转换
struct StringHashFunc
{
size_t operator()(const string& key)
{
size_t hashi = 0;
for (auto ch : key)
{
hashi = hashi * 131 + ch;
}
return hashi;
}
};
template <class K, class V, class Hash = HashFunc<K>>//强转方式的缺省值是强转为整型
class HashTable
{
public:
//实例化哈希表时,将哈希表的大小和底层数组的容量均调整为10
HashTable()
{
_table.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
//防止冗余
if (Find(kv.first))
return false;
//当负载因子大于等于0.7时进行扩容,以空间换时间
if (_n * 10 / _table.size() >= 0.7)//采用/取整得不到小数,所以直接令_n * 10
{
//新建一个哈希表
HashTable<K,V, Hash> newHT;
//确定新哈希表的大小
newHT._table.resize(_table.size() * 2);
//将旧表中的键重新映射到新表中
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)//原表中某下标的状态为存在时,将原表某下标中存放的键值对插入新表中
{
newHT.Insert(_table[i]._kv);//直接复用Insert操作
}
}
_table.swap(newHT._table);
}
Hash sh;//处理强转为整型的仿函数
size_t hashi = sh(kv.first) % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数
//线性探测
while (_table[hashi]._state == EXIST)//探查到目标映射位置存在数字时就进行探测
{
++hashi;
hashi %= _table.size();//取模的方式防止hashi越界
}
//确定好位置后进行插入
_table[hashi]._kv = kv;//在合适的映射位置插入
_table[hashi]._state = EXIST;//然后后将该位置的状态标识为EXIST
++_n;//哈希表中有效数据元素+1
return true;
}
HashDate<K, V>* Find(const K& key)
{
Hash hs;//处理强转为整型的仿函数
//计算要查早的键的映射位置
size_t hashi = hs(key) % _table.size();//获取映射位置,应该键值的key % 数组中有效元素的个数
//线性探测
while (_table[hashi]._state != EMPTY)//到映射位置上去查找,如果该位置上不为空,
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
//错误的:if (_table[hashi]._kv.first == key)//不为空还有两种删除和存在两种情况,需要满足位置的状态为存在时才可以
{
return &_table[hashi];//返回该位置的地址
}
++hashi;
hashi %= _table.size();//取模的方式防止hashi越界
}
return nullptr;//找不到就返回空
}
bool Erase(const K& key)
{
HashDate<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;//删除时只是将该位置的标识符置为了DELETE该位置上还存放着一个键,只不过该键可以被随时替换
--_n;
return true;
}
}
private:
vector<HashDate<K, V>> _table;//哈希表的大小和底层数组的容量都是通过 _table来管理的,正常情况下哈希表的大小_table.size和底层数组的容量是要分开的
size_t _n;//哈希表中的有效数据个数
};
void TeshHT1()
{
int a[] = { 10001,11,55,24,19,12,31 };
HashTable<int, int> ht;//使用缺省的强转方式
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
cout << ht.Find(55) << endl;
cout << ht.Find(31) << endl;
ht.Erase(55);
cout << ht.Find(55) << endl;
cout << ht.Find(31) << endl;
}
void TeshHT2()
{
使用StringHashFunc
//HashTable<string, int, StringHashFunc> ht;
//ht.Insert(make_pair("sort", 1));
//ht.Insert(make_pair("left", 1));
//ht.Insert(make_pair("insert", 1));
//查看是否可以用仿函数StringHashFunc将string转换为整型(与上面的内容无关)
StringHashFunc()实例化一个匿名的仿函数类对象,然后("bacd")向该仿函数传参
//cout << StringHashFunc()("bacd") << endl;
//cout << StringHashFunc()("abcd") << endl;
//cout << StringHashFunc()("aadd") << endl;
//使用特化版本
HashTable<string, int> ht;
ht.Insert(make_pair("sort", 1));
ht.Insert(make_pair("left", 1));
ht.Insert(make_pair("insert", 1));
//查看是否可以用仿函数的特化版本HashFunc<string>将string转换为整型
cout << HashFunc<string>()("bacd") << endl;
cout << HashFunc<string>()("abcd") << endl;
cout << HashFunc<string>()("aadd") << endl;
}
}
namespace hash_bucket
{
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 T>
//struct HashNode
//{
// T _data;
// HashNode<T>* _next;
// HashNode(const T& data)
// :_data(data)
// , _next(nullptr)
// {}
//};
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_table.resize(10, nullptr);//初始化时,数组中有十个位置,每个位置都存放的是空指针
_n = 0;
}
//哈希表的底层容器vector函数结束后自动析构,但是vector中各个位置存放的都是自定义类型Node,自定义类型的析构时会调用它们的析构函数,所以还要重新写一个析构函数
~HashTable()
{
//遍历删除
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;//删除一个位置上的一整条链表就将该位置存放的指针置空
}
}
//采用头插的方式
bool Insert(const pair<K, V>& kv)
{
//防止冗余
if (Find(kv.first))
return false;
Hash hs;
//如果还是重新建立映射关系,如果结点个数过多,在新表创建结点和移动后删除旧表的结点需要很多的资源
//因为是队列是一个个挂在数组上的,所以当存放的有效元素个数 == 数组大小时才需要扩容
//if (_n == _table.size())
//{
// //新建一个哈希表
// HashTable<K, V> newHT;
// //确定新哈希表的大小
// newHT._table.resize(_table.size() * 2);
// //将旧表中的键重新映射到新表中
// for (size_t i = 0; i < _table.size(); i++)//每一次的for循环都是将某一个数组下标中的结点都链接到新表中
// {
// Node* cur = _table[i];
// while (cur)
// {
// newHT.Insert(_table[i]._kv);//直接复用Insert操作
// cur = cur->_next;
// }
// }
// _table.swap(newHT._table);//使用交换新旧表名中代表的地址信息
//}
//应该在确认了映射关系后,直接将旧表中的所有结点移动到新表中,这样移动后旧表中只用析构一个vector,且不用创建新的结点
if (_n == _table.size())
{
vector<Node*> newTable(_table.size() * 2, nullptr);//设定新表大小及初始化各个位置
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];//cur指向旧表的下标为i位置的结点(该结点是一条链表的头结点)
//遍历一条链表
while (cur)
{
Node* next = cur->_next;//next存放cur的下一个结点的位置
//确定将旧表cur指向的结点要头插新表的哪个位置
size_t hashi = hs(cur->_kv.first) % newTable.size();
cur->_next = newTable[hashi];//头插(忘了回去看外面的注释,这点就是有点会让人发懵)
newTable[hashi] = cur;
cur = next;//cur指向自己的下一个结点
}
_table[i] = nullptr;//置空
}
/*cout <<"交换前旧表的地址为:" << & _table << endl;
cout <<"交换前新表的地址为:" << &newTable << endl;*/
_table.swap(newTable);//交换
/* cout << "交换后旧表的地址为:" << &_table << endl;
cout << "交换后新表的地址为:" << &newTable << endl;*/
}
size_t hashi = hs(kv.first) % _table.size();
Node* newnode = new Node(kv);//匿名结点对象
//头插(数组中某个位置没有结点插入时_table[hashi] == nullptr)
newnode->_next = _table[hashi];//_新结点的next结点指向当前链表的头指针指向的结点
_table[hashi] = newnode;//令链表头指针指向新结点
++_n;
return true;
//哈希桶的头插类似于:
//Node* head = nullptr; // 初始化一个空的链表,现在数组中每个位置都是一个空指针
//void insertAtHead(int value) {
// Node* newNode = new Node(value); // 创建一个新节点
// newNode->next = head; // 将新节点的后继节点指向当前的头节点
// head = newNode; // 更新头节点指针,使其指向新节点
//}
}
//链表的遍历
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(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)
{
Hash hs;
size_t hashi = hs(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];//直接去映射位置上查找
while (cur)
{
if (cur->_kv.first == key)
{
delete cur;
}
else
{
prev = cur->_next;
cur = prev;
}
cur = cur->_next;
}
return nullptr;
}
private:
vector<Node*> _table;//数组中的每个位置存放的都是结点的指针
size_t _n;
};
void HashTest1()
{
int a[] = { 10001,11,55,24,19,12,31,4,34,44 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
//ht.Insert(make_pair(32, 32));
//ht.Insert(make_pair(32, 32));
}
void HashTest2()
{
HashTable<string, int> ht;
ht.Insert(make_pair("sort", 1));
ht.Insert(make_pair("left", 1));
ht.Insert(make_pair("insert", 1));
}
}
~over~