漫步STL-Hash与unordered容器

1. unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到logN,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同
同样的容器在Java中就很接地气
c++ | Java |
---|---|
map | TreeMap |
set | TreeSet |
unordered_map | HashMap |
unordered_set | HashSet |
2. unordered_map
2.1 unordered_map快速入门
🍁 unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
🍁 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
🍁 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
🍁 unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
🍁 unordered_maps实现了直接访问操作符(operator[])
,它允许使用key作为参数直接访问value。
🍁 它的迭代器至少是前向接口迭代器。
2.2 unordered_map常用接口
哈希表的话其实和map和set的诸多接口都是类似的
2.2.1 unordered_map()
函数声明 | 功能介绍 |
---|---|
unordered_map() | 构造不同格式的unordered_map对象 |
2.2.2 unordered_map capacity
函数声明 | 功能介绍 |
---|---|
bool empty() const | 检测unordered_map是否为空 |
size_t size() const | 获取unordered_map的有效元素个数 |
2.2.3 unordered_map:: iterator
函数声明 | 功能介绍 |
---|---|
begin | 返回unordered_map第一个元素的迭代器 |
end | 返回unordered_map最后一个元素下一个位置的迭代器 |
cbegin | 返回unordered_map第一个元素的const迭代器 |
cend | 返回unordered_map最后一个元素下一个位置的const迭代器 |
所有的容器使用迭代器遍历的时候都是类似的方式
unordered_set<int>::iterator it = us.begin();
while (it != us.end())
{
cout << *it << " ";
++it;
}
cout << endl;
范围for也是一样的
for (auto e : us)
{
cout << e << " ";
}
cout << endl;
2.2.4 element元素访问
函数声明 | 功能介绍 |
---|---|
operator[] | 返回与key对应的value,没有一个默认值 |
该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶中插入,如
果key不在哈希桶中,插入成功,返回V(),插入失败,说明key已经在哈希桶中,将key对应的value返回。
2.2.5 Element lookup
函数声明 | 功能介绍 |
---|---|
iterator find(const K& key) | 返回key在哈希桶中的位置 |
size_t count(const K& key) | 返回哈希桶中关键码为key的键值对的个数 |
由于unordered_map中key是不能重复的,因此count函数的返回值最大为1
🌿find
其实我们的查找是由两种方法的
🌸 如果是哈希表的专有查找
优点是:利用率hash特性,效率很高-- O(1)
🌸 如果是使用通用算法(来自于<algorithm>
)
优点:每个容器都可以使用,泛型算法。 缺点:暴力查找 – O(N)
⚡️p.s. 如果使用的不是hash的而是普通的set的话,那么红黑树查找效率是O(logN)
// unordered_set专用查找算法。优点:使用哈希特性去查找,效率高 -- O(1)
// 类似如果是set的,效率是O(logN)
auto pos = us.find(2);
// 通用算法,优点:每个容器都可以使用,泛型算法。 缺点:暴力查找 -- O(N)
auto pos = find(us.begin(), us.end(), 2);
if (pos != us.end())
{
cout << "找到了" << endl;
}
else
{
cout << "找不到" << endl;
}
2.2.6 Modifiers
函数声明 | 功能介绍 |
---|---|
insert | Insert elements (public member function ) |
erase | Erase elements (public member function ) |
clear | Clear content (public member function ) |
swap | Swap content (public member function) |
2.2.7 Buckets
函数声明 | 功能介绍 |
---|---|
size_t bucket_count()const | 返回哈希桶中桶的总个数 |
size_t bucket_size(size_t n)const | 返回n号桶中有效元素的总个数 |
size_t bucket(const K& key) | 返回元素key所在的桶号 |
size_t max_bucket_count() const | 返回潜在可拥有的最大桶数 |
2.2.8 Hash policy
load_factor | 当前负载因子 |
---|---|
max_load_factor | 容器的当前最大负载因子 |
rehash | 相当于reserve,预留空间,将容器中的桶数设置为n或更多 |
reserve | 将容器中的桶数设置为最适合包含至少n 个元素的桶数 |
3. unordered_set
类似结合set和unordered_map
4. unordered V.S. ordered
下面通过一段代码,来演示一下差别
void test_op()
{
int N = 100000;
vector<int> v;
v.reserve(N);
srand(time(0));
for (int i = 0; i < N; ++i)
{
v.push_back(rand());
}
unordered_set<int> us;
set<int> s;
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "set insert:" << end1 - begin1 << endl;
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "set find:" << end3 - begin3 << endl;
cout << "unordered_set find:" << end4 - begin4 << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "set erase:" << end5 - begin5 << endl;
cout << "unordered_set erase:" << end6 - begin6 << endl;
}
可见unordered_map的效率是显著的高的
5. Hash底层
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。Hash结构其实又可以叫散列结构,本质上是一种建立映射,计数排序中可以说就是用到了哈希
Hash是一种数学函数,可将任意长度的输入转换为固定长度的加密输出。因此,无论所涉及的原始数据量或文件大小如何,其唯一的哈希值始终是相同的大小。此外,散列不能用于“逆向工程”来自散列输出的输入,因为散列函数是“单向”的(就像绞肉机;你不能把碎牛肉放回牛排中)。尽管如此,如果您对相同的数据使用这样的函数,它的哈希值将是相同的,因此如果您已经知道它的哈希值,您可以验证数据是否相同(即未更改)。
5.1 Intro of Hash
非哈希
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数。
哈希
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
🌿 插入元素
⚡️ 根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
🌿 搜索元素
⚡️ 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
5.2 Hash function
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 哈希函数设计原则:
🐉 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
🐉 哈希函数计算出来的地址能均匀分布在整个空间中
🐉 哈希函数应该比较简单
5.3.1 直接定址法(常用)
取关键字的某个线性函数为散列地址:
H
a
s
h
(
K
e
y
)
=
A
∗
K
e
y
+
B
Hash(Key)= A*Key + B
Hash(Key)=A∗Key+B
😄 优点:简单、均匀 ,速度快O(1)
😢 缺点:需要事先知道关键字的分布情况, 不能处理浮点数,字符串等等,如果给定一个很大范围,会浪费很多空间
🎲 使用场景:适合查找比较小且连续的情况,只能是整数,而且数据范围比较集中
5.3.2 除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:
H
a
s
h
(
k
e
y
)
=
k
e
y
%
p
(
p
<
=
m
)
Hash(key) = key\ \%\ p \ (p<=m)
Hash(key)=key % p (p<=m)
将关键码转换成哈希地址
😄 优点:数据范围可以很大,使用场景一下子广起来了
😢 缺点:不同的值会映射到同一个位置上:哈希冲突,冲突越多,效率下降越厉害
🎲 使用场景:数据范围很大
5.3.3 平方取中法(少用)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
🎲 使用场景:不知道关键字的分布,而位数又不是很大的情况
5.3.4 折叠法(少用)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加
求和,并按散列表表长,取后几位作为散列地址。
🎲 使用场景:事先不需要知道关键字的分布,适合关键字位数比较多的情况
5.3.5 随机数法(少用)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为
随机数函数。
🎲 使用场景:通常应用于关键字长度不等时采用此法
5.3.6 数学分析法(少用)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我
们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字
进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改
成12+34=46)等方法。
🎲 使用场景:通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布
较均匀的情况
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
5.3 Hash collision
哈希冲突是哈希表中的两条数据共享相同的哈希值。 在这种情况下,哈希值来自一个哈希函数,该函数接受数据输入并返回固定长度的位。
尽管哈希算法的创建是为了防止冲突,但它们有时仍然可以将不同的数据映射到同一个哈希(根据鸽巢原理)。恶意用户可以利用这一点来模仿、访问或更改数据
对于两个数据元素的关键字ki 和kj和 (i != j),有ki != kj ,但有:
H
a
s
h
(
k
i
)
=
=
H
a
s
h
(
k
j
)
Hash(k_i) ==Hash(k_j)
Hash(ki)==Hash(kj)
即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
6. Resolve Hash collisions
解决哈希冲突两种常见的方法是:闭散列和开散列
当发生Collision时,Chaining会将所有被Hash Function分配到同一格slot的资料透过Linked list串起来,像是在书桌的抽屉下面绑绳子般,把所有被分配到同一格抽屉的物品都用绳子吊在抽屉下面。
相较于Chaining提供额外空间(node)来存放被分配到相同slot的资料,Open Addressing则是将每笔资料都放在书桌(Table)本身配备的抽屉(slot),一格抽屉只能放一个物品,如果抽屉都满了,就得换张书桌(重新配置记忆体给新的Table)。
6.1 闭散列
闭散列:也叫开放定址法,在开放空间内找一个位置定值,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
6.1.1 线性探测
已知模出来映射的位置已经冲突,那线性探测的想法很简单,我们采用一个固定大小的哈希表,每次遇到哈希冲突时,我们以循环方式线性遍历该表以找到下一个空槽。
H(0)-> 0%5 = 0
H(1)-> 1%5 = 1
H(2)-> 2%5 = 2
H(4)-> 4%5 = 4
H(5)-> 5%5 = 0
在这种情况下,我们的哈希函数可以被认为是:
H
(
x
,
i
)
=
(
H
(
x
)
+
i
)
%
l
e
n
H(x, i) = (H(x) + i)\%len
H(x,i)=(H(x)+i)%len
其中 N 是表的大小,i 表示从 1 开始的线性递增变量(直到找到空桶)。
我们可以发现空间越大冲突的可能性会越小,然而,冲突还是会存在,没有被完全解决
线性探测举例
下面的哈希函数取得是:
H
a
s
h
(
k
e
y
)
=
k
e
y
%
l
e
n
+
i
Hash(key) = key\%len + i
Hash(key)=key%len+i
线性探测优缺点
😄 优点:实现非常简单
😢 缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据
了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
6.1.2 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就
是挨着往后逐个去找,因此二次探测为了避免该问题
这种方法处于高速缓存性能和集群问题的中间。总体思路保持不变,唯一的区别是我们在寻找空桶时查看每次迭代的 Q(i) 增量,其中 Q(i) 是 i 的某个二次表达式。Q 的一个简单表达式是 Q(i) = i2,在这种情况下,哈希函数看起来像这样:
H
(
x
,
i
)
=
(
H
(
x
)
+
i
2
)
%
l
e
n
H(x, i) = (H(x) + i^2)\%len
H(x,i)=(H(x)+i2)%len
二次探测举例
下面的哈希函数取得是:
H
a
s
h
(
k
e
y
)
=
k
e
y
%
l
e
n
+
i
2
Hash(key)=key\%len+i^2
Hash(key)=key%len+i2
如果加太多超过了capacity,又会从头开始找,相当于再mod一下capacity
二次探测优缺点
二次探测可以解决数据“堆积”
6.2 开散列
开散列法又叫链地址法(开链法),也可一叫做哈希桶首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
本质上我们可以看出来,它就是一个指针数组
6.3 扩容机制
6.3.1 负载因子
哈希表总是需要扩容的,这里就涉及到了负载因子
负载因子/载荷因子=存储的有效个数/空间的大小
#️⃣ 负载因子越大,冲突的概率越高,增删改查的效率越低
#️⃣ 负载因子越小,冲突的概率越高,增删改查的效率越高,但是空间的浪费越多,利用率越低
闭散列
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
开散列
已知桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
还是要控制一个负载因子,负载因子越小,冲突概率越低,效率越高,但是浪费的空间越多,反之冲突概率越高,效率越低
6.3.2 素数优化
有人说,除留余数法,最好模一个素数,这次不是直接扩容翻倍那么如何每次快速取一个类似两倍关系的素数,STL源码中给出了如下的操作,记录下了一些素数,就可以更好的解决冲突
const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
size_t GetNextPrime(size_t prime)
{
size_t i = 0;
for(; i < PRIMECOUNT; ++i)
{
if(primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
6.4 开散列 V.S. 闭散列
实际当中哈希桶结构其实相对来说更加实用
Situation | 哈希桶(开散列) | 闭散列 | 描述 |
---|---|---|---|
空间 | 空间利用率高 | 略低 | 负载因子哈希桶可以大一点 |
极端情况 | 极端情况还有解决方案 | 极端情况不好解决 | 极端情况是数据不多,负载因子很低,但是这些数据还是大部分冲突了 |
解决方案 | 冲突数据过多的桶,进行红黑树树化(Java) | 解决不了 | 树化操作 |
Java中的解决方案
当然如果数据被删除到小于8的时候也要把数据还原成链表
类似的我们可以这么写
如何统计个数,其实每次插入的时候都需要遍历一遍链表,这时如果还是链表的话,可以计算长度
第二种实现方式是一种复用,一种封装,写起来稍微简单一点,如果forward_list超过8就可以把数据导入到set里面去
拓展阅读: