文章目录
概念
哈希表通常底层实现是一个数组,它的特点就是弥补了数组查找、插入、删除慢的缺点。哈希表它就是最终存放数据的数组。但我们平时说哈希表,确切说是一种操作数组的方式,一种算法。
哈希表巧妙的地方就在于转换了思维角度。
之前搜索一个东西,我们不知道它位置,所有要一个一个找,为啥不知道位置?因为放的时候是随机放的。而哈希表是放的时候不允许随机放,直接就规定了位置,那去找的时候,不就按位置直接去拿即可。比如你知道苹果一定放第三个格子,现在你要找苹果,就会直奔第三个格子,而不是从第一个格子找起。
哈希化
那怎么确定苹果就是放第三个格子呢?
这就需要哈希函数来映射了,这个过程称为哈希化。
理想情况三个水果,算出来就放在三个对应的格子里,但现实很骨感。哈希函数的映射算法没这么完美,会出现只有三个水果,但算出来一个水果要放在第1000个格子的情况,因此中间会出现很多空格子浪费了。优化哈希算法,使之不要这么浪费是衡量哈希算法优劣的一个重要指标。
冲突
那会不会出现哈希函数设计不好,算出来苹果和梨都被放到第三个格子的情况呢?
存在的,通常有两种方式解决这个问题。
链地址法
可以在第三个格子里保存一个链表或数组,然后把苹果和梨都挂在链表上。这种方式称为链地址法。当然挂一个数组也可以,如果重复的数据实在太多,还可以挂一个二叉树。
套娃的这个链表或者数组,我们通常称为打水的吊桶 bucket。
有个误区要注意:设计哈希表的时候要明白,存储过程不是这个位置本是没内容的,然后放进一个内容,直到此处出现了冲突,这个位置才开始挂一个桶,然后新旧内容一起都放桶里。而是哈希表生成的时候,确实没内容,为 undefined,但第一个元素放进去的时候,此处就会生成一个桶存放数据,不用等到发生冲突才开始换成桶。
开放地址法
还可以让重复的元素去后面找空白的位置坐下,后面还有空位,老弟往后走。这是开发地址法。
至于怎么找后面的空位,又有三种方式:
- 线性探测:一个一个往后找
- 二次探测:也是线性探测,但是不是一个一个找,而是步长为 2,2、4、8… 跳着找。
- 再次hash
经过前人的验证,总的来说,链地址法效率是更高的,所以链地址法也用的最多。
装填因子(loadFactor)
装填因子是数据量与哈希表数组容量的比值,也就是哈希表装的满不满。
整这么个概念,主要是为哈希表的性能优化做一个指标以及作为是否自动扩容的依据。
我们知道完美的哈希表,装填因子应该是 1,三个水果,就放三个格子,没浪费没冲突。
但冲突和浪费都不可避免,开发地址法装填因子不会超过1,虽然它很好的利用了格子,可是多了找新地址的计算。
链地址法装填因子可能超过 1,装的数据比哈希表数组长度还多,因为它可以在里面挂一个超长的链。当然挂太多,效率肯定不高。
- 一般装填因子小于 0.25 需要缩容,大于 0.75 需要扩容。
效率对比
下面的等式显示了线性探测时,探测序列§和填装因子(L)的关系。公式来自于Knuth(算法分析领域的专家,现代计算机的先驱人物)。
经过上面的比较我们可以发现,链地址法相对来说效率是好于开放地址法的。
所以在真实开发中,使用链地址法的情况较多。因为它不会因为添加了某元素后性能急剧下降。比如在Java的HashMap中使用的就是链地址法。
哈希函数
说白了哈希化,也就是哈希函数的功能就是将字符串转成对应的数组下标,也就是数字。
那具体怎么做呢?
字符串转数字算法 —— 幂的连乘
现在我们需要设计一种方案,可以将单词转成适当的下标值:
其实计算机中有很多的编码方案就是用数字代替单词的字符。就是字符编码。(常见的字符编码?)
比如ASCII编码:a是97,b是98,依次类推122代表z
我们也可以设计一个自己的编码系统,比如a是1,b是2,c是3,依次类推,z是26。
当然我们可以加上空格用0代替,就是27个字符(不考虑大写问题)
但是,有了编码系统后,一个单词如何转成数字呢?
方案一:数字相加
一种转换单词的简单方案就是把单词每个字符的编码求和。
例如单词cats转成数字:3+1+20+19=43,那么43就作为cats单词的下标存在数组中。
◼ 问题:按照这种方案有一个很明显的问题就是很多单词最终的下标可能都是43。
比如was/tin/give/tend/moan/tick等等。
我们知道数组中一个下标值位置只能存储一个数据
如果存入后来的数据,必然会造成数据的覆盖。
一个下标存储这么多单词显然是不合理的。
虽然后面的方案也会出现,但是要尽量避免。
方案二:幂的连乘
现在,我们想通过一种算法,让cats转成数字后不那么普通。
数字相加的方案就有些过于普通了。
有一种方案就是使用幂的连乘,什么是幂的连乘呢?
其实我们平时使用的大于10的数字,可以用一种幂的连乘来表示它的唯一性:比如:7654 = 710³+610²+510+4
我们的单词也可以使用这种方案来表示:比如 cats = 327³+127²+2027+17= 60337
这样得到的数字可以基本保证它的唯一性,不会和别的单词重复。
两种方案总结:
第一种方案(把数字相加求和)产生的数组下标太少。
第二种方案(与27的幂相乘求和)产生的数组下标又太多。
第一种方案缺陷无解,第二种方案,我们可以采用数字压缩算法缓解。
压缩数字范围 —— 取余
除以几,就能把数字压缩到[0,这个数字 - 1]
的范围。比如除以 10,就压缩到了 [0, 9] 的范围。
优秀哈希算法的优点
两点:
- 快速的计算:计算 hashcode 要快。
- 提高速度的一个办法就是让哈希函数中尽量少的有乘法和除法。因为它们的性能是比较低的。
- 分布均匀,也就是冲突少。
前面我们已经知道,幂的连乘 和 取余操作实现哈希算法。但这还不够,还可以继续优化。
快速计算:霍纳法则
霍纳法则可以减少多项式中的乘法,转换成加法。
做法就是一直在提取公因式,提到不能再提为止。
以 abc 为例:
原本公式:a的编码 * 31^2 + b的编码 * 31^1 + c的编码 * 31^0
公因式就是幂底:31
霍纳法则:
31*(a*31 + b) + c
31*(31*(31*0 + a) + b) + c
上面提取到第二次已经无法再提了,用语言描述就是 hashcode 从 0 开始与幂底的积再加上字符串第一个字符的编码的和作为下一次的 hashcode,继续乘幂底与第二个字符的编码的和再次作为 hashcode 进入下一轮循环,直到加完所有的字符。
可以见到一个循环即可完成霍纳算法对多项式的计算。
const POWER_BASE = 31;
let hashcode = 0;
// 霍纳法则,计算hash值
for (let i = 0; i < key.length; i++) {
hashcode = hashcode * POWER_BASE + key.charCodeAt(i);
}
均匀分布 —— 质数
在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法。但是无论哪种方案,为了提供效率,最好的情况还是让数据在哈希表中均匀分布。
因此,我们需要在使用常量的地方,尽量使用质数。
质数的使用:
- 哈希表的长度。
- N次幂的底数(我们之前使用的是27)
为什么他们使用质数,会让哈希表分布更加均匀呢?
质数和其他数相乘的结果相比于其他数字更容易产生唯一性的结果,减少哈希冲突。
Java中的N次幂的底数选择的是31,是经过长期观察分布结果得出的;
Java 中的 HashMap
◼ Java中的哈希表采用的是链地址法。
◼ HashMap的初始长度是16,每次自动扩展,长度必须是2的次幂。
这是为了服务于从Key映射到index的算法。60000000 % 100 = 数字。下标值
◼ HashMap中为了提高效率,采用了位运算的方式。
HashMap中index的计算公式:index = HashCode(Key) & (Length - 1)
比如计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001
假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111
把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9
为什么 Java hashmap 中使用的数组长度不是质数,因为它使用了位运算,而不是取模。
N次幂的底数
◼ 这里采用质数的原因是为了产生的数据不按照某种规律递增。
比如我们这里有一组数据是按照4进行递增的:0 4 8 12 16,将其映射到长度为8的哈希表中。
它们的位置是多少呢?0 - 4 - 0 - 4,依次类推。
如果我们哈希表本身不是质数,而我们递增的数量可以使用质数,比如5,那么 0 5 10 15 20
它们的位置是多少呢?0 - 5 - 2 - 7 - 4,依次类推。也可以尽量让数据均匀的分布。
我们之前使用的是27,这次可以使用一个接近的数,比如31/37/41等等。一个比较常用的数是31或37。
◼ 总之,质数是一个非常神奇的数字。
◼ 这里建议两处都使用质数:
哈希表中数组的长度。
N次幂的底数。
实现
哈希函数
/**
* 哈希函数, 将key映射成index
* @param key 转换的key
* @param capacity 容量 (数组的长度)
* @returns index 索引值
*/
export function hashFun(key: string, capacity: number): number {
const POWER_BASE = 31;
let hashcode = 0;
// 霍纳法则,计算hash值
for (let i = 0; i < key.length; i++) {
hashcode = hashcode * POWER_BASE + key.charCodeAt(i);
}
// 取余压缩范围
return hashcode % capacity;
}
哈希表
export class HashTable<T = any> {
// 哈希主表数组,采用拉链法,元素本是链表,这里以数组代替,并且元素类型为元祖,便于将 key 和 value 都存一起。
// 简言之数组元素还是数组,这个元素数组里元素为元祖类型。
private store: [string, T][][] = [];
// 装填因子:自动扩容依据
// 容量
private capacity: number = 7;
// 装载数
private count: number = 0;
// 哈希函数
private hashFun(key: string, capacity: number): number {
const POWER_BASE = 31;
let hashcode = 0;
// 霍纳法则,计算hash值
for (let i = 0; i < key.length; i++) {
hashcode = hashcode * POWER_BASE + key.charCodeAt(i);
}
// 取余压缩范围
return hashcode % capacity;
}
}
插入/更新操作
哈希表的插入和修改操作是同一个函数:
因为,当使用者传入一个<Key,Value>时
如果原来不存该key,那么就是插入操作。
如果已经存在该key,那么就是修改操作。
// 插入/更新
put(key: string, value: T) {
const index = this.hashFun(key, this.capacity);
// 获取对应下标的数组
const bucket = this.store[index];
if (!bucket) {
// 插入
this.store[index] = [[key, value]];
this.count++;
} else {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
// 更新
tuple[1] = value;
return;
}
}
// 插入
bucket.push([key, value]);
this.count++;
}
}
获取
// 获取值
get(key: string): T | null {
const index = this.hashFun(key, this.capacity);
const bucket = this.store[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
}
return null;
}
删除
// 删除
remove(key: string): T | null {
const index = this.hashFun(key, this.capacity);
const bucket = this.store[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count--;
return tuple[1];
}
}
}
return null;
}
扩容、缩容
修改容量有两步操作:
- 修改容量
- 把之前的数据再次哈希化计算一次,放进新数组
private MIN_CAPACITY = 7;
private MIN_LOAD_FACTOR = 0.25;
private MAX_LOAD_FACTOR = 0.75;
private capacity: number = this.MIN_CAPACITY;
private resize(newCapacity: number) {
const oldStore = this.store;
// 1. 构建新数组
this.store = [];
this.capacity = newCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : newCapacity;
this.count = 0;
// 2. 旧数据哈希化放入新数组
oldStore.forEach(bucket => {
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
this.put(tuple[0], tuple[1]);
}
}
});
}
修改插入和删除方法:
// 插入/更新
put(key: string, value: T): void {
const index = this.hashFun(key, this.capacity);
// 获取对应下标的数组
const bucket = this.store[index];
if (!bucket) {
// 插入
this.store[index] = [[key, value]];
this.count++;
} else {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
// 更新
tuple[1] = value;
return;
}
}
bucket.push([key, value]);
this.count++;
}
// 自动扩容
if (this.loadFactor > this.MAX_LOAD_FACTOR) this.resize(this.capacity * 2);
}
// 删除
remove(key: string): T | null {
const index = this.hashFun(key, this.capacity);
const bucket = this.store[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count--;
// 缩容
if (this.loadFactor < this.MIN_LOAD_FACTOR && this.capacity > this.MIN_CAPACITY) {
this.resize(Math.floor(this.capacity / 2));
}
return tuple[1];
}
}
}
return null;
}
但现在有个问题,扩容和缩容都是按照 2 倍来做的,那容量不就不是质数了吗,这不利于保证元素均匀分布呀?
是的,因此容量不能简单设为 2 倍,而是应该通过一个算法找附近的质数。
判断一个数是否为质数
质数表示大于1的自然数中,只能被1和自己整除的数。
因此让这个数字 n 从 2 开始除,一直除到小于根号 n 的最大整数即可。如果除尽了,说明 n 不是质数。
怎么判断除没除尽?
用取余,有余数说明没除尽。
/**
* 判断数字是否为质数
* @param num
* @returns boolean
*/
export function isPrime(num: number) {
if (num < 2) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
保证容量为质数,并且当缩容后的容量小于最小容量时,保持最小容量:
// 判断质数
private isPrime(num: number) {
if (num < 2) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
// 获取质数
private getPrime(num: number) {
while (!this.isPrime(num)) {
num++;
}
return num;
}
private resize(newCapacity: number) {
const primeCapacity = this.getPrime(newCapacity);
const oldStore = this.store;
this.store = [];
// 最小容量为底线
this.capacity = primeCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : primeCapacity;
this.count = 0;
...
}
完整代码
- 注:数组实现的 bucket,并非链表。
export class HashTable<T = any> {
// 哈希主表数组,采用拉链法,元素本是链表,这里以数组代替,并且元素类型为元祖,便于将 key 和 value 都存一起。
// 简言之数组元素还是数组,这个元素数组里元素为元祖类型。
private store: [string, T][][] = [];
// 容量
private MIN_CAPACITY = 7;
private MIN_LOAD_FACTOR = 0.25;
private MAX_LOAD_FACTOR = 0.75;
private capacity: number = this.MIN_CAPACITY;
// 装载数
private count: number = 0;
// 装填因子:自动扩容依据
private get loadFactor() {
return this.count / this.capacity;
}
// 哈希函数
private hashFun(key: string, capacity: number): number {
const POWER_BASE = 31;
let hashcode = 0;
// 霍纳法则,计算hash值
for (let i = 0; i < key.length; i++) {
hashcode = hashcode * POWER_BASE + key.charCodeAt(i);
}
// 取余压缩范围
return hashcode % capacity;
}
// 判断质数
private isPrime(num: number) {
if (num < 2) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
// 获取质数
private getPrime(num: number) {
while (!this.isPrime(num)) {
num++;
}
return num;
}
private resize(newCapacity: number) {
const primeCapacity = this.getPrime(newCapacity);
const oldStore = this.store;
// 1. 构建新数组
this.store = [];
this.capacity = primeCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : primeCapacity;
this.count = 0;
// 2. 旧数据哈希化放入新数组
oldStore.forEach(bucket => {
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
this.put(tuple[0], tuple[1]);
}
}
});
}
// 插入/更新
put(key: string, value: T): void {
const index = this.hashFun(key, this.capacity);
// 获取对应下标的数组
const bucket = this.store[index];
if (!bucket) {
// 插入
this.store[index] = [[key, value]];
this.count++;
} else {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
// 更新
tuple[1] = value;
return;
}
}
bucket.push([key, value]);
this.count++;
}
// 自动扩容
if (this.loadFactor > this.MAX_LOAD_FACTOR) this.resize(this.capacity * 2);
}
// 获取值
get(key: string): T | null {
const index = this.hashFun(key, this.capacity);
const bucket = this.store[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
}
return null;
}
// 删除
remove(key: string): T | null {
const index = this.hashFun(key, this.capacity);
const bucket = this.store[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count--;
// 缩容
if (this.loadFactor < this.MIN_LOAD_FACTOR && this.capacity > this.MIN_CAPACITY) {
this.resize(Math.floor(this.capacity / 2));
}
return tuple[1];
}
}
}
return null;
}
}