Rust 16: HashMap掰开揉碎:数组 + 哈希冲突处理 + 扩容

可能很多人都知道:程序 = 算法 + 数据结构

但是很多人可能没有想过,数据结构里面被用得最多的数据结构是什么?
这里不卖关子,答案是数组

为什么这么说呢?

首先,数组结构跟内存的结构天然是一致的,通过下标能够直接寻址,高效存取。

当然,更重要的是,很多“高级”的数据结构都是在数据的基础上包装而来。

比如:数组+动态扩容=列表(Java里面的ArrayList),而列表稍加封装又可以作为栈/队列使用。

再比如:

数组 + 哈希冲突处理 + 扩容 = HashMap

关于这一点,下面就让笔者来细细说道说道。

从数组说起

Hash表,从底层存储上看,就是一个不饱和的可扩容的数组

数组作为最简单、最基本的数据容器,通过下标(key)可以直接对内存进行寻址,是效率最高的容器。

比如,我们可以用一个长度为101的数组arr[101],来存放得分从0-100的人数。

在统计得分的过程中,遇到得分k(0<=k<=100),就让arr[k]加上1。

虽然数组这种最简单、直接、高效的数据容器结构,通过下标作为key,可以快速存取元素;

但是,如果key的可能取值范围非常大,比如key可以取0~Integer.MAX_VALUE中任何数。那这种情况下,就不能将key直接作为数组的下标了——内存有限,根本没办法构建这么大的数组。

想想办法

假设能构建这么大的数组(长度很大),但数组里面其实存放了个数远比数组长度少的一些离散数据。

那么,我们能不能将这个假想的大数组“压缩一下”,让内存中能够放得下这样的“数组结构”呢?并且,依然保持很高的存取效率(达到平均O(1))呢?

比如,假设我们需要对3个数的出现次数进行计数:1,5,32767。

虽然,我们可以通过长度为3的数组arr[3]来完成计数的事情——让arr[0]表示1的个数,arr[1]表示5的个数,arr[2]表示32767的个数。

但是我们想让这个“数组容器”更通用一点,不管下次是4个数、5个数,甚至更多数、事先都不知道是多少的数,都能很方便地完成计数的工作。
我们想到了一个办法:将取值范围可能很大的key值,“压缩”到一个较小的取值范围上

举个例子,计数key有1,5,9,32767这4个数。我们可以考虑这样做:

定义长度为10的数组arr[10],将1的个数记录在1%10的位置arr[1],5的个数记录在5%10的位置arr[5],9的个数记录在9%10的位置arr[9],32767的个数记录在32767%7的位置arr[7]。

我们这样的一个结构就有了一定的通用性:可以对一堆key进行计数——只要key%10得到的结果不出现重复,就可行。这里对10取模的操作就可以理解为一个“哈希函数”。通过这个哈希函数,可以对key进行计算,得出该key应该出现在数组中的哪个下标位置。

其实,这里的arr[10]就被我们用作了Hash表。将1,5,9,32767这些key%10就能得到其在数组中的下标位置。
当然,数组的长度应该是要比key的数量大的。

待解决的问题

大的方向有了,但是还有2个问题需要解决:

1、如果再来个key=7,key%10后跟32767的位置相同,这个时候就出现了冲突(哈希碰撞),如何解决?

2、hash表底层的数组结构容量有限,插入的元素个数不能大于底层数组结构的长度,甚至需要设置一定的空余比例(比如Java中HashMap至少空余25%),以尽量避免Hash碰撞。如果往哈希表中插入的元素越来越多,空余比例低于约定的最低值了,就需要对存储的数组结构进行扩容。比如从arr[10]扩容到arr[20],相应key在新数组结构arr[20]中的下标位置需要重新计算。

对于这两个问题,下面分2个小节进一步进行阐述。

处理哈希碰撞

hash碰撞一般有3种处理方式。

1、链地址法

将需要存放在相同下标位置(称作同一个哈希桶hash bucket)的元素用一个链表(或者其他数据容器)串起来,Java中就是这种思路实现的HashMap。在查找的时候,先找到key对应的下标位置(定位到哈希桶),然后再在冲突链上挨个比较,直到找到key匹配的项。

2、开放寻址法

常见的有线性寻址、二次寻址。

线性寻址的做法是,当key计算出的下标位置已经存放过元素(被占用)时,尝试下标+1的位置,直到找到空闲的位置。根据key查找hash表中元素时也是如此:如果下标位置对应的key不是自身,就继续跟下一个下标位置进行比较,直到key相等。

二次寻址是在冲突发生时,不断探寻下标位置加减 k(k=1,2,3,…)的二次方的位置,直到找到空闲的位置插入。

3、多次哈希

如果下标位置已被占用,就用另外一个hash函数计算新的下标位置。当然理论上来讲,第二个hash函数算出的下标位置仍然可能已经被占用。

工程实践上,前2种方式比较容易实现。比如Java中的HashMap是用链地址法处理哈希冲突;Rust中的HashMap是用开放寻址法中的二次寻址方式处理哈希冲突。

如何扩容?

随着不停地往“数组容器”中放入元素,当容器被放满(Rust中是放满,Java中默认是占据75%的容量)后,就需要申请一个更大的数组容器,通常新容器的容量会翻倍。

我们以Rust中HashMap的实现为例,来了解一下扩容的“基本姿势”。

use std::{collections::HashMap};
fn print_map(label: &str, map: &HashMap<i32, char>) {
    println!("{label}: size={:?}, cap={:?}", map.len(), map.capacity());
}
fn print_cutting_line() {
    println!("{}", "-".repeat(20));
}
#[test]
fn test_cap_growth() {
    let mut map: HashMap<i32, char> = HashMap::new();

    print_map("constructed", &map);
    map.insert(1, 'a');
    print_map("insert 1", &map);
    map.insert(2, 'a');
    print_map("insert 2", &map);
    map.insert(3, 'a');
    print_map("insert 3", &map);
    map.insert(4, 'a');
    print_map("insert 4", &map);
    print_cutting_line();

    map.insert(5, 'a');
    map.insert(6, 'a');
    map.insert(7, 'a');
    print_map("insert 7", &map);
    print_cutting_line();

    map.insert(8, 'a');
    print_map("insert 8", &map);

    for i in 9..=14 {
        map.insert(i, 'a');
    }
    print_map("insert 14", &map);
    print_cutting_line();

    map.insert(15, 'a');
    print_map("insert 15", &map);

    for i in 16..=29 {
        map.insert(i, 'a');
    }
    print_map("insert 29", &map);

    //删除数据,key需要使用引用类型
    for i in 2..=29 {
        map.remove(&i);
    }
    print_map("after remove", &map);

    // shrink后哈希表收缩到适当大小
    map.shrink_to_fit();
    print_map("after shrink_to_fit", &map);
}

输出为:

constructed: size=0, cap=0
insert 1: size=1, cap=3
insert 2: size=2, cap=3
insert 3: size=3, cap=3
insert 4: size=4, cap=7
--------------------
insert 7: size=7, cap=7
--------------------
insert 8: size=8, cap=14
insert 14: size=14, cap=14
--------------------
insert 15: size=15, cap=28
insert 29: size=29, cap=56
after remove: size=1, cap=56
after shrink_to_fit: size=1, cap=3

据此,我们可以得出一些结论:

1、当调用HashMap::new()时,实际上还没有分配空间,容量为零。

2、随着哈希表不断插入数据,底层数组容量会以3-7-14-28-56-...的方式成倍变大。但跟Java中实现不同的是,Rust中HashMap并未要求有一个最低空余比例,一直到元素个数超过底层数组结构的长度时才进行扩容

Rust中对内存使用的“高标准、严要求”可见一斑。

3、当删除表中的数据时,其容量保持不变;只有显式地调用shrink_to_fit(),才会让哈希表变收缩为适当的大小。

Rust中HashMap的使用

HashMap::new()

调用静态方法HashMap::new(),构建一个HashMap出来。

use std::collections::HashMap;
let mut map: HashMap<i32, i32> = HashMap::new();
insert()/get()/remove()

根据key插入/查找/删除值。

let mut map: HashMap<i32, i32> = HashMap::new();
// insert
map.insert(1, 100);
map.insert(1, 10);//对key为1的值进行覆盖更新
map.insert(2, 20);
println!("{:?}", map);//{2: 20, 1: 10}

// get()的参数是引用类型,返回的值也是Map中值的引用
assert_eq!(Some(&1), map.get(&1));

// remove()的参数是引用类型
map.remove(&2);
println!("{:?}", map);//{1: 10}
遍历key
use std::collections::HashMap;
let mut map: HashMap<i32, i32>= HashMap::new();
for i in 1..=3 {
    map.insert(i, i);
}
for key in map.keys() {
    println!("key={key}");
}
遍历value
for val in map.values() {
    println!("value={val}");
}
遍历key-value
for (k, v) in map.iter() {
    println!("key={k}, value={v}");
}
HashMap实现“计数器”

本文一开头就举了计数的例子。HashMap的一个常见应用,就是对相同元素出现的次数进行计数。

在Rust中,可以通过如下2种操作来实现这种“计数器”的功能:

方式一:借助entry()
let nums = vec![1,2,2,3,3,3];//1出现1次,2出现2次,3出现3次
let mut map: HashMap<i32, i32> = HashMap::new();
for &x in nums.iter() {
    // 我们可以把一对key-value组合叫做一个entry
    // 先通过key找到相应的entry,再将entry中Value的引用,赋值给counter
    // 如果不存在相应的entry,则返回新插入的,值为0的entry中,Value的引用给counter
    let counter = map.entry(x).or_insert(0);
    // 得到的counter是Value类型的可变引用
    *counter += 1;
}
println!("{:?}", map);//{1: 1, 2: 2, 3: 3}

第一步是得到相应的Entry。

use std::collections::HashMap;
use std::collections::hash_map::Entry;
let mut map: HashMap<i32, i32> = HashMap::new();
map.insert(1, 10);
let entry: Entry<i32, i32> = map.entry(1);
//Entry(OccupiedEntry { key: 1, value: 10, .. })
println!("{:?}", entry);

第二步,是调用Entry的or_insert()方法。

// Entry实际上是一个枚举类型
pub enum Entry<'a, K: 'a, V: 'a> {
    /// An occupied entry.
    Occupied(OccupiedEntry<'a, K, V>),
    /// A vacant entry.
    Vacant(VacantEntry<'a, K, V>),
}
// Entry中实现了or_insert()/or_insert_with()等方法
pub fn or_insert(self, default: V) -> &'a mut V {
    match self {
        Occupied(entry) => entry.into_mut(),
        Vacant(entry) => entry.insert(default),
    }
}

可以看到,or_insert()返回的是V类型的可变引用,可以直接通过此引用修改Value的值。

方式二:map.get_mut()
let nums = vec![1,2,2,3,3,3];
let mut map = HashMap::new();
for x in nums.iter() {
    if let Some(v) = map.get_mut(x) {
        *v += 1;
    } else {
        map.insert(x, 1);
    }
}
println!("{:?}", map);//{2: 2, 1: 1, 3: 3}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值