哈希表
一、什么是哈希表?
一般来说,搜索的效率取决于搜索过程中元素的比较次数。例如顺序结构中,查找一个元素时需要挨个比对元素,因此时间复杂度为 O ( N ) O(N) O(N)。对于一颗平衡的搜索树,查找的时间复杂度为树的高度,即 O ( l o g 2 ( N ) ) O(log_2(N)) O(log2(N)).
而上述都并不是理想的搜索方法,理想的搜索方法是,可以不经过任何比较,一次直接从表中得到要搜索的元素。 这时就引出了哈希表
的数据结构。哈希表就是通过(散列函数)哈希函数,使元素的存储位置与它的关键码 key 之间能够建立一一映射的关系,那样我们在查找关键字时,不需要比较就可获得需要的记录的存储位置。时间复杂度可达到
O
(
1
)
O(1)
O(1).
采用散列技术
将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表
(HashTable).
二、两种散列函数构造方法
1、直接定址法
如果我们现在要对 0~100 岁的人口数字统计表,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时 f ( k e y ) = k e y f ( key) =key f(key)=key.
如果我们现在要统计的是 80 后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去 1980 来作为地址。此时 f ( k e y ) = k e y − 1980 f ( key) = key-1980 f(key)=key−1980.
也就是说,我们可以取关键字的某个线性函数值为散列地址,即 f ( k e y ) = a × k e y + b f ( key ) =a \times key+b f(key)=a×key+b (a 为常数).
这样的散列函数优点就是简单,均匀,也不会产生冲突
,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用
2、除留余数法(常用)
此方法为最常用的构造散列函数方法,对于散列表长为 m 的散列函数公式为: f ( k e y ) = k e y / p f ( key) = key / p f(key)=key/p ,(p <= m).
很显然,本方法的关键就在于选择合适的 p 如果选得不好,就可能会容易产生冲突
。根据前辈们的经验,若散列表表长为 m
通常 p
为小于或等于表长(最好接近 m )的最小质数。
三、散列地址冲突
在上文中,提到了冲突
这个概念,那么到底什么是冲突
呢?
在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是个理想。我们时常会碰到两个关键字 k e y 1 ≠ k e y 2 key_1 \neq key_2 key1=key2,但是却有 f ( k e y 1 ) = f ( k e y 2 ) f(key_1) = f(key_2) f(key1)=f(key2),这种现象我们称为冲突 (oollision) ,并把 k e y 1 key_1 key1、 k e y 2 key_2 key2 称为这个散列函数的同义词 (synonym)。出现了冲突当然非常糟糕,那将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。
四、常用冲突处理
1、负载因子调节(减少冲突概率)
散列表的负载因子定义为:loadFactor = 填入表中的元素个数 /散列表的长度
loadFactor
是散列表装满程度的标志因子。由于表长是定值,loadFactor
与“填入表中的元素个数
”成正比,所以,loadFactor
越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,Q越小,标明填入表中的元素越少,产生冲突的可能性就越小。
常见的负载因子阈值通常为 0.7 或 0.8,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75。当负载因子超过一定阈值时,可能会导致哈希冲突的增加,因此我们会对负载因子进行调节,由于表长是一定的,所以一般采用扩容的方式增加哈希表的大小,从而降低负载因子。扩容后,原有的元素需要重新计算哈希值和位置,并存储到新的哈希表中。
2、开放定址法(闭散列)
开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。根据寻找空散列的方式,可以分为以下两种:
(1)线性探测
线性探测就是从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
公式: f i ( k e y ) = ( f ( k e y ) + d i ) / m , ( d = 1 , 2 , . . . . , m − 1 ) f_i ( key ) = ( f ( key ) +d_i ) / m,(d =1,2,....,m-1) fi(key)=(f(key)+di)/m,(d=1,2,....,m−1)
(2)二次探测
从上面例子中可以看到,我们在解决冲突的时候,还会碰到如 25
这种本来都不是同义词却要争夺一个地址的情况 ,称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。为了解决上述种问题,提出了二次探测法,双向寻找到可能的空位置。
公式: f i ( k e y ) = ( f ( k e y ) + d i ) / m , ( d i = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , q 2 , − q 2 , q < = m / 2 ) f_i ( key ) = ( f (key) +d_i) / m ,(d_i=1^2,-1^2,2^2,-2^2,...,q^2,-q^2,q<=m/2) fi(key)=(f(key)+di)/m,(di=12,−12,22,−22,...,q2,−q2,q<=m/2)
例如使用二次探测,再向上述HashTable中插入 34
:
3、链地址法(开散列)
首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。此时,已经不存在什么冲突换址的问题了,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
4、冲突严重时的解决办法
上面提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味
着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
- 每个桶的背后是另一个哈希表
- 每个桶的背后是一棵搜索树
五、Java 中 HashMap 的实现
1、定义map结点
HashMap底层使用哈希表,下面就使用哈希表简单实现一下,HashMap 中的 插入
和 查询
方法。首先是定义 map 结点:
public class MyHashMap<K,V> {
// 定义 map 结点:key-value 模型
static class Node<K,V> {
public K key;
public V value;
public Node<K,V> next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
}
// 创建哈希表,初始容量为 10
public Node<K,V>[] hashTable = (Node<K, V>[]) new Node[10];
// 哈希表中有效元素个数
public int usedSize;
// 负载因子,此处取0.75(负载因子 = 填入表中元素个数/散列表长度)
public static final double LOAD_FACTOR = 0.75;
}
2、put 插入方法的实现
再进行插入操作时,我们需要根据结点 key 值计算散列位置,由于 key 不一定为数值类型,我们不能直接使用它散列函数的计算。此时我们需要用到 hashCode() 方法:
hashCode()
方法可以获取对象的哈希码,它是一个整数,用来表示对象的状态,且哈希码具有唯一性。
所以我们可以通过 hashCode() 方法求得 key 的哈希码,然后根据得到的哈希码带入散列函数去计算散列位置。这里需要注意一点是:
使用
hashCode()
获取到的哈希码是一个有符号的整形,这也就意味着得到的哈希码有可能是负数,而哈希表的底层是一个数组,它的下标值不可能为负数,因此需要将得到的哈希码按位与上Integer.MAX_VALUE
也就是0x7FFFFFFF
,使得到的值是一个正数。
// 1.put(K key,V value):插入操作
public void put(K key,V value) {
// 计算 key 值的 hash 值
int hash = key.hashCode() & Integer.MAX_VALUE;
// 取留余数法计算散列位置
int index = hash % hashTable.length;
// 拿到散列位置的哈希桶
Node<K,V> cur = hashTable[index];
// 判断是否已经存在key值(哈希表中不允许存在相同key值)
while (cur != null) {
if (cur.key.equals(key)) {
// 如果存在 key 相同的元素,更新 value 值
cur.value = value;
return;
}
cur = cur.next;
}
// 采用头插法
Node<K,V> newNode = new Node<>(key, value);
newNode.next = hashTable[index];
hashTable[index] = newNode;
usedSize++;
// 为减少冲突率,我们要判断负载因子是否超过预设值
if (calculate_LoadFactor() >= LOAD_FACTOR) {
// 如果大于等于负载因子,需要扩容
resize();
}
}
// (1) calculate_LoadFactor():计算此时负载因子
private double calculate_LoadFactor() {
return usedSize * 1.0 / hashTable.length;
}
// (2) resize():扩容并重新计算哈希值和位置,并存储到新的哈希表中。
private void resize() {
// 扩容
Node<K,V>[] newhashTable = (Node<K, V>[]) new Node[2* hashTable.length];
// 因为改变了散列表长度,需要重新将每一个结点散列到新的位置
for (int i = 0; i < hashTable.length; i++) {
Node<K,V> cur = hashTable[i];
while (cur != null) {
// 记录curNext的位置,防止后续调整丢失
Node<K,V> curNext = cur.next;
// 重新计算哈希值
int hash = cur.key.hashCode() & Integer.MAX_VALUE;
// 根据散列函数计算散列位置
int index = hash % newhashTable.length;
// 头插法
cur.next = newhashTable[index];
newhashTable[index] = cur;
cur = curNext;
}
}
// 更新哈希表
hashTable = newhashTable;
}
3、get 查询方法的实现
// 2.get(K key):查询
public V get(K key) {
// 计算哈希值
int hash = key.hashCode() & Integer.MAX_VALUE;
// 根据散列函数计算散列位置
int index = hash % hashTable.length;
// 遍历哈希桶,寻找元素
Node<K,V> cur = hashTable[index];
while (cur != null) {
if (cur.key.equals(key)) {
// 找到返回 值value
return cur.value;
}
cur = cur.next;
}
// 找不到返回 null
return null;
}