前言
之前我们介绍了,闭散列 的 开放地址法实现的 哈希表:C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客
但是 闭散列 的 开放地址法 虽然是哈希表实现的一种,但是这种方式实现的哈希表,有一个很大的弊端,就是可能会引起一大片的哈希冲突,因为当发生哈希冲突的时候,他是按照线性探测的方式去找新的位置的,那么在冲突的位置之后,可能有一大片都是有数据存在的,那么每一次寻找都会发生哈希冲突。
而且使用 开放地址法 实现的哈希表,在查找数据的时候,计算出的起始位置不是要查找的数据的时候,也是按照上述哈希冲突的方式去寻找数据的,那么这样的效率就更低了。
更不用想,当 这种哈希表需要扩容的时候,还是类似想 realloc 函数一样,先开一个更大空间,然后再把之前的值都赋值进去之后,再计算新插入的数的初始位置,然后按照上述的方式进行插入。
这种在表当中相邻位置元素比较多的情况,使用开放地址法就会发生拥堵的情况。把本来没有发生哈希冲突的值,都加到 冲突当中了。
二次探测哈希表介绍
在上述线性探测的基础之上,引出了二次探测,如果说线性探测是按照 每次 起始位置 + i (i >= 0,i 往后迭代) 这样的方式实现 一次往后遍历,那么 二次探测就是 起始位置 + i^2 (i>=0) 这样的方式进行探测,遮掩的话就缓解了一些 拥堵情况。但是,这并不是最优解,他的本质上还是 一种线性探测,只不过把 冲突的值 分开了一些而已。
两者的本质都是在块共用的空间当中,进行存储值,这样不管你怎么进行探测,当数据量比较密集之后,很难做到 不发生 一大片的拥堵情况。
在发生哈希冲突之后,只是无脑的往后寻找空位置插入,那么这个位置本来应该插入的元素就被占了,归根结底还是占了别人的空间,而且这种情况随着冲突的变多之后,会发生得更多。
开放地址法的缺点:冲突会互相影响。
所以,此时就有人想了,开放地址法都是在一个空间当中寻找可以插入的地址,那么能不能走出这块空间呢?不要让自己冲突之后,去占用别人的位置,来诱发哈希冲突。
开散列的拉链法(哈希桶)
如果知道计数排序的小伙伴应该很好理解,这里的哈希桶和计数排序当中有点类似。
如果他的规则是 hash = key % mol ,这个哈希函数的话。那么他会先开一个 mol 大小的指针数组,结点也不会像之前一样用一个 数组来存储,而且是把一个元素用一个结点存储,把 hash 值相同的元素用类似链表的方式链接在 对应 指针数组下标上元素指针来指向,如下图所示:
大体框架
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
hashNode* next; // 每个结点都有一个指针指向这个结点的下一个结点
};
template<class K, class V>
class Hash
{
typedef HashNode<K, V> Node;
public:
Hash()
{
_table.resize(10, nullptr);
}
private:
vector<Node*> _table; // 指针数组
int _n = 0; // 用于记录哈希桶当中的有效数据个数
};
指针数组我们使用 vector 的容器来实现,这样就方便我们对这个数组进行管理。每个结点要存储的是 本节点的 key 和 value 以及 指向下一个 结点的指针。
insert()插入函数
同样要先按照key值大小计算出 这个结点的 hash 值,然后再 对应数组下标位置进行链表的 头插或者尾查,两种插入都行。因为,后头插的元素可能会被先找到,但是我们不知道哪一个元素被查找的次数多,所以用哪一种都行。
核心插入逻辑:
// 计算hash值
size_t hash = kv.first % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hash];
_table[hash] = newnode;
++_n;
return true;
当然,因为哈希桶也是一种 开放地址法,那么只要是开放地址法都是需要扩容的,在哈希桶当中就是要扩容 指针数组。因为当数据很多的时候,就会导致 每一个 指针数组元素下面的链表会很长,那么对于查找时候的效率就会降低。
我们应当控制 负载因子 尽量到 1 ,也就是让每个桶当中都尽量存储一个 数据,这样的话,查找的效率就保持在一个很高的 标准。
而且扩容还有一个好处,我们发现一种极端情况,就会有大量的数据集中在一个桶当中,这种情况虽然概率小,但是还是有可能发生的,那么当我扩容的时候,每一个桶当中的数据都会得到分散,这样可以缓解一个桶的压力。
库容的思路不能再使用 闭散列开发地址法当中,在把 旧表当中的数据插入到 新表当中,直接复用 insert()函数,这样不好。
以为按照我们上述写 insert 的逻辑,如复用的话,每一次复用,都需要重新使用 new Node(kv) 开辟一个新结点出来,而且还有把旧表当中的该结点给释放了,但是,在旧表当中的结点空间是完全可以复用的。
所以,我们在依次变量的时候就 直接依次遍历链表和其中的结点,把每个结点直接挪到 新表的 指针数组后面,按照新表当中的 插入规则插入即可。
扩容代码:
// 负载因子 到 1 就扩容(每一个桶当中都有数据)
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newsize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next; // 保存链表的下一个结点
// 头插到新表当中
size_t hashi = kv.first % newTable.size();
cur->_next = newTable[hashi];
newTable[hashi] = cur;
// 向链表后迭代
cur = next;
}
}
// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
_table.swap(newTable);
}
上述使用现代写法,直接交换指针,出了insert()作用域,让编译器把旧表空间释放掉,但是释放的 vector 只是释放 vector 容器的空间,并不会释放 Node* 结点空间。vector 的销毁是调用 delete[] ,而 delete[] 分为两种,先要调用容器中与元素的析构函数,遍历析构各个元素,但是这个容器当中的 每一个元素的类型是一个指针,是一个内置类型,内置类型没有析构函数。所以不会调用析构函数,直接进行第二部,直接对 vector 空间释放。
只有 类似 vector<list> _table 这种结构,那么vector当中的每一个元素存储的都是一个 list 自定义类型,那么都是有析构函数的,那么我们上述 扩容逻辑使用的是 这种结构的话,就不能复用 结点了, delete[] vector 就会先遍历调用其中每个元素的析构函数来进行析构。
哈希表当中的扩容所带来的消耗是无法避免的,但是其实哈希表扩容次数并不多,按照我们上述书写的逻辑,一次扩容两倍的话,2^20 大概 100w,也就是说插入100w 个数据 只用扩容 20次,已经非常的少了。
但是,虽然哈希表的扩容优化不好写,但是还是有人提出:
比如上述,他是用两个表,一个是当前的表,一个是扩容之后的表,当 我们想要 没有桶的位置(比如指针数组下标1位置)插入一个数据的时候,就直接把这个桶移到 扩容的 指针数组当中的对应位置。他用这样的方式,相当于是把 一次扩容的消耗,分散到了每一次插入数据当中,那么在用户体验来说,就大大增强了。
但是这种方法实现起来非常麻烦,其中还有很多细节,我们就是用之前实现的扩容逻辑就已经很好用了。
写一个 print()函数方便查看 哈希桶当中的数据:
void Print()
{
for (size_t i = 0; i < _table.size(); i++)
{
printf("[%d]->", i);
Node* cur = _table[i];
while (cur)
{
cout << cur->_kv.first << "->";
cur = cur->_next;
}
printf("NULL\n");
}
}
insert()代码:
bool insert(const pair<K , V>& kv)
{
if (find(kv.first))
{
return false;
}
// 负载因子 到 1 就扩容(每一个桶当中都有数据)
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newsize, nullptr);
for (int 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.swap(newTable);
}
// 计算hash值
size_t hashi = kv.first % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
find()函数
哈希桶的find()查找函数,先利用哈希函数计算链表起始位置,然后就是一个链表的遍历查找。
Node* find(const K& key)
{
size_t hash = key % _table.size();
Node* cur = _table[hash];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
erase()函数
删除结点函数,在寻找结点这一块,我们不复用 find()函数,因为find()函数返回的是该结点指针,但是我们删除链表当中的结点需要修改链接关系,需要找到该结点的上一个结点,或者是指针数组当中元素,所以我们可以字节写查找逻辑:
bool erase(const K& key)
{
size_t hashi = key % _table.size();
Node* cur = _table[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (!prev)
{
_table[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
析构函数
因为,指针数组当中存储的是一个 Node* 是一个指针,也就是一个内置类型,在释放的时候编译器对内置类型不处理,但是对自定义类型会去调用这个自定义类型的析构函数。
所以,因为 _table 的类型是 vector<Node*> ,vector 当中的数组空间是属于 vector 自定义类型的空间,那么回去调用vector 的析构函数,但是对于 Node* 就不会了,所以我们要写析构函数把 哈希桶,也就是每一个 指针数组元素指向的链表的空间给释放了。
~hash()
{
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;
}
}
把 哈希当中的key 值转换成适用多多种类型的仿函数写法
当然,哈希表当中不能可能只存储 int 这一种类型,如果是 string类,也是有自己的比较方式的,所以,我们可以像 在优先级队列当中,实现多种类型的适配仿函数,利用模版参数的控制来控制key 值的取出方法:
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
// 不管是什么类型的,都转换成 size_t
// 不管是 负数还是正数,都转换为 正数
return (size_t)key;
}
};
// string 的特化
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string& str)
{
// 字符串转 int 的算法
int hash = 0;
for (auto& ch : str)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
template<class K, class V, class HashFunc = DefaultHashFunc<K>>
class hash
{
typedef HashNode<K, V> Node;
public:
····································
····································
····································
bool insert(const pair<K , V>& kv)
{
HashFunc hf;
if (find(kv.first))
{
return false;
}
// 负载因子 到 1 就扩容(每一个桶当中都有数据)
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newsize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next; // 保存链表的下一个结点
// 头插到新表当中
size_t hashi = hf(cur->_kv.first) % newTable.size();
cur->_next = newTable[hashi];
newTable[hashi] = cur;
// 向链表后迭代
cur = next;
}
}
// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
_table.swap(newTable);
}
// 计算hash值
size_t hashi = hf(kv.first) % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
把各个函数需要取出key 值的方法都用仿函数封装。
具体的实现逻辑,可以看看下面博客当中对 闭散列开放式地址法哈希表当中对 仿函数的书写:
C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客
哈希桶的完整代码:
namespace hash_bucket
{
template<class T>
struct HashNode
{
T _data;
HashNode* _next; // 每个结点都有一个指针指向这个结点的下一个结点
HashNode(const T& data)
:_data(data)
,_next(nullptr)
{}
};
template<class K, class T,class KeyOfT , class HashFunc = DefaultHashFunc<K>>
class hash
{
typedef HashNode<T> Node;
public:
hash()
{
_table.resize(10, nullptr);
}
bool Insert(const T& data)
{
HashFunc hf;
KeyOfT kot;
if (find(data))
{
return false;
}
// 负载因子 到 1 就扩容(每一个桶当中都有数据)
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newsize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next; // 保存链表的下一个结点
// 头插到新表当中
size_t hashi = hf(kot(cur->_data)) % newTable.size();
cur->_next = newTable[hashi];
newTable[hashi] = cur;
// 向链表后迭代
cur = next;
}
}
// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
_table.swap(newTable);
}
// 计算hash值
size_t hashi = hf(data) % _table.size();
Node* newnode = new Node(data);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
Node* find(const K& key)
{
HashFunc hf;
KeyOfT kot;
size_t hash = hf(kot(key)) % _table.size();
Node* cur = _table[hash];
while (cur)
{
if (kot(cur->_data) == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)
{
HashFunc hf;
KeyOfT kot;
size_t hashi = hf(kot(key)) % _table.size();
Node* cur = _table[hashi];
Node* prev = nullptr;
while (cur)
{
if (hf(kot(cur->_data)) == key)
{
if (!prev)
{
_table[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
void Print()
{
for (size_t i = 0; i < _table.size(); i++)
{
printf("[%zd]->", i);
Node* cur = _table[i];
while (cur)
{
cout << kot(cur) << "->";
cur = cur->_next;
}
printf("NULL\n");
}
cout << endl;
cout << endl;
}
~hash()
{
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;
}
}
private:
vector<Node*> _table; // 指针数组
int _n = 0; // 用于记录哈希桶当中的有效数据个数
};
}
结言
上述实现的 哈希桶,还有很多的封装工作没有做,因为上述的哈希桶准备用来实现 unordered_set 和 unordered_map 的底层实现,所以关于 key 值不能修改,或者是迭代器在 unordered_set 和 unordered_map 的 封装博客的当中还要对 哈希桶进行 改进。