手把手造哈希表:Rust 中的哈希函数与冲突解决秘籍

yumo6662小时前技术文章1

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倍)
}

代码解析

  1. 哈希函数:这里用了最简单的 "ASCII 码求和取模"。比如 "张三" 的字符 ASCII 码相加后除以桶数,得到存放的索引。但这函数很容易冲突(比如 "AB" 和 "BA" 的和相同),后面会优化。
  2. 链地址法:每个桶是LinkedList<Entry>,冲突的键值对都存在同一个链表上,就像 3 号柜里摞了好几个快递,取的时候一个个翻。
  3. 扩容机制:当负载因子(已存数据 / 总容量)超过 0.7 时,容量翻倍。这是为了减少冲突 —— 柜子越多,快递撞柜的概率就越小。
  4. 核心操作: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更靠谱。

标题

  1. 《Rust 哈希表:从 "快递撞柜" 到 "数据速查" 的艺术》
  2. 《手把手造哈希表:Rust 中的哈希函数与冲突解决秘籍》

简介

本文用 "快递柜" 类比通俗讲解了哈希表原理,通过 Rust 实现了基于链地址法和开放地址法的简易哈希表,展示了哈希函数设计、冲突解决及扩容机制,提供了完整代码和操作步骤,帮助读者轻松理解并实践哈希表开发。

关键词

#Rust #哈希表 #哈希函数 #冲突解决 #链地址法

相关文章

数据结构-位运算_数据结构按位查找

左移( << ):操作数的非0位左移n位,低位补0右移( >> ):操作数的非0位右移n位,高位补0无符号右移( >>> ):正数右移,高位用0补,负数右移,...

PLC的位逻辑运算指令_plc中的位怎么理解

PLC(可编程逻辑控制器)的位指令是针对单个二进制位(0 或 1)进行操作的基础指令,主要用于逻辑控制,是梯形图(LD)编程中最常用的指令类型。以下是 PLC 位指令的核心类别及常用指令:常开常闭输出...

【C语言·015】逗号运算符的求值顺序与返回值规则

很多人第一次看到 , 都把它当“分隔符”:函数实参之间的逗号、初始化列表里的逗号……但在表达式里,, 还有另一个身份——逗号运算符。它既能强制求值顺序,又能控制返回值,是解决副作用与顺序问题的一把小刀...

C 语言指针全解析:从门牌号到内存黑魔法,一文带你彻底搞懂!

很多人一提到 C 语言指针 就皱眉:“指针是不是地址?”“数组和指针是不是一样的?”“为什么 * 有时候是解引用,有时候是乘法?”其实指针没那么神秘。只要把它拆开理解,就会发现它不过是一串数字,存的就...

C语言应用笔记:整数字节大小端翻转

在C语言中,实现大小端(Endian)翻转可以通过位操作或内存操作完成。以下提供两种常用方法:方法1:位运算(推荐)通过移位和掩码操作直接交换字节位置:// 16位翻转(安全版本) #define S...

C语言应用笔记:获取结构体成员偏移地址

在C语言中,获取结构体成员的偏移地址(即成员相对于结构体起始地址的字节偏移量)有两种常用方法:1. 使用标准库宏 offsetof(推荐)<stddef.h> 头文件提供了 offseto...