Hash函数及HashMap的简单介绍



本篇文章将围绕以下几个问题进行学习:

一、散列函数

  • 散列函数的概念
  • 散列函数常见的几种构造方法

二、哈希冲突

  • 哈希冲突的概念
  • 哈希冲突的常见解决办法

三、HashMap

  • HashMap的简单介绍
  • HashMap的几个特点
  • HashMap的两个关键因子
  • HashMap查找的时间复杂度分析

四、问题小结

  • HashMap中哈希函数的实现方式是什么?为什么要这样实现?
  • HashMap中哈希表的容量为什么一定要是2的整数次幂?
  • HashMap中hashcode()方法和equals()方法有什么用?


一、散列函数

1.散列函数的概念

散列函数,又称哈希函数。它是一种映射关系,根据数据元素的关键字key作为自变量,通过一定的函数关系,计算出该元素存储地址的值
表示为:address = H[key]
  • 就是把任意长度的输入值通过哈希(散列)算法转换,得到固定长度的输出值,即哈希值。
  • 著名的MD5和SHA1算法等等都是目前应用最广泛的hash算法。

2.散列函数常见的几种构造方法

哈希函数的构造方法有很多,总的一个原则就是要尽可能的减少冲突。
  • 除留余数法

    取关键字key被某一个p值取余而得到的值作为散列地址(散列值),其中p不大于散列表长度n
    即 H(key)=key%p, p<=m

  • 直接寻址法

    取关键字key或关键字key的某个线性函数的值作为散列地址(散列值)
    即 H(key) = key 或者 H(key) = a * key + b,其中 a 和 b 为常数

  • 数字分析法

    当关键字key的位数大于地址的位数,对key的各位分布进行分析,选出分布均匀的任意几位作为散列值
    仅适用于所有关键字都已知的情况下,根据实际情况应用确定要选取的部分,尽量减少冲突的发生

  • 平方取中法

    先计算出关键字key值的平方,然后取平方值中间任意几位作为散列地址

  • 折叠法(叠加法)

    将关键字key分为位数相同的几部分,然后取这几部分的叠加和(舍去进位)作为散列地址
    用于关键字位数较多,并且关键字中每一位上的数字分布大致均匀

  • 随机数法

    选择一个随机函数,把关键字key的随机函数值作为它的散列值
    通常当关键字的长度不相等时使用这种方法



二、哈希冲突

1.哈希冲突的概念

不同的两个关键字key,因为选用哈希函数而计算出相同的哈希值,被映射到表的同一位置上,称为冲突或碰撞(collision)
发生冲突的两个关键字key称为该哈希函数的同义词(synonym)

2.哈希冲突的常见解决办法

  • 拉链法(链接法)

    将所有关键字key为同义词的结点链接在同一个单链表中。若选定的散列表长度为 m ,则可将散列表定义为一个由 m 个头指针组成的指针数组 T[0 .. m - 1]。凡是散列地址(散列值)为 i 的结点,均插入到以 T[i] 为头指针的单链表中。指针数组 T 中各分量的初值均应为空指针。

  • 开放定址法

    当冲突发生时,使用某种探测技术在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。
    根据形成探测序列的方法不同,将开放定址法区分为线性探查法,二次探查法,双重散列法等。

    这里介绍一下线性探查法

    Hi = ( H(key) + i ) % m, 0 <= i <= m-1
    探测时从地址d开始,首先探查T[d],然后依次探查T[d+1]…,直到T[m-1],然后又循环到T[0]、T[1],直到探测到有空余地址或者到T[d-1]为止。



三、HashMap

1.HashMap的简单介绍

  • HashMap是一个采用Hash表实现的键值对集合,继承自AbstractMap,实现了Map接口。
  • HashMap特殊的存储结构使得在获取指定元素时先经过哈希运算,得到目标元素在哈希表中的位置,然后再进行少量的比较即可获取到元素,因此,HashMap的查找效率较高。
  • 在HashMap中,发生哈希冲突的时候,采用拉链法进行解决,因此HashMap的底层实现是数组+链表
  • jdk1.8之后,HashMap中如果一个桶(bucket)中的元素个数超过TREEIFY_THRESHOLD(默认为8)时,就会使用红黑树来替换链表,提高查找速度,称作桶的树形化。

2.HashMap的几个特点

  • 存储的元素是无序的,顺序也会不定时的改变;
  • 底层实现是链表 + 数组,Java8之后加入了红黑树,大大提高了查找效率;
  • 允许有null键和null值存在,但空键只有一个,且放在第一位,看源码可知;
  • 插入、查找的时间复杂度基本为O(1),(前提是经过良好的哈希函数运算,使得元素分布在均匀的位置);
  • 遍历整个Map需要的时间与桶(数组)的长度成正比,因此在初始化HashMap容量 (capacity) 时不宜太大;
  • key采用Set存放,因此想要做到key不允许重复,需要在key对应的类中重写 hashcode() 和 equals() 方法;

3.HashMap的两个关键因子

  • 初始容量 ( DEFAULT_INITIAL_CAPACITY : 1<<4 ):默认初始容量为16,且必须是2的整数次方
  • 加载因子 ( DEFAULT_LOAD_FACTOR : 0.75f ):默认加载因子的大小为0.75

加载因子太大,发生冲突的可能性会加大,导致查找的效率降低;太小的话会频繁rehash,又会导致性能降低。

这两个关键因子决定了HashMap应该什么时候扩容,而HashMap扩容又伴随着相当大的开销(重新创建数组,重新哈希计算,重新分配等等),因此在设置初始容量时,应该根据实际情况先考虑Map中有多少对键值对,设置合理的加载因子,尽可能的去避免扩容,提高效率。

4.HashMap查找的时间复杂度分析

  • 在HashMap中,查找的时间复杂度O一般跟链表长度有关,因此哈希算法越好,元素分布的越均匀,get()方法就越快。
  • 当HashMap中有大量的元素都存放到同一个桶中,这时候哈希表中只有一个桶,桶下有一条长长的链表,这个时候HashMap与单链表无异,遍历的时间复杂度又回到了O(n),完全失去了优势。
  • 所以Java8以后,HashMap新增了红黑树 (红黑树的结构可以控制时间复杂度为O(logn)) ,优化了这种极端情况下的性能问题。


四、问题小结

1.HashMap中哈希函数的实现方式是什么?为什么要这样实现?

在HashMap的jdk1.8源码中,hash()方法是这样写的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里同样解释了为什么HashMap允许有null键,因为当键为 null 时,返回了值为0。

通过将传入键的hashcode进行无符号右移16位,然后按位异或,得到该键的哈希值。(无符号右移,高位总补0)

由于哈希表中的容量都是2的整数次幂,因此key的hashcode在很多情况下低位总是相同的,这大大加大了冲突的可能性,因此在jdk1.8以后进行了移位操作,将元素的hashcode和自己右移16位后的结果做异或运算,而int类型长度只有32位,因此无符号右移16位就相当于把自己的高位移到低位,然后再与自己的高位做异或运算,得到hash值,最后通过取模运算(n - 1) & hash得到下标值。

这样做可以避免只靠低位数据来计算hash值时导致的冲突,计算结果由高低位共同决定,极大的避免了哈希值分布不均匀,而且,采用位运算的效率非常高

2.HashMap中哈希表的容量为什么一定要是2的整数次幂?

  • capacity为2的整数次幂,在计算桶的位置hashcode&(length-1)的时候,相当于hashcode%length,即对length取模,提高了计算效率;
  • capacity为2的整数次幂,那么capacity-1永远为奇数,则最后一位永远为1,这样就可以保证在进行hashcode&(length-1)计算的时候,得到的hash值的最后一位可能为1也可能为0(取决于hashcode最后一位),即运算完后的hash值既可能为偶数也可能为奇数,保证了散列的均匀性,否则,capacity为奇数的话,capacity-1最后一位永远为0,hashcode&(length-1)最后一位也必为0,即只能为偶数,这样任意的hashcode值只会被散列到数组的偶数下标上,浪费了近一半的空间,也大大增加了碰撞的几率。

3.HashMap中hashcode()方法和equals()方法有什么用?

  • 当插入、查找时需要通过key的hashcode()的值进行hash()计算得到hash值,然后通过 hash&(length-1) 位运算得到下标,从而获得要找的桶的位置。
  • 而当发生冲突时,利用key.equals()方法去链表或树中去找对应的节点。


谢谢阅读!

猜你喜欢

转载自blog.csdn.net/ism_Script/article/details/81905660
今日推荐