文章目录
map和set的各种实现中的名称对应如表,低层实现几乎相同。
stl | boost | java |
---|---|---|
map/multimap、set/mutiset | map/multimap、set/mutiset | treemap/treemultimap、treeset/treemultiset |
unordered_map/unordered_multimap、unordered_set/unordered_multiset | hash_map/hash_multimap、hash_set/hash_multiset | hashmap/hashmultimap、hashset/hashmultiset |
map/multimap、set/mutiset【红黑树】
底层是红黑树实现,红黑树4大规则:
规则3和规则4保证了任意节点到其每个叶子节点路径最长不会超过最短路径的2倍,因为最长路径和最短路径的黑色节点数相同,极端情况下最短路径就是全有黑色节点组成,而红色节点不能连续出现,因此最多就交替出现,则极端情况下最长路径红色节点 = 黑色节点,所以最长路径不会超过最短路径两倍。
红黑树的操作包括旋转、插入、删除等,网上很多资料可以查看,此处不再赘述。。
unordered_map/unordered_multimap、unordered_set/unordered_multiset【哈希表】
扩容策略:c++/stl的实现和java的实现不太一样
c++/stl | java |
---|---|
桶元素超过8个或者装载因子超过0.75则扩容 | 桶元素超过8个则将桶指向的列表元素由链表转变为红黑树,当装载因子超过0.75则扩容 |
装载因子定0.75、桶大小定8原因?
首先定装载因子和桶大小是为了扩容和将链表转为红黑树。
扩容和链表转为红黑树是时间和空间成本的tradeoff,因为hash表的特性,随着数据增多,产生碰撞概率加大,则开链的长度越长。那么在每个桶的链条中找数据耗费时间就多了,因此扩容重哈希一下。
哈
希
表
可
存
放
数
据
大
小
=
哈
希
表
长
度
∗
装
载
因
子
哈希表可存放数据大小 = 哈希表长度 * 装载因子
哈希表可存放数据大小=哈希表长度∗装载因子
从公式可以看出,要想要哈希表可以存放更多的数据,可以从两个方面进行改进:
1. 牺牲空间换时间:增加哈希表长度(扩容),扩容后碰撞概率变小,桶的链大小变短,找到元素速度变快,但是牺牲了扩容的空间大小。
2. 牺牲时间换空间:增加装载因子大小(装载因子大小介于0~1之间),最大可以设为1,但是装载因子越大,碰撞概率越高,桶的链大小越长,找到元素速度变慢,但是不需要扩容哈希表的大小。
因此为了权衡时间和空间效率,需要设定一个合适的装载因子,通过计算装载因子在0.6~0.8之间,每个桶的链长度不会过长,桶的链长度为8时的概率是亿分之六了,所以业界就协商一个0.6-0.8之间的一个数,最好肯定是定0.8呀,毕竟装载因子越大,不许扩容条件下可以塞更多元素,但是最后还是源码编写大神们将装载因子定为了0.75(挺不错的数,为什么呢,因此哈希表的长度(初识长度为16按2倍扩容)一般是2的幂次方,2的幂次方乘0.75是个整数呀,乘于0.8可不一定是整数)。
为什么哈希表的容量要取2的幂呢?
采用二进制位操作 & 相对于 % 能够提高运算效率,取模运算中如果除数是2的幂次方则等价于其与除数减一的&操作,即:
hash
%
length
=
=
hash
&
(
length
−
1
)
\text { hash } \% \text { length }==\text { hash } \&(\text { length }-1)
hash % length == hash &( length −1)
数学推导(对数学不感兴趣的可以略过。。。)
假设哈希函数哈希数据位置是随机分布的,则哈希表每个桶出现数据的频率服从泊松分布。
泊松分布公式:
P
(
N
(
t
)
=
n
)
=
(
λ
t
)
n
e
−
λ
t
n
!
P(N(t)=n)=\frac{(\lambda t)^{n} e^{-\lambda t}}{n !}
P(N(t)=n)=n!(λt)ne−λt
P 表示概率,N表示某种函数关系,t 表示时间,n 表示数量。等号的右边,λ 表示事件的频率。
由于哈希函数随机分布,假设数据大小为x,装载因子为k,则哈希表最小长度为
x
k
\frac{x}{k}
kx ,哈希表最大长度为
2
x
k
\frac{2x}{k}
k2x,哈希表平均长度为
x
k
+
2
x
k
2
=
3
x
2
k
\frac{\frac{x}{k} + \frac{2x}{k}}{2} = \frac{3x}{2k}
2kx+k2x=2k3x,桶有数据的概率 λ 为
x
/
3
x
2
k
=
2
k
3
x/\frac{3x}{2k} = \frac{2k}{3}
x/2k3x=32k,当装载因此为0.75时,桶有数据的概率为 λ 为0.5,代入公式可得:
P
(
X
=
k
)
=
e
−
0.5
0.
5
k
k
!
,
k
=
0
,
1
,
2
…
P(X=k)=\frac{e^{-0.5} 0.5^{k}}{k !}, k=0,1,2 \ldots
P(X=k)=k!e−0.50.5k,k=0,1,2…
k各个取值如下表,可见每个桶大小为8的时候,概率就很小了。因此决定桶大小为8就转为红黑树或扩容。
桶的大小 | 概率 |
---|---|
0 | 0.60653066 |
1 | 0.30326533 |
2 | 0.07581633 |
3 | 0.01263606 |
4 | 0.00157952 |
5 | 0.00015795 |
6 | 0.00001316 |
7 | 0.00000094 |
8 | 0.00000006 |
总结来说:
就是时间和空间成本的权衡,决定了装载因子是0.75,然后通过泊松分布计算出每个桶的链长度为8的概率很低亿分之六,因此要是桶的链大小为8时,说明你的哈希函数可能设置不合理了,那么c++的做法就是扩容重哈希,java的做法是不扩容,只是将链表结构改成红黑树的结构,java这样的好处在于方便我们调试代码,在调试代码的时候,发现桶中的元素是红黑树节点而不是链表节点,说明你的哈希函数设置不合理,哈希得不够随机,此时就要考虑重新设置哈希函数了。
再补充一点知识:
stl的容器都是线程不安全的,并发情况下,vector和deque可能造成迭代器失效、list会造成数据覆盖、关联容器会造成迭代器失效、数据覆盖等并发问题。
以哈希表在多线程环境下为例,存在的并发问题有:
并发问题1:死循环问题
原因:扩容时采用头插法,使得桶中链表元素的顺序与扩容前桶中链表元素顺序相反,多线程下会造成死循环问题
解决:头插法改尾插法,因为尾插法在扩容时元素在链表中的顺序不变,不会出现链表成环问题。
问题分析
现在有两个线程执行rehash的过程,线程2早于线程1执行完rehash操作,此时线程1再执行rehash时,会出现链表成环的情况,则当执行比如说get(11)的操作的时候,就会陷入死循环!
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了,执行其他操作
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
并发问题2-3:数据丢失问题
有时间再分析。。
解决:
并发环境下java有concurrenthashmap,以提供并发环境下安全的使用哈希表,但是C++没有支持的实现,最简单粗暴的方法就是使用锁,但是效率太低,如何采用无锁的解决方案呢?当然是参考专家大佬们的论文学习啦,java的concurrenthashmap就是对大佬们论文思想的代码实现。