算法与数据结构(四) -- 散列表

作者:opLW
参考:王争老师的 《数据结构与算法之美》
学习 《数据结构与算法之美》 的一些简要的笔记。记录一个大体的思路,可能不是很详细。?

目录

1.散列表的定义
2.散列表的存放的方式
3.决定散列表性能的关键点

4.工业级散列表的设计要点
5.使用散列表的具体例子

  • 1. 散列表的定义 散列表来源于数组。它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。
  • 2. 散列表的存放的方式 散列表以键 – 值对形式存在。
    • 存放 将键转化为数组下标的方法称为散列函数,散列函数的计算结果称为散列值,将值存储在散列值对应的数组下标位置。
    • 查找 根据键找到对应的值。根据散列函数找到对应的数组下标,从而获取对应的值。
  • 3. 决定散列表性能的关键点
    • 3.1 散列函数的设计
      • 1) 基本要求

        1.散列函数计算得到的散列值是一个非负整数。因为要根据结果存放在数组中,而数组下标是一个非负数。
        2.若key1=key2,则hash(key1)=hash(key2)
        3.若key≠key2,则hash(key1)≠hash(key2)。

      • 2) 存在的问题 理想情况下是要求不同的key对应不同的hash值,但是由于数组的大小有限,所以无可避免的散列函数会产生相同的值,就是哈希冲突。
      • 3) 总体要求
        • 散列值要均匀 尽可能让散列后的值随机且均匀分布,这样会尽可能减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。
        • 散列值计算简单耗时短 如果为了均匀而使用太过复杂的散列函数,那么会浪费不必要的计算时间,从而得不偿失。
      • 4) 常见方法 直接寻址法、平方取中法、折叠法、随机数法等。
      • 5) 例子 java中HashMap的散列函数
        int hash(Object key) { 
        	int h = key.hashCode()return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
        }
        

        亮点1 获取对象的hashcode以后,先进行移位运算,然后再和自己做异或运算,即:hashcode ^ (hashcode >>> 16),这一步甚是巧妙,是将高16位移到低16位,这样计算出来的整型值将“具有”高位和低位的性质。
        亮点2 & (capitity -1) java的HashMap中会使capitity的值始终为2的倍数,这会使得与的结果更加均匀。

    • 3.2 哈希冲突的解决
      • 1) 开放地址法 如果出现散列冲突,就重新探测一个空闲位置,将其插入。

        • 线性探测法
          • 插入数据 当我们往散列表中插入数据时,如果某个数据经过散列函数之后,存储的位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
          • 查找数据 我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素是否相等,若相等,则说明就是我们要查找的元素;否则,就顺序往后依次查找。如果遍历到数组的空闲位置还未找到,就说明要查找的元素并没有在散列表中。
          • 删除数据 为了不让查找算法失效,可以将删除的元素特殊标记为deleted,当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。
          • 结论 最坏时间复杂度为O(n)
        • 二次探测 线性探测每次探测的步长为1,即在数组中一个一个探测,而二次探测的步长变为原来的平方。
        • 双重散列 使用一组散列函数,直到找到空闲位置为止。
        • 总结:适用场景 如果存放的数据量不是很大,那么可以采取开放地址法。如ThreadLocalMap。
      • 2) 链地址法 发生冲突时,在当前下标对应的位置上追加链表结构。

        • 插入数据 当插入的时候,我们需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,所以插入的时间复杂度为O(1)。

        • 查找或删除数据 当查找、删除一个元素时,通过散列函数计算对应的槽,然后遍历链表查找或删除。对于散列比较均匀的散列函数,链表的节点个数k=n/m,其中n表示散列表中数据的个数,m表示散列表中槽的个数,所以是时间复杂度为O(k)。

        • 优点 链表法比起开放寻址法,对大装载因子的容忍度更高。

        • 缺点 使用链表需要额外的指针空间;当链表长度太大时,查询的时间复杂度会下降为O(n)。

        • 优化思路 当链表长度太大时,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是O(logn)。

          如 java1.8版本的HashMap。当链表长度到达8时,会改用红黑树来代替链表。小于8时又会变回链表。

    • 3.3 装载因子 / 阀值
      • 1) 装载因子 用装载因子(load factor)来表示目前散列表已有的数据占散列表长度的比例。装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
      • 2) 阀值 装载因子的上限。
      • 3) 出现的目的 不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。当达到装载因子达到阀值时,需要扩容,来保持一定比例的空闲槽。
      • 4) 计算公式 散列表的装载因子=填入表中的元素个数/散列表的长度
      • 5) 装载因子的大小
        • 当使用开放地址发解决冲突时,装载因子 要小于1。
        • 当使用链地址法解决冲突时,因为可以在对应下标处追加链表,所以装载因子可以大于1。
      • 6) 阈值设置 需要权衡时间复杂度和空间复杂度。
        • 如果内存空间不紧张,对执行效率要求很高,可以降低装载因子的阈值;
        • 如果内存空间紧张,对执行效率要求又不高,可以增加装载因子的阈值。
      • 7) 避免低效扩容
        • 当需要扩容的时候,不必一次性将原来的数据移到新的位置,可以采取分批移动的思想。
        • 分批扩容的插入操作:当有新数据要插入时,我们将数据插入新的散列表,并且从老的散列表中拿出一个数据放入新散列表。每次插入都重复上面的过程。这样插入操作就变得很快了。
        • 分批扩容的查询操作:先查新散列表,再查老散列表。
        • 通过分批扩容的方式,任何情况下,插入一个数据的时间复杂度都是O(1)。
  • 4. 工业级散列表的设计要点
    • 基本要求
      • 支持快速的查询、插入、删除操作。
      • 内存占用合理,不能浪费过多空间。
      • 性能稳定,在极端情况下,散列表的性能也不会退化到无法接受的情况。
    • 设计思路
      • 设计一个合适的散列函数。
      • 选择合适的散列冲突解决方法。
      • 定义装载因子阈值,并且设计动态扩容策略。
  • 5. 使用散列表的具体例子
    • Word文档中单词拼写检查功能是如何实现的?
      • 思路 字符串占用内存大小为8字节,20万单词占用内存大小不超过20MB,所以用散列表存储20万英文词典单词,然后对每个编辑进文档的单词进行查找,若未找到,则提示拼写错误。
    • 假设我们有10万条URL访问日志,如何按照访问次数给URL排序?
      • 思路 字符串占用内存大小为8字节,10万条URL访问日志占用内存不超过10MB,通过散列表统计url访问次数,然后用TreeMap(可以排序的散列表)存储散列表的元素值(作为key)和数组下标值(作为value)

万水千山总是情,麻烦手下别留情。
如若讲得有不妥,文末留言告知我,
如若觉得还可以,收藏点赞要一起。

opLW原创七言律诗,转载请注明出处

发布了21 篇原创文章 · 获赞 28 · 访问量 7319

猜你喜欢

转载自blog.csdn.net/qq_36518248/article/details/90903932