c++进阶之------哈希(开放寻址法)
注意:本篇文章内容我们了解即可,后续对unordered_set和unorder_map的封装是基于哈希桶实 现的!
首先,为了理解开放寻址法,我们要从哈希的概念入手,哈希简单来说就是对一堆数,通过某种特定的方式(即哈希函数)将其映射出来,但是有可能两个数会被映射到同一位置上,这便产生了冲突,我们成为哈希冲突,为了解决这一冲突,我们可以采用开放寻址法来解决问题!
1. 基本原理
开放寻址法的核心思想是:当发生冲突时,按照某种探测序列在哈希表中寻找下一个空的槽位来存储冲突的键值对。探测序列可以是线性的、二次的或基于另一个哈希函数的。说人话就是这个位置有人了,我去下一个位置蹲着去,不管后进来的人,后面的人来了发现自己的坑被占了,在去占别人的位置,这是一种不文明的行为,大家不要学哈
2. 探测方法
-
线性探测(Linear Probing):当发生冲突时,依次检查后续的槽位,直到找到一个空槽。
-
二次探测(Quadratic Probing):当发生冲突时,按照二次函数的步长来探测后续槽位。
-
双重哈希(Double Hashing):第一个哈希函数计算出的值发生冲突,使用第二个哈希函数 计算出一个跟key相关的偏移量值,不断往后探测,直到寻 找到下一个没有存储数据的位置为止。
3. 操作步骤
1)框架构造
我们的哈希表可以采用vector结构,这样方便我们寻址,我们还需要设置一个参数用来检验我们的哈希表的饱满程度,还需要设置一个状态位,以及哈希节点。其他的一些基本结构和前面的哈希桶是一样的!
参考代码:
//哈希的线性探测
#pragma once
#include<string>
#include<vector>
#include<utility>
#include<iostream>
using namespace std;
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
};
inline unsigned long __stl_next_prime(unsigned long n)
{
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hashi = 0;
for (auto ch : key)
{
hashi *= 131;
hashi += ch;
}
return hashi;
}
};
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class k,class v>
struct HashData
{
pair<k, v> _kv;
State _state = EMPTY;
};
template<class k,class v,class Hash=HashFunc<k>>
class HashTable
{
public:
HashTable(size_t size = __stl_next_prime(0))
:_n(0)
, _tables(size)
{}
private:
vector <HashData<k, v>> _tables;
size_t _n; // 表中存储数据个数
};
}
2)插入操作
-
计算键的哈希值,得到初始槽位。
-
如果该槽位为空,插入键值对。
-
如果该槽位已占用,按照探测序列寻找下一个空槽位。
-
重复步骤3,直到找到空槽位或遍历完整个表。
-
要注意扩容问题,扩容代价是很大的,不能像顺序表或者链表一样在后面扩容,因为哈希表是具有映射关系的,扩容后要重新映射的
参考代码:
bool Insert(const pair<k, v>& kv)
{
if ((double)_n / (double)_tables.size() >= 0.7)
{
// 0.7负载因子就开始扩容
//vector<HashData<K, V>> newtables(_tables.size()*2);
遍历旧表,重新映射
//for (size_t i = 0; i < _tables.size(); i++)
//{
// if (_tables[i]._state == EXIST)
// {
// //...
// }
//}
//HashTable<K, V> newHT(_tables.size()*2);
//扩容代价是很大的,不能像顺序表或者链表一样在后面扩容,因为哈希表是具有映射关系的,扩容
后要重新映射的
HashTable<k, v, Hash> newHT(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hs;
size_t hash0 = hs(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 线性探测
while (_tables[hashi]._state == EXIST)
{
hashi = (hash0 + i) % _tables.size();
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
3)查找操作
-
计算键的哈希值,得到初始槽位。
-
检查该槽位是否包含目标键。
-
如果包含,返回对应的值。
-
如果不包含,按照探测序列继续查找。
-
如果遍历完整个表仍未找到,返回空。
参考代码:
HashData<k, v>* Find(const k& key)
{
Hash hs;
size_t hash0 = hs(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 线性探测
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._kv.first == key && _tables[hashi]._state != DELETE)
{
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
4)删除操作
-
计算键的哈希值,得到初始槽位。
-
检查该槽位是否包含目标键。
-
如果包含,标记该槽位为已删除(通常使用特殊标记)。
-
如果不包含,按照探测序列继续查找。
-
如果遍历完整个表仍未找到,返回空。
参考代码:
bool Erase(const k& key)
{
HashData<k, v>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
return true;
}
else
{
return false;
}
}
4.与哈希桶的对比
1. 工作原理
方法 | 工作原理 |
---|---|
开放寻址法 | 当发生冲突时,按照某种探测序列在哈希表中寻找下一个空槽位来存储冲突的键值对。 |
哈希桶(链地址法) | 每个桶是一个链表,当发生冲突时,将冲突的键值对插入到对应桶的链表中。 |
2. 内存使用
方法 | 内存使用 |
---|---|
开放寻址法 | 使用单一的桶数组,内存使用效率较高,但需要预留一定的空槽位来处理冲突。 |
哈希桶 | 每个桶是一个链表,内存使用效率较低,但可以动态扩展链表长度来处理冲突。 |
3. 查找效率
方法 | 查找效率 |
---|---|
开放寻址法 | 查找效率较高,特别是在低负载因子下。高负载因子下冲突频繁,查找效率下降。 |
哈希桶 | 查找效率取决于链表的长度。链表越长,查找效率越低,但冲突处理较为灵活。 |
4. 插入效率
方法 | 插入效率 |
---|---|
开放寻址法 | 插入效率较高,但需要处理探测序列中的空槽位。 |
哈希桶 | 插入效率较高,直接将冲突的键值对插入到链表中即可。 |
5. 删除效率
方法 | 删除效率 |
---|---|
开放寻址法 | 删除操作需要特殊处理,通常标记槽位为已删除,不能简单地将槽位设置为nullptr 。 |
哈希桶 | 删除操作较为简单,直接从链表中删除对应的键值对即可。 |
6. 实现复杂度
方法 | 实现复杂度 |
---|---|
开放寻址法 | 实现相对简单,但需要处理探测序列和删除操作的特殊标记。 |
哈希桶 | 实现稍微复杂,需要维护链表结构,但删除操作较为直观。 |
7. 适用场景
方法 | 适用场景 |
---|---|
开放寻址法 | 适用于内存有限或对查找速度要求较高的场景。 |
哈希桶 | 适用于动态数据或冲突较多的场景,链表可以灵活扩展以处理冲突。 |
8.总结
开放寻址法:内存使用效率较高,查找效率在低负载因子下较好,但删除操作需要特殊处理。
哈希桶(链地址法):内存使用效率较低,但冲突处理灵活,删除操作简单,适用于动态数据。