前端数据结构----散列表

135 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

一、什么是散列表?

散列表(hashTable)也成为哈希表,是根据键直接访问在内存储存位置的数据结构。

它通过计算一个键值的函数,将所查询的数据映射到表中相应的位置让人访问,以加快访问速度,通俗来说就是是时间换空间的一种方式。

通俗意义的定义,我们把找个键值称为key,把对应的存储的内容记录为value,这样即通过key访问一个应对应value的地址。而这个映射关系我们称为散列函数或哈希函数,存放记录的数组叫做散列表

1.1 结构

  • 散列表的Key则是以字符串类型为主的

1.2 特点

  1. 访问速度:由于散列表是key到value的映射,我们在查找具体数据时,可以做到直接访问,不需要一个一个的查找。
  2. 需要额外的空间:散列表是存储不满的,但当散列表中元素的使用率越来越高时性能会下降,一般会选择扩容来解决这个问题
  3. 无序:为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的
  4. 可能会产生冲突:散列函数可能会对不同的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方法,即往散列表中插入数据

  1. 判断传入值的有效性
  2. 返回结果: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 实例之外还需要额外的存储空间。 例如,他是这样的一种结构

image.png

接下来我们来写它的实现方法:主要应用连链表的插入和删除元素,如果大家还不清楚链表时如何使用的可查看这篇文章:数据结构----链表

注意点:删除数据的时候,需要判定链表数据结构是否已经为空。若为空,则直接删除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 线性探查

方法的原理是将元素直接存储到表中。 原理图如下: 往散列表中插入数据时,如果某个数据经过散列函数后,存储的位置已经被占用了,那么我们就从当前位置开始,依次往后查找,看到有空闲位置后,插入即可

image.png

我们知道每个key对应的散列值:

  • 4 - Ygritte
  • 5 - Jonathan
  • 5 - Jamie
  • 7 - Jack
  • 8 - Jasmine
  • 9 - Jake
  • 10 - Nathan
  • 7 - Athelstan 则完成上述操作后,散列的结构为

image.png

如何去查找元素: 查找过程与插入过程是类似的,先通过散列函数计算出对应的hash值,然后比较数组中下标为散列值的元素和要查找的元素

  • 若相等,则是我们查找的元素
  • 若不相等,我们就顺序的往后查找,index++,如果遍历到数组的空闲位置仍旧没有找到,则说明要查找的元素并没有在散列表中

线性探查技术分为两种,分别为软删除方法和 第二种移动元素

3.2.1 软删除

我们通过查找可以得知,我们只需要顺序查找到空闲位置。

我们设想一种场景,删除了其中几个散列值,则此位置的值为空,循环的时候遇到被删除的元素就不会向后边查找,会产生错误结果。

例如:我们删除Jonathan,则5 位置上对应的键值为null,当我们查找Jamie时,hash值为5,但是对应数据为空,我们没有找到,直接就返回undefined了。明显的可以看出结果是错误的,产生这种错误的原因是,我们删除时没有做任何特殊处理,使得链表出现很多删除的空节点,与正常的空节点无法发区分。

基于上述的问题,我们在删除数据的时候,使用一个特殊的值(标记)来表示键值对被删除了(惰性删除或软删除),而不是真的删除它。但是经过一段时间的操作后,会降低散列表的效率,搜索的过程会逐渐变慢。下图展示了这种结果:

image.png 可参考下面实现的代码:

 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 第二种检测办法:移动元素

过程如下图所示:

image.png

    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等, 接下来我会用一篇文章讲解散列函数,欢迎大家点赞,关注,订阅。