一、哈希(Hash)查找
1.哈希表
要了解哈希查找,首先要知道什么是哈希表。哈希表,又称散列表,是通过对键(key)进行计算能直接得到要访问的数据的位置,即将数据存储的位置与该数据的key之间建立一种确定的函数关系,这个函数关系叫做散列函数,而存放数据的数组就称之为哈希表或散列表。
之前我们所学的查找都是通过对关键字key与给定值的比较来确定key的位置。效率和查找的次数相挂钩。而哈希表中的查找则不同,他是通过对key的散列运算直接得到key存储的位置(数组的索引查找也是通过数学运算来确定位置),效率要高得多。由此我们队哈希表有个初步的印象:
-
特点:查找速度极快。
-
结构:常用结构是数组+链表。
那么哈希表是如何实现快速查找的呢。答案是通过散列函数对待查找的key进行散列运算得到数据元素在数组中的索引,这样就可以通过索引直接访问到数组中对应数据。想法虽然很美好,但这只是理想情况下才能达到的情形,即理想状态下key与数组的索引是一一对应的关系,每个key都有唯一的索引与之对应。然而实际情况却是索引可能与多个key相对应,即存在多个key进行散列运算后得到的索引是相同的。这就导致哈希表不能仅仅是数组结构(若仅仅是数组,则当存在key冲突时,数据将出现丢失的情况),还必须加上链表的结构,以保证当出现冲突时,数据元素仍有空间存储。因此,哈希表常用的结构会是数组+链表的形式。
散列函数常用的方法:
-
若key是正整数,则常用除留取余法,即假设数组长度为N,则取key/N的余数作为key在数组中的索引,f(key)=key %N。N一般是素数。
-
直接定址法:即通过f(key)=a*key+b来直接得到索引地址。
-
随机数法:即f(key)=random(key)。
-
平方取中法:取key*key的中间几位数字。
还有其他的散列方法,这里就不一一做介绍了。
2.哈希表在Java中的应用
哈希表在Java中应用主要是HashMap和HashTable,下面就以HashMap为例了解哈希表的添加和删除数据的过程。
HashMap的继承关系如下图:
由前面的学习,我们已经知道Map接口和AbstractMap抽象类的具体作用,下面我们直接看HashMap的构造方法部分源码:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
//hashmap的初始化默认容量,为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量,即2的31次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子,即当hashmap中数组table使用率达到0.75(即数组中有百分75地方存有数据),就要进行扩容,hashmap扩容直接变大2倍。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当链表长度超过8时,不在使用链表结构,换成红黑树
static final int TREEIFY_THRESHOLD = 8;
//当红黑树的结点数小于6时,改成链表结构
static final int UNTREEIFY_THRESHOLD = 6;
//hashmap转为红黑树时要求的最小容量,即只有容量大于64且链表长度超过8才能使用红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//hashmap中对Map.Entry接口的实现,jdk1.8之前使用的还是Entry,1.8后变为Node。这是因为1.8后当链表中Node的个数大于8,就会将链表转化成红黑树,用以提高查找的速度
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key经过散列函数后的hash值
final K key;
V value;
Node<K,V> next; //链表的下个结点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
//Node的哈希码是有key和value的哈希码相加
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//用于判断两个Node是否相同
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
//hashmap的散列函数,即f(key)=key.hashcode^(h>>>16)。(h>>>16)表示h无符号右移16位,相当于除以2的16次方,^表示按位与。
//当key为null时的hash对应为0,这表示hashmap中允许存在key为null的结点,但只能有一个,且key为null的结点必然处于数组中索引为0的位置,因为hash为0只能对应数组索引0
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//返回一个2的n次方值,该值为最接近的大于或等于cap的2的n次方值
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//存放链表起点或红黑树根结点的数组
transient Node<K,V>[] table;
//hashmap中存放所有键值对的set集合
transient Set<Map.Entry<K,V>> entrySet;
//hashmap中的数据个数
transient int size;
//hashmap中允许容纳Node结点的最大数量,threshold=cap *loadFactor
//也就是说负载因子越大,相同table.length的情形下hashmap存放数据越多,但负载因子不建议随意更改(负载因子是可以大于1的)。
int threshold;
//hashmap的负载因子,默认值是DEFAULT_LOAD_FACTOR=0.75
final float loadFactor;
//带初始容量和负载因子的构造方法
public HashMap(int initialCapacity, float loadFactor) {
//判断初始容量initialCapacity是否合法
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity); //令数组可用空间值为2的n次方。
}
//使用默认负载因子的构造器
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//使用默认负载因子即默认初始化容量的构造器
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//以传入的m为基础初始化一个hashmap
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize(); //扩容方法,下面会讲解
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict); //新增方法,下面会讲解
}
}
}
}
可以看出,构造器做的事不多,只是初始化了loadFactor和threshold两个变量,连table数组都没有初始化,可见hashmap中使用的table数组必然是懒加载,在put数据时才初始化,下面来看看put方法的过程:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true); //实际执行键值对新增的方法是putVal(),hash(key)则是对key进行散列运算
}
//
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) //当table数组为null时,执行tab=resize()方法,即创建一个新的table数组。
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //判断hash对应的数组索引是否为null,为null表示该索引下尚未有数据,直接将新增键值对作为链表头结点存入该索引
tab[i] = newNode(hash, key, value, null);
else { //该索引下链表不为null,则向链表中执行新增或替换操作
Node<K,V> e; K k;
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))) //判断key对应的node结点在是否是p结点,若是,则p就是要找的结点
e = p;
else if (p instanceof TreeNode)
//判断p结点是否是treeNode,如果是,就采用红黑树新增结点方法putTreeVal(),本篇不涉及红黑树,有兴趣可以看之前关于TreeMap的笔记
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { //到这,表示p结点为普通链表中的一个结点
//遍历以p为起点的链表,binCount统计链表的结点数
for (int binCount = 0; ; ++binCount) {
//判断p的下个结点是否为null,为空表示新增数据在表中不存在,直接添加新节点到表尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); //添加新节点到表尾。
if (binCount >= TREEIFY_THRESHOLD - 1) //判断链表长度是否超过8,超过8就要将链表转化成红黑树
treeifyBin(tab, hash);
break;
}
//判断当前结点e是否和将要新增的结点key相同/hash相同,若相同表示key已存在,退出查找。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//判断e是否为null,不为null,表示要put的结点在表中已存在,用新值value替换旧值e.value即可。
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //onlyIfAbsent如果为true,表示不允许value值的替换,即只能新增不能替换
e.value = value;
afterNodeAccess(e); //该方法用于LinkedHashMap中,此处为空方法,啥也没干
return oldValue; //返回旧值
}
}
++modCount; //快速失败机制相关,这里不讨论
if (++size > threshold) //新增数据后,数据个数是否超过临界值,超过要扩容
resize();
afterNodeInsertion(evict); //该方法用于LinkedHashMap中,此处为空方法,啥也没干
return null;
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //获取当前的table数组
int oldCap = (oldTab == null) ? 0 : oldTab.length; //获取table数组的长度
int oldThr = threshold; //当前hashmap扩容的临界值
int newCap, newThr = 0;
//判断旧table数组的长度是否大于0,大于0表示不是对table数组的初始化,而是正常扩容
if (oldCap > 0) {
//旧容量大于int数的最大值,则不能再扩容(达到能扩容的最大值)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //新容量为oldCap扩大2倍
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //临界值扩大2倍
}
else if (oldThr > 0)
// oldCap==0,表示table数组为null,需要初始化,此时若旧临界值大于0,则将其作为数组的初始化长度
// 由构造方法中的this.threshold = tableSizeFor()可知数组长度只能是2的n次方。
newCap = oldThr;
else { // 未指定threshold值时,采用默认值初始化数组长度,该条件用不到,因为4个构造方法都会初始化threshold值,oldThr不存在为0的情况
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//新的临界值若为0,则根据临界值的定义为newThr赋初值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; //将扩容后的临界值赋予threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //得到扩容后的数组
table = newTab; //将扩容后的数组赋予table
//判断旧数组是否为空。若不为null,将旧table中的数据转移到新的数组中
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)
//若e链表的长度为1.即oldTab[j]中只有一个表头,就直接放到新的数组中。
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //结点是红黑树的情况,这里不深究红黑树的情形
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//链表长度大于1时链表中数据迁移到新数组中的规则。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//判断链表中的结点是否需要移动,(e.hash & oldCap) == 0表示e结点在在新数组中的索引位置不变。
//例子:e.hash=6,二进制为0000 0110。oldCap假设为16,二进制位0001 0000。newCap为32,二进制位0010 0000
//(e.hash & oldCap)=0000 0110 & 0001 0000=0000 0000 ;
//(e.hash & oldCap-1)=0000 0110 & 0000 1111=0000 0110 ;
//(e.hash & newCap)=0000 0110 & 0010 0000=0000 0000
//(e.hash & newCap-1)=0000 0110 & 0001 1111=0000 0110 ;
//例子:e.hash=19,二进制为0001 0011。oldCap假设为16,二进制位0001 0000。newCap为32,二进制位0010 0000
//(e.hash & oldCap)=0001 0011 & 0001 0000=0001 0000 ;
//(e.hash & oldCap-1)=0001 0011 & 0000 1111=0000 0011 ;
//(e.hash & newCap)=0001 0011 & 0010 0000=0000 0000
//(e.hash & newCap-1)=0001 0011 & 0001 1111=0001 0011 ;
//有上面就可以看出当(e.hash & oldCap) == 0时,(e.hash & oldCap-1)与(e.hash & newCap-1)的值时相同的,即在新数组中索引不变
//而当(e.hash & oldCap) != 0 时,(e.hash & oldCap-1)与(e.hash & newCap-1)的值不相同的,即在新数组的索引变了
if ((e.hash & oldCap) == 0) {
//将e为起点的链表,分为两个链表,一个链表是由索引在新数组中不变的结点组成的链表lo,另一个是由索引在新数组中发生改变的所有结点组成的链表hi
//由于数组扩容是直接变大2倍,可得出所有索引改变的结点在新数组中的索引是相同的,即,若有(e.hash & oldCap-1)=3,(e.hash & newCap-1)=19
//则与e结点在同一个链表中的其他结点的索引位置要么是3,要么是19。
if (loTail == null) //将索引不变的所有结点组成一个新的链表lo
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null) //索引改变的所有结点组成一个新的链表hi
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //将索引不变的lo链表放到数组的j位置
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //将索引改变的hi链表放到数组的j+oldCap位置
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
可以看出hashmap中的put过程还是比较复杂的,要考虑扩容,是否使用红黑树等,但这样也是为了保证查找的效率。因为哈希表难以避免的一个问题便是:随着数据的逐渐增多,即使再好的散列算法,也不能避免冲突的次数的增加,而哈希冲突的增加,必然使得链表长度过长,从而降低查找的效率(哈希表中数组的查询事件复杂度为O(1),链表的查询时间复杂度则为O(n)),为了保证哈希表的查找效率,只能从减少冲突或降低链表的时间复杂度两个方面入手,这就有了数组扩容和红黑树结构的使用。
讨论完put,下面来看看get方法。
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;
//判断table数组是否有数据且(n - 1) & hash所在的索引是否有数据,即要查找的结点是否在数组中存在
if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {
//查找的hash值是否是first头结点,是的话,直接返回头结点。
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
//链表中是否有其他结点,即hash值所对应的索引下是否只有头结点first一个结点。若只有first一个结点,则要查找的结点不存在。
if ((e = first.next) != null) {
//若是红黑树的话,执行红黑树中查找方法getTreeNode
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { //遍历链表查找对应的key是否存在。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
可以看出hashmap中查找相对简单,因为hashmap就是为了方便查找的,下面在看看删除的源码:
public V remove(Object key) {
Node<K,V> e;
//删除操作真正的执行方法removeNode()。要删除的结点存在就将其从hashmap中移除且返回value,不存在则返回null。
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//table数组不为null且要删除的结点的在数组中存在时,继续执行删除操作
if ((tab = table) != null && (n = tab.length) > 0 &&(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//判断要删除的结点是否为数组中对应索引的头结点p,若不是则继续遍历查找
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//若是红黑树,则执行红黑树的查找方法getNodeTree()
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//普通链表结构,直接遍历查询待删除结点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//node即为查找到的待删除结点,为null则表示要删除的结点不存在。matchValue表示是否比对value的值,即要key和value一致菜删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//若结点是treeNode,执行红黑树的删除方法
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) //node为链表的头结点时
tab[index] = node.next;
else //node为普通结点时
p.next = node.next;
++modCount;
--size; //数据数减1
afterNodeRemoval(node); //LinkedHashMap中用到的方法,此处无用,是个空方法。
return node;
}
}
return null;
}
以上便是hashmap的一些基本实现。可以看出hashmap有如下特点:
-
基本底层实现是数组加链表,当链表结点数超过8个,链表转化成红黑树的结构。
-
允许一个key为null的键值对,value为null不限制。
-
数据元素时无序的,且每次扩容都有可能改变。
-
插入,查找的效率高,耗时操作基本都是用在扩容上。
3.HashTable的简单介绍
HashTable的底层与HashMap基本相同(jdk1.8后差别就比较大了),用法也基本一致,差别只是在增删改查的方法上加了 synchronized。保证线程安全。因此,HashTable和HashMap相比有:
-
HashMap: 线程不安全,效率高。允许key或value为null。
-
HashTable: 线程安全,效率低。不允许key或value为null。