现有若干个号码,希望保存在一种数据结构中并且能快速地检索。考虑数组、链表、二叉树等各种结构。
我们知道链表的均摊搜索时间为 O ( N ) O(N) O(N);最优秀的二叉树均摊搜索时间为 O ( l g N ) O(lgN) O(lgN);能够在 O ( 1 ) O(1) O(1)时间内检索一个数据的数据结构—数组。
电话号码有11位,则需要开辟一个11位数字那么大的数组;但问题是,11位数字那么大的空间只存了若干号码,90%甚至99%以上的空间都被浪费掉了。
那么需要能够在 O ( 1 ) O(1) O(1)时间内检索一条数据并且不会浪费大量空间的数据结构。
数组为何能够在 O ( 1 ) O(1) O(1)时间内检索一条数据,主要是数组知道存放数据的"位置规则",该规则就是:一条数据存放在与该数据同值的数组索引中。
知道规则就可以通过规则快速检索到数据,而是否可以制定一种规则,使得在保持检索效率的同时减少空间的浪费呢?
这样的位置规则,称之为哈希函数,以此开辟的地址空间称为哈希表。
存放100个电话号码,希望可以只用100个号码的空间存放,又可以在 O ( 1 ) O(1) O(1)内检索某号码的存在。
因此,我们设计hash函数,希望使得这100个号码刚好可以存放在开辟的100个地址中。遗憾的是,谁也无法保证,对于当前100个号码可以适宜存放的时候对于另外100个号码也能适宜存放,显然会有很多号码通过这个hash函数得到同样的存放地点,这叫做哈希冲突。
也就是说,hash冲突是无法避免的,除非开辟足够大的空间。
但是,我们不希望浪费这么多空间,因此提出:
(1)提供优秀的hash函数,保证hash映射尽可能均匀地出现在每一个地址上。
(2)提供解决hash冲突的方法。
优秀的哈希函数应该近似地满足均匀散列假设:每个关键字都尽可能地散列到槽位中地任何一个,并且与其他关键字已经散列到那个槽位无关。例如,有:
(1)乘法散列法
h ( k e y ) ⌊ m ( ( k e y ⋅ A ) m o d 1 ) ⌋ h(key)\lfloor m((key\cdot A)\bmod1) \rfloor h(key)⌊m((key⋅A)mod1)⌋
step1: k e y ⋅ A key\cdot A key⋅A(A是一个小于1大于0的数,最佳为 5 − 1 2 \frac {\sqrt 5 -1}{2} 25−1)
step2:乘以m(m是2的某个次幂)
(2)除法散列法
h ( k e y ) = k e y m o d m h(key)=key\bmod m h(key)=keymodm
m为不太接近2的整数幂的整数
(3)全域散列法
暂略
把散列到同一个槽位的数据存放在一个链表中。
分析:
给定一个能存放n个元素的,具有m个槽位的散列表T,定义T的装载因子为 α = n / m \alpha=n/m α=n/m,即每一个链表的平均存储个数。
拉链法在哈希函数不好的时候效率很低,所有的数据均在一个链表上,这样查找时间复杂度为 O ( N ) O(N) O(N)级别。
若假定哈希函数能均匀散列,很容易计算,平均查找时间为 O ( 1 + α ) O(1+\alpha) O(1+α)
开放地址法把所有的元素都放在散列表里,因此装载因子不能超过1。当查找某元素是,需要系统地检查所有表项,直至找到或查明该元素不存在表中。
为了给hash值重复的元素寻找一个可以插入的位置,需要连续地检查散列表,称为探查,直到找到一个空槽来防止。
开放地址法解决哈希冲突的哈希表一次成功查找的平均探查次数期望值为 1 α ln 1 1 − α \frac{1}{\alpha}\ln \frac{1}{1-\alpha} α1ln1−α1,不成功的为 1 1 − α \frac {1}{1-\alpha} 1−α1
h ( k e y , i ) = ( h ′ ( k e y ) + i ) m o d n , i = 0 , 1 , ⋯ , n h(key,i)=(h^{'}(key)+i)\bmod n,i=0,1,\cdots,n h(key,i)=(h′(key)+i)modn,i=0,1,⋯,n
待插入元素若与已有元素的hash值重复,则在该槽后面依次寻找位置。
这种方法容易出现一次群集。当一个空槽前面有i个满槽时,该槽被下一个元素占用的概率是 ( i + 1 ) / m (i+1)/m (i+1)/m,显然出现了各个槽占用概率极不均等的情况,连续占用的槽会越来越长,即一次群集。
h ( k e y , i ) = ( h ′ ( k ) + c 1 i + c 2 i 2 ) m o d m h(key,i)=(h^{'}(k)+c_1i+c_2i^2)\bmod m h(key,i)=(h′(k)+c1i+c2i2)modm
后续探查位置不像线性探查一样依次查找,而加上一个二次项的偏移量,该偏移量依赖于探查次数i。该方法效果要比线性探查好得多。
但 c 1 , c 2 , m c_1,c_2,m c1,c2,m的值需要有一些较为苛刻的限制才能使得整个哈希表能够利用得较为充分。
还有一个问题是, h ( k e y 1 , 0 ) = h ( k e y 2 , 0 ) h(key_1,0)=h(key_2,0) h(key1,0)=h(key2,0)必然可得到 h ( k e y 1 , j ) = h ( k e y 2 , j ) h(key_1,j)=h(key_2,j) h(key1,j)=h(key2,j),因此还会导致群集,但这种群集更轻微,称为"二次群集"
h ( k e y , i ) = ( h 1 ( k e y ) + i h 2 ( k ) ) m o d m h(key,i)=(h_1(key)+ih_2(k))\bmod m h(key,i)=(h1(key)+ih2(k))modm
双重散列初始探查位置、偏移量、或者二者都可能发生变化;不像线性探查一样依次寻找位置,避免了一次群集;又不像平方探查一样具有相同哈希值的数据探查路径一致,避免了二次群集。
双重散列的 h 2 ( k e y ) h_2(key) h2(key)值需要与表的大小m互素。
基于JDK1.8,首先需要了解的基本信息:
(1)HashMap采用拉链法(单链表)解决哈希冲突。
(2)HashMap哈希函数与hashCode()有关。
(3)HashMap接的单链表超过一定长度会变为红黑树,表填充的数据超过一定比例会扩容。
(4)需要有一定的红黑树知识
现在具体分析其实现细节。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //初始表容量
static final int MAXIMUM_CAPACITY = 1 << 30; //最大表容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认装载因子
static final int TREEIFY_THRESHOLD = 8;
//挂载的冲突数据的数量不小于此值,挂载的数据结构由链表转为红黑树
static final int UNTREEIFY_THRESHOLD = 6;
//挂载的冲突数据的数量不大于此值,挂载的数据结构由红黑树转为链表
static final int MIN_TREEIFY_CAPACITY = 64;
//表容量不小于此值时,才允许链表转红黑树;
transient Node<K,V>[] table; //开辟的表空间
transient int size; //表存放的数据个数
int threshold;
//扩容阈值,表中存放的数据超过此值时扩容 table.length*loadfactor
final float loadFactor; //实际装载因子
(1)最大表容量为 2 30 2^{30} 230 一个原因是 2 31 2^{31} 231的int是一个负数,另一个原因是容量最好符合2的倍数,具体为何需要2的倍数见后面分析
(2)链表树化有两个条件,1是容量小于64,2是链表长度大于8。满足条件2不满足条件1的解决方法是扩容而不是树化。
槽位计算和辅助hash函数的计算需要一个可以计算的key值(或者说需要一个整数值),而传入"key-value"中的key值有些是无法直接进行计算的,诸如某个对象,浮点数等。
每个对象或基本类型都可以计算其HashCode,不同的对象HashCode通常是不同的(相同也没有关系,就是发生冲突)。因此,最终数据存放位置计算可以看作:
槽位计算方法(hash(hashCode(key)))
其实际实现为:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
index = (table.length - 1) & hash
(1)辅助hash函数中h无符号右移16为相当于将高区16位移动到了低区的16位,再与原hashCode做异或运算,可以将高低位二进制特征混合起来。目的是为了减少碰撞。
(2)槽位计算的方法使用&运算取代除法计算,比用除法取模运算速度快。
事实上,将hash表的容量限制为 2 x 2^x 2x就是是为了追求极致的位置计算速度,对 2 x 2^x 2x的取模运算可以转化为&(x-1)
该方法可以分为两部分:
final Node<K,V>[] resize() {
//1.确定新的表容量
//2.将旧表的数据依次传递给新表
}
(1)新表重构规则
(2)旧表的数据依次传递给新表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//遍历旧表中的数据
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//数据判空
oldTab[j] = null; //释放旧表空间,以便回收
if (e.next == null) //该位置只有一个数据,无挂载,重新计算槽位
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//挂载为红黑树的情况,split后面分析
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//挂载为链表的情况,将链表分为两条,挂载到不同的地方
Node<K,V> loHead = null, loTail = null; //链1头尾
Node<K,V> hiHead = null, hiTail = null; //链2头尾
Node<K,V> next;
do {
//内部一些基本的链表操作
next = e.next;
if ((e.hash & oldCap) == 0) {
//结点分配策略,情况1
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
//结点分配策略,情况2
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
//挂载到指定位置
loTail.next = null;
newTab[j] = loHead; //原索引位置
}
if (hiTail != null) {
//挂载到指定位置
hiTail.next = null;
newTab[j + oldCap] = hiHead; //原索引+原表长位置
}
}
}
}
}
红黑树的切分策略见2.4
(3)新槽位选取策略(链表和红黑树拆分策略)
无挂载的时候是直接计算其新的索引位置:
newTab[e.hash & (newCap - 1)] = e;
而有挂载的情况没有采取该方式,而是采取了另一种计算方式:
if ((e.hash & oldCap) == 0)
//loHaed .....
newTab[j] = loHead;
if ((e.hash & oldCap) == 1)
//hiHaed .....
newTab[j + oldCap] = hiHead;
实际上与直接计算其新的索引位置效果一样,分配策略较为均匀。
以一个原槽位16,新槽位32的例子说明:
值得注意的是,即使冲突的结点以红黑树的形式挂载,仍然保持其结点的链表链接关系。
因此其红黑树拆分方法与链表拆分方法(见2.3)及其相似:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
//....
//与链表相同的分配策略,见2.3
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
//结点数小于等于链化的阈值(6),则以链式形式挂载到哈希表中
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null)
//否则,判断红黑树是否被切分,是则以当前链中结点重构红黑树
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
//结点数小于等于链化的阈值(6),则以链式形式挂载到哈希表中
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
//否则,判断红黑树是否被切分,是则以当前链中结点重构红黑树
hiHead.treeify(tab);
}
}
}
(1)与链表不同的地方在于,如果当前链(被拆分的红黑树)中数目小于阈值6,则不需要构建红黑树,直接以链表的形式挂载到哈希表中。
(2)若数目不小于6,则以当前链表构建红黑树。
(3)对于treeify()方法,只需要遍历链表,依次将结点添加到红黑树中即可。
元素查找较为简单,步骤为:
(1)表的存在性及非空校验
(2)相应槽位非空性校验
(3)若相应槽位非空,校验数组中元素与待查找元素的一致性
(4)若与数组中元素不一致,则判断结点是否为红黑树,若不是,则直接以链表形式查找
(5)红黑树的查找方式也较为简单,根据hash值的大小对比结果往左子树或右子树搜索
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
//内部调用getNode方法查找
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//表非空校验;相应槽位非空性校验
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
//与数组中元素相同,直接返回
return first;
if ((e = first.next) != null) {
//挂载判空
if (first instanceof TreeNode) //挂载形式为,则到红黑树里查找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//查找链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
(1)插入元素需要先判断是否重复,若传入的键值在哈希表中已经存在,则只修改Value值即可
(2)若挂载的是链表,则需要在插入元素后,判断当前链表节点数是否大于等于阈值8,是则需要构建为红黑树
(3)若插入元素后,当前哈希表(包括挂载的)中的元素已经超过阈值(表容量*装载因子),则调用resize()方法扩容
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
//内部调用putVal方法
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//表若不存在或为空,则重构表(扩容)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//相应槽位为空,则直接添加入槽中
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k; //e为key判重的临时变量
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//校验槽中结点是否为传入的结点,如果是,赋予e
e = p;
else if (p instanceof TreeNode)
//若挂载为红黑树,在红黑树中搜索是否与传入的结点相同的结点,赋予e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//挂载为链表的情况
for (int binCount = 0; ; ++binCount) {
//链表遍历,计数,判重
if ((e = p.next) == null) {
//遍历到末尾,无重复,添加该元素
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
//链表长度大于等于阈值(8),将链表树化
//-1的原因是,没有将槽中的元素计算进来
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//发现重复
break;
p = e;
}
}
if (e != null) {
//相同键的元素在哈希表中存在,用传入的value覆盖该节点的value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); //用于LinkedHashMap
return oldValue;
}
}
++modCount;
if (++size > threshold)
//插入节点后,当前元素数目超过阈值,扩容
resize();
afterNodeInsertion(evict);//用于LinkedHashMap
return null;
}
(1)删除元素与查找元素十分类似,只需要找再删除即可
(2)值得注意的是,若某个槽挂载元素为红黑树,删除元素之后结点数小于等于6,会引起挂载的红黑树转为链表
HashSet是HashMap实例实现的,其传入的key为key,而HashMap所需的value是由常量PRESENT代替的:
private static final Object PRESENT = new Object();
因此,HashSet可以当作一个只有key值,没有value值的HashMap来看
(1)HashTable的大部分方法都使用了synchronized关键字,因而是线程安全的;而HashMap不是线程安全的
(2)HashTable的key和Value均不能为null;而HashMap可以HashMap可以以插入null键值对是因为对hash值做了处理,见2.2
而HashTable:
if (value == null) {
throw new NullPointerException();
}
int hash = key.hashCode(); //null无法调用方法
(3)HashMap函数值经过处理(高位与低位hashCode异或),HashTable直接使用hashCode
(4)HashMap 的默认初始容量为 16,Hashtable 为 11
public Hashtable() {
this(11, 0.75f);
}
(5)HashMap 的扩容为原来的 2 倍,Hashtable 的扩容为原来的 2 倍加 1;最大容量也有所区别HahsTable是Integer.MAX_VALUE - 8而HashMap为Integer.MAX_VALUE
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
return;
newCapacity = MAX_ARRAY_SIZE;
}