【需求】
设计一个容器,支持add操作,但是不允许添加重复的元素,如果元素重复则覆盖。支持contains操作,快速判断元素是否已存在。
大家看到这个需求的时候,脑子里第一个想到的实现思路是什么?
最简单的做法就是创建一个数组来存储元素,每次add时遍历一下数组,判断是否已存在。这么做确实可以实现需求,但是效率太低了,每次操作的时间复杂度都是O(n)。看我们如何用「散列表」这种数据结构来优化它。
1. 散列表概述
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
散列表也叫哈希表,它的思想是:使用数组来存储元素,元素具体放哪个存储单元里呢?它通过哈希函数为元素计算一个关键码值「哈希码」,然后对数组长度进行取余,得到的结果就是存放的数组下标。访问元素时无需遍历整个数组,通过哈希码来加快查找的速度。
1. 什么是哈希码
在Java中,哈希码代表的是一个对象的特征。它由哈希函数计算而来,设计良好的哈希函数会让不同的对象根据自己不同的特征来生成不同的哈希码。就像人的身份证号一样,根据每个人的特征生成,通过身份证号就可以知道这个人来自哪个区域,出生日期,性别信息等等。
2. 什么是哈希函数?
哈希函数又称“散列函数”,通过某种算法,可以将任意长度的输入转换为固定长度的输出。如在Java中,哈希码用int整型表示,由于int整型是有上限的,而对象的个数可以理解为无限个,哈希函数可以将无限个对象实例转换成有限个的哈希码。Object.hashCode()默认就是通过对象所在的内存地址经过处理计算而得出的。
3. 什么是哈希冲突
哈希函数的目的是将任意长度的输入转换为固定长度的输出,这就意味着,不同的输入可能会转换成相同的输出,这就导致了「哈希冲突」,也叫「哈希碰撞」。
如果出现了哈希冲突该怎么办呢?毕竟下标已经固定,数组的每个槽位只能存放一个数据。最常见的做法就是转链表了,将元素封装成节点,数组中只存储头节点,将哈希冲突的元素以链表的形式关联起来。一旦出现哈希冲突,元素转链表后,此时查找的时间复杂度就变成O(n)了。
综上所述,哈希函数计算的哈希码非常关键,它直接决定了哈希表的工作效率。如果哈希函数设计的不好,哈希码的分散性太差,就会导致大量的哈希冲突,查找的效率严重下降。
编写哈希函数的要求:
- 哈希码必须为非负整数。
- 如果a==b,则hash(a)==hash(b)。
- 如果a!=b,则hash(a)!=hash(b)。
- 如果a!=b,允许hash(a)==hash(b),哈希冲突。
2. 散列表实现
综上所述,我们就采用数组+链表这种最常用的方式,用Java语言来实现一个简单的散列表。
【分析】
初始化一个空数组,add操作时,根据元素的哈希码计算下标index,如果对应槽位为空,则直接插入。否则判断元素是否已存在,存在则覆盖。如果发生哈希冲突,则转为单向链表,采用头插法将元素插入。
【实现】
public class HashTable<E> {
private int size;
private Object[] table;
public HashTable() {
table = new Object[16];
}
// 不考虑数组的扩容
public void add(E e) {
// 计算哈希码
int hash = hash(e);
// 计算索引
int index = hash % table.length;
E item = (E) table[index];
if (item == null) {
table[index] = e;
size++;
return;
}
if (item instanceof Node) {
// 已经是链表了
Node<E> node = (Node<E>) item;
do {
E data = node.data;
if (data == e || (data.hashCode() == e.hashCode() && data.equals(e))) {
// 重复了
node.data = e;
return;
}
node = node.next;
} while (node != null);
// 哈希冲突
node = new Node<E>(e, (Node<E>) item);
table[index] = node;
} else {
if (item == e || (item.hashCode() == e.hashCode() && item.equals(e))) {
// 重复了
table[index] = e;
return;
} else {
// 哈希冲突,头插法
Node<E> node = new Node<>(e, new Node<E>(item, null));
table[index] = node;
}
}
size++;
}
public boolean contains(E e) {
int index = hash(e) % table.length;
E item = (E) table[index];
if (item == null) {
return false;
}
if (item instanceof Node) {
Node<E> node = (Node<E>) item;
while (node != null) {
E data = node.data;
if (data == e || (data.hashCode() == e.hashCode() && data.equals(e))) {
return true;
}
node = node.next;
}
return false;
} else {
return item == e || (item.hashCode() == e.hashCode() && item.equals(e));
}
}
private int hash(E e) {
return e.hashCode();
}
private class Node<E> {
private E data;
private Node<E> next;
public Node(E data, Node<E> next) {
this.data = data;
this.next = next;
}
}
}
3. 哈希冲突的解决方式
前面说过,由于哈希码的有限性,元素的无限性,因此哈希冲突是必然存在的客观事实,程序必须要解决冲突。
解决哈希冲突常见的有四种方式。
3.1 开放定址法
线性探测
哈希表中已经插入8、9元素,此时再插入14,下标2已经被8给占用了,出现哈希冲突。
线性探测会环形寻找next节点,先找到下标3,被9占用了,依然冲突,再找到下标4,没有被占用,即没有发生冲突,则将14放入下标4的节点中。
二次探测
也称二元探测,如果默认的哈希函数计算出的哈希码发生了哈希冲突,则哈希函数升级为:
(hash(key) + d) % table.length;
d = 1^2, -1^2, 2^2, -2^2, 3^2......
随机探测
和二次探测类似,只是d会更换为一组伪随机数列。
(hash(key) + d) % table.length;
d = 一组伪随机数列
开放定址法的优点就是:只要哈希表还有位置,通过不断的探测,总能找到合适的位置。
缺点是探测的次数不可控,一旦探测次数骤增,会严重影响哈希表的读写性能。
ThreadLocalMap就是用的「线性探测」技术解决哈希冲突的,当线程的ThreadLocal实例数量较多时,ThreadLocal的读效率会下降,因此Netty、Dubbo才会编写自己的ThreadLocal实现。
3.2 再散列法
提供一组哈希函数,而不是一个。
如果第一个哈希函数计算的哈希码发生冲突了,就采用第二个哈希函数重新计算哈希码,直到不冲突为止。、
查询时也是一样,依次调用不同的哈希函数计算哈希码,直到Key相等。
这种方式会增加哈希计算的开销,影响读写的效率。
int hash = hash1(key)、hash2(key)、hash3(key)......
3.3 链地址法
将哈希码对应一个链表,插入元素时,如果哈希码冲突了,就将元素插入到链表,可选头插或尾插。
查询时,遍历哈希码对应的链表。
HashMap采用的就是这种方式。
这种方式的缺点是:一旦哈希冲突多了,哈希表会退化成链表,查询效率会从O(1)变为O(n)。JDK8的HashMap针对这种情况有做优化,冲突超过8个会将链表转换为红黑树,提高查询效率。
3.4 公共溢出区
在创建哈希表的同时,再额外创建一个公共溢出区,专门用来存放发生哈希冲突的元素。查找时,先从哈希表查,查不到再去公共溢出区查。
这种方式的缺点是:哈希冲突多了,公共溢出区会膨胀的非常厉害,查询的效率也有影响。
4. 总结
数组的特点是「访问简单,插入删除困难」,链表的特点是「访问困难,插入删除简单」。散列表在某种程度上融合了二者的优点,可以说散列表是升级后的数组。散列表通过给元素生成一个哈希码来加速访问效率,因为哈希冲突的存在,在发生冲突时散列表可以将元素节点转换成链表,将冲突的元素放到一根链上。
转换成链表是为了解决哈希冲突不得已而为之的办法,为了保证散列表的性能,开发者需要尽量避免哈希冲突,严格控制链表的长度,一旦退化成链表,数据访问的效率将从O(1)下降到O(n)。