算法与数据结构之美—散列表

开篇思考

Word文档中的单词拼写检查功能是如何实现的?

散列思想

散列表,“Hash Table”,平时也叫“哈希表”,采用的是数组支持按照下标随机访问,相当于数组的一种扩展。

例如:学生参加学校运动会,100名选手每个人都有一个参赛编号,那么如何快速通过参赛编号找到运动员呢?那我们就可以将参赛编号与数组下标一一对应起来,当需要查询参赛编号x的时候,只需要将其从对应下标为x,从数组中取出来即可,就可以实现快速查找编号对应的选手信息了。

这里就用到了散列思想,参赛选手的编号作为key或者关键字,将编号映射为数组下标的方法就是散列函数,得到的值就是散列值;
散列函数

散列函数

散列函数设计要求:

  • 散列函数计算得到的散列值是一个非负整数,因为数组下标从0开始 ;
  • 如果key1 = key 2,那么hash(key1)==hash(key2);
  • 如果key1!= key 2,那么hash(key1)!=hash(key2);
    对于第三点,在实际情况中想要找到没有散列冲突的散列函数是不可能的,所以针对于散列冲突需要采取别的方式解决;

散列冲突

开放寻址法(open addressing)

开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置将其插入;
探测方法

  • 线性探测法
    从当前位置依次往后查找,看是否有空闲位置,直到找到为止。
    探测算法的弊端:
    当散列表中插入的数据越来越多时,散列冲突发生的可能性就越来越大,空闲位置会越来越少,线性探测的时间越久,在极端情况下的时间复杂度是O(n);

  • 二次散列(Quadratic probing)
    线性探测的步长是1,那么它探测的下标序列就是hash(key)+0,hash(key)+1,hash(key)+2…,二次探测的步长就变成了原来的"二次方",
    hash(key)+0,hash(key)+1^2

  • 双重散列(Double hashing)
    双重散列,就是不仅要使用一个散列函数,要用一组散列函数hash1(key),hash2(key),hash3(key)…我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

当散列表中的空闲位置不多时,不管哪种探测方法,散列冲突的概率都会大大提高。

链表法

在散列表中,每个“桶(bucket)”或者"槽(slot)"都会对应一条链表,所有散列值相同的饿元素都会放到相同槽位对应的链表中。
链表法
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?

实际上,这两个操作的时间复杂度跟链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中n表示散列中数据的个
数,m表示散列表中“槽”的个数。

如何设计散列函数

散列函数的设计不能太复杂,需要考虑关键字的长度、特点、分布还有散列表的大小等,散列函数的设计方法有,直接寻址法、平方取中法、折叠法、随机数法等;

装载因子

装载因子 = 填入表中的元素个数/散列表的长度;
装载因子越大,说明散列表的元素越多,空闲位置越小,散列冲突的概率越大;
当散列因子过大时,我们也可以进行动态扩容

工业级散列表举例分析

Java中的HashMap

  • 初始大小
    HashMap默认的初始值大小为16,可以修改默认初始大小,减少动态扩容次数,大大提高HashMap的性能;

  • 装载因子和动态扩容
    最大装载因子为0.75,当HashMap中的元素个数超过0.75*capacity的时候就会启动自动扩容,每次扩容都会变为原来的两倍;

  • 散列冲突解决方法
    HashMap的底层采用链表法来解决冲突,当链表长度过长(默认值为8)时,链表就转换为红黑树,可以利用红黑树快速增删改查,提高HashMap的性能

  • 散列函数
    简单高效,分布均匀;

散列表和链表的组合

LRU缓存淘汰算法

借助散列表可以将LRU缓存淘汰算法的时间复杂度降低为O(1);
一个缓存**(cache)**系统主要包含以下几个操作:

  • 往缓存中添加一个数据;
  • 从缓存中删除一个数据;
  • 在缓存中查找一个数据

Redis有序集合

在有序集合中,每个成员对象有两个重要的属性,key(键值)和score(分值)。我们不仅会通过score来查找数据,还会通过key来查找数据;

  • 添加一个成员对象;
  • 按照键值来删除一个成员对象;
  • 按照键值来查找一个成员对象;
  • 按照分值区间查找数据,比如查找积分在[100,356]之间的成员对象;
  • 按照分值从小到大排序成员变量;

Java LinkedHashMap

LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据;
以下面这段代码为例:

HashMap<Integer,Integer> m = new LinkedHashMap<>();
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);
for(Map.Entry e:m.entrySet()){
	System.out.println(e.getKey());
}

最终输出的结果是3,1,2,5

//初始大小为10,0.75是装载因子,true是指按照访问时间排序
HashMap<Integer,Integer> m = new LinkedHashMap<>()(10,0.75f,true);
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);

m.put(3,26);
m.get(5);
for(Map.Entry e:m.entrySet()){
	System.out.println(e.getKey());
}

这段代码的输出为1,2,3,5
思考一下为啥会这样:
咱们看一下过程,当调用完前4个put函数的时候,链表中的数据如下图所示:
链表1
将键值为3的数据再次假如LinkedHashMap的时候,先查找这个值是否存在,将已经存在的(3,11)删除,并将新的(3,26)加入链表尾部,此时的链表数据如下图所示:
链表2
当执行m.get(5)的时候,需要将key为5的数据取出,插入链表的尾部,此时链表中的数据变为:
链表3
最终打印的数据就是1,2,3,5
LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的LinkedHashMap中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。

解答开篇

Word文档中单词拼写检查功能如何实现?

常用的英文单词有20万个左右,假设单词的平均长度是10个字母,平均一个单词占用10个字节的内存空间,那20万英文单词大约占2MB的存储空间,就算放大10倍也就是20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。

当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值