正式开始
Rust 的哈希表
- 哈希表最核心的特点就是:巨量的可能输入和有限的哈希表容量
- 这就会引发哈希冲突,也就是两个或者多个输入的哈希被映射到了同一个位置,所以我们要能够处理哈希冲突
Rust 哈希表不是用冲突链来解决哈希冲突,而是用开放寻址法的二次探查来解决的
如何解决冲突?
主要的冲突解决机制有链地址法(chaining)和开放寻址法(open addressing)
链地址法
- 把落在同一个哈希上的数据用单链表或者双链表连接起来
- 在查找的时候,先找到对应的哈希桶(hash bucket),然后再在冲突链上挨个比较,直到找到匹配的项
开放寻址法
- 把整个哈希表看做一个大数组,不引入额外的内存,当冲突产生时,按照一定的规则把数据插入到其它空闲的位置。
- 比如线性探寻(linear probing)在出现哈希冲突时,不断往后探寻,直到找到空闲的位置插入。
- 二次探查,理论上是在冲突发生时,不断探寻哈希位置加减 n 的二次方,找到空闲的位置插入
HashMap 的数据结构
use hashbrown::hash_map as base;
#[derive(Clone)]
pub struct RandomState {
k0: u64,
k1: u64,
}
pub struct HashMap<K, V, S = RandomState> {
base: base::HashMap<K, V, S>,
}
- HashMap 有三个泛型参数,K 和 V 代表 key / value 的类型,S 是哈希算法的状态,它默认是 RandomState,占两个 u64
- RandomState 使用 SipHash 作为缺省的哈希算法,它是一个加密安全的哈希函数(cryptographically secure hashing)
- Rust 的 HashMap 复用了 hashbrown 的 HashMap。hashbrown 是 Rust 下对 Google Swiss Table 的一个改进版实现
HashMap 的内存布局
use std::collections::HashMap;
fn main() {
let map = HashMap::new();
let mut map = explain("empty", map);
map.insert('a', 1);
let mut map = explain("added 1", map);
map.insert('b', 2);
map.insert('c', 3);
let mut map = explain("added 3", map);
map.insert('d', 4);
let mut map = explain("added 4", map);
map.remove(&'a');
explain("final", map);
}
// HashMap 结构有两个 u64 的 RandomState,然后是四个 usize,
// 分别是 bucket_mask, ctrl, growth_left 和 items
// 我们 transmute 打印之后,再 transmute 回去
fn explain<K, V>(name: &str, map: HashMap<K, V>) -> HashMap<K, V> {
let arr: [usize; 6] = unsafe { std::mem::transmute(map) };
println!(
"{}: bucket_mask 0x{:x}, ctrl 0x{:x}, growth_left: {}, items: {}",
name, arr[2], arr[3], arr[4], arr[5]
);
unsafe { std::mem::transmute(arr) }
}
/*
empty: bucket_mask 0x0, ctrl 0x562cc5eda400, growth_left: 0, items: 0
added 1: bucket_mask 0x3, ctrl 0x562cc74419f0, growth_left: 2, items: 1
added 3: bucket_mask 0x3, ctrl 0x562cc74419f0, growth_left: 0, items: 3
added 4: bucket_mask 0x7, ctrl 0x562cc7441a50, growth_left: 3, items: 4
final: bucket_mask 0x7, ctrl 0x562cc7441a50, growth_left: 4, items: 3
*/
ctrl 表
ctrl 表的主要目的是快速查找
- 一张 ctrl 表里,有若干个 128bit 或者说 16 个字节的分组(group)
- group 里的每个字节叫 ctrl byte,对应一个 bucket,那么一个 group 对应 16 个 bucket
- 如果一个 bucket 对应的 ctrl byte 首位不为 1,就表示这个 ctrl byte 被使用
- 如果所有位都是 1,或者说这个字节是 0xff,那么它是空闲的。
一组 control byte 的整个 128 bit 的数据,可以通过一条指令被加载进来,然后和某个值进行 mask,找到它所在的位置。这就是一开始提到的 SIMD 查表
HashMap 是如何通过 ctrl 表来进行数据查询的
假设这张表里已经添加了一些数据,我们现在要查找 key 为 ‘c’ 的数据
- 首先对 ‘c’ 做哈希,得到一个哈希值 h;
- 把 h 跟 bucket_mask 做与,得到一个值,图中是 139;
- 拿着这个 139,找到对应的 ctrl group 的起始位置,因为 ctrl group 以 16 为一组,所以这里找到 128;
- 用 SIMD 指令加载从 128 对应地址开始的 16 个字节;
- 对 hash 取头 7 个 bit,然后和刚刚取出的 16 个字节一起做与,找到对应的匹配,如果找到了,它(们)很大概率是要找的值;
- 如果不是,那么以二次探查(以 16 的倍数不断累积)的方式往后查找,直到找到为止。
当 HashMap 插入和删除数据,以及因此导致重新分配的时候,主要工作就是在维护这张 ctrl 表和数据的对应
- ctrl 表是所有操作最先触及的内存
- 在 HashMap 的结构中,堆内存的指针直接指向 ctrl 表,而不是指向堆内存的起始位置 这样可以减少一次内存的访问
哈希表重新分配与增长
哈希表按幂扩容
- 分配新的堆内存后,原来的 ctrl table 和对应的 kv 数据会被移动到新的内存中。
- 实现了Copy trait的会拷贝,否则被移动,移动的话就会涉及哈希的重分配
删除一个值
- 先要找到要被删除的 key 所在的位置
- 在找到具体位置后,并不需要实际清除内存,只需要将它的 ctrl byte 设回 0xff(或者标记成删除状态)
当 key/value 有额外的内存时,比如 String,它的内存不会立即回收,只有在下一次对应的 bucket 被使用时,让 HashMap 不再拥有这个 String 的所有权之后,这个 String 的内存才被回收
可以通过 shrink_to_fit / shrink_to 释放掉不需要的内存
让自定义的数据结构做 Hash key
- 要使用到三个 trait:Hash、PartialEq、Eq
- 这三个 trait 都可以通过派生宏自动生成
- 实现了 Hash ,可以让数据结构计算哈希;
- 实现了 PartialEq/Eq,可以让数据结构进行相等和不相等的比较。Eq 实现了比较的自反性(a == a)、对称性(a == b 则 b == a)以及传递性(a == b,b == c,则 a == c),PartialEq 没有实现自反性。
use std::{
collections::{hash_map::DefaultHasher, HashMap},
hash::{Hash, Hasher},
};
// 如果要支持 Hash,可以用 #[derive(Hash)],前提是每个字段都实现了 Hash
// 如果要能作为 HashMap 的 key,还需要 PartialEq 和 Eq
#[derive(Debug, Hash, PartialEq, Eq)]
struct Student<'a> {
name: &'a str,
age: u8,
}
impl<'a> Student<'a> {
pub fn new(name: &'a str, age: u8) -> Self {
Self { name, age }
}
}
fn main() {
let mut hasher = DefaultHasher::new();
let student = Student::new("Tyr", 18);
// 实现了 Hash 的数据结构可以直接调用 hash 方法
student.hash(&mut hasher);
let mut map = HashMap::new();
// 实现了 Hash / PartialEq / Eq 的数据结构可以作为 HashMap 的 key
map.insert(student, vec!["Math", "Writing"]);
println!("hash: 0x{:x}, map: {:?}", hasher.finish(), map);
}
HashSet / BTreeMap / BTreeSet
HashSet
- 用来存放无序的集合,定义直接是 HashMap<K,()>
- 使用 HashSet 查看一个元素是否属于集合的效率非常高
use hashbrown::hash_set as base;
pub struct HashSet<T, S = RandomState> {
base: base::HashSet<T, S>,
}
pub struct HashSet<T, S = DefaultHashBuilder, A: Allocator + Clone = Global> {
pub(crate) map: HashMap<T, (), S, A>,
}
BTreeMap
BTreeMap 是内部使用 B-tree 来组织哈希表的数据结构,是有序的
BTreeSet
BTreeSet是 BTreeMap 的简化版,可以用来存放有序集合。
链接
- HashMap
- 标准集合 HashMap
- Swiss Table
- hashbrown
- Hash
- PartialEq
- Eq
- BTree
- collections
- Rust集合复杂度
- SipHash 概念
- aHash
- gdb
- lldb
- rust-gdb
- rust-lldb
- gdb手册
- gdb-lldb对应手册
精选问答
-
hashmap 在插入的时候,对key进行hash,这个地方怎么区别hash出来的key要不要进行二次探查呢?
a. hash 冲突,hash 原本对应的 bucket 被占用,这个时候就需要进行哈希冲突的处理了,需要找出来一个空闲的 bucket,这个时候用二次探查
b. 对原来的 key 的更新,查到 hash 对应的 slot 后,还会进一步和 key 做对比。发现key 相同,则做更新操作
-
说一下对 hashbrown 的理解。
a. 一般的哈希表是对数组大小取模(hash % len)来定位位置的,但是 hashbrown 把 hash 分两部分使用:
1. 低几位(& bucket_size)定位在数组中的位置 2. 高 7 位存到对应位置的 ctrl 块里,类似指纹的作用b. 一般哈希表获取时,取模定位到位置后,要完整对比 key 才能知道是找到(key相同)还是要探查(key 不同)
c. hashbrown 可以利用 ctrl 里存起来的高 7 位快速发现冲突的情况(低几位相同但高7 位不同),直接进入下一步探查
-
为什么 Rust 的 HashMap 要缺省采用加密安全的哈希算法?
a. 把 SipHash 作为 HashMap 的缺省的哈希算法,Rust 可以避免开发者在不知情的情况下被 DoS
b. 如果你确定使用的 HashMap 不需要 DoS 防护(比如一个完全内部使用的 HashMap),那么可以用 Ahash 来替换。你只需要使用 Ahash 提供的 RandomState 即可
-
如何使用 rust-gdb / rust-lldb?
a. gdb 适合在 Linux 下,lldb 可以在 OS X 下调试 Rust 程序