介绍
哈希表是基于数组来实现的,但是相对于数组,它有着更多的优势:
- 它可以提供非常快速的插入、删除、查找操作
- 无论多少数据,插入和删除的时间复杂度都可以达到O(1)
当然它也有一些不足:
- 哈希表中的数据是没有顺序的
- 通常情况下哈希表的key不允许重复
哈希表的原理大致可以用下面这张图来表示
数据还是使用数组来存储,我们通过hash函数将key转换成数组的索引,然后再通过索引去获取具体存储的内容。那么如何将字符串转换成数组的索引(数字)呢?其实有很多方案:比如把每个字符都转换成ASCII码然后相加,但是这样的话很多字符串得到的结果会是一样的,重复率会很高;为了避免重复率过高,还可以使用幂的连乘,像是这样
765=7*10^3+6*10^2+5
(假设7、6、5是字母对应的编码,10是总的字母数),但是这种方式也有些问题,如果key的字符过多,那么最后的值会非常大,就会造成数组非常大而浪费空间,所以现在就需要来压缩这个数值到一个可以接受的范围。其实压缩的方式也非常简单,只需要进行取余操作,比如想压缩到1000以内,那么我们直接%1000即可。
哈希冲突
虽然我们使用幂的乘积然后压缩的方式来尽量避免重复率过高的问题,但还是避免不了重复,那么我们就要想办法解决冲突。最常用到的有两种方式:链地址法和开放地址法
链地址法
链地址法很好理解,就像下图
链地址法数组的存储单元并不止存储一个信息,而是存储一个链表或者数组,对于冲突的索引数据,我们就可以放在一个链表或者数组中了。但是链地址法是有缺点的,那就是如果一个链表或者数组的数据过多,查询效率就会下降,因为需要遍历链表或者数组。
开放地址法
开放地址法一个位置就只插入一个元素,它的主要工作方式是寻找空白的单元格来添加重复的数据。
如图,如果此时又有一个索引为1的数据要插入,发现已经有数据了,那么此时就会向下寻找空白的位置比如3、5、9,对于如何寻找也有三种方式:线性探测、二次探测、再哈希法
- 线性探测:就是往下一个一个查找,每次索引都+1直到找到为止。这种方式在某些情况下会存在性能问题,比如说现在数组0到8都已经有数据了,现在0位置要重复插入一个数据,那么就需要一直往下+1来查找直到9。这种情况称之为聚集,如果数据量大的情况下,这种探测方式效率就会变低。
- 二次探测:二次探测是改变探测时的步长,比如说第一次查找+1、第二次查找+2^2、第三次查找+3^2,虽然这种方式解决了步长为1的聚集,但是还是避免不了与步长规律相同的插入数据带来的聚集。
- 再哈希法:为了解决步长的规律性,可以再执行一次哈希算法得到步长:
步长 = content - (key % constant)
content可以是一个质数且小于数组的容量。
哈希化效率
哈希表中有一个概念:装填因子,其计算公式是装填因子 = 总数据项 / 哈希表长度
,哈希表的查找效率与装填因子的大小是负相关关系即:装填因子越大,查找效率越低,所以当装填因子达到一个值时,我们就要对哈希表进行扩容以保证效率。
哈希表实现
链地址法受填装因子的影响较小,大多数语言也采用此方法,所以我们就实现链地址法。
hash算法
幂的乘积可以看作是一个多项式:a(n)x^n + a(n-1)x^(n-1) + ... + a(1)x + a(0),这个多项式要进行n(n+1)/2次乘法和n次加法,所以它的时间复杂度是O(n^2),对于追求效率的哈希表来说这个时间复杂度太高,所以应该进行优化,对于多项式的优化可以使用秦九韶算法(霍纳法则),即a(n)x^n + a(n-1)x^(n-1) + ... + a(1)x + a(0) = ((...(((anx + an - 1)x + an - 2)x + an - 3)...)x + a1)x + a0,此时需要进行n次乘法和n次加法,时间复杂度降为O(n)。
除了要优化计算速度,还有注意均匀分布,好的方式是使用质数,比如哈希表的长度、N次幂的底数。使用质数的原因是质数与其他数相乘的结果相对于其他数字更容易产生唯一性的结果,减少哈希冲突(Java中的n次幂的底数选择的是31)。
那么先来实现hash算法,它接受两个参数key和max
hashFunction(key: string, max: number) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
// 使用霍纳法则降低时间复杂度
hash = 31 * hash + key.charCodeAt(i);
}
// 返回hash值(索引值)
return hash % max;
}
哈希表方法
因为ts没有提供原生的链表,所以我们采用数组和元组作为bucket(桶,哈希表的基本单元),当然你也可以使用链表。
class HashTable<T> {
// 存储数据的数组
private storage: Array<[string, T]>[] = []
// 当前插入的元素个数
private count: number = 0
// 最大容量
private limit: number = 7
}
插入/修改操作
哈希表的插入和修改是使用同一个方法的,put方法接收key和value
put(key: string, value: T) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
let bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
// 创建桶
bucket = [];
this.storage[index] = bucket;
}
// 判断是否是修改数据
let override = false;
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
tuple[1] = value;
override = true;
}
}
// 如果不是修改数据 则插入数据
if (!override) {
bucket.push([key, value]);
this.count++;
// 判断是否需要扩容 装填因子超过0.75
if (this.count > this.limit * 0.75) {
// 扩容需要对以前容器的数据进行重新散列
// 因为扩容后取余的值发生了变化 对应的索引值也发生了变化
const newSize = this.limit * 2;
// 保证newSize是质数
const primeSize = this.getPrime(newSize);
this.resize(primeSize);
}
}
}
这里插入和修改操作还是比较简单实现的,值得注意的是扩容操作,上文我们说了当装填因子超过0.75时会导致效率下降,所以我们要将数组容量提升,一般是提升一倍,由于容器长度为质数时会获取到最佳的均匀分布效果,所以要获取到一个最近的质数。
// 获取最近的质数
private getPrime(num: number) {
while (!this.isPrime(num)) {
num++;
}
return num;
}
// 判断是否为质数
private isPrime(num: number) {
const temp = Math.sqrt(num);
for (let i = 2; i <= temp; i++) {
if (num % i === 0) {
return false;
}
}
return true;
}
扩容时要注意当容器长度发生变化后,hash函数的执行结果会跟以前的不一致,所以对于以前已经插入的数据要重新根据新长度再put一次
// 扩容/缩容
private resize(newLimit: number) {
// 保存旧的数组内容
const oldStorage = this.storage;
// 重置所有属性
this.storage = [];
this.count = 0;
this.limit = newLimit;
// 遍历oldStorage中的所有bucket
for (let i = 0; i < oldStorage.length; i++) {
const bucket = oldStorage[i];
if (bucket === undefined) {
continue;
}
// 遍历bucket中的所有元素
for (let j = 0; j < bucket.length; j++) {
const tuple = bucket[j];
this.put(tuple[0], tuple[1]);
}
}
}
删除操作
删除操作跟插入操作类似,值得注意的是当装填因子小于0.25时最好进行缩容操作,避免内存浪费
delete(key: string) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
const bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
return undefined;
}
// 遍历bucket
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count--;
// 判断是否需要缩容 装填因子小于0.25 最小容量为7
if (this.limit > 7 && this.count < this.limit * 0.25) {
const newSize = Math.floor(this.limit / 2);
// 保证newSize是质数
const primeSize = this.getPrime(newSize);
this.resize(primeSize);
}
return tuple[1];
}
}
return undefined;
}
获取操作
get(key: string) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
const bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
return undefined;
}
// 遍历bucket
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
return undefined;
}
以上就是一个比较完整的哈希表封装,下面是完整代码:
class HashTable<T> {
// 存储数据的数组
private storage: Array<[string, T]>[] = []
// 当前插入的元素个数
private count: number = 0
// 最大容量
private limit: number = 7
// hash函数
private hashFunction(key: string, max: number) {
let hash = 0;
for (let i = 0; i < key.length; i++) {
// 使用霍纳法则降低时间复杂度
hash = 31 * hash + key.charCodeAt(i);
}
// 返回hash值(索引值)
return hash % max;
}
// 扩容/缩容
private resize(newLimit: number) {
// 保存旧的数组内容
const oldStorage = this.storage;
// 重置所有属性
this.storage = [];
this.count = 0;
this.limit = newLimit;
// 遍历oldStorage中的所有bucket
for (let i = 0; i < oldStorage.length; i++) {
const bucket = oldStorage[i];
if (bucket === undefined) {
continue;
}
// 遍历bucket中的所有元素
for (let j = 0; j < bucket.length; j++) {
const tuple = bucket[j];
this.put(tuple[0], tuple[1]);
}
}
}
// 获取最近的质数
private getPrime(num: number) {
while (!this.isPrime(num)) {
num++;
}
return num;
}
// 判断是否为质数
private isPrime(num: number) {
const temp = Math.sqrt(num);
for (let i = 2; i <= temp; i++) {
if (num % i === 0) {
return false;
}
}
return true;
}
// 插入/修改
put(key: string, value: T) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
let bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
bucket = [];
this.storage[index] = bucket;
}
// 判断是否是修改数据
let override = false;
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
tuple[1] = value;
override = true;
}
}
// 如果不是修改数据 则插入数据
if (!override) {
bucket.push([key, value]);
this.count++;
// 判断是否需要扩容 装填因子超过0.75
if (this.count > this.limit * 0.75) {
// 扩容需要对以前容器的数据进行重新散列
// 因为扩容后取余的值发生了变化 对应的索引值也发生了变化
const newSize = this.limit * 2;
// 保证newSize是质数
const primeSize = this.getPrime(newSize);
this.resize(primeSize);
}
}
}
// 获取值
get(key: string) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
const bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
return undefined;
}
// 遍历bucket
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
return undefined;
}
// 删除
delete(key: string) {
// 获取索引值
const index = this.hashFunction(key, this.limit);
// 获取对应的bucket
const bucket = this.storage[index];
// 判断bucket是否为undefined
if (bucket === undefined) {
return undefined;
}
// 遍历bucket
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count--;
// 判断是否需要缩容 装填因子小于0.25 最小容量为7
if (this.limit > 7 && this.count < this.limit * 0.25) {
const newSize = Math.floor(this.limit / 2);
// 保证newSize是质数
const primeSize = this.getPrime(newSize);
this.resize(primeSize);
}
return tuple[1];
}
}
return undefined;
}
get max() {
return this.limit;
}
}