介绍hash散列表数据结构的个人理解
文章目录
概要
- code语言:java
- 测试环境:win、java8
- 参考书籍:《数据结构与算法分析java语言描述》 原书第三版
- 参考链接:暂无
hash概述
本文只用个人理解的语言来描述,因为与其copy或者照敲书上的文字,这样做不如贴上链接地址或者书名
- 散列表,散列,见文思意:把数据散开排列在一张数据表中
- 一种用于以常数、平均时间进行插入、删除和查找的技术。
- 一些crud操作需要进行元素间耦合操作比如排序的就没办法支持。再简单点说就是元素之间相互独立,无序。
- 结构就可以想象身份证号与一个人的对应表关系。只不过身份证表在中国来说巨大。
- hash表结构数据如何存放是根据一个函数来确定的。即所有需要存放到hash表的数据都同通过这个函数计算出一个值,这个结果值就相当于这个数据住进hash表的门牌号,类比成身份证号也行。这个函数叫做hash函数
- 上述概述即表明得到一个hash结构需要几个东西:hash函数、装填因子、hash表。而决定一个hash结构性能好坏的比较重要的一个东西就是hash函数。原因后面重点说明
- 若两个数据经过hash函数算出来的结果相同,这就叫哈希冲突。
- 解决hash冲突的方案最简单的两种:分离链接法、开放定址法
装填因子
- 概念:loadFactor = hash表中元素个数 / 该表大小
- 用途:决定是否再散列的一个因素
hash函数
一个前提1:任何计算都需要消耗时间和空间,这是设计一个算法(函数)需要考量的
hash函数演绎
这里用数学的取模11运算来作最简单的hash函数来说明,公式:hashcode = x % 3;
- 这里取模3,为什么?假如我们不知道存入hash表个数,那么取模数应该是一个素数,这样可以减少hash冲突,这个能明白吧。然后实际上素数应该越大越好,但是我说过的前提1是需要考虑的,所以这里就暂时用3来说明问题。
- 当然如果知道了存入数量,那取模数量大小就可以了。
假如我们要存入1~5的数
经过函数计算结果:
- 1 % 3 = 1
- 2 % 3 = 2
- 3 % 3 = 0
- 4 % 3 = 1
- 5 % 3 = 2
那他们存入hash表如下(根据结果作为key,数据放到value中):如下发生了hash冲突,1,4放到了key=1的位置,2,5放到了key=2的位置。
key | value |
---|---|
0 | 3 |
1 | 1,4 |
2 | 2,5 |
以下说明经查阅HashSet结构的contains方法实现的方式。
- 如果我们要查询值3是否在这个hash中时,经过hash函数得到key=0,然后发现hash表中有key=0的key,就说明3存在。
- 那当我们要查询值为7是否在这个hash中时,也是经过这个hash函数先得到key=1,然后发现有key=1的key,就说明7存在。实际上7不存在,这就是hash冲突带来的问题
ok,以上就说明了hash函数在构造hash结构和对hash结构操作时的用法
为什么说hash函数决定一个hash结构性能好坏
- 如上一节演绎过程,取模3的hash函数对于只存3个数,其实没有任何问题。但是如果需要存超过3个数,那么就发生hash冲突,当然可以解决,后面会介绍解决方式。
- 发生了hash冲突,就会导致原本常数耗时的对hash结构的crud操作会变得充满不确定性;对hash结构大小也会影响其改变(常用解决hash冲突的其中一种方式就需要扩展空间,而此过程将会较大影响hash性能,包括时间和空间)
- 并且有如前提1所述,函数运行有耗时有耗空间,这也会影响对hash结构的操作性能
- hash函数还有一个特点就是一经确定,就不能改变,否则之前所有的数据都需要重新分配空间并且只能手动
解决hash冲突
分离链接法
发生hash冲突使用分离链接法将会增加空间使用
- 使用一个链表来保存value值,即一个key对应一个链表,链表中的元素都是hash的key相同的数据,使用的是双向链接
- 除了使用链表,其他存储数据的结构都可以替代例如一个二叉查找树或者另一个散列表
- 使用分离链接法时,影响hash表性能的还有一个因素是装填因子,决定分离链接链表扩展大小,影响hash表的crud操作
分离链接法缺点
- 使用链表,给新单元分配地址需要时间,并且根据第二种数据结构类型,分配地址的算法还是不一样的
开放定址法
核心思想:尝试另外一些单元,知道找出空的单元的算法。目的是尽可能让所有的数据都一一对应放入表内
线性探测法
根据这个核心思想需要一个探测方案,也就是探测函数。这里就是f(i)=i。表示当前位置冲突了,我就往后找空单元
案例:还是上述的hashcode = x % 3;存1,2,3,4,5
- 1,2,3分别存入key为1,2,0位置
- 存4时由于经过hash函数key=1,且hash表中1位置已经被占了,再根据探测函数:找下一个位置是key=2,发现又被占了;就继续找下一个位置key=3,发现是空单元就可以存入。
- 存5时经过hash函数key=2,且hash表中2的位置已经被占了,再根据探测函数:找下一个位置是key=3,发现又被占了;就继续找下一个位置key=4,发现空单元就可以存入。
key | value |
---|---|
0 | 3 |
1 | 1 |
2 | 2 |
3 | 4 |
4 | 5 |
线性探测法特点
- 只要表足够大,总能找到一个空单元
- 花费时间多
- 存在一种情况,即使表相对较空,占据的单元也会形成一些区块,这样的结果称为一次聚集
- 就是说散列到区块中的任何关键字都需要多次探测才能能解决冲突。
- 由于容易发生一次聚集,对与crud操作效率还是有一定影响
平方探测法
- 此方法是为了消除线性探测中一次聚集问题的方案;f(i) = i平方;
- 平方探测对于线性探测只是下一次探测的步长呈平方在变化
- 一个定理:如果使用平方探测,并且表的大小是素数,那么当表至少有一半是空的时候,那么我们能保证弄能够插入一个新元素。
- 解决了一次聚集同样会发生二次聚集,只是量少,探测次数更少而已,但仍然需要解决
双散列
- 双散列也是一个探测方案。探测函数:f(i)=i*hash(x)
- 即下一步探测位置需要经过hash(x)来决定,此时hash(x)函数就非常需要慎重选
再散列
扩展散列表大小,把所有数据重新散列,什么时候执行再散列,由装填因子决定,假如装填因子为0.5,那么散列表达到一半时就会发生再散列。
- 再散列也可以选择策略为由失败装填hash时触发
- 开销非常大的操作
- 先把旧的一一散列到一个更大的hash表中,然后新的替换旧的
标准库中的散列表(常用集合)
- HashSet、HashMap、ConcurrentHashMap。若是对象作key时应提供equals和hashcode方法,最好进行重写
- HashSet、HashMap、ConcurrentHashMap解决冲突的方法是分离链接法
- 不可变的类如String使用闪存散列码的方式避免重复计算。
散列表性质
- 合理的装填因子和合适的散列函数时,可以期望插入、删除和查找的平均时间花销是O(1)
- 经典散列法:完美散列、布谷鸟散列、跳房子散列
- 散列函数必须可在常数时间内计算,与表项个数无关
- 散列函数必须将各项均匀分布在数组单元中
- 常用于以常数平均时间实现insert和查找操作
- 当关键字不是短的串或整数时,需要仔细选择散列函数
- 对于分离链接散列法,装填因子不大时性能影响不明显,但还是应该接近于1.
- 对于探测散列法,除非完全不可避免,否则装填因子不应该超过0.5
- 有序的输入可能使二叉树运行的很差,平衡查找树实现的代价很高,因此,不需要有序的信息以及对输入是否被排序存入,那么就应该选择散列表
散列表丰富的应用介绍
- 编译器使用散列表跟踪源代码中生命的变量,这种数据结构被叫做符号表。因为标识符不很长,故散列函数能被迅速算出,因此是这种场景的理想应用
- 缓存已经计算过的结果
- 在线拼写检查程序,即词典预先存到散列表,校验时单次可以被常数时间校验
- 等等等。。。
结束语,hash散列表里面的科学方案仅在本文表述的冰山一角,对其分析实际上还是挺困难的,并且还有很多未解决的问题。本文若有描述不准确的地方,首先感谢指出,并渴求指出,帮忙纠正!