关于哈希表(Hashtable)个人学习理解

数据结构–哈希表(Hashtable、又称散列表)


最近做了一个题目:想要查看集合中的某个指定元素,但是不知道具体的位置。
一般情况下是遍历这个数组的全部,然后去找到这个元素。若此时元素基数不是很大的话,还好,若集合的长度 n 趋于无穷大,而恰恰不巧的是这个元素的位置刚好在最后,那么可想而知消耗的时间是非常巨大的。那么就需要一个高效的存储结构来存储这个集合----哈希表。使用了这种结构能够提高查找元素的速度,但是却不能保证元素的顺序。

1. 基本概念

在学习哈希表之前我们需要先明白几个基本概念:

  1. 散列码
    在定义一个对象的时候可以通过重写hasCode() 方法来为不同对象生成一个独一无二的数值字符串,这个数值就是散列码;这个散列码一般情况下是不会相同的(但是也存在例外,这里会在后面说明)
  2. 键(key)
    哈希表是通过一个标记来寻找与这个标记相对应的值的。这个标记与值之间形成一种映射关系。
  3. 值(value)
    即需要获得的信息。
  4. 哈希函数
    散列表是将数据存放在散列表中的,那么一个数据在散列表汇总具体存放的位置就是由哈希函数来进行决定的,通过对 key 值的 散列码进行 哈希函数的计算,得出一个具体的地址(数值),然后将需要存放的数据放入这个地址中。
  5. 哈希地址
    这个地址记录的就是在哈希表中存放数据的的位置,但是:哈希地址只不过是相对于哈希表来说的一个地址,并不是真实的对应于一个屋里存储地址。意思是这个哈希地址只适用于相对应的哈希表中。(而且不同的哈希表可能采用的哈希算法也不同,得到的地址也不同,所以更别说适用了)。

前面几者之间的关系

哈希表是通过 哈希函数 来构建的,哈希函数类似于 address = f(x) address就是哈希地址,x 就是 key 值的散列码,不同的key值有不同的散列码,而 address 地址指向的就是散列表中的一个存储空间,这个存储空间里用来存放 value ,这个 value 和 相应的 key 之间形成了一种映射。

2. 哈希冲突

有的时候因为没有选取合适的 哈希函数 f(x) ,或者出现了相同的散列码,导致经过计算后的 哈希地址(address)出现了重复的现象。那么就会与之前位置上已经存放好的数据产生冲突。这就叫哈希冲突。
哈希冲突只能尽量减少,不能完全能避免

3. 常见的哈希函数

常见的方法有6种:

  1. 直接定制法
    一次函数,或者直接取值。也叫自身函数;
    例如:有一个从1到100岁的人口数字统计表,其中,年龄作为关键字,哈希函数取关键字自身。

  2. 数字分析法
    如果关键字由多位字符或者数字组成,就可以考虑抽取其中的 2 位或者多位作为该关键字对应的哈希地址,在取法上尽量选择变化较多的位,避免冲突发生。
    例如:

  3. 平方取中法
    平方取中法是对关键字做平方操作,取中间几位作为哈希地址(此方法是比较常用的构造哈希函数的方法)
    例如关键字序列为 {421,423,436},对各个关键字进行平方后的结果为{177241,178929,190096},我们则可以取中间的两位{72,89,00}作为其哈希地址。

  4. 折叠法
    将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址,这方法称为折叠法。
    例如:每一种西文图书都有一个国际标准图书编号,它是一个10位的十进制数字,若要以它作关键字建立一个哈希表,当馆藏书种类不到10,000时,可采用此法构造一个四位数的哈希函数。

  5. 除留余数法
    取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址。即:
    address = f(key) = x % p ( p <= m )

  6. 随机数法
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即
    f(key)=random(key),其中random为随机函数。通常用于关键字长度不等时采用此法。

    注意:这个随机函数是一个伪随机函数 , 一般的随机函数每次生成的值都是不一样的,但是在这里并不是的,这里针对同一个 key 值生成的随机数都是一样的,但是不同的 key 值生成的 随机数是不同的。

4. 解决哈希冲突

冲突:在哈希表中,不同的关键字值对应到同一个存储位置的现象。即关键字 key1 ≠ key2 ,但 f ( key1 ) = f ( key2 ) 。均匀的哈希函数可以减少冲突,但不能避免冲突。发生冲突后,必须解决;也即必须寻找下一个可用地址。
无论哈希函数设计有多么精细,都会产生冲突现象,也就是2个关键字处理函数的结果映射在了同一位置上,因此,有一些方法可以避免冲突。

具体的解决方案可以看我的另一篇博客:哈希冲突的解决方案

5. 哈希表的具体实现

话不多说,咱们直接看源码:

    /**
     * Constructs a new, empty hashtable with the specified initial
     * capacity and the specified load factor.
     *
     * @param      initialCapacity   the initial capacity of the hashtable.
     * @param      loadFactor        the load factor of the hashtable.
     * @exception  IllegalArgumentException  if the initial capacity is less
     *             than zero, or if the load factor is nonpositive.
     */
    public Hashtable(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal Load: "+loadFactor);

        if (initialCapacity==0)
            initialCapacity = 1;
        this.loadFactor = loadFactor;
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    }

在这个构造函数中,前面几行是用来处理错误的机制,注意 table = new Entry[initialCapacity] 可以看出来Hashtable的底层实现使用的 Entry 类数组。

下面看一下这个 Entry 是一个什么样的东东:

这个Entry 类是 HashTable类中的一个内部类,通过Entry 可以构成一个单向的链表,这也就能理解哈希表的底层是一个数组+链表实现的。(HashTable的具体实现示意图文末可见)

那么我们再来看看在向哈希表中添加一个元素时候是怎么实现的。

① HashTable中不允许插入空值。当value = null 时候抛出异常。

② 散列码就是针对 key 值的HashCode,index是哈希地址;而且可以看出使用的哈希函数是除留余数法。

③ 将原先哈希表中的 对应 index 的值取出来,如果当前的哈希地址中已经存在数据,那么久进入循环体,进行判断,是否完全覆盖掉之前的数据,若覆盖掉之前的数据,则返回之前数据,并成功覆盖。(判断两个数据一样的条件是散列码hash一样,然后key值也要相等)

④ 将当前满足的数据 put 入哈希表中。

下面再针对 第④ 步的 addEntry 进行进一步说明:

threshold 是一个数量标志,当哈希表中的数据大于或等于这个标志的时候就进行二次哈希。这里插入一下标志位的定义:

在一开始的构造器中对标志位 threshold 进行赋值,(标志位大小 = 哈希表容量 * 负载因子,即这个标志位就是哈希表中正常存放数据的数量,一旦大于这个数量就触发二次哈希,一般负载因子的默认值为 0.75 ,即一个 initialCapacity 为 16的哈希表,当存储数据量达到 16 x 0.75 = 12 的时候就触发二次哈希机制。 )

② 否则就新建一个 Entry 对象,然后将当前要put 进的元素放入 当前哈希地址所指向的位置。再count++。

刚才说到二次哈希,那么现在再看看二次哈希具体是怎么实现的。


① 可以看出来每次扩容的大小是乘以 2 的 次幂个 然后再 +1 ,即在当前容量的基础上翻一倍 再 +1 。

② 对之前的 哈希表中的数据进行遍历并且二次哈希,之后放入新的哈希表中。
一般情况下,哈希表的初始容量大小是 11 ,负载因子大小是 0.75; 但是HashMap的初始容量大小是 16,这里我也有些不是很明白,为什么要定义为 11.希望有人能够给我一个解答。

刚说完HashTable是怎么实现的以及如何put元素,那么不得不说一说如何get 元素;

给出参数 key ,对 key值 进行哈希运算,得到相应的哈希地址,然后判断是否有相同的哈希地址的数据,若存在,则开始遍历相同地址元素后面的链表。直到找到需要的元素。(判断元素一致的规则前面已经说过了)。

实现示意图

有些写的不完善的地方还请指正。感谢赐教!

参考资料

jdk8 源码
Java核心技术(卷一)
百度百科
csdn博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章