介绍
哈希表是用空间换时间的一种数据结构,它拥有理论上均摊时间复杂度O(1)的查找操作。
具体方法是通过哈希函数把“键Key”转换成索引。
但这样也会带来问题,因为我们很难保证每个键通过哈希函数转换都可以得到不同的索引,这就是所谓的哈希冲突。
要想完成哈希表的数据结构,主要问题就在于解决哈希冲突。
设计一个好的Hash
函数,可以让每个元素落点均匀,减少碰撞的概率。
hashcode与equals
我们在设计hashcode
时,最好要重写hashcode
代码和equals
代码,例子如下:
/**
* @author Nino
*/
public class Student {
int score;
String name;
public Student(int score, String name) {
this.score = score;
this.name = name;
}
/**
* 默认的hashCode会根据每个对象的地址产生
* 因此即使对象中属性相同,hash值也不同
* 重写hashCode方法,可以使相同的属性的对象的hash值相等
* @return hash值
*/
@Override
public int hashCode() {
int B = 31;
int hash = 0;
hash = hash*B + score;
hash = hash * B + name.toLowerCase().hashCode();
return hash;
}
/**
* 覆写equals方法
* @param o 必须是Object类型输入
* @return
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null) {
return false;
}
if (getClass() != o.getClass()) {
return false;
}
Student another = (Student) o;
return this.score == another.score &&
this.name.toLowerCase().equals(another.name.toLowerCase());
}
}
一般情况下,将会有以下特性:
1、如果两个对象equals,Java运行时环境会认为他们的hashcode一定相等。
2、如果两个对象不equals,他们的hashcode有可能相等。
3、如果两个对象hashcode相等,他们不一定equals。
4、如果两个对象hashcode不相等,他们一定不equals。
哈希函数的设计
哈希函数设计有三个原则:
- 一致性:
a == b -> hash(a) == hash(b)
- 高效性:计算起来高效、便捷
- 均匀性:哈希值均匀分布,也就是越均匀越好
哈希函数设计方式可以是键值取hashCode值后去掉符号位再模一个素数M:
hash(key) = ((key.hashCode() & 0x7fffffff) % M)
素数表可以参考:https://planetmath.org/goodhashtableprimes
而Java自带的util包中的HashMap
用的哈希函数则是:key取hashCode值后高16位不变,后16位与高16位异或。要取下标则需要( n-1 )& hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
哈希冲突的处理
链地址法
下图是链地址法的直观表现,由图片可以明显地看出,HashMap
是一个TreeMap
的数组。
在Java 8之前,数组的每个单元后面跟随的是一个链表。
在Java 8后,当哈希冲突达到一定程度后(链表长度超过8),数组的每个单元将从链表转成红黑树。
如果共M个数组地址,放入哈希表的元素为N,平均来看:
如果放入哈希表的每个地址是链表:时间复杂度为O(N/M);
如果每个地址是树结构:时间复杂度为O(log(N/M))。
如果采用静态数组,由于M容量有限,一旦N -> ∞
,则时间复杂度也将趋向无穷;一旦N太小,则造成了空间浪费。因此在哈希表中需要创造一个自适应的M数组。
在这里,我们设置两个参数upperTol
上限容忍度,和lowerTol
下限容忍度。一旦满足这两个条件之一,则进行扩容或缩容的操作。
我们设定元素过多的情况如下:
N/M >= upperTol
元素过少的情况如下:
N/M < lowerTol
在Java的util包自带的HashMap
中,有设置负载因子来决定是否扩容,其取值为0.75
,如果容量为M的数组中存在0.75
倍的“桶”被使用,则扩容。具体原因在于:开发人员在工程实践中这个数字是对空间和时间相对都比较友好的一个值。
同时需要注意,自带的HashMap
中没有缩容操作。
开放地址法
不采用链表数组或树数组的方式,而是采用普通的数组。
当遇到哈希冲突时,地址往下+1,线性探测空节点。
也可以+1、+4、+9…,平方探测空节点。
对比平衡树
为什么有如此高效的数据结构,还需要平衡树?
原因在于hash表虽然如此高效,但是牺牲了顺序性,针对需要顺序性的场景来说,还是需要使用树结构。
比如集合、映射中,有序集合、映射即可采用平衡树来设计,而无序的集合、映射则可用哈希表来设计。
自己的Hash代码实现
import java.util.TreeMap;
/**
* @author Nino
*/
public class HashTable<K extends Comparable<K>, V> {
/**
* N / M >= UPPER_TOL 则扩容
*/
private static final int UPPER_TOL = 10;
/**
* N / M < LOWER_TOL 则缩容
*/
private static final int LOWER_TOL = 2;
private static final int initCapacity = 7;
private TreeMap<K, V>[] hashtable;
/**
* 选择一个用来余的素数 M
* 同时也是hashtable数组的大小(桶bucket的数量)
*/
private int M;
private int size;
public HashTable(int M) {
this.M = M;
size = 0;
hashtable = new TreeMap[M];
for (int i = 0; i < M; i++) {
hashtable[i] = new TreeMap<>();
}
}
public HashTable() {
this(initCapacity);
}
/**
* 计算哈希值
* @param key
* @return
*/
private int hash(K key) {
//去符号并余M
return (key.hashCode() & 0x7fffffff) % M;
}
public int getSize() {
return size;
}
public void add(K key, V value) {
TreeMap<K, V> map = hashtable[hash(key)];
if (map.containsKey(key)) {
map.put(key, value);
} else {
map.put(key, value);
size++;
}
if (size >= UPPER_TOL * M) {
resize(2 * M);
}
}
public V remove(K key) {
TreeMap<K, V> map = hashtable[hash(key)];
V ret = null;
if (map.containsKey(key)) {
ret = map.remove(key);
size--;
}
if (size < LOWER_TOL * 2 && M / 2 >= initCapacity) {
resize(M / 2);
}
return ret;
}
public void set(K key, V value) {
TreeMap<K, V> map = hashtable[hash(key)];
if (map.containsKey(key)) {
map.put(key, value);
} else {
throw new IllegalArgumentException(key + "doesn't exist");
}
}
public boolean contains(K key) {
return hashtable[hash(key)].containsKey(key);
}
public V get(K key) {
return hashtable[hash(key)].get(key);
}
/**
* 改变hashtable的结构大小
* @param newM
*/
private void resize(int newM) {
//构建新的hashtable数组
TreeMap<K, V>[] newHashTable = new TreeMap[newM];
for (int i = 0; i < newM; i++) {
newHashTable[i] = new TreeMap<>();
}
int oldM = M;
//需要注意,这里一定要改变掉原有的M值,因为哈希值的获取依赖"余M"的操作
this.M = newM;
//将旧的hashtable数组的值传到新的hashtable中
for (int i = 0; i < oldM; i++) {
TreeMap<K, V> map = hashtable[i];
for (K key : map.keySet()) {
newHashTable[hash(key)].put(key, map.get(key));
}
}
//将新的hashtable传回去
this.hashtable = newHashTable;
}
}