摘要:概念——哈希、哈希表、哈希碰撞、负载因子;实现哈希表(插入删除查找)(开放定址法)
前面讲解了用红黑树封装 set 和 map,这两个容器的底层都是 BST(红黑树),而本章将讲解关联式容器 unordered_map 和 unordered_set .
unordered_map 和 unordered_set 的底层则为哈希表。
在 unordered_map 和 unordered_set 中元素的存储是无序的。
set 和 map 是存储有序不重复元素的容器;multiset 和 multimap 是存储有序可重复元素的容器。
注意区分这些容器之间的特点。
*本文中有些讲法融合了个人理解。如有疑问,请参看更权威的资料。
1. 哈希 · 相关概念
哈希(Hash),又称散列表,分为开散列和闭散列。哈希本质上是一种算法思想,是关键字(key)与另一个值建立起一个关联关系,这种思想类似于数学中的“函数”。( y = f(x) → index = H(val) )
哈希表(Hash Table),是用 哈希 这种算法思想建立的数据结构。
1)哈希的两种“映射”方式
①每个值对应唯一确定的位置。如左图,定义一个整型变量为 key(横轴),另一个整型变量为 position(纵轴),映射关系为 position = key,即每一个val值都对应唯一一个position.
选择一一对应的哈希映射方式(即每个键都有唯一确定的位置,没有冲突)
优点:
极高的查找效率:由于没有冲突,查找操作可以直接通过计算哈希值得到准确的位置,时间复杂度为 O(1),非常高效。
简单的实现和理解:概念和实现相对简单,不需要处理复杂的冲突解决策略。
良好的预测性:性能稳定,不受数据分布和负载因子的影响。
缺陷:
空间利用率低:为了确保一一对应,通常需要较大的存储空间,可能导致大量的空间浪费,尤其是当键的分布不均匀时。
对哈希函数要求高:需要一个非常优秀的哈希函数,以确保键能均匀地分布在整个可能的哈希值范围内,否则可能会出现大量的空槽位,降低空间利用率。
扩展性受限:当数据量增加需要扩容时,重新计算哈希值和重新分配空间的成本较高。
例如,如果有一个固定且较小的键集合,并且对查找速度有极高的要求,同时不关心存储空间的浪费,那么适合这种哈希映射方式。
②每个值按一定规则分类到对应位置 👉 这种方式会导致 “哈希碰撞 ”. 如左图,定义一个整型变量为 key(横轴),另一个整型变量为 position(纵轴),映射关系为 position = key². 从图中可以看出,存在不同的 key 值对应相同 position 值,即存在“多对一”的映射关系。
2)哈希碰撞/哈希冲突
哈希碰撞,又称哈希冲突。即不同的值将要被放入相同的位置,这个位置实际上是内存空间。
如下代码,当进行 HashTable[hash_i] = e; 操作时,如果多个元素的哈希值相同(即 e % 10 相同,例如 11 % 10 = 1,21 %10 = 1,1 %10 = 1),会覆盖之前存储的值,可能导致数据丢失。“多个元素的哈希值相同”,即多个值对应到了同一块内存空间,然而一份内存空间只能存储一份相应大小的数据,这样的情况即称为 “哈希碰撞”。
int arr[] = {3, 11, 8, 21, 7, 1, 4};
vector<int> HashTable;
HashTable.resize(sizeof(arr) / sizeof(arr[0]);
for(auto e : arr)
{
size_t hash_i = e % 10;
HashTable[hash_i] = e;
}
【如何解决哈希冲突】
针对 哈希碰撞 的问题,有两大解决思路:
①闭散列(开放定址法):当 当前对应的位置已经被其他数据覆盖,则按某种规则(另一种映射方式)去“找”一个没被覆盖的位置存储。
- 线性探测:线性探查是最简单的一种,当发生冲突时,依次检查下一个位置,直到找到空闲位置。例如,如果哈希函数计算出的位置 H(val) 已被占用,就检查下一个位置 H(val) + 1,再下一个位置 H(val) + 2,以此类推。但线性探查可能会导致聚集现象,降低查找效率。
- 二次探测:二次探查则是按照一定的二次函数来进行探查,例如第一次冲突检查 H(val) + 1²,第二次冲突检查 H(val) + 2² ,以此类推。
开放寻址法的优点是不需要额外的空间来处理冲突,节省了存储空间。但它的缺点是删除操作比较复杂,可能会影响后续元素的查找,并且容易导致 负载因子 过高,影响性能。
②开散列(哈希桶/拉链法):详见下篇文章
3)负载因子
定义
负载因子定义为哈希表中已存储的元素数量与哈希表大小(即表中的槽位数量)的比值。
计算公式
负载因子 = 已存储元素数量 / 哈希表的槽位数量
意义/用途
- 负载因子反映了哈希表的填充程度。例如,如果一个哈希表有 10 个槽位,当前已经存储了 5 个元素,那么负载因子就是 5 / 10 = 0.5 。
- 较小的负载因子意味着哈希表比较空闲,发生冲突的概率较低,查找、插入和删除操作的平均性能较好,但可能会浪费一定的存储空间。
- 较大的负载因子表示哈希表填充程度高,节省了空间,但冲突的概率增加,可能导致操作的性能下降。
- 通常,当负载因子超过某个阈值时(例如 0.7 或 0.8),为了保持较好的性能,会对哈希表进行扩容操作,增加槽位数量,重新计算元素的位置并进行重新分布。
2. 哈希表(闭散列)
1)框架
enum Status{ EXIST, EMPTY, DELETE };
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
Status _s;
};
template<class K, class V>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _n; //表征已经存储的元素个数,用于计算负载因子
};
注解: EXIST 表示该槽位存在数据;
EMPTY 表示该槽位未存储数据,当前为空;
DELETE 表示该槽位存在过数据,但已删除,当前为空。
2)insert · 实现插入函数
思路
①按映射关系(哈希函数)将值插入到用于存储数据的对象中;
②若发生哈希碰撞则通过线性探测处理;
③扩容的问题⭐
(1) 扩容的问题本质上是 size 的问题,而不是 capacity 的问题;
*为什么本质上是 size 的问题:🔴vector对越界检查非常严格,不要出现任何越界访问的行为❗
(2) 需要扩容的情况:负载因子达到 0.7 时;
(3) 🔴不能直接扩容
因为映射关系是与 size 相关的: H(key) → hashi = key % _tables.size(); 所以,当 vector<HashData<K, V>> 的 size 发生改变后,会连带地影响映射关系。
🟢正确的扩容方式为:
- 开新空间:首先创建一个新的、更大的哈希表。
- 插入原数据:重新计算原哈希表中所有元素的哈希值,并将它们重新插入到新的哈希表中。
- 释放旧空间:释放原哈希表所占用的内存。( swap )
例如,假设原哈希表的大小为 N ,负载因子达到阈值需要扩容。我们创建一个大小为 原来的两倍(2N) 或其他适当倍数的新哈希表。然后遍历原哈希表中的每个元素,根据新的哈希表大小重新计算其哈希值,并插入到新表中。
代码
*增加了一个模版参数(为了使用仿函数,下面“关于哈希函数”的部分有对此说明原因)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
//扩容的问题
if (_n * 100 / _tables.size() == 70)
{
HashTable newHashTable;
newHashTable._tables.resize(_tables.size() * 2);//“扩容”
for (size_t i = 0; i < _tables.size(); ++i)//转移数据
{
if ((_tables[i])._state == EXIST)
{
newHashTable.Insert((_tables[i])._kv);
}
}
//交换对象
std::swap(newHashTable, *this);
}
//插入新数据
size_t hashi = Hash()(kv.first) % 10;
if ((_tables[hashi])._kv.first != kv.first)//关键码值唯一性要求
{
while ((_tables[hashi])._state == EXIST) // DELETE and EMPTY 对 insert 都是一样的
{
++hashi;
hashi %= _tables.size();
}
(_tables[hashi])._kv = kv;
(_tables[hashi])._state = EXIST;
++_n;
return true;
}
return false;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};
注意:关于负载因子的计算,由于整型之间的除法只保留整数位,这里采用“先乘后除”的方式 _n * 100 / _tables.size() 并用 70 而不是 0.7 作比较。当然,也可以通过强制类型转化来解决这个问题。
关于哈希函数
- 根据实际的需求来选择哈希函数
- 哈希函数可以直接去网络上检索
此处实现插入函数是采用的哈希函数为 除留余数法,这个方法要求数据为整型,所以需要借助仿函数来将非整型的数据“转化”为整型数据。
// 哈希函数采用除留余数法
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 hash = 0;
for (auto e : key)
{
hash *= 31;
hash += e;
}
return hash;
}
};
3)find · 实现查找函数
🟣为什么 要有 EXIST , EMPTY , DELETE 这三种状态,DELETE 不就是 EMPTY 吗?
答:对于插入函数而言,DELETE状态和 EMPTY 状态本质上是一样的,都是可以插入新数据的状态。然而,对于查找函数而言,EMPTY 状态是 不必再继续查找 的标志性状态。哈希表的查找方式是通过哈希函数算出位置,再检查这个位置是否真的为要查找的数据,但由于哈希碰撞的存在,根据线性探测的规律,当前算出的位置如果与要查找的数据不相符合,则有可能我们要查找的这个数据可能因哈希碰撞被按一定规律放在了别的位置,因此需要继续查找。当查找到某个位置为DELETE状态时,仍需要继续查找,只有当查找到某位置为EMPTY时,则可以终止寻找,即为未查到该数据。即可以理解为 对于插入函数,DELETE = EMPTY;对于查找函数,DELETE ≈ EXIST。
HashData<K, V>* Find(const K& key)
{
size_t hashi = Hash()(key) % 10;
if (_tables[hashi]._state == EMPTY)
{
cout << "未查找到该数据" << endl;
return nullptr;
}
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._kv.first == key)
{
//cout << "查找成功" << endl;
return &_tables[hashi];
}
++hashi;
}
cout << "查找的目标不存在" << endl;
return nullptr;
}
4)Erase · 实现删除操作
先找到要删除的数据,将状态改为 DELETE 即为完成删除操作。
bool Erase(const K& key)
{
HashData<K, V>* pointer = Find(key);
if (!pointer)
{
cout << "要删除的目标不存在" << endl;
return false;
}
pointer->_state = DELETE;
return true;
}
END