开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
一、什么是散列表?
散列表(hashTable)也成为哈希表,是根据键直接访问在内存储存位置的数据结构。
它通过计算一个键值的函数,将所查询的数据映射到表中相应的位置让人访问,以加快访问速度,通俗来说就是是时间换空间的一种方式。
通俗意义的定义,我们把找个键值称为key,把对应的存储的内容记录为value,这样即通过key访问一个应对应value的地址。而这个映射关系我们称为散列函数或哈希函数,存放记录的数组叫做散列表
1.1 结构
- 散列表的Key则是以字符串类型为主的
1.2 特点
- 访问速度:由于散列表是key到value的映射,我们在查找具体数据时,可以做到直接访问,不需要一个一个的查找。
- 需要额外的空间:散列表是存储不满的,但当散列表中元素的使用率越来越高时性能会下降,一般会选择扩容来解决这个问题
- 无序:为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的
- 可能会产生冲突:散列函数可能会对不同的key计算后得到了相同的地址,则对应关系没办法统一,会造成冲突,这种时候就需要采用解决冲突的方法。
1.3 使用场景
- 我们日常生活中的电话簿(暂不考虑姓名相同)
- 缓存,对一些常用信息的缓存,就用到了散列表
- 防止投票重复
正在阅读的读者,如果您有什么其他的应用场景,欢迎留言补充。
二、实现散列表
2.1 创建散列表
注意点:我们知道散列表的Key则是以字符串类型为主的,所以需要定义个方法,将key转化为字符串
import { defaultToString } from '../util';
export default class HashTable {
constructor(toStrFn = defaultToString) {
this.toStrFn = toStrFn;
this.table = {}
}
}
export function defaultToString(item) {
if (item === null) {
return 'NULL';
} else if (item === undefined) {
return 'UNDEFINED';
} else if (typeof item === 'string' || item instanceof String) {
return `${item}`;
}
return item.toString();
}
我们接下来需要实现三个常用的函数
- put(key,value):向散列表增加一个新的项(也能更新散列表)。
- remove(key):根据键值从散列表中移除值。
- get(key):返回根据键值检索到的特定的值
2.2 创建散列函数
散列函数,其实就是一个函数。我们可以把它定义成 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算所得到的散列值。
loseloseHashCode(key) {
if (typeof key === 'number') {
return key;
}
const tableKey = this.toStrFn(key);
let hash = 0;
for (let i = 0; i < tableKey.length; i++) {
hash += tableKey.charCodeAt(i);
}
return hash % 37;
}
hashCode(key) {
return this.loseloseHashCode(key);
}
2.3 将键值加入到散列表中
接下来我们实现put方法,即往散列表中插入数据
- 判断传入值的有效性
- 返回结果:true,false
put(key, value){
if(key != null && value != null){
const position = this.hashCode(key)
this.table[position] = new ValuePair(key, value);
return true
}
return false
}
ValuePair 的作用,保留原始的key和值
export class ValuePair {
constructor(key, value) {
this.key = key;
this.value = value;
}
toString() {
return `[#${this.key}: ${this.value}]`;
}
}
2.4 从散列表中获取一个值
即实现get方法,其实这个方法比较简单,我们传入key,hashCode 获取对应的hash值,然后直接获取对应的数据即可。
get(key){
const valuePair = this.table[this.hashCode(key)]
return valuePair == null ? undefined :valuePair.value
}
2.5 从散列表移除一个值
delete方法,移除一个值,我们想到的办法是直接调用对象的delete方法
- 判定key是否有对应的值
- 若有,则删除相应的key,返回key
- 若无,则返回false
delete(key) {
const hash = this.hashCode(key)
const valuePair = this.table[hash]
if (valuePair != null) {
delete this.table[hash]
return true
}
return false
}
三、 散列函数
上述我们实现了一个简单的hashTable,但是针对散列函数,我们会遇到有冲突的情况存在。例如:
import HashTable from './HashTable.js'
const hash1 = new HashTable();
hash1.put('Ygritte', 'ygritte@email.com');
hash1.put('Nathan', 'nathan@email.com');
hash1.put('Sargeras', 'sargeras@email.com');
console.log(hash1.get('Ygritte')) // ygritte@email.com
console.log(hash1.get('Nathan')) //sargeras@email.com
console.log(hash1.get('Sargeras'))//sargeras@email.com
我们从结果中可以看到,key不同,但是产生的hash值是相同的。故造成了数据覆盖的问题。
接下来我们着重去介绍,如何解决散列表中的冲突。常见的解决冲突的方法有:分离链接、线性探查和双散列法。
3.1 分离链接
将散列到同一个存储位置的所有元素保存在一个链表中。它是解决冲突的最简单的方法,但是在 HashTable 实例之外还需要额外的存储空间。 例如,他是这样的一种结构
接下来我们来写它的实现方法:主要应用连链表的插入和删除元素,如果大家还不清楚链表时如何使用的可查看这篇文章:数据结构----链表
注意点:删除数据的时候,需要判定链表数据结构是否已经为空。若为空,则直接删除hashtable中的key
import { defaultToString } from './util.js';
import { ValuePair } from './models/value-pair.js';
import LinkedList from './linked-list.js';
export default class HashTableSeparateChaining {
constructor(toStrFn = defaultToString) {
this.toStrFn = toStrFn;
this.table = {};
}
loseloseHashCode(key) {
if (typeof key === 'number') {
return key;
}
const tableKey = this.toStrFn(key);
let hash = 0;
for (let i = 0; i < tableKey.length; i++) {
hash += tableKey.charCodeAt(i);
}
return hash % 37;
}
hashCode(key) {
return this.loseloseHashCode(key);
}
put(key, value) {
if(key != null && value != null){
const position = this.hashCode(key)
if(this.table[position] == null){
this.table[position] = new LinkedList()
}
this.table[position].push(new ValuePair(key,value))
return true
}
return false
}
get(key){
const position = this.hashCode(key);
const linkedList = this.table[position]
if(linkedList != null && !linkedList.isEmpty()){
let current = linkedList.getHead()
while(current != null){
if(current.element.key == key){
return current.element.value
}
current = current.next;
}
}
return undefined
}
remove(key){
const position = this.hashCode(key);
const linkedList = this.table[position]
if(linkedList != null && !linkedList.isEmpty()){
let current = linkedList.getHead()
while(current != null){
if(current.element.key = key){
linkedList.remove(current.element)
if(linkedList.isEmpty()){
delete this.table[position]
}
return true
}
current = current.next;
}
}
return undefined
}
}
3.2 线性探查
方法的原理是将元素直接存储到表中。 原理图如下: 往散列表中插入数据时,如果某个数据经过散列函数后,存储的位置已经被占用了,那么我们就从当前位置开始,依次往后查找,看到有空闲位置后,插入即可
我们知道每个key对应的散列值:
- 4 - Ygritte
- 5 - Jonathan
- 5 - Jamie
- 7 - Jack
- 8 - Jasmine
- 9 - Jake
- 10 - Nathan
- 7 - Athelstan 则完成上述操作后,散列的结构为
如何去查找元素: 查找过程与插入过程是类似的,先通过散列函数计算出对应的hash值,然后比较数组中下标为散列值的元素和要查找的元素
- 若相等,则是我们查找的元素
- 若不相等,我们就顺序的往后查找,index++,如果遍历到数组的空闲位置仍旧没有找到,则说明要查找的元素并没有在散列表中
线性探查技术分为两种,分别为软删除方法和 第二种移动元素
3.2.1 软删除
我们通过查找可以得知,我们只需要顺序查找到空闲位置。
我们设想一种场景,删除了其中几个散列值,则此位置的值为空,循环的时候遇到被删除的元素就不会向后边查找,会产生错误结果。
例如:我们删除Jonathan,则5 位置上对应的键值为null,当我们查找Jamie时,hash值为5,但是对应数据为空,我们没有找到,直接就返回undefined了。明显的可以看出结果是错误的,产生这种错误的原因是,我们删除时没有做任何特殊处理,使得链表出现很多删除的空节点,与正常的空节点无法发区分。
基于上述的问题,我们在删除数据的时候,使用一个特殊的值(标记)来表示键值对被删除了(惰性删除或软删除),而不是真的删除它。但是经过一段时间的操作后,会降低散列表的效率,搜索的过程会逐渐变慢。下图展示了这种结果:
可参考下面实现的代码:
remove(key) {
const position = this.hashCode(key);
if (this.table[position] != null) {
if (this.table[position].key === key && !this.table[position].isDeleted) {
this.table[position].isDeleted = true;
return true;
}
let index = position + 1;
while (this.table[index] != null && (this.table[index].key !== key || this.table[index].isDeleted)) {
index++;
}
if (this.table[index] != null && this.table[index].key === key && !this.table[index].isDeleted) {
this.table[index].isDeleted = true;
return true;
}
}
return false;
}
3.2.2 第二种检测办法:移动元素
过程如下图所示:
remove(key) {
const position = this.hashCode(key);
if (this.table[position] != null) {
if (this.table[position].key === key) {
delete this.table[position];
this.verifyRemoveSideEffect(key, position);
return true;
}
let index = position + 1;
while (this.table[index] != null && this.table[index].key !== key) {
index++;
}
if (this.table[index] != null && this.table[index].key === key) {
delete this.table[index];
this.verifyRemoveSideEffect(key, index);
return true;
}
}
return false;
}
// 参数:被删除的 key 和该 key 被删除的位置
verifyRemoveSideEffect(key, removedPosition) {
const hash = this.hashCode(key);
let index = removedPosition + 1;//从下一个位置开始遍历
while (this.table[index] != null) { // 当对应的值不为空
// 当前key对应的hash值
const posHash = this.hashCode(this.table[index].key);
// 如果当前元素的 hash 值小于或等于原始的 hash 值
// 或者当前元素的hash值小于或者等于上一个被移除 key 的 hash 值
if (posHash <= hash || posHash <= removedPosition) {
// 当前元素移动至 removedPosition 的位置
this.table[removedPosition] = this.table[index];
// 删除当前元素的值,removedPosition 更新为当前的 index,重复此过程。
delete this.table[index];
removedPosition = index;
}
index++;
}
}
四、总结
通过上述操作我们可以得知,一个好的散列函数能够降低冲突的可能性。 例如我们会使用到的MD5,HAVAL等, 接下来我会用一篇文章讲解散列函数,欢迎大家点赞,关注,订阅。