深入了解散列表

本次介绍主要为后面的基于拉链法和基于线性探索法的两种散列表做铺垫。谢谢大家!^.^

什么是散列表?

如果所有的键都是小整数,我们可以用一个数组来实现无序的符号表,将键作为数组的索引而数组中键i处储存的就是它对应的值。这样我们就能快速访问任意键的值。

散列表就是这种简易方法的扩展并能够处理更加复杂类型的键。我们需要用算数操作将键转化为数组的索引来访问数组中的键值对。

使用散列表的查找算法分为两步。

  1. 第一步是用散列函数将被查找的键转化为数组的一个索引。理想情况下,不同的键都能被转化为不同的索引值。当然这只是理想情况,所以我们需要面对两个或者多个键都会散列到相同的索引值的情况。
  2. 因为第一步中存在的碰撞情况。第二步就需要一个处理碰撞冲突的过程,两种解决碰撞的方法:拉链法和线性探索法。

散列表是算法在时间空间上作出权衡的经典例子,事实上,我们不必重写代码,只需要调整散列算法的参数就可以在空间和时间是进行取舍。

《深入了解散列表》
散列表的核心问题

散列函数

我们面临的第一个问题就是散列函数的计算,这个过程会将键转化为数组的索引。

如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键,即对于所有的键,0到M-1之间的每个整数都有相等的可能性与之对应(与键无关)。

对于求键的散列值,因为键有很多种类型:Integer、String、Double等。我们需要根据键的类型选择合适的求值方法!

键为正整数求散列值

将整数散列最常用的方法是除留余数法

我们选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数(在Java中为k%M)。如果M不为素数,我们可能无法利用键中包含的所有信息,这将导致我们无法均匀地散列散列值。例如键是十进制数而M为10。那么我们只能利用键的个位进行散列。

键为浮点数求散列值

如果键是0到1之间的实数,我们可以将它乘以M并四舍五入得到一个0至M-1之间的索引值。

尽管这个方法容易理解并且容易实现,但它是由缺陷的,因为这种情况下键的高位起的作用更大,低位对散列结果没有影响。修正这个问题的办法是将键表示为二进制数然后使用除留余数法(Java就是如此)。

键为字符串求散列值

除留余数法也可以处理较长的键,就比如说字符串,我们只需要将它们当作大整数即可。

int hash = 0;
for (int i = 0; i < s.length(); i++)
    hash = (R * hash + s.charAt(i)) % M;

Java的charAt()函数能够返回一个char值,即一个非负16整数。如果R比任何字符的值都大,这种计算相当于将字符串当作一个N位的R进制值,将它除以M取余。

一种叫做Horner方法的经典算法用N次乘法、加法和取余来计算一个字符串的散列值。只要R足够小,不造成溢出,那么结果就能如我们所愿,落在0至M-1之内。使用一个较小的素数,例如31,可以保证字符串中所有字符都能发挥作用。Java的String的默认实现使用了一个类似的方法。

键为组合键求散列值

如果键的类型含有多个整型变量,我们可以和String类型一样将它们混合起来。

例如假如被查找的键的类型是Date,其中含有几个整型域:day(两个数字表示的日),month(两个数字表示的月)和year(4个数字表示的年)。我们可以这样计算它的散列值:

int hash = (((day * R + month) % M) * R + year) % M;

Java中的约定情况

每种数据类型都需要相应的散列值,于是Java令所有的数据类型都继承了一个能够返回一个32比特整数的hashCode()方法。

每一种数据类型的hashCode()方法都必须和equals()方法一致。这说明如果你要为自定义的数据类型定义散列函数,那么需要同时重写hashCode()和equals()两个方法。默认散列函数会返回内存地址,但这只适用很少的情况。

将hashCode()的返回值转化为一个数组索引,因为我们需要的是数组的索引值而不是一个32位的整数,我们在实现中会将默认的hashCode()方法和除留余数法结合起来产生一个0到M-1的整数,

private int hash(Key key) {
   return (key.hashCode() & 0x7fffffff) % M;
}

这段代码会将符号位屏蔽(将一个32位整数变为一个31位非负整数),然后用除留余数法计算它除以M的余数。在使用这样的代码时我们一般会将数组的大小M取为素数以充分利用原散列值的所有位。

自定义的hashCode()方法

散列表的用例hashCode()方法能够将键平均地散布为所有可能的32位整数。也就是说,对于任意对象x,可以调用x. hashCode()并认为有均等机会得到2^32个不同整数中的任意一个32位整数值。

//String实现的hashCode()
  public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
}

软缓存

如果散列值的计算很耗时,那么我们也许可以将每个键的散列值缓存起来,即在每个键中使用一个hash变量来保存它的hashCode()的返回值。第一次调用hashCode()方法时,我们需要计算对象的散列值,但之后对hashCode()方法调用会直接返回hash变量的值。如上Java的String对象的hashCode()方法就使用了这种方法来减少计算量。

小小总结:

总的来说,要为一个数据类型实现一个优秀的散列方法需要满足三个条件

  • 一致性 —— 等价的键必然产生相等的散列值
  • 高效性 —— 计算简便
  • 均匀性 —— 均匀的散列所有的键
    原文作者:SeYuFac
    原文地址: https://zhuanlan.zhihu.com/p/62133064
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞