哈希表通常是基于数组进行实现的,随机查找非常快速。
相对于数组,它的优点是提供了非常快速地插入、删除和查找操作;缺点是哈希表中的数据是没有顺序的,并且 key 不允许重复。
数组进行插入操作,效率不高。
数组进行查找操作:如果是基于索引进行查找,效率非常高;如果是基于内容进行查找,效率不高。
数组进行删除操作,效率不高。
案例:
现在想要存储 5000 个联系人的姓名和电话号码。
若使用数组,增加和删除联系人性能会比较低,而且当想要查找某个联系人的电话号码时,由于不知道他所对应的下标值,查找也很麻烦;若使用链表,获取联系人性能会比较低。
解决方案:有一种数据结构,能把员工的姓名转换成他对应的编号,然后根据编号来增删改查员工的电话号码,这就是哈希表。哈希表是基于数组来实现的,只不过哈希表能够通过哈希函数把字符串转化为对应的下标值,建立字符串和下标值的对应关系。
-
为了把字符串转换成对应的下标值,需要有一套编码系统。为了深入理解,自己来实现一个编码系统,比如:a 是 1,b 是 2,依次类推,z 是 26,加上空格是 0,不考虑大小写,一共是 27 个字符。
计算机中已经有很多的编码方案就是用数字来代替字符的,比如 ASCII 编码:a 是 97,b 是 98。
-
有了编码系统后,又如何将一个字符串转化成数字呢?有很多种方式:
-
数字相加:把每个字符转化为数字后进行相加。但是,这种方案产生的数组下标太小,很多字符串的下标可能都一样。
计算方法:3+1+20+19=43
-
幂的连乘:基本可以保证数字的唯一性。但是,这种方案如果字符串比较长,那么产生的数组下标就会很大,长度过大的数组其中却有很多下标值指向的是无效的数据,造成了内存空间的浪费。
计算方法:
cats = 3*27^3 + 1*27^2 + 20*27 + 17 = 60337
,27 是自己实现的编程系统中的字符数量。
-
-
现在需要一种压缩方法,把幂的连乘方案中得到的巨大的数字范围压缩到可接受的范围中,可以通过取余操作实现。
假设这 5000 个联系人转化成的数字最大为 1000000000000,使用 largeNumber 来表示;5000 个联系人,那么可能需要定义一个长度为 10000 的数组,使用 smallRange 来表示,因为在实际情况中,往往需要更大的空间来存储这些数据,因为不能保证数据会映射到每一个位置,有些位置是没有数据的;那么下标值的结果为
index = largeNumber % smallRange
。
哈希化:将一个大数字压缩为小数字,转化成一个数组范围内的下标的过程,就称之为哈希化。
哈希函数:将单词转成大数字,大数字再进行哈希化的代码封装在一个函数中,该函数就称之为哈希函数。
哈希表:对最终数据插入的数组进行整个结构的封装,得到的就是哈希表。
冲突:
通过哈希化后的下标值可能会重复,这种情况称为冲突。
解决冲突常见的两种方案:链地址法(拉链法)和开放地址法。
链地址法(拉链法):
链地址法解决冲突的方法是:每个数组单元中存储的不再是单个数据,而是一个链条,这个链条常见的是数组或链表。也就是说,每个数组单元中存储着一个数组或链表,一旦发现哈希化后的下标值重复,就将重复的元素插入到数组或链表的首端或末端;当查询时,先根据哈希化后的下标值找到对应的位置,再取出数组或链表,依次线性查找数据即可。
在实际开发中,使用链地址法的情况较多。
上图中,将每一个数字都对 10 进行取余操作,则余数的范围 0~9 作为数组的下标值。数组每一个下标值对应的位置存储的不再是一个数字,而是经过取余操作后得到相同余数的数字组成的数组或链表。
开放地址法:
开放地址法解决冲突的主要工作方式是寻找空白的单元格来添加重复的数据。
开放地址法寻找空白单元格有三种方法:
-
线性探测:就是线性地查找空白的单元。
插入:经过哈希化得到 index,但是在插入的时候,发现该位置已经有元素了;那么就从 index + 1 开始查找空的位置来存放元素。
查询:首先经过哈希化得到 index;如果 index 位置的元素和要查找的元素相同,那么就直接返回;否则的话,从 index + 1 开始查找;如果查询到一个空的位置,那么就停止,代表哈希表中没有这个元素;否则的话,就一直向后查找,直到找到查询的元素。如果查询到一个空的位置,那么就停止,代表没有这个元素的原因:插入的时候,不可能会跳过一个空的位置,而将元素插入到再后面的空位置上。
删除:找到查询的元素之后,不可以将这个位置下标的内容设置为 null,而是要对其进行特殊处理,比如设置为 -1。
找到查询的元素之后,不可以将这个位置下标的内容设置为 null,而是要对其进行特殊处理的原因:如果设置为 null,那么这个删除导致的空位置的存在,可能会造成之后查询元素的时候,将元素误判断为不存在。
线性探测存在的问题:线性探测有一个比较严重的问题,就是聚集。如果之前的数据是连续插入的,那么新插入的一个数据可能需要探测很长的距离。
比如:在没有任何数据的时候,插入的是 22、23、24、25、26,那么意味着下标值为 2、3、4、5、6 的位置都有元素,这种一连串填充单元就叫做聚集。
聚集会影响哈希表的性能,无论是插入、查询、删除都会影响。比如:要插入一个 32,会发现连续的单元都已经存储了元素,不允许放置新插入的数据,在这个过程中就需要不断地查找,跳过很多单元,才能找到合适的空位置。
二次探测可以解决一部分这个问题。 -
二次探测:二次探测在线性探测的基础上进行了优化,主要优化的是探测时的步长,二次探测的探测步长是固定的:1、4、9、16 依次类推。
线性探测,可以看成是步长为 1,比如:从下标值 x 开始,那么线性探测就是 x + 1,x + 2,x + 3 等依次探测。
二次探测,对步长做了优化,比如:从下标值 x 开始,x + 1²,x + 2²,x + 2³ 等。这样就可以一次性探测比较长的距离,一定程度上避免聚集带来的影响。二次探测的问题:二次探测还是会造成步长不一的一种聚集,虽然出现的可能性相对小,但还是会影响效率。
比如:要连续插入 32、112、82、2、192。
- 首先是 32,通过哈希化得到下标值为 2,将 32 插入到下标值为 2 的位置。
- 然后是 112,通过哈希化得到下标值仍然为 2,已存在元素;二次探测 2 + 1² 得到下标值将其插入。
- 再然后是 82,通过哈希化得到下标值仍然为 2,已存在元素;二次探测 2 + 1² 得到下标值,仍然存在元素;再次二次探测 2 + 2² 得到下标值将其插入。
- 再然后是 2,通过哈希化得到下标值仍然为 2,已存在元素;二次探测 2 + 1² 得到下标值,仍然存在元素;再次二次探测 2 + 2² 得到下标值,仍然存在元素;再次二次探测 2 + 2³ 得到下标值将其插入。
后面的元素依次类推。
再哈希法可以根本解决这个问题。
-
再哈希法:根据不同的关键字产生不同的探测步长。
再哈希法的做法是:把关键字用另外一个哈希函数,再做一次哈希化,用这次哈希化的结果作为步长。再哈希化的哈希函数不能和第一个哈希函数相同,否则哈希化后的结果仍是原来位置;不能输出为 0,否则将没有步长,每次探测都是原地踏步,算法就进入了死循环。
计算机专家已经设计出了一种很好的再哈希法的哈希函数:
stepSize = constant - (key % constant)
,其中,constant 是质数且小于数组的容量。
效率:
哈希表中执行插入和查询等操作的效率是非常高的。
如果没有产生冲突,那么效率就会更高。
如果产生了冲突,那么效率就和探测长度有关系了。链地址法相对来说效率是好于开放地址法的。
填充因子:loadFactor,当前哈希表中已经包含的数据项和整个哈希表长度的比值。
探测长度取决于填充因子,随着填充因子变大,探测长度也越来越长,效率越来越低。
开放地址法中填充因子最大是 1,因为数组的每个位置最多都只能存储一个元素。
链地址法的填充因子可以大于 1,因为数组的每个位置都可以存储一个数组或链表,包含的数据项可以是无限多个。
扩容和缩容:
在实际开发中,使用链地址法的情况较多。因为使用链地址法,因此哈希表可以无限制地插入新的数据,随着数据量的增多,每一个 index 对应的 bucket 会越来越长,因为在 bucket 中是线性查找的,效率就会越来越低,所以,在合适的情况下要对数组进行扩容。当数组长度过长,而数据项很少时,过长的数组就会浪费内存空间,此时,就要进行缩容。
扩容和缩容后,所有的数据项都要进行修改,这个过程是必须的。因为如果进行增删改查操作,重新调用哈希函数,limit 的改变会导致获取到不同的 index。比较常见的情况是 loadFactor > 0.75 的时候进行扩容, loadFactor < 0.25 的时候进行缩容。
容量最好是质数,有利于数据的均匀分布。
// 判断一个数是质数
function isPrimeNumber (num) {
// 获取平方根
var temp = parseInt(Math.sqrt(num))
// 遍历循环
for (var i = 2; i <= temp; i++) {
if (num % i === 0) {
return false
}
}
return true
}
console.log(isPrimeNumber(7)) // true
console.log(isPrimeNumber(8)) // false
哈希函数的实现:
优秀的哈希函数应该具备的优点:
- 快速的计算:哈希表的优势就在与效率,所以通过快速地计算快速地获取到对应的 hashCode 非常重要。提高速度的一个办法就是让哈希函数中尽量少有乘法和除法,因为它们的性能是比较低的。
在前面,将单词转换成大数字的时候,使用了幂的连乘的方式:cats = 3*27^3 + 1*27^2 + 20*27 + 17 = 60337
,这个表达式可以抽象成一个多项式:a(n)x^n + a(n-1)x^(n-1) + ... + a(1)x + a(0)
,可以使用霍纳法则对这个多项式进行优化。 - 均匀的分布:哈希表中,无论是链地址法还是开放地址法,当多个元素映射到同一个位置的时候,都会影响效率,所以,优秀的哈希函数应该尽可能地将元素映射到不同的位置,让元素在哈希表中均匀的分布。
可以在使用常量的地方,尽量使用质数。比如:哈希表的长度、N 次幂的底数(前面的例子中使用的是 27)。
// 封装哈希函数:1. 将字符串转成比较大的数字 hashCode;2. 将大的数字 hashCode 压缩到数组范围之内
function hashFunc(str, size) {
// 1. 定义 hashCode 变量
var hashCode = 0
// 2. 通过霍纳法则来计算出大数字 hashCode 的值
for (var i = 0; i < str.length; i++) {
// hashCode * n 中的 n 用质数比较好,一般用 37 的比较多
// 通过 str.charCodeAt(i) 获取字符对应的 Unicode 编码
hashCode = hashCode * 37 + str.charCodeAt(i)
}
// 通过取余操作将大数字 hashCode 压缩到数组范围之内
var index = hashCode % size
return index
}
// 测试哈希函数
console.log(hashFunc('Lee', 7)) // 5
console.log(hashFunc('Mary', 7)) // 2
console.log(hashFunc('Bob', 7)) // 3
console.log(hashFunc('Tom', 7)) // 2,有冲突
哈希表的实现:
// 采用链地址法来封装哈希表
// 哈希表基于数据(storage)实现;每个 index 对应的又是一个数组(bucket);bucket 中存放的还是一个个的数组,会将 key 和 value 都存储起来。因此哈希表的数据格式是:[[[key, value], [key, value]], [[key, value]], ...]
function HashTable() {
// 属性
this.storage = [] // 使用数组来存放哈希表中的元素
this.count = 0 // 当前哈希表中已经存放的元素的数量。可以通过当前哈希表中已经存放的元素的数量除以哈希表的总长度来计算填充因子。当填充因子大于0.75的时候,再插入元素性能就会变得比较低,这个时候就需要进行扩容;当小于0.25的时候,就需要对数组进行缩容
this.limit = 7 // 当前哈希表的总长度,先默认为7
}
// 方法
// 哈希函数
HashTable.prototype.hashFunc = hashFunc
// 插入和修改:如果不存在该 key,那么就是插入操作;如果已经存在该 key,那么就是修改操作
HashTable.prototype.put = function(key, value) {
// 1. 通过哈希函数,根据 key 获取索引值
var index = this.hashFunc(key, this.limit)
// 2. 根据索引值 index 获取对应的桶 bucket
var bucket = this.storage[index]
// 3. 判断该 bucket 是否存在,如果桶不存在的话,创建桶并将其放置到 index 对应的位置
if (!bucket) {
bucket = []
this.storage[index] = bucket
}
// 4. 判断是否是修改数据
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] === key) {
tuple[1] = value
return
}
}
// 5. 不是修改数据,那么就是新增数据
bucket.push([key, value])
// 6. 将当前哈希表中已经存放的元素的数量+1
this.count++
// 7. 判断是否需要进行扩容操作
if (this.count > this.limit * 0.75) {
var newLimit = this.limit * 2
var primeNumber = this.getPrimeNumber(newLimit)
this.resize(primeNumber)
}
}
// 获取
HashTable.prototype.get = function(key) {
// 1. 通过哈希函数,根据 key 获取索引值
var index = this.hashFunc(key, this.limit)
// 2. 根据索引值 index 获取对应的桶 bucket
var bucket = this.storage[index]
// 3. 判断该 bucket 是否存在,如果桶不存在的话,直接返回 null
if (!bucket) return null
// 4. 如果桶存在的话,线性查找 bucket 中的 key 是否等于传入的 key,如果找到的话,返回对应的 value
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] === key) {
return tuple[1]
}
}
// 5. 遍历完之后依然没有找到对应的 key,返回 null
return null
}
// 删除
HashTable.prototype.remove = function(key) {
// 1. 通过哈希函数,根据 key 获取索引值
var index = this.hashFunc(key, this.limit)
// 2. 根据索引值 index 获取对应的桶 bucket
var bucket = this.storage[index]
// 3. 判断该 bucket 是否存在,如果桶不存在的话,直接返回 null
if (!bucket) return null
// 4. 如果桶存在的话,线性查找 bucket 中的 key 是否等于传入的 key,如果找到的话,删除并返回对应的 value,将当前哈希表中已经存放的元素的数量-1
for (var i = 0; i < bucket.length; i++) {
var tuple = bucket[i]
if (tuple[0] === key) {
bucket.splice(i, 1)
this.count--
// 5. 判断是否需要进行缩容操作
if (this.limit > 7 && this.count < this.limit * 0.25) {
var newLimit = this.limit / 2
var primeNumber = this.getPrimeNumber(newLimit)
this.resize(Math.floor(primeNumber))
}
return tuple[1]
}
}
// 6. 遍历完之后依然没有找到对应的 key,返回 null
return null
}
// 判断哈希表是否为空
HashTable.prototype.isEmpty = function() {
return this.count === 0
}
// 获取哈希表中元素的个数
HashTable.prototype.isEmpty = function() {
return this.count
}
// 哈希表扩容
HashTable.prototype.resize = function(newLimit) {
// 1. 保存旧的数组内容
var oldStorage = this.storage
// 2. 重置所有的属性
this.storage = []
this.count = 0
this.limit = newLimit
// 3. 遍历 oldStorage 中所有的 bucket
for (var i = 0; i < oldStorage.length; i++) {
// 4. 取出对应的 bucket
var bucket = oldStorage[i]
// 5. 判断 bucket 是否存在,如果 bucket 不存在
if (!bucket) continue
// 如果 bucket 存在,遍历取出所有数据,重新插入
for (var j = 0; j < bucket.length; j++) {
var tuple = bucket[j]
this.put(tuple[0], tuple[1])
}
}
}
// 判断某个数字是否为质数
HashTable.prototype.isPrimeNumber = function(num) {
// 获取平方根
var temp = parseInt(Math.sqrt(num))
// 遍历循环
for (var i = 2; i <= temp; i++) {
if (num % i === 0) return false
}
return true
}
// 获取质数
HashTable.prototype.getPrimeNumber = function(num) {
while(!this.isPrimeNumber(num)) {
num++
}
return num
}
// 测试哈希表
var hashTable = new HashTable()
hashTable.put('Lee', 17319964671)
hashTable.put('Mary', 17319964672)
hashTable.put('Bob', 17319964673)
hashTable.put('Tom', 17319964674)
console.log(hashTable.get('Tom'))
hashTable.put('Tom', 17319964675)
console.log(hashTable.get('Tom'))
hashTable.remove('Tom')
console.log(hashTable.get('Tom'))