散列表及其应用

散列表及其应用

一.散列思想

散列技术是在记录的存储位置和它的一个关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key),根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则key必定在f(key)的位置上。

我们称这种对应关系f为散列函数,又或者称为哈希函数。按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间成为散列表或者哈希表,关键字对应的记录存储位置我们称为散列地址

散列表用的是数组支持按照下表随机访问数据的特性,所以散列表其实是数组的一种扩展,由数组演化而来,可以说如果没有数组,就不会有散列表。

二.散列函数

散列函数顾名思义是一个函数,我们一般将其定义为hash(key),key表示元素的键值,hash(key)计算的就是经过散列函数计算得到的散列值。

散列函数按道理应该要满足三点要求

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

2.如果 key1 = key2,那 hash(key1) == hash(key2);

3.如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

第一点很好解释,因为数组下标从0开始,而散列值是关键字的存储地方。第二点也很好理解,关键字相同,散列值也应当相同。第三点,就是为了避免不相同的关键字经过同一个散列函数,得到了相同的散列值,这就是散列冲突。但是想要避免这种冲突是很难的,最著名的几个哈希算法也无法避免开冲突问题。

因此,既然我们无法找到一个完美的无冲突散列函数,我们就应该通过付出一些时间或者空间成本等途径去针对散列冲突问题找到合适的解决方法。

三.散列函数如何设计

构建一个散列函数遵循两个原则:

1.计算简单,不然增删查改的计算的效率会很低。

2.散列地址分布均匀,有效利用已有的空间,尽量减少散列冲突问题,避免处理这些问题带来的时间的损耗。

1.直接定址法

比如对于0~100岁的人口的数字统计表,就可以以年龄这个数作为地址,此时f(key)=key。这种方法简单均匀,但是只能用于已知关键字并且查找表较小且连续的情况。

2.数字分析法

如果关键字是手机号,我们选取前三位还是后三位呢?显然前三位重复的比较多,我们可以选后三位作为key哈希值。这里就是根据实际情况从关键字中抽取一部分用作哈希值,在关键字位数比较大的情况使用这个方法。

3.平方取中法

这个方法就是将关键字平方抽取中间的几位作为哈希值,这种方法适用于不知道关键字的分布,而位数不是很大的情况。

4.折叠法

折叠法就是将关键字从左到右分割为位数相等的几部分,最后一部分位数不够可以短些,然后将这几部分叠加求和,并根据散列表长取最后几位作为散列地址。该方法不需要事先知道关键字的分布,适用于关键字位数较多的情况。

5.除留余数法

f(key) = key mod p (p<=m)

m为散列表长,这个方法最主要是选好p,p没选好则会导致很严重的散列冲突,根据经验来说,一般选择p为小于或者等于m(最好接近于m)的最小质数或者不包含小于20质因子的合数

6.随机数法

f(key) = random(key)

当关键字长度不等时,采用这个方法构造散列函数比较合理。

总之,构造散列函数方法有很多,没有那种最好,只能根据以下因素参考:

1.计算散列地址所需时间

2.关键字的长度

3.散列表的大小

4.关键字的分布情况

5.记录查找的频率。

四.简单的散列冲突解决方法

1.开放寻址法

一个比较简单的探测方法就是线性探测了。

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。当hash(x)被占用了一直往后线性探测,直到找到值为空的地址。

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

散列表和数组一样,除了上述的插入和查找,还有删除功能,那么问题来了,如果我们将一个元素删除了,将会影响之后的元素的查找的结果,所以我们不能单纯将删除元素的地址设置为空,而应该设置一个标记比如delete,当查找到某个地址对应的值为delete标记时,应该继续探测而不是停止查找。

除了线性探测以外还有二次探测双重探测

所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0²,hash(key)+1²,hash(key)+2²……

所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。这样可以很好地使得关键字不聚集,但是也付出了时间和空间的代价。

不论哪种他测方法,当散列表的空闲位置不多的时候散列冲突的概率就会大大提高,为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子来表示空位的多少。

散列表的装载因子=填入表中的元素个数/散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

2.链表法

链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。在散列表中,每个 “桶” 或者 “槽” 会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

理论上,插入链表法插入的时间复杂度和在链表中插入是一样的O(1),而查找和删除操作时间复杂度和**k=n/m(n为元素个数 m为散列表槽的个数)**成正比,应该是O(k)。

五.高级的散列冲突解决方法

1.动态扩容

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。

对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。

所以遇到动态散列表我们应该动态扩容

针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。

针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

动态扩容需要考虑的最重要的一点是设置装载因子的阈值,阈值设置过大,冲突会过多,阈值设置过小,内存内存,所以要权衡时间空间去设置阈值。

2.如何高效地动态扩容

有时候散列表太大,一次性扩容加上计算哈希值,将关键字搬到新的地址会很浪费时间,最好的办法是,多次搬家。

比如扩容之后,将新数据插入到新的散列表,旧的散列表先暂时留着,之后每插入一个新数据到新散列表,就将旧的散列表中地一个数据搬过去,这样就一点点将旧的数据搬到新的散列表中了,每次操作就很快执行完了,当旧的散列表没有元素之后就将其删除。

对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

六.散列的应用以及实例

1.散列表的应用场景

1.Office中写入错误单词会报错,正是因为在散列表中检测不到单词。

2.网站的缓存页面,利用散列表存入个人主页,当某个用户下次登陆不用重新生成页面,直接将页面反馈给用户。

3.Java LinkedHashMap利用到了散列表+链表的结构。

2.利用散列表+链表实现URL缓存淘汰算法

借助散列表,我们可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。

我们使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext。

因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。

了解了这个散列表和双向链表的组合存储结构之后,我们再来看,增删查三个操作是如何做到时间复杂度是 O(1) 的?

首先,我们来看如何查找一个数据。我们前面讲过,散列表中查找数据的时间复杂度接近 O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的头部。

其次,我们来看如何删除一个数据。我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在 O(1) 时间复杂度里找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。

最后,我们来看如何添加一个数据。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的头部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表尾部的结点删除,然后再将数据放到链表的头部;如果没有满,就直接将数据放到链表的头部。这整个过程涉及的查找操作都可以通过散列表来完成。

其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 O(1) 的时间复杂度内完成。

所以,这三个操作的时间复杂度都是 O(1)。

至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。

表的头部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表尾部的结点删除,然后再将数据放到链表的头部;如果没有满,就直接将数据放到链表的头部。这整个过程涉及的查找操作都可以通过散列表来完成。

其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 O(1) 的时间复杂度内完成。

所以,这三个操作的时间复杂度都是 O(1)。

至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值