算法——(4)哈希、hashmap、hashtable
1. Hash
把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值。拥有四个特性:
1. 拥有无限的输入域和固定大小的输出域
2. 如果输入值相同,返回值一样
3. 如果输入值不相同,返回值可能相同,可能不同
4. 不同输入值得到的哈希值,整体均匀的分布在输出域s中——优秀哈希函数的判断。
2. HashMap
HashMap是Node[] table哈希桶数组,其中Node是内部类,实现键值对Entry接口,并采用链表解决Hash碰撞问题。
数组默认初始长度为16,含有Loadfactor负载因子(默认为0.75),threshold=LoadFactor*length。当数组元素超过了threshold,则扩容成原来长度的两倍,并重新对map的内容进行hash,容量总是2的幂
所以保证Hash Map高效的因素是:好的hash算法使得hash值分布均匀和扩容机制。
在JDK8以后,加入了红黑树,当链表数量超过8时使用红黑树。
2.1 结构实现
HashMap是数组+链表实现。
1)是Entry键值对的数组 Node[] table,即哈希桶数组,其中Node是内部类,实现键值对Entry接口
2)HashMap就是使用哈希表来存储的,并且采用了链地址法解决冲突。
简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。
- 如果哈希桶数组很大,即使比较差的Hash算法也会比较分散
- 如果哈希桶数组很小,即使很好的Hash算法也会出现较多的碰撞
所以,需要在时间成本和空间成本作出权衡,根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。——解决方式:Hash算法和扩容机制
2.2 扩容机制
- Node[] table初始化长度length(默认16),一般为2的幂次方(主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。)
- vs HashTable:长度默认为11(素数,相对来说素数导致冲突的概率小于合数)
- Load factor负载因子(默认是0.75)
- threshold是Hash Map所能容纳的最大数据量的Node键值对。threshold = length*loadfactor
- 当元素数量超过了threshold,那么就扩容resize,扩容后的容量是原来的两倍,并且重新计算每个元素在数组中的位置。
注意: 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
2.3 功能实现
1. 确定哈希桶数组索引位置
Hash算法本质上就是三步:1)取key的HashCode值;2)高位运算;3)取模运算
方法一:把Hash值对数组取模运算。——时间消耗大
方法二:hash&(table.length-1)来得到该对象的保存位,当长度总是为2的幂次方时,相当于对length取模,但是&比%更高效。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
2. HashMap的Put方法:
1. 首先根据键值key计算hash值,得到索引值i。
- 如果table[i]=null,直接新建节点添加。并判断元素的数量是否超过threshold,
- 如果table[i] !=null,则在该索引对应的链表中插入该元素
2. 判断数组中的元素数目是否超过thrshold,
- 如果超过,则扩容resize成原来的两倍,并重新计算各元素的数组索引值。
此外:
(1) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(2) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(3) JDK1.8引入红黑树大程度优化了HashMap的性能。
https://zhuanlan.zhihu.com/p/21673805
3. 其他结构
1. HashMap:
- 根据键的hashcode值来存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序确实不确定的。
- HashMap最多只允许一条记录的键为null,允许多条记录的值为null。
- 非线程安全。即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
- 若要线程安全:synchronizedMap、ConcurrentHashMap
2. HashTable
是遗留类,很多映射的常用功能和Hash Map类似,不同的是它承自Dictionary类,是线程安全的。任意时刻只能有一个线程能够写HashTable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。
一般不建议在新代码中使用
3. TreeMap
实现SortedMap接口,能够把它保存的记录按照键排序,默认是按键值的升序排序,也可以指定排序的比较器。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。
4. LinkedHashMap
是Hash Map的一个字类,保存了记录的插入顺序,在使用Iterator遍历LinkedhashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
HashMap VS HashTable
1. HashMap
- Entry[] table; - size>=threshold就会扩容,threshold等于capacity*loadFactor - HashMap默认的初始容量是16,负荷系数是0.75。
扩容会对map的内容进行重新哈希,容量总是2的幂。 -
数组第一个位置放得是key=null的元素 - reHash()时: int i = indexFor(e.hash, newCapacity); 然后头插法插入新数组 -自定义对象重写equals()时也要重写hashcode();使用不可变类当key;原因都是怕存入Hashmap之后get(key)找不到元素。 - jdk1.8 链表数量超过8时改用红黑树。node<k,v>[] table;node是内部类,实现Map.Entry接口{K key; V value; node<K,V> next; int hash} - jdk1.8 扩容不需要重新计算hash,因为容量扩大两倍, 那么n-1的mask范围在高位多1bit, 只需要看看原来元素的hash值新增的那个bit是1还是0,是0的元素位置没变,是1的话索引变成“原索引+旧容量(即扩大的容量)” - jdk1.8中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素bu不会倒置 - jdk1.8旧链表rehash时新建两条链表AB,如果 (e.hash&oldCap)==0是指元素位置不变,尾插法插入链表A,最后B链表插入 原索引+oldCap放到bucket里 Hashtable - Entry<K,V>{final K key; V value; Entry<K,V> next;final int hash} - 计算哈希值:int hash = hash(key.hashCode()); - 计算数组落位:hash & (capacity-1),其中capacity是2的幂 - 新增加的Entry<K,V>放在链表头部 - 扩容:使用新的容量创建新数组,将每一个元素重新计算重新落位,重新计算临界值 - 查找value必须遍历整个数组遍历每个位置上的整条链表 - Hashtable不允许键或者值是null。 - Hashtable默认数组大小11,增长old*2+1,HashMap默认数组大小16,增长2的指数。 - Hashtablehash值直接使用hashCode