认识哈希和unordered_set、unordered_map的介绍及模拟
哈希表基础
哈希的概念
哈希表(Hash Table),也称为散列表,是一种通过哈希函数将键值(Key) 映射到对应位置(Bucket) 的数据结构。哈希表使用一个数组来存储数据,通过哈希函数计算出键值的索引,插入、查找和删除操作的时间复杂度可以接近 O ( 1 ) O(1) O(1)。
unordered
系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
哈希表的基本操作
当向哈希表的结构中:
- 插入元素。
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
哈希冲突
哈希冲突的定义
哈希冲突(Hash Collision) 是指两个或多个不同的键通过哈希函数映射到同一个位置。当哈希表发生冲突时,需要使用某种机制来解决冲突,确保所有键值对能够正确存储和查找。
哈希冲突的影响
哈希冲突会导致数据存储位置重叠,增加查找时间,降低哈希表的整体性能。频繁的冲突会使哈希表的操作复杂度从 O ( 1 ) O(1) O(1)恶化为 O ( n ) O(n) O(n),影响系统性能。
常见的哈希冲突的解决方法
1. 开放地址法
开放地址法通过探测空闲位置来解决冲突。常见的探测方法有:
下面讲重点讲解线性探测,二次探测和分离链接法。
线性探测(Linear Probing)
- 发生冲突时,从当前位置开始,逐个检查后续位置,直到找到空位为止。
- 优点:实现简单。
- 缺点:可能导致“堆积”现象,即冲突连续发生。
比如上述中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr
为4,
因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
-
插入
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
-
删除
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索素。 比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
二次探测(Quadratic Probing)
- 发生冲突时,从当前位置开始,以二次方为步长逐个检查后续位置(例如,1^2, 2^2, 3^2,…),直到找到空位为止。
- 优点:减少线性探测中的堆积现象。
- 缺点:需要处理哈希表大小和步长选择的问题。
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: H i H_i Hi =( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者: H i H_i Hi = ( H O H_O HO - i 2 i^2 i2 )% m。其中: i =1,2,3., H o H_o Ho是通过散列函数Hash(x)对元素的关键码key进行计算得到的位置,m是表的大小。对于上述中如果要插入44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜素时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
双重哈希(Double Hashing)
- 发生冲突时,使用第二个哈希函数计算新的步长,从当前位置开始探测空闲位置。
- 优点:进一步减少冲突聚集。
- 缺点:需要设计两个有效的哈希函数。
2. 链地址法
链地址法通过在每个哈希表位置存储一个链表来解决冲突。
分离链接(Separate Chaining)
- 分离链接法通过将每个哈希表槽位维护一个链表来处理冲突。
- 每个哈希表槽位存储一个链表,所有冲突的元素都插入该链表中。
- 优点:简单易实现,动态调整链表长度。
- 缺点:在最坏情况下(所有元素都落在同一个桶中),查找时间复杂度退化为 O ( n ) O(n) O(n)。
自适应链表(Adaptive Chaining)
- 根据链表长度和冲突情况,动态调整存储结构,例如在链表长度较长时将其转换为红黑树。
- 优点:在链表较短时保持简单性,在链表较长时提高查找效率。
- 缺点:实现较为复杂,需要维护多种数据结构。
哈希函数
哈希函数的定义
哈希函数的定义
哈希函数(Hash Function)是将键值转换为哈希值的函数。哈希值用于确定键值在哈希表中的存储位置。一个好的哈希函数能均匀分布键,减少冲突,提高哈希表的性能。
哈希函数的设计原则
- 均匀分布:哈希值应均匀分布在整个哈希表,避免冲突集中在某些位置。
- 快速计算:哈希函数应高效计算,减少额外开销,提高性能。
- 最小冲突:尽量避免相同哈希值的情况,使不同的键尽量映射到不同的位置。
常见的哈希函数
1. 除留余数法 (Modulo Division Method)
解释:除留余数法是最简单和常用的哈希函数之一。它通过将键除以哈希表的大小并取余数来生成哈希值。这样生成的哈希值会在0到表大小-1的范围内,适合直接作为哈希表的索引。
优点:
实现简单,计算快速。
缺点:
如果键的分布不均匀,可能导致冲突较多。
C++代码
int modHash(int key, int table_size)
{
return key % table_size;
}
2. 平方取中法 (Mid-Square Method)
解释:平方取中法是将键值平方,然后取中间几位作为哈希值。这样做的优点是可以通过平方操作增加哈希值的复杂性,从而使得哈希值更均匀地分布。
优点:
中间位数的选择可以减少键值的偏差,减少冲突。
缺点:
适用于键值长度较短的情况。
C++代码:
int midSquareHash(int key, int table_size)
{
int squared = key * key;
// 假设key是一个4位数,取中间2位
int mid_digits = (squared / 100) % 100;
return mid_digits % table_size;
}
3. 直接定址法 (Direct Addressing Method)
解释:直接定址法将键值直接映射到哈希表的索引上,适用于键值范围较小且较为密集的情况。键值直接作为索引使用,不需要额外计算。
优点:
查找、插入和删除操作的时间复杂度都是O(1)。
实现非常简单。
缺点:
需要保证键值的范围在哈希表大小之内,否则会导致索引越界。
键值范围大且稀疏时会浪费内存。
C++代码:
int multiplicativeHash(int key, int table_size)
{
double A = 0.618033; // (sqrt(5) - 1) / 2 的值
double frac = key * A - int(key * A);
return int(table_size * frac);
}
unordered系列关联式容器
unordered系列关联式容器 包含 unordered_map
和 unordered_set
,在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到
l
o
g
2
N
log_2 N
log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。
unordered_map/unordered_set 和 map/set 的区别:
特性 | map/set | unordered_map/unordered_set |
---|---|---|
内部实现 | 红黑树(自平衡二叉搜索树) | 哈希表 |
元素顺序 | 有序(按键值排序) | 无序(按哈希值存储) |
时间复杂度 | O(log n) | 平均 O(1),最坏 O(n) |
迭代顺序 | 按键值顺序 | 无固定顺序 |
内存占用 | 较高(存储节点信息) | 较低(可能有冲突链表开销) |
使用场景 | 需要有序存储和范围查询 | 需要快速查找、插入和删除 |
简要总结
- map 和 set:适用于需要保持元素有序的场景,例如有序字典、按键值范围查询。
- unordered_map 和 unordered_set:适用于需要快速查找、插入和删除的场景,且对元素顺序没有要求。
unordered_map/unordered_set 的接口可以查询cplusplus.com使用用法与之前介绍的相似。
hash模拟
开放地址法hash
#pragma once
#include <vector>
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 ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
namespace open_address
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
struct StringHashFunc
{
size_t operator()(const string& k)
{
size_t hash = 0;
for (auto& e : k)
{
hash *= 131;
hash += e;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//扩容
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHT;
newHT._tables.reserve(_tables.size() * 2);
//旧表插入到新表
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 hashi = hs(kv.first) % _tables.size();
//线性探测
while (_tables[hashi]._state == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
_n++;
return true;
}
HashData<K, V>* Find(const K& k)
{
Hash hs;
size_t hashi = hs(k) % _tables.size();
//线性探测
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == k)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
bool Erase(const K& k)
{
HashData<K, V>* ret = Find(k);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
_n--;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //有效数据个数
};