unordered_set 和 unordered_map
unordered_set 和 unordered_map 这两个容器是在 C++11 新增的。下面我们依次了解。
unordered_set 的声明如下所示:
template < class Key, // unordered_set::key_type/value_type
class Hash = hash<Key>, // unordered_set::hasher
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;
关于 unordered_set 容器需要了解的特点
•使用 unordered_set 容器需要包含头文件<unordered_set>
•Key 是 unordered_set 底层关键字的类型
•unordered_set 默认要求Key支持转换为整形,如果不支持或者想按自己的需求走可以自行实现支持将 Key 转成整形的仿函数传给第二个模板参数
•unordered_set 默认要求 Key 支持比较相等,如果不支持或者想按自己的需求走可以自行实现支持将 Key 比较相等的仿函数传给第三个模板参数
•⼀般情况下,都不需要传后三个模板参数
•unordered_set 底层是用哈希桶实现,增删查平均效率是 O(1) ,迭代器遍历不再有序。为了和 set 区分,故取名 unordered_set
set 和 unordered_set 的功能高度相似,只是底层结构不同,有⼀些性能和使用上的差异,本文主要讲解 set 和 unordered_set 的不同。
不同之处之一:set 和 unordered_set 的声明差异
set 的声明:
template < class T, // set::key_type/value_type
class Compare = less<T>, // set::key_compare/value_compare
class Alloc = allocator<T> // set::allocator_type
> class set;
unordered_set 的声明:
template < class Key, // unordered_set::key_type/value_type
class Hash = hash<Key>, // unordered_set::hasher
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;
set 的 Key 类型只需支持比较大小;unordered_set 的 Key 类型需要支持转换成整型和支持比较相等
unordered_set 的增删查接口的使用与 set 的一模一样,这里我们不过多赘述。
不同之处之二:迭代器的差异
set 的 iterator 是双向迭代器,unordered_set 是单向迭代器。其次 set 底层是红黑树,红黑树是二叉搜索树,走中序遍历是有序的,所以 set 迭代器遍历是有序+去重;而 unordered_set 底层是哈希表,迭代器遍历是无序+去重
不同之处之三:性能的差异
整体而言大多数场景下,unordered_set 的增删查改的效率更高⼀些,因为红黑树增删查改效率是O(logN),而哈希表增删查平均效率是O(1)
我们可以对比 set 和 unordered_set 容器排序相同的数据所需要的时间
代码:
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
// 将随机数存储到vector容器中
for (size_t i = 0; i < N; ++i)
{
//v.push_back(rand()); // N比较大时,重复值比较多
v.push_back(rand() + i); // 重复值相对少
//v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
us.reserve(N);
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
cout << endl;
int m1 = 0;
size_t begin3 = clock();
for (auto e : v)
{
auto ret = s.find(e);
if (ret != s.end())
{
++m1;
}
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << "->" << m1 << endl;
int m2 = 0;
size_t begin4 = clock();
for (auto e : v)
{
auto ret = us.find(e);
if (ret != us.end())
{
++m2;
}
}
size_t end4 = clock();
cout << "unorered_set find:" << end4 - begin4 << "->" << m2 << endl;
cout << endl;
cout << "插入数据个数:" << s.size() << endl;
cout << "插入数据个数:" << us.size() << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
运行结果:
debug 版本

release 版本

unordered_map 的声明如下所示:
template < class Key, // unordered_map::key_type
class T, // unordered_map::mapped_type
class Hash = hash<Key>, // unordered_map::hasher
class Pred = equal_to<Key>, // unordered_map::key_equal
class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type
> class unordered_map;
关于 unordered_map 容器需要了解的特点
•使用 unordered_map 容器需要包含头文件 <unordered_map>
•Key 就是 unordered_map 底层关键字的类型
•unordered_map 默认要求 Key 支持转换为整形,如果不支持或者想按自己的需求走可以自行实现支持将 Key 转成整形的仿函数传给第二个模板参数
•unordered_map 默认要求 Key 支持比较相等,如果不支持或者想按自己的需求走可以自行实现支持将 Key 比较相等的仿函数传给第三个模板参数
• 一般情况下,都不需要传后三个模板参数
•unordered_map 底层是用哈希桶实现,增删查平均效率是O(1),迭代器遍历不再有序,为了跟 map 区分,所以取名 unordered_map
map 和 unordered_map 的功能高度相似,只是底层结构不同,有一些性能和使用上的差异,本文主要讲解 map 和 unordered_map 的不同。
不同之处之一:map 和 unordered_map 声明的差异
map 的声明:
template < class Key, // map::key_type
class T, // map::mapped_type
class Compare = less<Key>, // map::key_compare
class Alloc = allocator<pair<const Key,T> > // map::allocator_type
> class map;
unordered_map 的声明:
template < class Key, // unordered_map::key_type
class T, // unordered_map::mapped_type
class Hash = hash<Key>, // unordered_map::hasher
class Pred = equal_to<Key>, // unordered_map::key_equal
class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type
> class unordered_map;
map 的 Key 类型只需支持比较大小;unordered_map 的 Key 类型需要支持转换成整型和支持比较相等
unordered_map 的增删查接口的使用与 map 的使用⼀模⼀样,这里我们不过多赘述。
不同之处之二:迭代器的差异
map 的 iterator 是双向迭代器, unordered_map 是单向迭代器。其次 map 的底层是红黑树,红黑树是二叉搜索树,走中序遍历是有序的,所以 map 迭代器遍历是 Key 有序+去重;而 unordered_map 底层是哈希表,迭代器遍历是 Key 无序+去重
不同之处之三:性能的差异
整体而言大多数场景下,unordered_map 的增删查改效率更高一些,因为红黑树增删查改效率是 O(logN),而哈希表增删查平均效率是O(1)
我们可以对比 map 和 unordered_map 容器排序相同的数据所需要的时间
代码:
const size_t N = 1000000;
unordered_map<int, int> um;
map<int, int> m;
vector<int> v;
v.reserve(N);
srand(time(0));
// 将随机数存储到vector容器中
for (size_t i = 0; i < N; ++i)
{
//v.push_back(rand()); // N⽐较⼤时,重复值⽐较多
v.push_back(rand() + i); // 重复值相对少
//v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
m.insert({e, e});
}
size_t end1 = clock();
cout << "map insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
um.reserve(N);
for (auto e : v)
{
um.insert({e,e});
}
size_t end2 = clock();
cout << "unordered_map insert:" << end2 - begin2 << endl;
cout << endl;
int m1 = 0;
size_t begin3 = clock();
for (auto& e : v)
{
auto ret = m.find(e);
if (ret != m.end())
{
++m1;
}
}
size_t end3 = clock();
cout << "map find:" << end3 - begin3 << "->" << m1 << endl;
int m2 = 0;
size_t begin4 = clock();
for (auto& e : v)
{
auto ret = um.find(e);
if (ret != um.end())
{
++m2;
}
}
size_t end4 = clock();
cout << "unorered_map find:" << end4 - begin4 << "->" << m2 << endl;
cout << endl;
cout << "插入数据个数:" << m.size() << endl;
cout << "插入数据个数:" << um.size() << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
m.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
um.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
运行结果:
debug 版本

release 版本

unordered_multimap/unordered_multiset 与 multimap/multiset 功能完全类似,支持 Key 冗余。unordered_multimap/unordered_multiset 跟 multimap/multiset 的差异也是三个方面的差异, Key的要求的差异,iterator 及遍历顺序的差异,性能的差异。这里不过多赘述。
哈希表
哈希表的概念
哈希(hash)又称散列,是⼀种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 Key 存储的位置,进行快速查找。哈希函数指的是通过一个值算出该值在哈希表中的位置的方法,一个 Key 在哪个位置取决于采用哪种哈希函数。
哈希冲突
下面来介绍一种常见的哈希函数的构造方法:直接定址法。
当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如一组关键字都在 [0,99] 之间,那么开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都是在 [a,z] 范围内的小写字母,那么开⼀个26个数的数组,每个关键字acsii码 - a 的ascii码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出⼀个绝对位置或者相对位置。
直接定址法的缺点也非常明显,它的使用范围非常小。当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是 [0,9999] 的 N 个值,要映射到⼀个 M 个空间的数组中(⼀般情况下 M>=N ),那么就要借助哈希函数 hf,关键字 key 被放到数组的 h(key) 位置,要注意的是 h(key) 计算出的值必须在 [0,M) 之间。
这里可能会存在⼀个问题,两个不同的 key 可能会映射到同一个位置,这种问题我们叫做哈希冲突, 或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的, 所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。
负载因子
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么负载因子 = N/M,负载因子有些地方也翻译为载荷因子/装载因子等。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
哈希函数
一个好的哈希函数应该让 N 个关键字被等概率的均匀的散列分布到哈希表的 M 个空间中,实际中是很难做到的,但是我们要尽量往这个方向去考量设计。接下来介绍三种哈希函数。
除法散列法/除留余数法
除留余数法顾名思义,假设哈希表的大小为 M,那么通过 key 除以 M 的余数作为映射位置的下标,也就是哈希函数为:h(key)=key%M。这里能使用除留余数法的前提是 Key 类型能够使用%运算符,即 Key 可以转化成整形。使用除留余数法容易出现多对一的映射,也就是哈希冲突。
使用除留余数法时,针对 M 取值的建议:
•避免M为某些值,如2的幂,10的幂等如果 M 为 2^x,那么 key%2^x 本质相当于保留 key 的后 x 位(二进制表示),那么后 x 位相同的值,计算出的哈希值都是一样的,就冲突了。
如:{63 , 31}看起来是两个没有关联的值,如果 M 是16,也就是 ,那么计算出的哈希值都是15,因为63的二进制后8位是00111111,31的二进制后8位是00011111;
如果 M 为 10^X ,就更明显了,保留的都是10进值的后 x 位,如:{112,12312},如果 M 是100,也就是10^2,那么计算出的哈希值都是12
•建议M取不太接近2的整数次幂的一个质数(素数)
这仅仅是建议,实践中还得灵活运用,虽然不建议 M 的取值为2的幂,但是还是有些地方会这么取,如 java 的 hashMap 采用除留余数法时 M 取的就是2的整数次幂。
乘法散列法
乘法散列法对哈希表大小 M 没有要求,它的思路第一步:用关键字 k 乘上常数 A(0<A<1) ,并抽取出 k*A 的小数部分;第二步:用 M 乘以 k*A 的小数部分,再向下取整。哈希函数为:h(key) = floor(M ×((A ×key)%1.0)),其中 floor 表示对表达式进行向下取整,%1.0是为了使结果是小数,A∈(0,1)。这里最重要的是 A 的值应该如何设定,Knuth 认为 A =( √5 - 1)/2 = 0.6180339887.... (⻩金分割点) 比较好。
假设 M 为1024,key 为1234,A = 0.6180339887,A*key = 762.6539420558,取小数部分为0.6539420558,M×((A×key)%1.0)=0.6539420558*1024= 669.6366651392,那么 h(1234)=669。
全域散列法
针对上述所介绍的哈希函数,可能会有极端情况,所有关键字全部落入同一个位置中,我们可以给哈希函数增加随机性,这种方法叫做全域散列法。哈希函数为:hab(key) = ((a * key + b)%P)%M,P 需要选一个足够大的质数,a 可以随机选 [1,P-1] 之间任意整数,b 可以随机选 [0,P-1] 之间的任意整数,这些函数构成了一个 P*(P-1) 组全域散列函数组。假设 P=17,M=6,a=3,b=4,则 h34(8) = ((3 ×8+4)%17)%6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key。
解决冲突的方法
实践中哈希表一般还是选择除法散列法作为哈希函数。当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两方法,开放定址法和链地址法。
开放定址法
在开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储。开放定址法中负载因子一定是小于 1 的。开放定址法的规则有三种:线性探测、二次探测、双重探测。
线性探测
•从发生冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置
• 哈希函数为:h(key) = hash0 = key % M,hc(key,i) = hashi = (hash0+i) % M i = {1,2,3,...,M −1} ,若hash0 位置冲突了,则线性探测公式为:hc(key,i) = hashi = (hash0+i) % M,i 从1开始。因为负载因子小于1,则最多探测 M-1 次,一定能找到一个存储 key 的位置
•线性探测理解起来比较简单且容易实现,如果 hash0 位置连续冲突,hash0,hash1, hash2 位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3 的值都会争夺hash3 位置,这种现象叫做群集/堆积
下面来演示使用线性探测法将{19,30,5,36,13,20,21,12}这组值值映射到 M=11 的表中:

二次探测
•从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置
•哈希函数为:h(key) = hash0 = key % M , 若 hash0 位置冲突了,则进行二次探测,公式为:hc(key,i) = hashi = (hash0±i^2 ) % M i = M {1,2,3,..., M/2},位置冲突了,i 每次从 1 开始
•优先使用正向探测,即 +i^2 。当二次探测的公式为:hashi = (hash0−i^2 )%M,即 hashi < 0 时,需要 hashi+=M
下面来演示使用二次探测法将 {19,30,52,63,11,22} 这组值值映射到 M=11 的表中:

双重散列法
•第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止
•h1(key) = hash0 = key % M,hash0位置冲突了,则双重探测公式为:hc(key,i) = hashi = (hash0+ i∗h2(key)) % M,i = {1,2,3,..., M},从 1 开始
•建议h2(key) < M 且 h2(key) 和 M互为质数,有两个简单的方法:1. 当M为2的整数次幂时,h2(key)从[0, M-1]区间中任选一个奇数
2. 当M为质数时,h2(key) = key % (M – 1) + 1
•保证 h2(key) 与 M 互质(互质指两个数的公因数只有1)是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p = gcd(M,h1(key)) > 1,那么所能寻址的位置的个数为 M/P < M,使得对于一个关键字来说无法充分利用整个散列表
下面来演示使用二次探测法将 {19,30,52,74} 这组值值映射到 M=11 的表中,设 h2(key) = key%10+1:

使用线性探测法实现哈希表
假设我要删除某个数,那么如何在哈希表中表示这个数被删除了呢?有人可能会想直接删除那个数不就好了吗?假设删除的数是 19,数据直接删除后,它所在的位置为空,如果我要查找30,30 % 11 = 8,并且19所在的位置的下标也是8,将19删除之后,下标为8的位置为空,遇到空就停止查找,所以查找结果是30不在哈希表内,这与实际情况相悖。所以删除数据,不能直接删除,因为可能会影响冲突值的查找,应该设置哈希表中每个位置的状态,共三种状态,有数据,没数据,数据被删除了,如果删除了某个数据,将该数据所在的位置的状态由有数据置为数据被删除了。
为了能够自行设置哈希表中数据的结构,可以实现一个 HashData 类。
哈希表的基本框架:
// 设置哈希表中的位置状态
enum Status
{
EMPTY, // 空
EXIST, // 存在
DELETE // 删除
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
Status _status = EMPTY;
};
template<class K, class V>
class HashTable
{
public:
// 构造函数
HashTable()
: _ht(11)
, _n(0)
{}
private:
// 底层是vector
vector<HashData<K, V>> _ht;
size_t _n = 0; // 哈希表中的有效数据个数
};
insert 函数实现
// 插入
bool Insert(const pair<K, V>& kv)
{
// h(key) = hash0 = key % M
// M 是 hash 表的大小,hash 表的大小是size,而不是capacity
// 计算第一个元素存储在hash表中的下标位置
size_t hash0 = hs(kv.first) % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
既然要插入数据,在插入之前应该检查是否需要扩容,什么情况下需要扩容呢?当哈希表的负载因子为0.7时,就需要扩容。怎么扩容呢?是二倍扩容吗?不能直接往二倍容积去扩,扩容后容器的大小会改变,进而影响 key 的映射位置。正确的扩容方式如下代码所示:
// 插入
bool Insert(const pair<K, V>& kv)
{
// 判断是否需要扩容
// 当负载因子大于等于0.7时,需要扩容
// 负载因子的计算公式:N/M
//N -- 哈希表中存储的数据个数,M -- 哈希表的大小
if ((double)_n / _ht.size() >= 0.7)
{
// 创建一个新的哈希表,在创建的同时开辟好空间
vector<hashData<K, V>> newhashTable(_ht.size() * 2);
// 将旧表中的数据重新映射到新表中
for (auto& data : _ht)
{
// 如果hash表的位置状态为EXIST,就映射在新hash表中
if (data._status == EXIST)
{
// 计算第一个元素存储在hash表中的下标位置
size_t hash0 = kv.first % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = (hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
}
}
// 新旧两表的引用进行交换
_ht.swap(newhashTable);
}
// h(key) = hash0 = key % M
// M 是 hash 表的大小,hash 表的大小是size,而不是capacity
// 计算第一个元素存储在hash表中的下标位置
size_t hash0 = hs(kv.first) % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
我们可以清楚的看得到扩容逻辑与 insert 函数的插入逻辑基本一致,因此我们可以优化扩容代码的实现方式,调用哈希表中的 insert 函数。
// 插入
bool Insert(const pair<K, V>& kv)
{
// 判断是否需要扩容
// 当负载因子大于等于0.7时,需要扩容
// 负载因子的计算公式:N/M
// N -- 哈希表中存储的数据个数,M -- 哈希表的大小
if ((double)_n / _ht.size() >= 0.7)
{
// 新建一个hash表
HashTable<K, V, Hash> newHT;
// 开辟两倍大小空间
// 获取素数表中的下一个素数
// 假设ht.size()的结果是53,53+1=54
// 在素数表中获取大于等于54的素数,也就是97,这样就达到扩容的目的了
newHT._ht.resize(2 * _ht.size());
// 遍历旧表,将所有的值重新映射到新表
for (auto& data : _ht)
{
// 如果hash表的位置状态为EXIST,就映射在新hash表中
if (data._status == EXIST)
{
// 内部逻辑:线性探测解决冲突问题
// 与Insert函数中的逻辑一致,直接调用表中的insert函数
newHT.Insert(data._kv);
}
// 新旧两表进行交换
_ht.swap(newHT._ht);
}
}
// h(key) = hash0 = key % M
// M 是 hash 表的大小
// 计算第一个元素存储在hash表中的下标位置
size_t hash0 = kv.first % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = (hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
再回看Hash表的构造函数,_ht 初始空间大小为11,之前曾说过M,即哈希表的大小建议取接近2^x的质数,初始时大小是质数,但是2倍扩容之后就不是了。那么如何解决这个问题呢?我们可以参考STL中的解决方法,代码如下所示:
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
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
};
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;
}
由 lower_bound 函数的功能可知,该内联函数的作用是返回第一个大于 n 的数,返回值在 __stl_next_prime 函数中的素数表中选取。如参数 n 为0,则返回第一个大于0的数,也就是53。在类中实现该函数之后,我们可以优化哈希表的构造函数和 insert 函数。
// 构造函数
HashTable()
: _ht(__stl_next_prime(0))
, _n(0)
{}
// 插入
bool Insert(const pair<K, V>& kv)
{
// 判断是否需要扩容
// 当负载因子大于等于0.7时,需要扩容
// 负载因子的计算公式:N/M
// N -- 哈希表中存储的数据个数,M -- 哈希表的大小
if ((double)_n / _ht.size() >= 0.7)
{
// 新建一个hash表
HashTable<K, V, Hash> newHT;
// 开辟两倍大小空间
// 获取素数表中的下一个素数
// 假设ht.size()的结果是53,53+1=54
// 在素数表中获取大于等于54的素数,也就是97,这样就达到扩容的目的了
newHT._ht.resize(__stl_next_prime(_ht.size() + 1));
// 遍历旧表,将所有的值重新映射到新表
for (auto& data : _ht)
{
// 如果hash表的位置状态为EXIST,就映射在新hash表中
if (data._status == EXIST)
{
// 内部逻辑:线性探测解决冲突问题
// 与Insert函数中的逻辑一致,直接调用表中的insert函数
newHT.Insert(data._kv);
}
// 新旧两表进行交换
_ht.swap(newHT._ht);
}
}
// h(key) = hash0 = key % M
// M 是 hash 表的大小
// 计算第一个元素存储在hash表中的下标位置
Hash hs;
size_t hash0 = hs(kv.first) % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
在之前讲解除留余数法时,曾提到使用该方法的前提是 key 可以使用取模运算符,即可以转化成整型,倘若 key 不能使用取模运算符呢?当 key 是 string/Date 等类型时,key 不能取模,那么就需要给 HashTable 增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整形。如果 key 可以转换为整形并且不容易冲突,那么这个仿函数就使用默认参数即可;如果这个 key不能转换为整形,我们就需要自己实现一个仿函数传给这个参数,实现这个仿函数的要求就是尽量让 key 的每值都参与到计算中,让不同的 key 转换出不同的整形值。怎么让 string 类型的数据转化成整型?可以将字符串中所有字符的 ASCII 码相加得到的结果当作转化的整型,需要注意的是可能不同的字符串中的字符的 ASCII 码值相加之后是相等的,针对这种情况有特殊处理,具体处理方式看代码注释。由于 string 类型做哈希表的 key 非常常见,所以可以考虑把仿函数特化一下。
// 仿函数
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
// 强制类型转化为无符号的整型,为了解决key为负数的情况
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
// 将字符串中所有的ASCII都相加
// 但是不同的字符串的字符相加之后的值相等
// 这里我们使用 BKDR 哈希的思路,用上次的计算结果去乘以一个质数
// 这个质数一般取 31, 131 等数值效果会比较好
// 这里选取 31
size_t hash = 0;
for (auto e : str)
{
hash += e;
hash *= 31;
}
return hash;
}
};
因此 hashTable 需要新增模板参数仿函数,此外凡是需要使用取模运算符的数据,都需要调用仿函数,因此优化后代码如下所示:
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// 插入
bool Insert(const pair<K, V>& kv)
{
// 判断是否需要扩容
// 当负载因子大于等于0.7时,需要扩容
// 负载因子的计算公式:N/M
// N -- 哈希表中存储的数据个数,M -- 哈希表的大小
if ((double)_n / _ht.size() >= 0.7)
{
// 新建一个hash表
HashTable<K, V, Hash> newHT;
// 开辟两倍大小空间
// 获取素数表中的下一个素数
// 假设ht.size()的结果是53,53+1=54
// 在素数表中获取大于等于54的素数,也就是97,这样就达到扩容的目的了
newHT._ht.resize(__stl_next_prime(_ht.size() + 1));
// 遍历旧表,将所有的值重新映射到新表
for (auto& data : _ht)
{
// 如果hash表的位置状态为EXIST,就映射在新hash表中
if (data._status == EXIST)
{
// 内部逻辑:线性探测解决冲突问题
// 与Insert函数中的逻辑一致,直接调用表中的insert函数
newHT.Insert(data._kv);
}
// 新旧两表进行交换
_ht.swap(newHT._ht);
}
}
// h(key) = hash0 = key % M
// M 是 hash 表的大小
// 计算第一个元素存储在hash表中的下标位置
Hash hs;
size_t hash0 = hs(kv.first) % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
查找函数和 erase 函数
这两个函数不必多说,不了解的地方看代码注释,直接实现代码:
// 查找
HashData<K, V>* Find(const K& key)
{
Hash hs;
// h(key) = hash0 = key % M
// M 是 hash 表的大小
// 计算第一个元素存储在hash表中的下标位置
size_t hash0 = hs(key) % _ht.size();
// 从第一个元素存储的下标位置开始查找
size_t hashi = hash0;
// 从0位置开始查起
size_t i = 0;
// 循环查找 --- 循环条件为查找的位置的状态不为空
while (_ht[hashi]._status != EMPTY)
{
// 如果状态为EXIST,且key值相等,则说明找到了
if (_ht[hashi]._status == EXIST && _ht[hashi]._kv.first == key)
{
return &_ht[hashi];
}
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
return nullptr;
}
// 删除
bool Erase(const K& key)
{
// 查找要删除的数据
auto* ptr = Find(key);
// 如果找到了,将待删除的数据所在的位置状态置为DELETE
if (ptr)
{
ptr->_status = DELETE;
// 表的有效数据减1
--_n;
return true;
}
return false;
}
find 函数实现后,我们可以进一步的优化 insert 函数,哈希表是 unordered_set 和 unordered_map 的底层数据结构,它们都不支持数据冗余,哈希表也是如此。在插入数据之前需要检查该数据是否已经在表中存在,存在就不插入,不存在就插入。
insert 函数的最终代码实现结果如下所示:
// 插入
bool Insert(const pair<K, V>& kv)
{
// 去冗余
if (Find(kv.first))
{
return false;
}
// 判断是否需要扩容
// 当负载因子大于等于0.7时,需要扩容
// 负载因子的计算公式:N/M
// N -- 哈希表中存储的数据个数,M -- 哈希表的大小
if ((double)_n / _ht.size() >= 0.7)
{
// 新建一个hash表
HashTable<K, V, Hash> newHT;
// 开辟两倍大小空间
// 获取素数表中的下一个素数
// 假设ht.size()的结果是53,53+1=54
// 在素数表中获取大于等于54的素数,也就是97,这样就达到扩容的目的了
newHT._ht.resize(__stl_next_prime(_ht.size() + 1));
// 遍历旧表,将所有的值重新映射到新表
for (auto& data : _ht)
{
// 如果hash表的位置状态为EXIST,就映射在新hash表中
if (data._status == EXIST)
{
// 内部逻辑:线性探测解决冲突问题
// 与Insert函数中的逻辑一致,直接调用表中的insert函数
newHT.Insert(data._kv);
}
// 新旧两表进行交换
_ht.swap(newHT._ht);
}
}
// h(key) = hash0 = key % M
// M 是 hash 表的大小
// 计算第一个元素存储在hash表中的下标位置
Hash hs;
size_t hash0 = hs(kv.first) % _ht.size();
// 从第一个元素存储的下标位置开始探测
size_t hashi = hash0;
size_t i = 0;
// 线性探测 --- 循环条件探测的位置的状态为空
while (_ht[hashi]._status == EMPTY)
{
// hc(key,i) = hashi = (hash0+i) % M
hashi = hs(hash0 + i) % _ht.size();
++i;
}
// 将值存储到hash表中
_ht[hashi]._kv = kv;
// 更改 hashi 位置的状态
_ht[hashi]._status = EXIST;
// hash表中的有效数据个数加1
++_n;
return true;
}
链地址法
开放定址法与链地址法解决哈希冲突的方法不同:开放定址法是将所有的元素都放到哈希表里;链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下面。因此链地址法也叫做拉链法或者哈希桶。
下面来演示使用链地址法将 {19,30,5,36,13,20,21,12,24,96} 这组值值映射到 M=11 的表中:

如果在极端场景下,某个桶特别长怎么办?可以考虑使用全域散列法。若单是偶然情况下,某个桶很长,查找效率很低怎么办?可以将长的那个桶由链表转换成红黑树(STL源码不是这样实现的)。
哈希表的基本框架
既然哈希表中存储的数据是指针,指针指向一个链表,那使用链地址法实现哈希表的底层数据结构就为 vector<list<pair<K, V>> 吗?不建议底层结构使用它,该结构使用了三个类,在后续的封装实现上会很麻烦;建议自己实现单链表的结点的结构。
template<class K, class V>
// 链表的结点的结构
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
// 构造函数
HashNode(const pair<K, V>& kv)
: _kv(kv)
, _next(nullptr)
{}
};
template<class K, class V>
class HashBucket
{
typedef HashNode<K, V> Node;
public:
private:
// 哈希桶的底层结构不能这样,这个方式代码实现起来有些复杂
//vector < list<pair<K, V>> _hb;
// 推荐使用这种方式
vector<Node*> _hb;
// 桶中的有效数据个数
size_t _n;
};
基于使用开放定址法的线性探测实现的哈希表的经验,需要考虑 M 的大小总是为素数,key 不能取模的问题。解决方案:使用STL源码中的素数表;自己实现仿函数。
// 仿函数
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
// 强制类型转化为无符号的整型,为了解决key为负数的情况
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
// 将字符串中所有的ASCII都相加
// 但是不同的字符串的字符相加之后的值相等
// 这里我们使用 BKDR 哈希的思路,用上次的计算结果去乘以一个质数
// 这个质数一般取 31, 131 等数值效果会比较好
// 这里选取 31
size_t hash = 0;
for (auto e : str)
{
hash += e;
hash *= 31;
}
return hash;
}
};
插入数据时,需要调用结点的构造函数新建一个结点,因此类中申请了额外的资源,需要用户实现析构函数
// 构造函数
HashBucket()
: _hb(__stl_next_prime(0), nullptr)
, _n(0)
{}
// 析构函数
~HashBucket()
{
// 遍历哈希表
for (size_t i = 0; i < _hb.size(); i++)
{
Node* cur = _hb[i];
// 将链表的结点逐一释放
while (cur)
{
// 提前存储下一个结点的地址
Node* next = cur->_next;
// 释放当前结点
delete cur;
cur = next;
}
// 再将桶位置置为空
_hb[i] = nullptr;
}
}
insert 函数
哈希桶的插入逻辑与开放定址法的插入逻辑差不多,都是使用除留余数法计算出插入的位置,只是哈希桶这里的插入逻辑还需要用到单链表。
// 插入
bool Insert(const pair<K, V>& kv)
{
Hash ht;
// 计算出数据插入的位置
size_t hashi = ht(kv.first) % _hb.size();
// 插入时,怎么插?头插还是尾插?
// 这里的链表是我们自己实现的,是单向链表
// 尾插还需要找尾,建议头插
Node* newnode = new Node(kv);
// 头结点的指针就在哈希表中,新的结点的next指针指向头结点
newnode->_next = _hb[hashi];
// 插入的结点成为新的头结点,指针存在hash表中
_hb[hashi] = newnode;
return true;
}
在插入数据之前需要判断哈希表是否需要扩容,那么链地址法的扩容逻辑与开放定址法的扩容逻辑有什么不同吗?开放定址法负载因子必须小于1,链地址法的负载因子没有限制,可以大于1。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。stl中 unordered_xxx 的最大负载因子基本控制在1,大于1就扩容。所以这里实现 insert 的扩容逻辑就是负载因子等于1就扩容。
理清了扩容的条件之后,内部扩容的逻辑又是怎样的?这里扩容的思路不能像直接定址法的线性探测那样去扩容,因为之前某些数据是在同一个桶中,但是扩容之后就不一定在同一个桶中了;如果像线性探测直接调用哈希表中的 insert 函数,这样的实现方法可以是可以,但是不怎么好。因为遍历旧表,拿到旧表中的数据之后,调用 insert 函数,基于该数据再新建结点,如果旧表中有大量的结点,在扩容时又需要额外空间新建这些结点,会降低效率。所以使用链地址法扩容时,不建议复用哈希表中的 insert 函数。
代码如下所示:
// 插入
bool Insert(const pair<K, V>& kv)
{
Hash ht;
// 插入结点前,需要判断是否需要扩容
// 当负载因子等于1时就需要扩容
if (_n == _hb.size())
{
// 创建新表
vector<Node*> newbucket(__stl_next_prime(_hb.size() + 1));
// 遍历旧表 --- 建议使用下标访问
for (size_t i = 0; i < _hb.size(); i++)
{
// 遍历旧表,将旧表的数据插入到新表中
Node* cur = _hb[i];
// 遍历哈希桶
while (cur)
{
// 保存链表中下一个结点的地址
Node* next = cur->_next;
// 计算出数据插入新表的位置
size_t hashi = ht(cur->_kv.first) % newbucket.size();
// 头结点的指针就在哈希表中,新的结点的next指针指向头结点
cur->_next = newbucket[hashi];
// 插入的结点成为新的头结点,指针存在hash表中
newbucket[hashi] = cur;
// 继续向后遍历链表
cur = next;
}
}
// 新旧两表交换
_hb.swap(newbucket);
}
// 计算出数据插入的位置
size_t hashi = ht(kv.first) % _hb.size();
// 插入时,怎么插?头插还是尾插?
// 这里的链表是我们自己实现的,是单向链表
// 尾插还需要找尾,建议头插
Node* newnode = new Node(kv);
// 头结点的指针就在哈希表中,新的结点的next指针指向头结点
newnode->_next = _hb[hashi];
// 插入的结点成为新的头结点,指针存在hash表中
_hb[hashi] = newnode;
return true;
}
查找函数和 erase 函数
这两个函数不必多说,逻辑与插入函数差不多,不了解的地方看代码注释。需要注意的是 erase 函数删除数据时,需要分情况:头删和中间删除。
代码:
// 查找
Node* Find(const K& key)
{
Hash ht;
// 计算查找数据的位置
size_t hashi = ht(key) % _hb.size();
// 在表中位于hashi位置的链表中找
Node* cur = _hb[hashi];
// 遍历链表
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
// 继续往后查找
cur = cur->_next;
}
// 找不到返回空指针
return nullptr;
}
// 删除
bool Erase(const K& key)
{
Hash ht;
// 计算删除数据的位置
size_t hashi = ht(key) % _hb.size();
// 在表中位于hashi位置的链表中找
Node* cur = _hb[hashi];
// 找 cur 结点的前一个结点
Node* prev = nullptr;
// 遍历链表
while (cur)
{
if (cur->_kv.first == key)
{
// 如果cur就是头结点
if (prev == nullptr)
{
// 将头结点的下一个结点的地址给表头
_hb[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
// 继续往后查找
prev = cur;
cur = cur->_next;
}
return false;
}
两个解决哈希冲突的方法推荐使用链地址法。
448

被折叠的 条评论
为什么被折叠?



