散列表知识点总结

最近在复习数据结构以及在上极客时间的网课,对散列表的只是进行部分总结:

1.当使用散列表进行存储元素时,主要使用散列函数对键值进行计算得到存储位置。基于“鸽巢原理”,我们不可避免地必须处理冲突的问题,解决冲突的方法一般有两种:

     ① 链表法:每个槽对应一条链表,当散列函数计算后会进入某个槽,每个槽有一个指向链表的头指针,所以插入链表时直接插入头部就行,因此插入的效率是O(1)。查找一个元素时,同样先对其键值进行散列函数计算,然后进入对应的槽中查找,假设我们有n个元素,一共有m个槽。那么每个槽平均而言就有n/m个元素。查找的话就取决于这个m了,当槽的数目和元素的数组接近时,O(n/m+1)查找的效率就接近O(1)。删除的话和查找和类似,主要就是那个删除的操作。如果采用单链表存储的话,找到待删除元素时还得从链表头找到待删除元素的前一个元素。而如果采用双链表的话效率会更高。删除的效率也是接近O(1)。

      缺点:最坏的情况就是所有的元素都进入了一个槽,那么在散列表中的查找就退化成了在链表中查找,效率也就成了O(n)。

      优化方法:每个槽内的元素采用“红黑树”或者“跳表”这种数据结构存储,这样我们就能保证在最坏情况下依然能有基本的效率保证。假如我们用红黑树存储的话,查找最坏只不过退化为O(logn)。

    ②开放寻址法:当发生冲突时,我们继续往后探测,如果找到空闲位置就插入。如果没找到就一直探测。那么使用开放寻址法处理冲突的散列表如何执行查找操作呢?首先通过散列函数计算得到的散列值那个位置开始查找,如果当前位置的值就等于待找值的话就直接返回,如果不等于就继续往后探测,如果一直找到空闲位置还未找到待找值,则返回未找到。插入的过程与查找类似,如果发生冲突,就不断继续探测,直到遇到位置为空闲位置就插入。删除的操作有些特别,我们不能简单地就把元素置为空闲,因为我们查找是以遇到空闲位置结束的。如果我们在某个值之前的位置执行了删除操作,那么我们重新查找该值得时候将会显示查不到。因为在它之前遇到了空闲位置。所以删除我们不能简单将位置置为空。而是将其给一个特殊的标志位deleted,当查找遇到deleted的位置时不要停止继续往后查找。

    缺点:当插入元素越来越多时,散列表发生冲突的概率就会越来越大,空闲位置越来越少,线性探测时间也越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

   ③其他还有二次探测法和双重散列法  二次探测是每次以0²、1²、2²。。。。步进行探测。双重散列是使用多个散列函数,如果一个散列函数计算出来的位置被占了,就接着使用第二个。。以此类推直到找到空闲位置。

2.散列函数设计基本要求

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

   ② 如果key1==key2,那么hash(key1)==hash(key2);

   ③  如果 key1!=key2,那么hash(key1)!=hash(key2);

  第三点要求不一定能满足,没有完美的散列函数能做到,既然避免不了冲突,我们就是用那两种解决冲突的方法。

  ④ 散列函数生成的值要尽可能均匀分布在散列表中。

  ⑤ 根据关键字的长度、特点、分布、还有散列表的大小等综合设计

  ⑥散列函数不能太复杂,否则计算时间太长,影响散列表的效率。

习题:假设我们有10万条URL访问日志,如何按照访问次数给URL排序?

        答:先将这10万条URL日志放入散列表,key为URL,vlaue为访问次数。同时记录下访问次数的最大值K,时间复杂度为O(n),如果K不是很大的话我们可以采用计数排序,时间复杂度为O(n)。如果k很大的话就采用快速排序,时间复杂度为O(nlogn)。

         有两个字符串数组,每个数组大约有10万条字符串,如何快速查找两个数组中相同的字符串?

        答:先对一个数组遍历构建散列表,key为字符串,value为访问次数。然后遍历另一个数组,以字符串为key在散列表中查找,如果访问次数大于0,说明存在相同字符串。时间复杂度为O(N)。

  3.装载因子过大了怎么办?

  答:我们知道随着装载因子不断变大,散列表的效率会越来越差。使用开放寻址法解决冲突的散列表为了找到空闲位置需要不停探测,而使用链表法解决冲突的散列表随着装填因子的变大,在每个槽里存储的对象数目也越来越多。查找效率也减少了好多。所以我们一般要在散列表的装填因子达到某个阈值时,进行动态扩容,与Vector的扩容相类似,都是将原来的元素搬到新的内存中去,这段新的内存可能是原内存的两倍。这样装填因子就成了原来的一半。与数组的扩容不同的是,数组只需要简单的搬运元素就行,而散列表的扩容由于散列表的大小发生了改变,需要对所有元素进行重新散列。复杂度的分析也与数组扩容类似,正常插入的效率一般是O(1),在最坏情况下需要进行扩容,重新计算散列位置。那么效率成了O(n)。采用均摊分析的话,时间复杂度接近O(1)。如果我们对于空间消耗较为敏感的话,既然有动态扩容我们也可以动态缩容,当装填因子小于某个值时,我们开启缩容操作。

 4.装填因子阈值的设置

  答:要综合权衡时间、空间复杂度、如果内存不紧张、对执行效率要求很高的话,可以降低装填因子的阈值;相反如果内存空间紧张,对执行效率要求不高,可以增加装填因子阈值。

  开放寻址法装填因子不能大于1     链表法可以大于1

5.如何避免低效扩容?

   答:当我们的用户要求较高时,不允许出现较为明显卡顿时。采用一次性扩容的政策就显得有些局限。尽管大多数操作都能很快执行,但是偶尔的一次插入会特别慢,用户体验就会下降。所以我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装填因子达到阈值时,我们只申请新空间,不搬运元素。当有新元素插入时,将其将入到新的散列表中,同时从老的散列表中拿出一个元素放入新的散列表中。每一次插入操作都这样重复。经过多次操作老的散列表所有元素就都搬到了新的散列表中去,这样每次插入操作都会很快了。采用了新的搬运操作的话,查找方法就要发生改变,当我们要查找一个元素时,我们先在新的散列表中查找,没找到再去旧的散列表中找。避免了一次性扩容耗时过久的代价。在新的实现方式下,所有的插入操作都有了O(1)的效率。

6.比较开放寻址法和链表法优缺点

  开放寻址法  优点:散列表的数据都存放在数组中,可以有效利用CPU的缓存加快查询速度。而且香断链表法它不需要很多指针,也避免了不必要的开销。

缺点:删除操作比较麻烦,相对于链表法,所有的数据都存储在一个数组中,冲突的代价更高。所以采用开放寻址法的散列表不能有太高的装填因子,也使得其比链表法更浪费内存空间。

   总结:当数据量小,装填因子小的时候采用开放寻址法。

  链表法  优点:对内存的利用率比开放寻址法更高,因为链表结点可以在需要的时候再进行创建,并不需要像开放寻址法那样事先申请好。链表法的装填因子可以很大,最多就是链表的长度变长而已,效率会下降,但是相比顺序查找还是很快。

  链表由于不是顺序存储所以无法使用CPU缓存,而且链表的每个结点都需要有一个指针的消耗。当然如果我们存储的是大对象的话这一点消耗完全是可以忽略的。

    总结:链表法比较适合大对象、大数据量的散列表,而且比起开放寻址法,它更加灵活,支持更多的优化策略,比如红黑树代替链表。

7.工业化的散列表实例

   Java的HashMap的默认大小为16,默认装填因子为0.75。当每个槽的链表长度大于8时采用红黑树存储,小于6时采用链表,因为当数据量较小时,红黑树要维持平衡,比起链表性能并无太大优势。

  C++ STL 中的hash_map 也有动态扩容,但是并没有采用红黑树,也没有退化采用的链表。直接采用就是链表法。

 

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注