数据结构 - 散列表

散列表

1. 什么是散列表

根据关键码值(Key value)直接进行访问的数据结构;它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度;这个映射函数叫做散列函数,存放记录的数组叫做散列表。

散列算法的作用是尽可能快地在数据结构中找到一个值。

例子:

使用最常见的散列函数 - 'lose lose’散列函数,方法是简单地将每个键值中的每个字母的ASCII值相加。如下图:
在这里插入图片描述

2. 实现一个散列表

class HashTable {

    constructor() {
        this.table = []
    }

    // 散列函数
    static loseloseHashCode(key) {
        let hash = 0
        for (let codePoint of key) {
            hash += codePoint.charCodeAt()
        }
        return hash % 37
    }

    // 修改和增加元素
    put(key, value) {
        const position = HashTable.loseloseHashCode(key)
        console.log(`${position} - ${key}`)
        this.table[position] = value
    }

    get(key) {
        return this.table[HashTable.loseloseHashCode(key)]
    }

    remove(key) {
        this.table[HashTable.loseloseHashCode(key)] = undefined
    }
}

使用 HashTable 类

const hash = new HashTable()
hash.put('Surmon', '[email protected]') // 19 - Surmon
hash.put('John', '[email protected]') // 29 - John
hash.put('Tyrion', '[email protected]') // 16 - Tyrion

// 测试get方法
console.log(hash.get('Surmon')) // [email protected]
console.log(hash.get('Loiane')) // undefined
console.log(hash)

下面的图表展现了包含这三个元素的 HashTable 数据结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Aj9JTSp1-1585222511024)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1585145886547.png)]

3. 散列函数

散列函数,顾名思义,它是一个函数。我们可以把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

散列函数的作用是给定一个键值,然后返回值在表中的地址。

散列函数设计的基本要求:

  • 散列函数计算得到的散列值是一个非负整数;

    因为数组下标是从 0 开始的,所以散列函数生成的散列值也要是非负整数。

  • 如果 key1 = key2,那 hash(key1) == hash(key2);

    相同的 key,经过散列函数得到的散列值也应该是相同的。

  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

    这个要求看起来合情合理,但是在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5SHACRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

4. 散列冲突

(1)开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲的位置。

开放寻址法解决方案有线性探测法、二次探测、双重散列等方案:

  • 线性探测法(Linear Probing):

    • 插入数据:当我们往散列表中插入数据时,如果某个数据经过散列函数之后,存储的位置已经被占用了,我们就从当前位置开始,依次往后查找(到底后从头开始),看是否有空闲位置,直到找到为止。

    • 查找数据:我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素是否相等,若相等,则说明就是我们要查找的元素;否则,就顺序往后依次查找。如果遍历到数组的空闲位置还未找到,就说明要查找的元素并没有在散列表中。

      当然这里存在一个问题,就是存数据那块位置往前的某个数据被删除了,那么线性探索查到那块位置的时候就会判断元素不在散列表,查找就会失效,面对这个问题,我们在删除的时候,用下面删除的方法

    • 删除数据:为了不让查找算法失效,可以将删除的元素特殊标记为deleted,当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。

  • 二次探测(Quadratic probing)

    线性探测每次探测的步长为1,即在数组中一个一个探测,而二次探测的步长变为原来的平方。

  • 双重散列(Double hashing)

    使用一组散列函数,直到找到空闲位置为止。

线性探测法的性能描述
用“装载因子”来表示空位多少,公式:散列表装载因子=填入表中的个数/散列表的长度。
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

(2)链表法

散列表中,每个“桶(bucket)”都会对应一个条链表,在查找时先听过hash(key)找到位置,然后遍历链表找到对应元素

插入数据:当插入的时候,我们需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,所以插入的时间复杂度为O(1)。
查找或删除数据:当查找、删除一个元素时,通过散列函数计算对应的槽,然后遍历链表查找或删除。对于散列比较均匀的散列函数,链表的节点个数k=n/m,其中n表示散列表中数据的个数,m表示散列表中槽的个数,所以是时间复杂度为O(k)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Q0boaFq-1585222511027)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1585148252357.png)]

(3)冲突解决方法对比:
优点 缺点 适用场景 案例
开放寻址法 1. 数据存储在数组,可以有效利用CPU缓存加速查询速度.
2. 序列化简单
1. 删除需要特殊标记已删除数据
2. 所有数据存储在一个数组,发生冲突时,解决的代价更高,造成装载因子不能太大,使得更加浪费内存空间
1. 数据量小
2. 装载因子小
Java的ThreadLocalMap
链表法 1. 内存利用率高,需要时再申请
2. 对大装载因子容忍度高,可大于1
1. 因为链表需要存储指针,存储指针需要消耗内存,不适合小对象存储.
2. 链表节点不是连续空间,因此CPU缓存不友好
1. 存储大对象、大数据量的散列表
2. 支持更多优化策略,如红黑树代替链表。
Java的LinkedHashMap

参考资料:

散列表及散列冲突解决方案

JavaScript中的数据结构和算法学习

发布了77 篇原创文章 · 获赞 181 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_46124214/article/details/105126189