哈希表并不好理解,不像数组、链表和树等可通过图形的形式表示其结构和原理。
哈希表的结构就是数组,但它神奇之处在于对下标值的一种变换,这种变换我们可以称之为哈希函数,通过哈希函数可以获取HashCode。哈希表通常是基于数组实现的,但是相对于数组,它存在更多优势
通过以下案例了解哈希表:
也就是说:哈希表最后还是基于数组来实现的,只不过哈希表能够通过哈希函数把字符串转化为对应的下标值,建立字符串和下标值的映射关系。
哈希化
将大数字转化成数组范围内下标的过程,称之为哈希化。
哈希函数
我们通常会将单词转化成大数字,把大数字进行哈希化的代码实现放在一个函数中,该函数就称为哈希函数。
哈希表
对最终数据插入的数组进行整个结构的封装,得到的就是哈希表。
为了把字符串转化为对应的下标值,需要有一套编码系统,为了方便理解我们创建这样一套编码系统:比如 a 为 1,b 为 2,c 为 3,以此类推 z 为 26,空格为 27(不考虑大写情况)。
有了编码系统后,将字母转化为数字也有很多种方案:
例如 cats 转化为数字:3 + 1 + 20 + 19 = 43
,那么就把 43 作为 cats 单词的下标值储存在数组中;
但是这种方式会存在这样的问题:很多的单词按照该方式转化为数字后都是 43,比如 was。而在数组中一个下标值只能储存一个数据,所以该方式不合理。
我们平时使用的大于 10 的数字,就是用幂的连乘来表示它的唯一性的。 比如: 6543 = 6 * 10^3 + 5 * 10^2 + 4 * 10 + 3
;这样单词也可以用该种方式来表示:cats = 3 * 27^3 + 1 * 27^2 + 20 * 27 + 17 = 60337
。
虽然该方式可以保证字符的唯一性,但是如果是较长的字符(如 aaaaaaaaaa)所表示的数字就非常大,此时要求很大容量的数组,然而其中却有许多下标值指向的是无效的数据(比如不存在 zxcvvv 这样的单词),造成了数组空间的浪费。
两种方案总结:
现在需要一种压缩方法,把幂的连乘方案系统中得到的巨大整数范围压缩到可接受的数组范围中。可以通过取余操作来实现。虽然取余操作得到的结构也有可能重复,但是可以通过其他方式解决。
如下图所示,我们将每一个数字都对10进行取余操作,则余数的范围0~9作为数组的下标值。并且,数组每一个下标值对应的位置存储的不再是一个数字了,而是存储由经过取余操作后得到相同余数的数字组成的数组或链表
这样可以根据下标值获取到整个数组或链表,之后继续在数组或链表中查找就可以了。而且,产生冲突的元素一般不会太多。
总结:链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一条链条,这条链条常使用的数据结构为数组或链表,两种数据结构查找的效率相当(因为链条的元素一般不会太多)
开放地址法的主要工作方式是寻找空白的单元格来放置冲突的数据项。
根据探测空白单元格位置方式的不同,可分为三种方法:
下面我们根据上图举例感受一下什么是线性探测
经过哈希化(对 10 取余)之后得到的下标值 index=3,但是该位置已经放置了数据 33。而线性探测就是从 index 位置+1 开始向后一个一个来查找合适的位置来放置 13,所谓合适的位置指的是空的位置,如上图中 index=4 的位置就是合适的位置。
线性探测存在的问题:
二次探测法可以解决该问题
上文所说的线性探测存在的问题:
二次探测是在线性探测的基础上进行了优化:
线性探测:我们可以看成是步长为 1 的探测,比如从下表值 x 开始,那么线性探测就是按照下标值:x+1、x+2、x+3 等依次探测;
二次探测:对步长进行了优化,比如从下标值 x 开始探测:x+12、x+22、x+33 。
这样一次性探测比较长的距离,避免了数据聚集带来的影响。
二次探测存在的问题:
在开放地址法中寻找空白单元格的最好的解决方式为再哈希化。
第二次哈希化需要满足以下两点:
优秀的哈希函数:
哈希化的效率:
哈希表中执行插入和搜索操作效率是非常高的。
装填因子:
不同探测方式性能的比较:
线性探测
可以看到,随着装填因子的增大,平均探测长度呈指数形式增长,性能较差。实际情况中,最好的装填因子取决于存储效率和速度之间的平衡,随着装填因子变小,存储效率下降,而速度上升。
二次探测和再哈希化的性能
二次探测和再哈希法性能相当,它们的性能比线性探测略好。由下图可知,随着装填因子的变大,平均探测长度呈指数形式增长,需要探测的次数也呈指数形式增长,性能不高。
链地址法的性能
可以看到随着装填因子的增加,平均探测长度呈线性增长,较为平缓。在开发中使用链地址法较多,比如 Java 中的 HashMap 中使用的就是链地址法。
哈希表的优势在于它的速度,所以哈希函数不能采用消耗性能较高的复杂算法。提高速度的一个方法是在哈希函数中尽量减少乘法和除法。
性能高的哈希函数应具备以下两个优点:
霍纳法则:在中国霍纳法则也叫做秦久韶算法,具体算法为:
求多项式的值时,首先计算最内层括号内一次多项式的值,然后由内向外逐层计算一次多项式的值。这种算法把求 n 次多项式 f(x)的值就转化为求 n 个一次多项式的值。
如果使用大 O 表示时间复杂度的话,直接从变换前的 O(N^2)降到了 O(N)。
在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法。但是,为了提供效率,最好的情况还是让数据在哈希表中均匀分布。因此,我们需要在使用常量的地方,尽量使用质数。比如:哈希表的长度、N 次幂的底数等。
Java 中的 HashMap 采用的是链地址法,哈希化采用的是公式为:index = HashCode(key) & (Length-1) 即将数据化为二进制进行与运算,而不是取余运算。这样计算机直接运算二进制数据,效率更高。但是 JavaScript 在进行较大数据的与运算时会出现问题,所以我们使用 JavaScript 实现哈希化时采用取余运算。
function hashFunction(str, size) {
// 1.定义hashCode
var hashCode = 0;
// 2.霍纳算法,来计算hashCode的值
for (var i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i);
}
// 缩小hashCode的范围
var index = hashCode % size;
return index;
};
hashFunction("abc", 7); //? 4
hashFunction("cba", 7); //? 3
hashFunction("nba", 7); //? 5
hashFunction("mba", 7); //? 1
put(key, value)
插入或修改操作。get(key)
获取哈希表中特定位置的元素。remove(key)
删除哈希表中特定位置的元素。isEmpty()
如果哈希表中不包含任何元素,返回 trun
,如果哈希表长度大于 0 则返回 false
。size()
返回哈希表包含的元素个数。resize(value)
对哈希表进行扩容操作。封装的哈希表的数据结构模型:
// 根据 链地址法(拉链法) 封装哈希表
function HashTable() {
this.storage = [];
this.count = 0; // 保存已经存放的总数,用于计算装载因
this.limit = 7; // 数组长度,用于动态扩容
}
哈希表的插入和修改操作是同一个函数:因为,当使用者传入一个 [key, value]
时,如果原来不存在该 key,那么就是插入操作,如果原来已经存在该 key,那么就是修改操作。
实现思路:
代码实现
// 1.插入和修改
HashTable.prototype.put = function (key, value) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
// 1.根据key获取对应的index
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) {
bucket = [];
this.storage[index] = bucket;
}
// 修改操作
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
tuple[1] = value;
return;
}
}
// 插入操作
bucket.push([key, value]);
this.count += 1;
};
为了方便测试,我们下面去实现get方法
实现思路:
storage
中对应的索引值 index
。bucket
。bucket
是否为 null
,如果为 null
,直接返回 null
。bucket
中每一个 key
是否等于传入的 key
。如果等于,直接返回对应的 value
。bucket
后,仍然没有找到对应的 key
,直接 return null
即可。代码实现
// 2.获取操作
HashTable.prototype.get = function (key) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) return null;
// 从 bucket 中线性查找key
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
// bucket中没有找到返回 null
return null;
};
测试代码
//测试代码
var ht = new HashTable();
ht.put("abc", "111");
ht.put("cba", "222");
ht.put("nba", "333");
console.log(ht);
console.log(ht.get("abc")); // ? 111
console.log(ht.get("cba")); // ? 222
console.log(ht.get("nba")); // ? 333
// 测试修改
ht.put("abc", "444");
console.log(ht.get("abc")); // ? 444Ï
实现思路:
storage
中对应的索引值 index
。bucket
。bucket
是否为 null
,如果为 null
,直接返回 null
。bucket
,寻找对应的数据,并且删除。null
。// 3.删除操作
HashTable.prototype.remove = function (key) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) return null;
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count -= 1;
return tuple[1];
}
}
return null;
};
HashTable.prototype.isEmpty = function () {
return this.count === 0;
};
HashTable.prototype.size = function () {
return this.count;
};
为什么需要扩容?
index
对应的 bucket
数组(链表)就会越来越长,这就会造成哈希表效率的降低。什么情况下需要扩容?
loadFactor > 0.75
的时候进行扩容。如何进行扩容?
实现思路:
storage
。this.storage
指向它。this.storage
指向的新数组中。装填因子 = 哈希表中数据 / 哈希表长度,即 loadFactor = count / HashTable.length
。
resize 方法,既可以实现哈希表的扩容,也可以实现哈希表容量的压缩。
// 4.哈希表扩容
HashTable.prototype.resize = function (newLimit) {
var oldStorage = this.storage;
this.storage = [];
this.count = 0;
this.limit = newLimit;
for (var i = 0; i < oldStorage.length; i++) {
var bucket = oldStorage[i];
if (bucket === undefined) continue;
for (var j = 0; j < bucket.length; j++) {
var tuple = bucket[j];
this.put(tuple[0], tuple[1]);
}
}
};
通常情况下当装填因子 laodFactor > 0.75
时,对哈希表进行扩容。在哈希表中的put方法中添加如下代码,判断是否需要调用扩容函数进行扩容。
// 动态扩容
if (this.count > this.limit * 0.75) {
let newLimit = this.getPrime(this.limit * 2);
this.resize(newLimit);
}
当装填因子 laodFactor < 0.25
时,对哈希表容量进行压缩。在哈希表中的remove方法中添加如下代码,判断是否需要调用扩容函数进行压缩。
// 动态减少容量
if (this.limit > 7 && this.count < this.limit * 0.25) {
let newLimit = this.getPrime(this.limit * 2);
this.resize(newLimit);
}
这里封装两个工具函数
// 判断是否为质数
HashTable.prototype.isPrime = function (number) {
var temp = parseInt(Math.sqrt(number));
for (var i = 2; i <= temp; i++) {
if (number % i === 0) {
return false;
}
}
return true;
};
// 获取一个质数
HashTable.prototype.getPrime = function (number) {
while (!this.isPrime(number)) {
number += 1;
}
return number;
};
// 根据 链地址法(拉链法) 封装哈希表
function HashTable() {
this.storage = [];
this.count = 0; // 保存已经存放的总数,用于计算装载因
this.limit = 7; // 数组长度,用于动态扩容
HashTable.prototype.hashFunction = function (str, size) {
// 1.定义hashCode
var hashCode = 0;
// 2.霍纳算法,来计算hashCode的值
// 这里就采用37作为公式里的质数(无强制要求,质数即可)
for (var i = 0; i < str.length; i++) {
hashCode = 37 * hashCode + str.charCodeAt(i);
}
// 缩小hashCode的范围
var index = hashCode % size;
return index;
};
// 1.插入和修改
HashTable.prototype.put = function (key, value) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
// 1.根据key获取对应的index
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) {
bucket = [];
this.storage[index] = bucket;
}
// 修改操作
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
tuple[1] = value;
return;
}
}
// 插入操作
bucket.push([key, value]);
this.count += 1;
// 动态扩容
if (this.count > this.limit * 0.75) {
let newLimit = this.getPrime(this.limit * 2);
this.resize(newLimit);
}
};
// 2.获取操作
HashTable.prototype.get = function (key) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) return null;
// 从 bucket 中线性查找key
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
return tuple[1];
}
}
// bucket中没有找到返回 null
return null;
};
// 3.删除操作
HashTable.prototype.remove = function (key) {
if (typeof key === "String") {
throw new Error("key must be a string");
}
var index = this.hashFunction(key, this.limit);
var bucket = this.storage[index];
if (bucket === undefined) return null;
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i];
if (tuple[0] === key) {
bucket.splice(i, 1);
this.count -= 1;
// 动态减少容量
if (this.limit > 7 && this.count < this.limit * 0.25) {
let newLimit = this.getPrime(this.limit * 2);
this.resize(newLimit);
}
return tuple[1];
}
}
return null;
};
HashTable.prototype.isEmpty = function () {
return this.count === 0;
};
HashTable.prototype.size = function () {
return this.count;
};
// 4.哈希表扩容
HashTable.prototype.resize = function (newLimit) {
var oldStorage = this.storage;
this.storage = [];
this.count = 0;
this.limit = newLimit;
for (var i = 0; i < oldStorage.length; i++) {
var bucket = oldStorage[i];
if (bucket === undefined) continue;
for (var j = 0; j < bucket.length; j++) {
var tuple = bucket[j];
this.put(tuple[0], tuple[1]);
}
}
};
// 判断是否为质数
HashTable.prototype.isPrime = function (number) {
var temp = parseInt(Math.sqrt(number));
for (var i = 2; i <= temp; i++) {
if (number % i === 0) {
return false;
}
}
return true;
};
// 获取一个质数
HashTable.prototype.getPrime = function (number) {
while (!this.isPrime(number)) {
number += 1;
}
return number;
};
}
//测试代码
var ht = new HashTable();
ht.put("abc", "111");
ht.put("cba", "222");
ht.put("nba", "333");
console.log(ht);
console.log(ht.get("abc")); // ? 111
console.log(ht.get("cba")); // ? 222
console.log(ht.get("nba")); // ? 333
ht.put("abc", "444");
console.log(ht.get("abc")); // ? 444
ht.remove("abc");
console.log(ht.get("abc")); // ? null