第十四章 哈希表
14-1 哈希表基础
14-2 哈希函数的设计
14-3 Java 中的 hashCode 方法
14-4 链地址法 Separate Chaining
14-5 实现属于我们自己的哈希表
14-6 哈希表的动态空间处理与复杂度分析
14-7 哈希表更复杂的动态空间处理方法
14-8 更多哈希冲突的处理方法
14-1 哈希表基础
在本节开始前,我们先看一个Leetcode题目:
Leetcode-387 字符串中的第一个唯一字符
代码实现如下:
class Solution {
public int firstUniqChar(String s) {
int[] freq = new int[26];
for(int i = 0 ; i < s.length() ; i ++)
freq[s.charAt(i) - 'a'] ++;
for(int i = 0 ; i < s.length() ; i ++)
if(freq[s.charAt(i) - 'a'] == 1)
return i;
return -1;
}
}
- 什么是哈希表?
上述代码中的 int [26] freq 就是一个哈希表。每一个字符都和一个索引相对应(a - - > 0; b - - > 1; c - - > 2; … z - - > 25),之后直接用这个索引在数组中去寻找相应的对应的信息,也就是所谓的映射的内容。
对于每一个字符来说,我们必须将它转化为一个索引;更一般的,我们可以在哈希表中存储各种类型的数据,对于每种数据类型,我们都需要一种方法来将它转化为一个索引,那么相应的我们关心的这个数据类型,转化为索引的这个函数,称作哈希函数。更严谨的来说,本问题中的哈希函数可以写为:f(ch) = ch - ‘a’,其中 f 是函数,f(ch)表示的是对于给定的一个字符,我们通过这个函数 f 就把它转化成一个索引,转化的方法就是 ch - ‘a’ . 我们将关心的数据类型转化成索引之后,接下来就可以在哈希表上进行操作。
每一个“键盘“ 转换为 ”索引”:
哈希表充分体现了算法赊借领域的经典思想:空间换时间 身份证号码:110108198512166666 如果我们有 999999999999999999 的空间,我们可以用O(1)时间完成各项操作 如果我们有 1
的空间,我们只能用O(n)时间完成各项操作(线性表)哈希表是时间和空间之间的平衡。
哈希函数的设计是很重要的。
“键”通过哈希函数得到的“索引”分布越均匀越好。
14-2 哈希函数的设计
“键”通过哈希函数得到的“索引”分布越均匀越好
对于一些特殊领域,有特殊领域的哈希函数设计方式,甚至有专门的论文
这个课程主要关注一般的哈希函数的设计原则
(1)整型:
-
小范围的正整数直接使用
小范围负整数进行偏移( -100 ~ 100 - - - > 0 ~ 200 ) -
大整数(身份证号 110108198512166666 - - - > 110108198512166666)
通常做法:取模。
比如取后四位,等同于mod 10000
取后六位?等同于 mod 1000000 - - -> 110108198512166666
(然而这里16是出生年月日的日期,它只可能在0~31之间取值,不可能取到99,因此会出现分布不均匀的情况,并且没有利用所有信息:只利用了后六位,前面的都扔了,也会出现哈希冲突问题)
- 一个简单的解决办法:模一个素数(背后的数学理论超出课程范畴)
素数的选择
(2)浮点型(转成整型处理):
在计算机中都是32位或者64位的二进制表示,只不过计算机解析成了浮点数。我们可以将浮点数所在的32位的空间或者64位的空间,使用整数的方式来解析,同样可以表示为整数。
(3)字符串(转成整型处理):
对于一个整数可表示为每一位数字的十进制表示:166 = 1 * 10 2 + 6 * 101 + 6 * 100
对于字符串也可以表示为26进制的表示:code = c * 263 + o * 262 + d * 261 + e * 260
同样地,这里我们也可以将它表示为 B 进制:code = c * B3 + o * B2 + d * B1 + e * B0
- 字符串的哈希函数设计:
hash(code) = (c * B3 + o * B2 + d * B1 + e * B0) % M
通用技巧简化上述公式:
hash(code) = (((( c * B ) + o ) * B + d) * B + e) % M
保证不会整型溢出(比M小):
hash(code) = (((( c % M ) * B + o ) % M * B + d ) % M * B + e ) % M
int hash = 0
for (int i = 0; i < s.length(); i ++ )
hash = (hash * B + s.charAt(i)) % M
(4)复合类型(转成整型处理):
和字符串一致
hash(code) = (((( c % M ) * B + o ) % M * B + d ) % M * B + e ) % M
Date: year, month, day
hash(date) = ((( date.year % M) * B + date.month)% M * B + date.day) % M
上述四种情况我们都将其转化成整型处理,但这并不是唯一的方法!
- 哈函数设计的原则:
1.一致性:如果a = = b, 则 hash(a) = = hash(b)
2.高效性:计算高效简便
3.均匀性:哈希值均匀分布
14-3 Java 中的 hashCode 方法
mport java.util.HashSet;
import java.util.HashMap;
public class Main {
public static void main(String[] args) {
int a = 42;
System.out.println(((Integer)a).hashCode());
int b = -42;
System.out.println(((Integer)b).hashCode());
double c = 3.1415926;
System.out.println(((Double)c).hashCode());
String d = "imooc";
System.out.println(d.hashCode());
System.out.println(Integer.MAX_VALUE + 1);
System.out.println();
Student student = new Student(3, 2, "Bobo", "Liu");
System.out.println(student.hashCode());
HashSet<Student> set = new HashSet<>();
set.add(student);
HashMap<Student, Integer> scores = new HashMap<>();
scores.put(student, 100);
Student student2 = new Student(3, 2, "Bobo", "Liu");
System.out.println(student2.hashCode());
}
}
public class Student {
int grade;
int cls;
String firstName;
String lastName;
Student(int grade, int cls, String firstName, String lastName){
this.grade = grade;
this.cls = cls;
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public int hashCode(){
int B = 31;
int hash = 0;
hash = hash * B + ((Integer)grade).hashCode();
hash = hash * B + ((Integer)cls).hashCode();
hash = hash * B + firstName.toLowerCase().hashCode();
hash = hash * B + lastName.toLowerCase().hashCode();
return hash;
}
@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.grade == another.grade &&
this.cls == another.cls &&
this.firstName.toLowerCase().equals(another.firstName.toLowerCase()) &&
this.lastName.toLowerCase().equals(another.lastName.toLowerCase());
}
}
注视掉类中的hashCode方法后,程序依然能够运行,因为Java中默认每一个obkect类就有一个hashCode的实现(根据我们创建的每一个object地址相应地将其转化为一个整型)
Main函数中new了两个对象student、student2,地址不同,所以对应的hashCode也不同(尽管对象的内容相同)
14-4 链地址法 Separate Chaining
哈希冲突的处理:链地址法
哈希表的本质是一个数组
计算机对整型的表示,最高位是符号位;最高位是1表示负数,最高位是0表示整数;整型数有32位,与31个1相与(0x7fffffff)最高位与0相与,将最高位置零表示为正数。
14-5 实现属于我们自己的哈希表
哈希表的实现代码
import java.util.TreeMap;
public class HashTable<K, V> {
private TreeMap<K, V>[] hashtable;
private int size;
private int M;
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(97);
}
private int hash(K key){
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 ++;
}
}
public V remove(K key){
V ret = null;
TreeMap<K, V> map = hashtable[hash(key)];
if(map.containsKey(key)){
ret = map.remove(key);
size --;
}
return ret;
}
public void set(K key, V value){
TreeMap<K, V> map = hashtable[hash(key)];
if(!map.containsKey(key))
throw new IllegalArgumentException(key + " doesn't exist!");
map.put(key, value);
}
public boolean contains(K key){
return hashtable[hash(key)].containsKey(key);
}
public V get(K key){
return hashtable[hash(key)].get(key);
}
}
14-6 哈希表的动态空间处理与复杂度分析
- 和静态数组一样,固定地址空间是不合理的,需要resize
平均每个地址承载的元素多过一定程度,即扩容:N / M >= upperTol
平均每个地址承载的元素少过一定程度,即缩容:N / M < lowerTol
private static final int upperTol = 10;
private static final int lowerTol = 2;
private static final int initCapacity = 7;
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 >= upperTol * M)
resize(2 * M);
}
}
public V remove(K key){
V ret = null;
TreeMap<K, V> map = hashtable[hash(key)];
if(map.containsKey(key)){
ret = map.remove(key);
size --;
if(size < lowerTol * M && M / 2 >= initCapacity)
resize(M / 2);
}
return ret;
}
private void resize(int newM){
TreeMap<K, V>[] newHashTable = new TreeMap[newM];
for(int i = 0 ; i < newM ; i ++)
newHashTable[i] = new TreeMap<>();
int oldM = M;
this.M = newM;
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));
}
this.hashtable = newHashTable;
}
BST: 0.330964512 s
AVL: 0.320891094 s
RBTree: 0.305721141 s
HashTable: 0.183768075 s
14-7 哈希表更复杂的动态空间处理方法
回忆动态数组的均摊复杂度分析
-
对于哈希表来说,元素个数从 N 增加到 upperTol * N;地址空间增倍。
平均复杂度 O(1)
每个操作在O(lowerTol) ~ O(upperTol) - - - > O(1)
缩容同理。 -
更复杂的动态空间处理方法:
扩容 M -> 2 * M
扩容 2 * M 不是素数
解决方案(选择合适的素数) -
哈希表:均摊复杂度为O(1) -> 牺牲了顺序性
哈希表的bug:
14-8 更多哈希冲突的处理方法
(1)开放地址法:每一个地址不再是一个查找表了,位置被占了就继续向下招空位置(每一个地址都对所有的元素都是开放的)。这个方法又叫线性探测,遇到哈希冲突+1,这个方法的哈希冲突会比较多,可能会出现一整片空间全都被占据的情况,性能较低,时间消耗较多;因此有一个改进方法:平方探测;遇到哈希冲突的时候不去简单的一直加 1,如果加 1 的位置被占了,就加9,加9的位置被占了就加16 etc:+1 +4 +9 + 16 + …; 二次哈希:遇到哈希冲突时,使用另外一个哈希函数 + hash2(key) 来计算出我下一个位置要去哪儿。
开放地址法的空间是有可能被占满的,因此它也需要扩容。这个指标叫做负载率:整个哈希表中存储元素的总数占整个地址数量的百分比。大于负载率的某个值就要进行扩容/缩容。
(2)再哈希法 Rehashing:当我们使用一个哈希函数产生的索引,发生哈希冲突后,就用另外一个哈希函数去找相应的索引就好了。
(3)Coalesced Hashing:综合了Seperate Chaining(链地址法)和 Open Addressing(开放地址法)
- 找到一个通俗易懂的哈希表解释:
原文链接:https://blog.csdn.net/tanggao1314/article/details/51457585
哈希表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即key,即可查找到其对应的值。
哈希的思路很简单,如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值。这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。
使用哈希查找有两个步骤:
-
使用哈希函数将被查找的键转换为数组的索引。在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突
-
处理哈希碰撞冲突。有很多处理哈希碰撞冲突的方法,本文后面会介绍拉链法和线性探测法。
哈希表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1);如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。
在Hash表中,记录在表中的位置和其关键字之间存在着一种确定的关系。这样我们就能预先知道所查关键字在表中的位置,从而直接通过下标找到记录。使ASL趋近与0.
- 哈希(Hash)函数是一个映象,即: 将关键字的集合映射到某个地址集合上,它的设置很灵活,只要这个地 址集合的大小不超出允许范围即可;
- 由于哈希函数是一个压缩映象,因此,在一般情况下,很容易产生“冲突”现象,即: key1!=key2,而 f (key1) = f(key2)。
3). 只能尽量减少冲突而不能完全避免冲突,这是因为通常关键字集合比较大,其元素包括所有可能的关键字, 而地址集合的元素仅为哈希表中的地址值
在构造这种特殊的“查找表” 时,除了需要选择一个“好”(尽可能少产生冲突)的哈希函数之外;还需要找到一 种“处理冲突” 的方法。