在之前我介绍了unordered系列关联式容器的使用。
上篇博客中,我又讲到unordered 系列的底层结构——哈希冲突的解决方法之闭散列。
在这里,我将继续讲unordered系列的底层结构——哈希冲突的解决方法之开散列。
开散列
开散列法又叫链地址法(开链法)/哈希桶/拉链法, 首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
且开散列中的每个桶中放的都是发生哈希冲突的元素。
插入:
1)由哈希函数计算得出所属桶的位置
2)检测元素是否已经在桶中,若已经存在则不再插入,若不存在则采用头插法提高插入效率
bool insert(const V& v)
{
CheckCapacity(); //考虑增容
KeyOfValue kov;
const K& key = kov(v); //通过仿函数获取关键字
size_t index = key % _table.size(); //通过哈希函数计算所属桶
Node* cur = _table[index];
while (cur)
{
if (kov(cur->_valuefiled) == key) //已经存在待插入元素
return false;
cur = cur->_next;
}
//不存在且哈希桶不要求产生冲突的序列有序,可以用头插提高效率
Node* newnode = new Node(v);
newnode->_next = _table[index];
_table[index] = newnode;
++_size;
return true;
}
删除:
1)通过哈希函数计算找到待删元素所属桶
2)在桶中找到待删元素则删除
bool Erase(const K& key)
{
size_t index = key % _table.size();
Node* cur = _table[index];
Node* prev = nullptr; //待删元素的前一个节点
while (cur)
{
if (KeyOfValue()(cur->_valuefiled) == key)
{
if (prev == nullptr) //若是第一个节点则修改_table[index] 的值
_table[index] = cur->_next;
else //否则将前一个节点的next修改为cur的next
prev->_next = cur->_next;
delete cur; //删除节点
--_size;
return true;
}
}
return false; //不存在则直接返回
}
查找:
通过哈希函数计算得到所属桶,通过指针在桶中查找
Node* Find(const K& key)
{
size_t index = key % _table.size();
Node* cur = _table[index];
while (cur)
{
if (KeyOfValue()(cur->_valuefiled) == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
增容:
桶的个数一定,随着元素的不断增加,每个桶中元素的个数也在不断增加。极端情况下可能会出现某个桶中的链表节点非常多从而影响哈希表的性能,因此在一定条件下,我们也需要对哈希表进行增容。
开散列最好的状态是:每个哈希桶中刚好挂一个节点。当再继续插入元素时,每一次都会发生哈希冲突。因此在元素个数刚好等于桶的个数时,就给哈希表增容。
增容方法有两种:
- 创建新表,计算旧表中元素在新表中的位置重新挂起,这里挂起可用insert 实现,但是insert 每次都需要新建结点,而旧表中的结点在重新计算后就不再有用只等析构,这样太浪费空间
- 创建新表,计算旧表中元素在新表中的位置,将旧表中的结点拆出来头插到新表中——推荐使用
void CheckCapacity()
{
//当负载因子==1时扩容
if (_table.size() == _size)
{
size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2; //新表大小
vector<Node*> newtable;
newtable.resize(newsize); //创建新表
//遍历旧表,在新的vector数组中找到对应位置,将旧表节点插入
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[index];
//将节点从旧表中拆出来,再重新计算节点在新表中的位置进行插入
while (cur)
{
Node* next = cur->_next;
size_t index = KeyOfValue()(cur->_valuefiled) % newsize;
//头插入新表中
cur->_next = newtable[index];
newtable[index] = cur;
cur = next;
}
//将原来的表置空
_table[i] = nullptr;
}
//交换新旧两标的资源,出作用域后新表自动调用析构函数释放旧表资源
_table.swap(newtable);
}
}
完整代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdlib.h>
#pragma once
//开散列解决哈希冲突
#include <iostream>
#include <vector>
using namespace std;
template<class V>
struct HashNode //定义结点,包括自己的值及下一个值的指针
{
//由于哈希桶要封装成unordered_map/set,因此这里可能存储pair<K,V>也可能存value
V _valuefiled;
HashNode<V>* _next; //同一哈希桶中指向下一个节点的指针
HashNode(const V& v)
:_valuefiled(v)
, _next(nullptr)
{}
};
//通过仿函数实现对unordered系列容器中key的比较
template<class K, class V, class KeyOfValue>
class HashTable //定义哈希表
{
typedef HashNode<V> Node;
typedef HashTable<K, V, KeyOfValue> HashTable;
public:
HashTable()
:_size(0)
{}
//代码在上面,这里不再重复
bool insert(const V& v);
void CheckCapacity();
Node* Find(const K& key);
bool Erase(const K& key);
private:
vector<Node*> _table; //vector中的size为哈希表的大小
size_t _size; //哈希表中存储的有效元素的个数
};
开散列和闭散列的比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储的开销。
事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探测法要求覆载因子a<=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。