闭散列哈希表
哈希表的结构
在闭散列的哈希表中,哈希表每个位置除了存储所给数据之外,还应该存储该位置当前的状态,哈希表中每个位置的可能状态如下:
- EMPTY(无数据的空位置)。
- EXIST(已存储数据)。
- DELETE(原本有数据,但现在被删除了)。
我们可以用枚举定义这三个状态。
cpp
// 闭散列哈希表
enum State {
EMPTY,// 哈希表位置为NULL
EXITS,// 哈希表位置有值了
DELETE// 哈希表位置为删除标志
};
为什么需要标识哈希表中每个位置的状态?
若是不设置哈希表中每个位置的状态,那么在哈希表中查找数据的时候可能是这样的。以除留余数法的线性探测为例,我们若是要判断下面这个哈希表是否存在元素40,步骤如下:
- 通过除留余数法求得元素40在该哈希表中的哈希地址是0。
- 从0下标开始向后进行查找,若找到了40则说明存在。
但是我们在寻找元素40时,不可能从0下标开始将整个哈希表全部遍历一次,这样就失去了哈希的意义。我们只需要从0下标开始往后查找,直到找到元素40判定为存在,或是找到一个空位置判定为不存在即可。
因为线性探测在为冲突元素寻找下一个位置时是依次往后寻找的,既然我们已经找到了一个空位置,那就说明这个空位置的后面不会再有从下标0位置开始冲突的元素了。比如我们要判断该哈希表中是否存在元素90,步骤如下:
- 通过除留余数法求得元素90在该哈希表中的哈希地址是0。
- 从0下标开始向后进行查找,直到找到下标为5的空位置,停止查找,判定元素90不存在。
但这种方式是不可行的,原因如下:
- 如何标识一个空位置?用数字0吗?那如果我们要存储的元素就是0怎么办?因此我们必须要单独给每个位置设置一个状态字段。
- 如果只给哈希表中的每个位置设置存在和不存在两种状态,那么当遇到下面这种情况时就会出现错误。
我们先将上述哈希表当中的元素1000找到,并将其删除,此时我们要判断当前哈希表当中是否存在元素40,当我们从0下标开始往后找到2下标(空位置)时,我们就应该停下来,此时并没有找到元素40,但是元素40却在哈希表中存在。
因此我们必须为哈希表中的每一个位置设置一个状态,并且每个位置的状态应该有三种可能,当哈希表中的一个元素被删除后,我们不应该简单的将该位置的状态设置为EMPTY,而是应该将该位置的状态设置为DELETE。
这样一来,当我们在哈希表中查找元素的过程中,若当前位置的元素与待查找的元素不匹配,但是当前位置的状态是EXIST或是DELETE,那么我们都应该继续往后进行查找,而当我们插入元素的时候,可以将元素插入到状态为EMPTY或是DELETE的位置。
因此,闭散列的哈希表中的每个位置存储的结构,应该包括所给数据和该位置的当前状态。
cpp
template<class K, class V>
struct HashData {
pair<K, V> _kv;
State _state = EMPTY;//状态初始化为空
};
而为了在插入元素时好计算当前哈希表的负载因子,我们还应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。
cpp
//哈希表
template<class K, class V>
class HashTable{
public:
//...
private:
vector<HashData<K, V>> _tables;// 将Hash值存放在vector中
size_t _n = 0; // 存储的数据个数
};
哈希表的查找
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为0,若为0则查找失败。
- 通过哈希函数计算出对应的哈希地址。
- 从哈希地址处开始,采用线性探测向后向后进行数据的查找,直到找到待查找的元素判定为查找成功,或找到一个状态为EMPTY的位置判定为查找失败。
注意: 在查找过程中,必须找到位置状态为EXIST,并且key值匹配的元素,才算查找成功。若仅仅是key值匹配,但该位置当前状态为DELETE,则还需继续进行查找,因为该位置的元素已经被删除了。
cpp
HashData<K, V> *Find(const K &key) {
//哈希表大小为0,表示哈希表为空,返回nullptr
if (this->_tables.size() == 0) {
return nullptr;
}
//哈希函数
size_t hashi = key % this->_tables.size();
size_t i = 1;
size_t index = hashi;// index是插入的位置
// 当哈希状态为EXITS,说明表中位置已经有值,那么就继续查找
while (this->_tables[index]._state != EMPTY) {
// 当表中值跟key相等,并且状态为存在时才返回,因为可能值的状态被改为了delete说明刚刚被删除,不可以返回
if (this->_tables[index]._kv.first == key && this->_tables[index]._state == EXITS) {
return &this->_tables[index];
}
index = hashi + i; //线性探测
//index = hashi + i * i; //二次探测
index %= this->_tables.size();// 防止index越界,绕回去
i++;
// 这里的_state可能都是存在或者删除,那么程序就可能陷入死循环,所以需要给定条件退出
// 如果已经查找一圈,那么说明全是存在+删除
if (index == hashi) {
break;
}
}
return nullptr;
}
哈希表的插入
向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
- 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
- 将键值对插入哈希表。
- 哈希表中的有效元素个数加一。
其中,哈希表的调整方式如下:
- 若哈希表的大小为0,则将哈希表的初始大小设置为10。
- 若哈希表的负载因子大于0.7,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。
注意: 在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据对应的挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的位置,然后再进行插入。
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址。
- 若产生哈希冲突,则从哈希地址处开始,采用线性探测向后寻找一个状态为EMPTY或DELETE的位置。
- 将键值对插入到该位置,并将该位置的状态设置为EXIST。
注意: 产生哈希冲突向后进行探测时,一定会找到一个合适位置进行插入,因为哈希表的负载因子是控制在0.7以下的,也就是说哈希表永远都不会被装满。
cpp
bool Insert(const pair<K, V> &kv) {
//1.查找值
if (Find(kv.first)) {
return false;
}
// 当我们的哈希表是空或者负载因子大于0.7的时候,我们需要给将哈希表增容
// 负载因子 = 表中有效数据个数 / 空间的大小
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {
//为空的时候,给初始10,负载因子大于0.7就扩容两倍
size_t newsize = this->_tables.size() == 0 ? 10 : this->_tables.size() * 2;
HashTable<K, V> newHashTable;// 重新创建一个HashTable类
newHashTable._tables.resize(newsize);
// 遍历旧表,重新映射到新表
for (auto &data: this->_tables) {// data是_table中的类型,对应的HashData
if (data._state == EXITS) {
newHashTable.Insert(data._kv);// 将旧的kv插入到新的类对象中
}
}
//交换
this->_tables.swap(newHashTable._tables);
}
//哈希函数
size_t hashi = kv.first % this->_tables.size();
// 线形探测
size_t i = 1;
size_t index = hashi;// index是最后要插入的位置
// 当哈希状态为EXITS,说明表中位置已经有值,那么就继续查找
while (this->_tables[index]._state == EXITS) {
index = hashi + i;
//index = hashi + i * i; //二次线性探测
index %= this->_tables.size();// 防止index越界,绕回去
i++;
}
this->_tables[index]._kv = kv;
this->_tables[index]._state = EXITS;
this->_n++;// 存储的数据个数+1
return true;
}
哈希表的删除
删除哈希表中的元素非常简单,我们只需要进行伪删除即可,也就是将待删除元素所在位置的状态设置为DELETE。
在哈希表中删除数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若不存在则删除失败。
- 若存在,则将该键值对所在位置的状态改为DELETE即可。
- 哈希表中的有效元素个数减一。
注意: 虽然删除元素时没有将该位置的数据清0,只是将该元素所在状态设为了DELETE,但是并不会造成空间的浪费,因为我们在插入数据时是可以将数据插入到状态为DELETE的位置的,此时插入的数据就会把该数据覆盖。
cpp
bool Erase(const K &key) {
HashData<K, V> *ret = Find(key);
if (ret) {
ret->_state = DELETE;
this->_n--;
return true;
} else {
return false;
}
}
完整代码
cpp
#pragma once
#include <iostream>
#include <vector>
using namespace std;
// 闭散列哈希表
enum State {
EMPTY,// 哈希表位置为NULL
EXITS,// 哈希表位置有值了
DELETE// 哈希表位置为删除标志
};
template<class K, class V>
struct HashData {
pair<K, V> _kv;
State _state = EMPTY;//状态初始化为空
};
template<class K, class V>
class HashTable {
public:
HashData<K, V> *Find(const K &key) {
//哈希表大小为0,表示哈希表为空,返回nullptr
if (this->_tables.size() == 0) {
return nullptr;
}
//哈希函数
size_t hashi = key % this->_tables.size();
size_t i = 1;
size_t index = hashi;// index是插入的位置
// 当哈希状态为EXITS,说明表中位置已经有值,那么就继续查找
while (this->_tables[index]._state != EMPTY) {
// 当表中值跟key相等,并且状态为存在时才返回,因为可能值的状态被改为了delete说明刚刚被删除,不可以返回
if (this->_tables[index]._kv.first == key && this->_tables[index]._state == EXITS) {
return &this->_tables[index];
}
index = hashi + i;//线性探测
//index = hashi + i * i; //二次探测
index %= this->_tables.size();// 防止index越界,绕回去
i++;
// 这里的_state可能都是存在或者删除,那么程序就可能陷入死循环,所以需要给定条件退出
// 如果已经查找一圈,那么说明全是存在+删除
if (index == hashi) {
break;
}
}
return nullptr;
}
bool Insert(const pair<K, V> &kv) {
//1.查找值
if (Find(kv.first)) {
return false;
}
// 当我们的哈希表是空或者负载因子大于0.7的时候,我们需要给将哈希表增容
// 负载因子 = 表中有效数据个数 / 空间的大小
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {
//为空的时候,给初始10,负载因子大于0.7就扩容两倍
size_t newsize = this->_tables.size() == 0 ? 10 : this->_tables.size() * 2;
HashTable<K, V> newHashTable;// 重新创建一个HashTable类
newHashTable._tables.resize(newsize);
// 遍历旧表,重新映射到新表
for (auto &data: this->_tables) {// data是_table中的类型,对应的HashData
if (data._state == EXITS) {
newHashTable.Insert(data._kv);// 将旧的kv插入到新的类对象中
}
}
//交换
this->_tables.swap(newHashTable._tables);
}
//哈希函数
size_t hashi = kv.first % this->_tables.size();
// 线形探测
size_t i = 1;
size_t index = hashi;// index是最后要插入的位置
// 当哈希状态为EXITS,说明表中位置已经有值,那么就继续查找
while (this->_tables[index]._state == EXITS) {
index = hashi + i;
//index = hashi + i * i; //二次线性探测
index %= this->_tables.size();// 防止index越界,绕回去
i++;
}
this->_tables[index]._kv = kv;
this->_tables[index]._state = EXITS;
this->_n++;// 存储的数据个数+1
return true;
}
bool Erase(const K &key) {
HashData<K, V> *ret = Find(key);
if (ret) {
ret->_state = DELETE;
this->_n--;
return true;
} else {
return false;
}
}
private:
vector<HashData<K, V>> _tables;// 将Hash值存放在vector中
size_t _n = 0; // 存储的数据个数
};
开散列哈希表(哈希桶)
哈希表的结构
在开散列的哈希表中,哈希表的每个位置存储的实际上是某个单链表的头结点,即每个哈希桶中存储的数据实际上是一个结点类型,该结点类型除了存储所给数据之外,还需要存储一个结点指针用于指向下一个结点。
cpp
template<class K, class V>
struct HashNode {
HashNode<K, V> *_next;
pair<K, V> _kv;
HashNode(const pair<K, V> &kv)
: _kv(kv), _next(nullptr) {}
};
与闭散列的哈希表不同的是,在实现开散列的哈希表时,我们不用为哈希表中的每个位置设置一个状态字段,因为在开散列的哈希表中,我们将哈希地址相同的元素都放到了同一个哈希桶中,并不需要经过探测寻找所谓的"下一个位置"。
哈希表的开散列实现方式,在插入数据时也需要根据负载因子判断是否需要增容,所以我们也应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。
cpp
//哈希表
template<class K, class V>
class HashTable{
public:
//...
private:
vector<Node *> _tables;
size_t n = 0;// 存储有效数据的个数
};
只能存储key为整形的元素,其他类型怎么解决?
使用模板特化编写仿函数
cpp
template<class K>
struct HashFunc {
size_t operator()(const K &key) {
return key;
}
};
// 特化模板,传string的话,就走这个
template<>
struct HashFunc<string> {
size_t operator()(const string &s) {
size_t hash = 0;
for (auto ch: s) {
hash += ch;
hash *= 31;
}
return hash;
}
};
这样我们的结构就变成了:
cpp
template<class K, class V, class Hash = HashFunc<K>>// Hash用于将key转换成可以取模的类型
class HashTable {
public:
//
private:
vector<Node *> _tables;
size_t n = 0;// 存储有效数据的个数
};
哈希表的查找
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为0,若为0则查找失败。
- 通过哈希函数计算出对应的哈希地址。
- 通过哈希地址找到对应的哈希桶中的单链表,遍历单链表进行查找即可。
cpp
Node *Find(const K &key) {
if (this->_tables.size() == 0) {
return nullptr;
}
Hash hash; //用于处理各种类型的仿函数
size_t hashi = hash(key) % this->_tables.size();
Node *cur = this->_tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
哈希表的插入
向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
- 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
- 将键值对插入哈希表。
- 哈希表中的有效元素个数加一。
其中,哈希表的调整方式如下:
- 若哈希表的大小为0,则将哈希表的初始大小设置为10。
- 若哈希表的负载因子已经等于1了,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。
重点: 在将原哈希表的数据插入到新哈希表的过程中,不要通过复用插入函数将原哈希表中的数据插入到新哈希表,因为在这个过程中我们需要创建相同数据的结点插入到新哈希表,在插入完毕后还需要将原哈希表中的结点进行释放,多此一举。
实际上,我们只需要遍历原哈希表的每个哈希桶,通过哈希函数将每个哈希桶中的结点重新找到对应位置插入到新哈希表即可,不用进行结点的创建与释放。
说明一下: 下面代码中为了降低时间复杂度,在增容时取结点都是从单链表的表头开始向后依次取的
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址。
- 若产生哈希冲突,则直接将该结点头插到对应单链表即可。
cpp
bool Insert(const pair<K, V> &kv) {
Hash hash;// 仿函数用于不能取模的值
// 已经有这个数,就不用插入了
if (Find(kv.first)) {
return false;
}
// 负载因子 == 1时扩容
if (this->n == this->_tables.size()) {
// size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
size_t newsize = this->GetNextPrime(_tables.size());
vector<Node *> newtables(newsize, nullptr);
for (auto &cur: this->_tables) {// cur是Node*
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.swap(newtables);
}
size_t hashi = hash(kv.first) % this->_tables.size();
// 头插
Node *newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
this->n++;
return true;
}
哈希表的删除
在哈希表中删除数据的步骤如下:
- 通过哈希函数计算出对应的哈希桶编号。
- 遍历对应的哈希桶,寻找待删除结点。
- 若找到了待删除结点,则将该结点从单链表中移除并释放。
- 删除结点后,将哈希表中的有效元素个数减一。
注意: 不要先调用查找函数判断待删除结点是否存在,这样做如果待删除不在哈希表中那还好,但如果待删除结点在哈希表,那我们还需要重新在哈希表中找到该结点并删除,还不如一开始就直接在哈希表中找,找到了就删除。
cpp
bool Erase(const K &key) {
Hash hash;
size_t hashi = hash(key) % this->_tables.size();
//删除的时候需要找到前一个节点和后一个节点进行链接
Node *prev = nullptr;
Node *cur = this->_tables[hashi];//cur初始为头结点
//遍历单链表
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
//要找的结点就是头结点则直接更新头
this->_tables[hashi] = cur->_next;
} else {
//链接
prev->_next = cur->_next;
}
delete cur;
return true;
} else {
//更新prev和cur
prev = cur;
cur = cur->_next;
}
}
return false;
}
扩容优化
在哈希表中,使用素数作为表的大小可以有效地减少哈希冲突。这主要基于以下两点:
- 哈希函数的设计:哈希函数的目的是将键均匀地散列在哈希表中,以尽可能减少哈希冲突。许多哈希函数都会利用取模操作来计算元素在表中的位置,例如
hash(key) = key % table_size
。在这种情况下,如果table_size
是素数,那么哈希函数就能够更好地将不同的键散列在表的不同位置,从而减少哈希冲突。 - 避免周期性模式:如果我们使用的哈希表大小不是一个素数,特别是如果它有多个不同的因子,那么可能会产生周期性的模式,同样的键可能会被映射到同一个位置。这是因为两个数字如果它们的差是哈希表大小的因子,那么它们的哈希值将会相同。使用素数作为表的大小可以避免这种情况,因为素数只有两个因子,1和它自己。
因此,当哈希表需要扩容时,通常选择下一个较大的素数作为新的表的大小,以优化哈希表的性能。
cpp
// 扩容优化,使用素数扩容
size_t GetNextPrime(size_t prime) {
// SGI
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};
size_t i = 0;
for (; i < __stl_num_primes; ++i) {
if (__stl_prime_list[i] > prime)
return __stl_prime_list[i];
}
return __stl_prime_list[i];
}
完整代码
cpp
#pragma once
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <vector>
using namespace std;
template<class K, class V>
struct HashNode {
HashNode<K, V> *_next;
pair<K, V> _kv;
HashNode(const pair<K, V> &kv)
: _kv(kv), _next(nullptr) {}
};
template<class K>
struct HashFunc {
size_t operator()(const K &key) {
return key;
}
};
// 特化模板,传string的话,就走这个
template<>
struct HashFunc<string> {
size_t operator()(const string &s) {
size_t hash = 0;
for (auto ch: s) {
hash += ch;
hash *= 31;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>// Hash用于将key转换成可以取模的类型
class HashTable {
typedef HashNode<K, V> Node;
public:
~HashTable() {
for (auto &cur: this->_tables) {
while (cur) {
Node *next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
Node *Find(const K &key) {
if (this->_tables.size() == 0) {
return nullptr;
}
Hash hash;
size_t hashi = hash(key) % this->_tables.size();
Node *cur = this->_tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K &key) {
Hash hash;
size_t hashi = hash(key) % this->_tables.size();
//删除的时候需要找到前一个节点和后一个节点进行链接
Node *prev = nullptr;
Node *cur = this->_tables[hashi];//cur初始为头结点
//遍历单链表
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
//要找的结点就是头结点则直接更新头
this->_tables[hashi] = cur->_next;
} else {
//链接
prev->_next = cur->_next;
}
delete cur;
return true;
} else {
//更新prev和cur
prev = cur;
cur = cur->_next;
}
}
return false;
}
// 扩容优化,使用素数扩容
size_t GetNextPrime(size_t prime) {
// SGI
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};
size_t i = 0;
for (; i < __stl_num_primes; ++i) {
if (__stl_prime_list[i] > prime)
return __stl_prime_list[i];
}
return __stl_prime_list[i];
}
bool Insert(const pair<K, V> &kv) {
Hash hash;// 仿函数用于不能取模的值
// 已经有这个数,就不用插入了
if (Find(kv.first)) {
return false;
}
// 负载因子 == 1时扩容
if (this->n == this->_tables.size()) {
// size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
size_t newsize = this->GetNextPrime(_tables.size());
vector<Node *> newtables(newsize, nullptr);
for (auto &cur: this->_tables) {// cur是Node*
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.swap(newtables);
}
size_t hashi = hash(kv.first) % this->_tables.size();
// 头插
Node *newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
this->n++;
return true;
}
// 获取哈希表索引最大长度(哈希桶长度)
size_t MaxBucketSize() {
size_t max = 0;
for (int i = 0; i < _tables.size(); ++i) {
auto cur = _tables[i];
size_t size = 0;
while (cur) {
++size;
cur = cur->_next;
}
printf("[%d]->%d\n", i, size);
if (size > max) {
max = size;
}
if (max == 5121) {
printf("%d", i);
break;
}
}
return max;
}
private:
vector<Node *> _tables;
size_t n = 0;// 存储有效数据的个数
};