$O(1)$单点插入、删除、查询。在数据库里主要用在join
和group by
中。
¶静态哈希表
有些哈希表是不稳定的(flat_map
),存储在其中的value的地址可能因为插入和删除而失效(因为发生了rehash)。稳定的方法通常需要通过间接寻址(node_map
)。
node_map
和flat_map
的区别是flat_map
将value直接存到数组中,因此在rehash时地址会改变,而node_map
将value存在另一个保持不变的数组中,其优点是提供了稳定性,并且可以存储不可移动或不可复制的元素,并且也更适合大value对象(因为把大对象直接存到数组中会破坏cache),但也导致了额外开销。
关键点:哈希函数、冲突解决方法。
MurmurHash、Facebook XXHash
¶开放寻址
- 线性探测:如果映射位置已经被占用,就检查紧接着的下一个位置,不断重复
- 二次探测:如果被占用,则检查+1^2, -1^2, +2^2, -2^2…
删除的时候一种是所有元素都重hash,另一种是用tombstome标记。
¶布谷鸟哈希
布谷鸟在孵化时会将一些蛋/幼鸟推出巢外,布谷鸟哈希在插入新key时,可能把旧key移动到其它位置。
通用的讲:布谷鸟哈希使用n个哈希函数,然后将key插入到其中第一个空闲的哈希位置,如果都非空,就选择踢掉一个冲突的key,并重新插入这个冲突的key。
这里介绍使用两个哈希表$T_1$和$T_2$,以及两个哈希函数$h_1$和$h_2$的版本。
¶查找
查找$T_1[h_1(x)]$和$T_2[h_2(x)]$即可。
¶删除
同理。
¶插入
如果$T_1[h_1(x)]$为空,就插入;否则替换为$x$(踢掉旧值$x’$),并将原有的$x’$插入到$T_2$,不断重复至多MAX_LOOP次(这个过程中可能发生死循环)。超过固定阈值,就扩展两个哈希表,进行重哈希。
1 | def insert(x): |
如果将$T_1[h_1(x)]$和$T_2[h_2(x)]$看作一条边,就构成一张图。
其优点是最坏复杂度有保证,并且查找过程可以完全向量化,适用于大量单点查询的场景。
其缺点是缓存不友好。rocksdb的一个优化方案是在某个哈希映射冲突时,进一步尝试连续的若干位置(+1,+2,…),这被称作布谷鸟块,这样可以通过预取这个块来加速(85%的key都在第一个布谷鸟块中)。
¶布谷鸟过滤器
相比于布隆过滤器,能够支持删除操作。
¶std::unordered_map
使用拉链法实现,有多个桶,每个桶都是一个链表。max_load_factor
是元素个数除以桶个数的上限(默认为1),当超过这个上限时,说明桶的个数过少了,就会将bucket_size
扩大2倍,然后重新进行hash。
1 | class unordered_map { |
这种实现方式是很慢的,因为数据局部性很差,cache不友好。但因为C++标准规定在插入或删除元素时其他元素必须有效,因此选择了拉链法,但更现代的hashmap通常采用线性探测或其变种。
¶robin_hood::unordered_flat_map
robin hood是线性探测的变种。通过劫富济贫来优化每个元素的实际位置与其哈希位置的距离。在插入时,如果当前元素的距离已经比占用元素的距离大(占用元素更富有),那就取代这个占用元素,让占用元素继续找下一个空的位置。避免了线性探测中某些元素极度贫困的问题,需要连续多次探测。
¶slotmap
与常规的hashmap需要自己提供key和value不同,slotmap只在插入时只需要提供value,然后会自动返回一个唯一且永久有效的key。
¶优化
- 通常会根据键值的类型、大小以及取值分布选择合适的哈希函数和解决冲突方式
- 使用版本号实现快速的清空操作
¶动态哈希表
¶拉链法
拉链法可以算作一种动态哈希表的类型。因为线性探测中元素的个数只能固定在预设的N个,要扩大只能rehash,而拉链法中链表没有大小限制,但实际会通过负载因子来进行平衡。
可以套上布隆过滤器避免无用的查找。