散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value) 的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。 可以说,如果没有数组,就没有散列表。
问:不过,散列表是如何根据Key值来快速查找到它所匹配的Value值呢?
答:散列表其本质也是一个数组,数组只能根据下标来访问而散列表是用Key值来访问,所以我们需要一个“中转站”,通过某种方式,把Key和数组下标进行转换。这个中转站就叫作哈希函数。
在Java及大多数面向对象的语言中,每一个对象都有属于自己的hashcode,这个
hashcode是区分不同对象的重要标识。无论对象自身的类型是什么,它们hashcode都是一个整型变量。所以哈希函数就是通过哈希码(hashcode)转换成为数组下标,按照数组长度进行取模运算。
index = HashCode (Key) % Array.length
通过哈希函数,我们可以把字符串或其他类型的Key,转化成数组的下标index。
如给出一个长度为8的数组:
则当key=001121时,
index = HashCode ("001121") % Array.length = 1420036703 % 8 = 7
而当key=this时,
index = HashCode ("this") % Array.length = 3559070 % 8 = 6
写操作就是在散列表中插入新的键值对(在JDK中叫作Entry)。例如调用hashMap.put(“002931”, “王五”),意思就是插入一组Key为002931、Value为王五的键值对。
第一步: 通过哈希函数,把Key转化成数组下标5。
第二步: 如果数组下标5对应的位置没有元素,就把这个Entry填充到数组下标5的位置。
不过,如果出现了哈希冲突的情况(例如002936这个Key对应的数组下标是2;002947这个Key对应的数组下标也是2.),就需要对冲突进行解决。
解决哈希冲突的方法主要有两种,一种是开放寻址法,一种是链表法。
开放寻址法: 当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就”,寻找下一个空档位置。重新探测一个空闲位置,将其插入。探测方法主要有线性探测、二次探测、双重散列三种。在Java中,ThreadLocal所使用的就是开放寻址法。当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
线性探测:当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
二次探测:跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+1^2,hash(key)+2^2……
双重散列:意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
链表法: 这种方法被应用在了Java的集合类HashMap当中。HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
读操作就是通过给定的Key,在散列表中查找对应的Value。调用hashMap.get(“002936”),意思是查找Key为002936的Entry在散列表中所对应的值。
第一步: 通过哈希函数,把Key转化成数组下标2。
第二步: 找到数组下标2所对应的元素,如果这个元素的Key是002936,那么就找到了;如果这个Key不是002936也没关系,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与Key相匹配的节点。
当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。这时,散列表就需要扩展它的长度,也就是进行扩容。
对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个。
1:Capacity,即HashMap的散列表容量
2:LoadFactor,即HashMap的负载因子,默认值为0.75f
衡量HashMap需要进行扩容的条件如下。
HashMap.Size >= Capacity×LoadFactor
散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
扩容不是简单地把散列表的长度扩大,需要分为下面两个步骤。
第一步: 扩容,创建一个新的Entry空数组,长度是原数组的2倍。
第二步: 重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。经过扩容,原本拥挤的散列表重新变得稀疏,原有的Entry也重新得到了尽可能均匀的分配。
例如,在未扩容的散列表中Value=21的位置为0,散列表扩容之后Value=21的位置会通过公式:key%散列表的大小
变成位置7。
对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果对空间消耗非常敏感,可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了。
package 散列表;
import java.util.Map.Entry;
/**
* 散列表实现
* @author 15447
*
*/
public class HashTable<K, V> {
/**
* 散列表默认长度
*/
private static final int DEFAULT_INITAL_CAPACITY = 8;
/**
* 负载因子
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 实际元素数量
*/
private int size = 0;
/**
* 散列表索引数量
*/
private int use = 0;
/**
* 初始化散列表
*/
private Entry<K, V>[] table;
/**
* 无参构造初始化HashTable
*/
public HashTable() {
table = (Entry<K, V>[])new Entry[DEFAULT_INITAL_CAPACITY];
}
/**
* 键值对类
*/
static class Entry<K, V> {
K key;
V value;
Entry<K, V> next;
public Entry(K key, V value, Entry<K, V> next) {
this.key = key;
this.value = value;
this.next = next;
}
}
/**
* 新增
*
* @param key
* @param value
*/
public void put(K key, V value) {
int index = hash(key);
// 位置未被引用,创建哨兵节点
if (table[index] == null) {
table[index] = new Entry<>(null, null, null);
}
Entry<K, V> tmp = table[index];
// 新增节点
if (tmp.next == null) {
tmp.next = new Entry<>(key, value, null);
size++;
use++;
// 动态扩容
if (use >= table.length * LOAD_FACTOR) {
resize();
}
}
// 解决散列冲突,使用链表法
else {
do {
tmp = tmp.next;
// key相同,覆盖旧的数据
if (tmp.key == key) {
tmp.value = value;
return;
}
} while (tmp.next != null);
Entry<K, V> temp = table[index].next;
table[index].next = new Entry<>(key, value, temp);
size++;
}
}
/**
* 散列函数
*
* 参考hashmap散列函数
*
* @param key
* @return
*/
private int hash(Object key) {
int h;
return (key == null) ? 0 : ((h = key.hashCode()) ^ (h >>> 16)) % table.length;
}
/**
* 扩容
*/
private void resize() {
Entry<K, V>[] oldTable = table;
table = (Entry<K, V>[]) new Entry[table.length * 2];
use = 0;
for (int i = 0; i < oldTable.length; i++) {
if (oldTable[i] == null || oldTable[i].next == null) {
continue;
}
Entry<K, V> e = oldTable[i];
while (e.next != null) {
e = e.next;
int index = hash(e.key);
if (table[index] == null) {
use++;
// 创建哨兵节点
table[index] = new Entry<>(null, null, null);
}
table[index].next = new Entry<>(e.key, e.value, table[index].next);
}
}
}
/**
* 删除
*
* @param key
*/
public void remove(K key) {
int index = hash(key);
Entry e = table[index];
if (e == null || e.next == null) {
return;
}
Entry pre;
Entry<K, V> headNode = table[index];
do {
pre = e;
e = e.next;
if (key == e.key) {
pre.next = e.next;
size--;
if (headNode.next == null) use--;
return;
}
} while (e.next != null);
}
/**
* 获取
*
* @param key
* @return
*/
public V get(K key) {
int index = hash(key);
Entry<K, V> e = table[index];
if (e == null || e.next == null) {
return null;
}
while (e.next != null) {
e = e.next;
if (key == e.key) {
return e.value;
}
}
return null;
}
}
一个缓存(cache)系统主要包含下面这些操作
1:往缓存中添加一个数据
2:从缓存中删除一个数据
3:在缓存中查找一个数据
这三个操作都要涉及“查找”操作,如果单纯地采用链表的话,时间复杂度只能是 O(n)。如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到 O(1)。具体的结构就是下面这个样子:
查找一个数据: 散列表中查找数据的时间复杂度接近 O(1)
删除一个数据: 借助散列表,我们可以在 O(1) 时间复杂度里找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。
添加一个数据: 添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。