文章目录
可能很多人都知道:程序 = 算法 + 数据结构
但是很多人可能没有想过,数据结构里面被用得最多的数据结构是什么?
这里不卖关子,答案是数组。
为什么这么说呢?
首先,数组结构跟内存的结构天然是一致的,通过下标能够直接寻址,高效存取。
当然,更重要的是,很多“高级”的数据结构都是在数据的基础上包装而来。
比如:数组+动态扩容=列表(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}