文章目录
0 哈希表
哈希表:
底层是一个数组,通过关键字key,找到合法的下标,读取数据。
将数据中的key,通过一定的关系——哈希函数,可以确定该数据在数组中的坐标。
哈希冲突:不同关键字通过相同的哈希函数计算出相同的哈希地址。
如上例中,1, 11, 21与10取模得到的哈希索引坐标都是1,这就造成了哈希冲突。
常见的哈希函数:
1)直接定址法:取某个线性函数为散列地址。
2)除留余数法:设哈希表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址.
负载因子:r = 填入表中的数据个数/散列表(哈希表)的长度
当负载因子过大时,就要想办法来降低冲突率,而哈希表中的数据个数是不能变的,能够改变的只有哈希表的数组大小,我们往往通过对哈希表的数组进行扩容来降低负载因子。
解决哈希冲突常见方法是:闭散列(开放定址法)和开散列(链地址法) 再哈希法, 公共溢出区法
1 闭散列(开放定址法)
开放定址法,当发生哈希冲突时,如果哈希表未被装满,那么就可以将key存放到冲突位置的“下一个”空位置中去。
(1)线性探测
比如1,11,21在存放数据时,都发生了哈希冲突,那么就可以用线性探测法,将其移至下一个空位置中。
注意:
- 使用闭散列处理哈希冲突时,不能直接删除某个元素,因为用闭散列处理的数据都是互相关联的,可以采用标记的伪删除法来删除一个元素。
(2)二次探测
在线性探测中,数据容易堆积到一起,找位置都是按顺序往后查找空位。因此二次探测提供了一个找空位置的方法:
Hi = (H0 +i2) % compacity
其中,H0为哈希函数求得的索引,i为冲突的次数。
研究表明:当表的长度为质数且装载因子a不超过0.5时,新的数据一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。因此散列表最大的缺陷就是空间利用率比较低,同时这也是哈希的缺陷。
2 开散列(链地址法)(哈希桶)
首先通过哈希函数计算散列地址,具有相同地址的元素归于同一个子集合中,集合中的元素通过一个单链表链接起来,将各链表的头节点存储于哈希表中。
开散列可以认为是把一个大集合中的搜索问题转化为小集合中的搜索问题。但是如果冲突严重时,小集合的搜索性能就会变差,这时需要将小集合进行转换,可以将每个小集合替换为另一个哈希表,也可以替换为一颗搜索树。
注意:
- 在应用中,我们一般都会限制,每个桶的链表长度,如果冲突链表长度,就考虑对哈希数组进行扩容。
- 哈希表的插入,删除,查找的时间复杂度都是O(1)
- HashMap 和HashSet 即java 中利用哈希表实现的Map 和Set
- java 中使用的是哈希桶方式解决冲突的
- java 会在冲突链表长度大于阈值8后,将链表转变为搜索树(红黑树)
- java 中计算哈希值实际上是调用的类的hashCode 方法,进行key 的相等性比较是调用key 的equals 方法。所以如果要用自定义类作为HashMap 的key 或者HashSet 的值,必须覆写hashCode 和equals 方法,而且要做到equals 相等的对象,hashCode 一定是一致的。
3 HashMap源码解析
1.8之前采用头插法,1.8之后采用尾插法,解决了1.7在并发扩容时的死循环问题。
(1) HashMap的初始容量—— 16
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
(2)初始的负载因子—— 0.75
知道了负载因子,那么它的默认阈值就是16*0.75 = 12
即threshold = loadFactor * 容量
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
(3)HashMap()的构造方法
HashMap默认的构造方法就是将默认的负载因子赋值给loaddFactor
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// all other fields defaulted
}
(4)长度为什么必须是2的幂?如果输入值不是2的幂会怎么样?
比如说我们传入一个5,HashMap<String,String> map = new HashMap<>(5);
tableSizeFor函数会向上寻找一个离5最近的2的幂(8)
作为初始容量。
将tableFor函数返回的容量赋值给一个阈值threshold
(哈希表所能容许的最大容量)
当在put的时候,它会把我们刚得到的阈值赋值给oldThr
, 最终创建一个Node类型的数组。
int oldThr = threshold;
newCap = oldThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
至于为什么HashMap必须要求一个2的幂的容量,我们需要看一下它put的过程。
需要数组的大小为2次幂主要是为了减少哈希冲突,(n-1)&hash
就是对应的数组下标。
对于哈希结果,显然当n为2的幂时,散列效果最好。
注:
此处采用与运算符进行计算效率也比较高
(5)HashMap的get过程
(6)HashMap的扩容机制
int threshold
:表示当前HashMap能够承受的最多的键值对数量,一旦超过这个数量HashMap就会进行扩容
当负载情况达到负载因子水平的时候,容器会自动扩容,HashMap默认使用的负载因子值为0.75f。当负载因子越大的时候能够容纳的键值对就越多但是查找的代价也会越高。
在进行put时,如果存在Hash碰撞就会以链表的形式保存,把当前传进来的参数生成一个新的节点保存在链表的尾部(JDK1.7保存在首部)。而如果链表的长度大于8那么就会以红黑树的形式进行保存。
HashMap扩容可以分为三种情况:
1 ) : 使用默认构造方法初始化HashMap。HashMap在一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY
也就是16。同时阈值
threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。
2):指定初始容量的构造方法初始化HashMap。那么从下面源码可以看到初始容量会等于threshold,接着threshold = 当前的容量(threshold) * DEFAULT_LOAD_FACTOR
。
3):HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍。
注意:
HashMap初始化后首次插入数据时,先发生resize扩容再插入数据,之后每当插入的数据个数达到threshold时就会发生resize,此时是先插入数据再resize。
扩容机制核心方法Node<K,V>[] resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化后table为Null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//默认构造器的情况下为0
int newCap, newThr = 0;
if (oldCap > 0) {//table扩容过
//当前table容量大于最大值得时候返回当前table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//使用带有初始容量的构造器时,table容量为初始化得到的threshold
newCap = oldThr;
else { //默认构造器下进行扩容
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//使用带有初始容量的构造器在此处进行扩容
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
// help gc
oldTab[j] = null;
if (e.next == null)
// 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
// 扩容都是按照2的幂次方扩容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode)
// 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 把当前index对应的链表分成两个链表,减少扩容的迁移量
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 扩容后不需要移动的链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 扩容后需要移动的链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// help gc
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// help gc
hiTail.next = null;
// 扩容长度为当前index位置+旧的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
1.7时扩容直接采用hash和新数组长度相与,1.8扩容时采用哈希值和原数组长度进行相与的操作,当为0时,位置保持不变,否则位置变为原位置+原数组长度。 1.8的这一扩容机制主要根据下面的内容。
(7) 单链表树化
static final int TREEIFY_THRESHOLD = 8
: 链表长度大于该参数转红黑树
static final int UNTREEIFY_THRESHOLD = 6
: 当树的节点数小于等于该参数转成链表
当数组长度达到64和链表长度达到8之后才会变成红黑树。当数组长度小于64时,它会优先选择扩容,而不是转化为红黑树。
插入哈希桶中的数据会遵循泊松分布,链表长度达到8的概率是比较小的。
4 基本数据类型哈希表的简单实现:
public class HashBucket {
static class Node {
private int key;
private int value;
private Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private Node[] array = new Node[8];//存放单链表的头结点
private int size;//当前数据的个数
public int getValue(int key) {
int index = key % array.length;
//遍历array[index]下标的链表,找到值为key的数据,并且返回
Node head = array[index];
Node cur = head;
while(cur!=null){
if(cur.key == key) {
return cur.value;
}
cur=cur.next;
}
return -1;
}
//插入数据
public void put(int key,int value) {
int index = key % array.length;
//遍历链表,如果有相同的key,则将后插入的key-value代替前面的
Node cur = array[index];
while(cur!=null){
if(cur.key == key) {
cur.value = value;
return;
}
cur= cur.next;
}
//头插法
Node node = new Node(key,value);
node.next = array[index];
array[index] = node;
this.size++;
//计算负载因子,如果大于0.75,就对哈希数组进行扩容
if(loadFactor() >= 0.75) {
resize();
}
}
//计算负载因子
private double loadFactor() {
return this.size*1.0 / array.length;
}
//哈希数组扩容
public void resize() {
Node[] newArray = new Node[2*array.length];
//重新哈希
//1、遍历原来的数组,将里面的元素重新进行哈希到新的数组里面
for(int i = 0;i < array.length;i++) {
Node curNext = null;
//拿到原来链表的头节点
Node cur = array[i];
while(cur!=null){
curNext = cur.next;
//重新计算哈希数组坐标
int index = cur.key % newArray.length;
//头插法插入到新的链表中去
cur.next = newArray[index];
newArray[index] = cur;
cur=curNext;
}
}
array = newArray;
}
}