手把手造哈希表:Rust 中的哈希函数与冲突解决秘籍
Rust 哈希表:给数据办张 "身份证" 的艺术
各位同学,今天咱们来聊哈希表 —— 这个编程界的 "超级字典"。它能让你像查字典一样,瞬间找到想要的数据,不管数据有多少。但哈希表背后的小秘密可不简单:怎么给每个数据编个唯一 "身份证号"(哈希值)?万一两个数据撞号了(冲突)该咋办?
别慌,今天咱们就用 Rust 亲手造一个简易哈希表,把这些问题掰扯明白。保证比拆快递还过瘾!
现实类比:哈希表就像小区快递柜
想理解哈希表,先想想小区的快递柜:
- 每个快递(数据)都有个收件人(键,Key)
- 快递柜有很多格子(桶,Bucket),每个格子有编号(索引)
- 快递员(哈希函数)会根据收件人信息,算出该放哪个格子(比如 "张三"→3 号柜,"李四"→5 号柜)
- 万一两个快递该放同一个格子(冲突),就像两个人的快递同时塞进 3 号柜,这时候就得想办法(冲突解决)
核心概念速览
- 哈希函数:把键(Key)转换成数组索引的函数(就像快递员的 "分配算法")
- 冲突:不同的键算出同一个索引(快递撞柜)
- 链地址法:每个格子里放个链表,冲突的键值对都挂在同一个链表上(3 号柜塞不下?那就摞起来放)
- 负载因子:已存数据量 / 总容量,太大了就扩容(柜子不够用了?加一排新柜子)
案例 1:最简易哈希表(字符串键 + 链地址法)
咱们先实现一个支持字符串键、整数值的哈希表,用链地址法解决冲突。
步骤 1:创建项目
bash
cargo new simple_hashmap
cd simple_hashmap
步骤 2:编写代码(src/main.rs)
rust
use std::collections::LinkedList;
// 键值对结构体(快递信息)
#[derive(Debug, PartialEq)]
struct Entry {
key: String,
value: i32,
}
// 哈希表结构体(快递柜)
struct HashMap {
buckets: Vec<LinkedList<Entry>>, // 每个格子是一个链表(桶)
size: usize, // 已存数据量
}
impl HashMap {
// 创建新哈希表(初始化快递柜)
fn new(capacity: usize) -> Self {
let mut buckets = Vec::with_capacity(capacity);
// 初始化每个桶为空链表
for _ in 0..capacity {
buckets.push(LinkedList::new());
}
HashMap { buckets, size: 0 }
}
// 简易哈希函数(给键算"柜号")
// 原理:把字符串每个字符的ASCII码相加,再对桶数量取模
fn hash(&self, key: &str) -> usize {
let mut hash_code = 0;
for c in key.chars() {
hash_code += c as usize; // 累加ASCII码
}
hash_code % self.buckets.len() // 取模得索引
}
// 插入键值对(放快递)
fn insert(&mut self, key: String, value: i32) {
// 计算索引
let index = self.hash(&key);
// 检查桶里是否已有相同键,有则更新值
let bucket = &mut self.buckets[index];
for entry in bucket.iter_mut() {
if entry.key == key {
entry.value = value;
return; // 更新完就溜
}
}
// 没有相同键,新增一个键值对
bucket.push_back(Entry { key, value });
self.size += 1;
// 负载因子超过0.7就扩容(柜子快满了,加柜子)
let load_factor = self.size as f64 / self.buckets.len() as f64;
if load_factor > 0.7 {
self.resize();
}
}
// 查找值(找快递)
fn get(&self, key: &str) -> Option<&i32> {
let index = self.hash(key);
// 在对应桶的链表中找键
self.buckets[index]
.iter()
.find(|entry| entry.key == key)
.map(|entry| &entry.value)
}
// 删除键值对(取快递)
fn remove(&mut self, key: &str) -> Option<i32> {
let index = self.hash(key);
let bucket = &mut self.buckets[index];
// 找到要删除的位置
let pos = bucket.iter().position(|entry| entry.key == key)?;
// 移除并返回值
let entry = bucket.remove(pos);
self.size -= 1;
Some(entry.value)
}
// 扩容(加一排新柜子)
fn resize(&mut self) {
let new_capacity = self.buckets.len() * 2; // 容量翻倍
let mut new_buckets = Vec::with_capacity(new_capacity);
for _ in 0..new_capacity {
new_buckets.push(LinkedList::new());
}
// 把旧桶里的所有数据移到新桶(重新计算索引)
for bucket in self.buckets.drain(..) {
for entry in bucket {
let new_index = {
// 用新容量重新计算哈希
let mut hash_code = 0;
for c in entry.key.chars() {
hash_code += c as usize;
}
hash_code % new_capacity
};
new_buckets[new_index].push_back(entry);
}
}
self.buckets = new_buckets;
}
}
fn main() {
// 创建一个容量为4的哈希表(4个柜子)
let mut map = HashMap::new(4);
// 插入数据
map.insert("张三".to_string(), 25);
map.insert("李四".to_string(), 30);
map.insert("王五".to_string(), 28);
println!("插入后大小:{}", map.size); // 3
// 查找数据
if let Some(age) = map.get("李四") {
println!("李四的年龄:{}", age); // 30
}
// 测试冲突:"张散"和"张三"的ASCII码之和可能相同(假设冲突)
map.insert("张散".to_string(), 35);
println!("张散的年龄:{}", map.get("张散").unwrap()); // 35
println!("张三的年龄:{}", map.get("张三").unwrap()); // 25(冲突后依然能找到)
// 删除数据
if let Some(age) = map.remove("王五") {
println!("删除了王五,年龄:{}", age); // 28
}
println!("删除后王五是否存在:{}", map.get("王五").is_none()); // true
// 触发扩容(当前容量4,已存3个,再插2个会超负载因子0.7)
map.insert("赵六".to_string(), 40);
map.insert("钱七".to_string(), 33);
println!("扩容后的容量:{}", map.buckets.len()); // 8(原来的2倍)
}
代码解析
- 哈希函数:这里用了最简单的 "ASCII 码求和取模"。比如 "张三" 的字符 ASCII 码相加后除以桶数,得到存放的索引。但这函数很容易冲突(比如 "AB" 和 "BA" 的和相同),后面会优化。
- 链地址法:每个桶是LinkedList<Entry>,冲突的键值对都存在同一个链表上,就像 3 号柜里摞了好几个快递,取的时候一个个翻。
- 扩容机制:当负载因子(已存数据 / 总容量)超过 0.7 时,容量翻倍。这是为了减少冲突 —— 柜子越多,快递撞柜的概率就越小。
- 核心操作:insert(插入 / 更新)、get(查找)、remove(删除)都围绕哈希索引 + 链表遍历展开,逻辑清晰。
编译运行
bash
cargo run
输出结果:
plaintext
插入后大小:3
李四的年龄:30
张散的年龄:35
张三的年龄:25
删除了王五,年龄:28
删除后王五是否存在:true
扩容后的容量:8
案例 2:优化哈希函数(减少冲突)
上面的哈希函数太简单,容易冲突。咱们换个稍微好点的 ——DJB2 哈希算法(简单高效的经典算法)。
改进代码(替换 hash 方法)
rust
// 优化后的哈希函数:DJB2算法
fn hash(&self, key: &str) -> usize {
let mut hash_code = 5381; // 初始值
for c in key.chars() {
// 公式:hash = hash * 33 + c的ASCII码
hash_code = ((hash_code << 5) + hash_code) + c as usize;
// 等价于 hash_code * 33 + c,位运算更快
}
hash_code % self.buckets.len()
}
为啥这个更好?
DJB2 通过 "乘以 33 再加字符码" 的方式,让不同的字符串更容易产生不同的哈希值,就像给快递编了更复杂的编号,撞柜概率大大降低。
案例 3:开放地址法解决冲突(另一种思路)
除了链地址法,开放地址法也是常用的冲突解决方式。思路是:如果目标桶被占了,就找下一个空桶(线性探测)。
代码实现(简易版)
rust
// 开放地址法哈希表(线性探测)
struct OpenAddressHashMap {
entries: Vec<Option<Entry>>, // 直接用向量存储,None表示空
size: usize,
}
impl OpenAddressHashMap {
fn new(capacity: usize) -> Self {
OpenAddressHashMap {
entries: vec![None; capacity],
size: 0,
}
}
// 用DJB2哈希
fn hash(&self, key: &str) -> usize {
let mut hash_code = 5381;
for c in key.chars() {
hash_code = ((hash_code << 5) + hash_code) + c as usize;
}
hash_code % self.entries.len()
}
// 插入:冲突就找下一个空位置
fn insert(&mut self, key: String, value: i32) {
let capacity = self.entries.len();
let mut index = self.hash(&key);
// 线性探测:最多找一圈
for _ in 0..capacity {
match &self.entries[index] {
// 找到空位置,插入
None => {
self.entries[index] = Some(Entry { key, value });
self.size += 1;
// 负载因子>0.7就扩容
if self.size as f64 / capacity as f64 > 0.7 {
self.resize();
}
return;
}
// 找到相同键,更新值
Some(entry) if entry.key == key => {
self.entries[index] = Some(Entry { key, value });
return;
}
// 位置被占,继续找下一个
_ => {
index = (index + 1) % capacity;
}
}
}
// 循环一圈都没找到位置(理论上扩容后不会出现)
panic!("哈希表已满!");
}
// 查找:冲突就顺着找
fn get(&self, key: &str) -> Option<&i32> {
let capacity = self.entries.len();
let mut index = self.hash(&key);
for _ in 0..capacity {
match &self.entries[index] {
None => return None, // 找到空位置,说明没有
Some(entry) if entry.key == key => return Some(&entry.value),
_ => index = (index + 1) % capacity,
}
}
None
}
// 扩容逻辑类似,略...
fn resize(&mut self) {
// 实现和链地址法类似,把旧数据重新哈希到新容量的数组中
// 代码略(可参考案例1的resize)
}
}
开放地址法 VS 链地址法
- 开放地址法:数据存在数组里,缓存友好(内存连续),但删除复杂(不能直接删,否则会断链)。
- 链地址法:实现简单,删除方便,但链表节点分散,可能影响缓存效率。
标准库的 HashMap
其实 Rust 标准库已经提供了std::collections::HashMap,它用更复杂的哈希函数(SipHash)和链地址法,性能和安全性都很优秀。咱们平时开发直接用它就行,自己实现只是为了理解原理。
标准库用法示例:
rust
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert("苹果", 5);
map.insert("香蕉", 3);
println!("苹果数量:{}", map.get("苹果").unwrap()); // 5
// 遍历
for (key, value) in map {
println!("{}: {}", key, value);
}
}
总结一下
- 哈希表 = 哈希函数 + 冲突解决 + 扩容机制,就像快递柜系统 = 分配算法 + 撞柜处理 + 加柜子。
- 简易哈希函数可以用 ASCII 求和,优化版可选 DJB2、SipHash 等。
- 冲突解决常用链地址法(简单)和开放地址法(缓存友好)。
- 自己实现是为了学原理,实际开发用标准库HashMap更靠谱。
标题
- 《Rust 哈希表:从 "快递撞柜" 到 "数据速查" 的艺术》
- 《手把手造哈希表:Rust 中的哈希函数与冲突解决秘籍》
简介
本文用 "快递柜" 类比通俗讲解了哈希表原理,通过 Rust 实现了基于链地址法和开放地址法的简易哈希表,展示了哈希函数设计、冲突解决及扩容机制,提供了完整代码和操作步骤,帮助读者轻松理解并实践哈希表开发。
关键词
#Rust #哈希表 #哈希函数 #冲突解决 #链地址法