哈希表
什么是哈希表
Hash表也称散列表,也有直接译作哈希表,Hash表是一种特殊的数据结构,它同数组、链表以及二叉排序树等相比较有很明显的区别,它能够快速定位到想要查找的记录,而不是与表中存在的记录的关键字进行比较来进行查找。这个源于Hash表设计的特殊性,它采用了函数映射的思想将记录的存储位置与记录的关键字关联起来,从而能够很快速地进行查找。
要想了解哈希表我们先从LeetCode的一道题目开始。
387. 字符串中的第一个唯一字符给定一个字符串,找到它的第一个不重复的字符,并返回它的索引。如果不存在,则返回 -1。
案例:
s = "leetcode"
返回 0.s = "loveleetcode",
返回 2.
注意事项:您可以假定该字符串只包含小写字母。
public class Solution {
public static int firstUniqChar(String s) {
//声明26个字母的长度数组
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++) {
//判断从第一位开始哪一位是1则返回当前字符,1表明只出现了一次
if(freq[s.charAt(i) - 'a'] == 1){
return i;
}
}
return -1;
}
}
在这道题中,int[26] freq 就是一个哈希表!
每一个字符都和索引进行对应
a ===> 0
b ===> 1
c ===> 2
.....
z ===> 25
并且我们的查找是O(1)
级别!
哈希函数的设计
"键"通过哈希函数得到的"索引"分布越均匀越好
原则:
- 一致性:如果a==b 则 hash(a) == hash(b)
- 高效性:计算高效方便
- 均匀性:哈希值均匀分布
Hash函数设计的好坏直接影响到对Hash表的操作效率。下面举例说明:
假如对上述的联系人信息进行存储时,采用的Hash函数为:姓名的每个字的拼音开头大写字母的ASCII码之和。
address(张三)=ASCII(Z)+ASCII(S)=90+83=173;
address(李四)=ASCII(L)+ASCII(S)=76+83=159;
address(王五)=ASCII(W)+ASCII(W)=87+87=174;
address(张帅)=ASCII(Z)+ASCII(S)=90+83=173;
假如只有这4个联系人信息需要进行存储,这个Hash函数设计的很糟糕。
首先,它浪费了大量的存储空间。因为假如采用char型数组存储联系人信息的话,每个人的信息需要12个字节来存储。
(手机号为11位,数值上为100多亿,2^64 =1.844674407371 * 10^19,2^32 = 4294967296
,所以需要64位也就是8个字节来存储手机号。
每个汉字占两个字节,两个汉字占四个字节。
所以总共需要8 + 4 = 12Byte)
这样的话,至少需要开辟174*12字节的空间。然而空间利用率只有4/174,不到3%。
另外,根据Hash函数计算结果之后,address(张三)和address(张帅)具有相同的地址,这种现象称作冲突,对于174个存储空间中只需要存储4条记录就发生了冲突,这样的Hash函数设计是很不合理的。所以在构造Hash函数时应尽量考虑关键字的分布特点来设计函数使得Hash地址随机均匀地分布在整个地址空间当中。
通常有以下几种构造Hash函数的方法:
1 直接定址法
取关键字或者关键字的某个线性函数为Hash地址,即address(key)=a*key+b;如知道学生的学号从2000开始,最大为4000,则可以将address(key)=key-2000作为Hash地址。
2 平方取中法
对关键字进行平方运算,然后取结果的中间几位作为Hash地址。假如有以下关键字序列{421,423,436},平方之后的结果为{177241,178929,190096},那么可以取中间的两位数{72,89,00}作为Hash地址。
3 折叠法
将关键字拆分成几部分,然后将这几部分组合在一起,以特定的方式进行转化形成Hash地址。假如知道图书的ISBN号为8903-241-23,可以将address(key)=89+03+24+12+3作为Hash地址。
4 除留取余法
如果知道Hash表的最大长度为m,可以取不大于m的最大质数p,然后对关键字进行取余运算,address(key)=key%p。
在这里p的选取非常关键,p选择的好的话,能够最大程度地减少冲突,p一般取不大于m的最大质数。
链地址法
我们首先建立一个数组,M = 素数
这个时候我们添加进去K1和K2
然后我们再添加进去一个K3,如果他和K2的哈希冲突了就会形成如下图这样。这样就会形成一个链表。
相应的我们可以不用链表来存储,可以使用树结构TreeMap也是可以的。
在Java8中当哈希冲突达到一定程度后,每一个位置就会转换为红黑树。
实现我们自己的哈希表
public class HashTable {
private TreeMap[] hashTable;
//素数
private int m;
private int size;
public HashTable(int m) {
this.m = m;
this.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 map = hashTable[hash(key)];
if(map.containsKey(key)){
map.put(key, value);
}else{
map.put(key, value);
size++;
}
}
public V remove(K key){
TreeMap map = hashTable[hash(key)];
V ret = null;
if(map.containsKey(key)){
ret = map.remove(key);
size --;
}
return ret;
}
public void set(K key,V value){
TreeMap map = hashTable[hash(key)];
if(map.containsKey(key)){
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);
}
}
哈希表的动态空间
public class HashTable {
private TreeMap[] hashTable;
//素数
private int m;
private int size;
//哈希冲突上界
private static final int upperTol = 10;
//哈希冲突下界
private static final int lowerTol = 2;
//初始容量
private static final int initCapacity = 7;
public HashTable(int m) {
this.m = m;
this.size = 0;
hashTable = new TreeMap[m];
for (int i = 0; i < m; i++) {
hashTable[i] = new TreeMap<>();
}
}
public HashTable(){
this(initCapacity);
}
private int hash(K key){
return key.hashCode() & 0x7fffffff % m;
}
public int getSize(){
return size;
}
public void add(K key,V value){
TreeMap 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){
TreeMap map = hashTable[hash(key)];
V ret = null;
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[] 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 map = hashTable[i];
for (K key : map.keySet()) {
newHashTable[hash(key)].put(key,map.get(key));
}
}
this.hashTable = newHashTable;
}
public void set(K key,V value){
TreeMap map = hashTable[hash(key)];
if(map.containsKey(key)){
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);
}
}
开放地址法
除了链地址法解决哈希冲突外,还有很多种可以解决哈希冲突的方法,这里再说一下开放地址发。意思就是每一个地址都对所有的元素是开放的。
假设当前数组是这个样子。
我们如果要添加一个31,发现它和11的哈希值发生了冲突,这个时候他会去找11下一个索引为空位的地方来存放。结果就是放在了2的位置。
如果又来了一个81也是冲突的呢,自然就是会放到3索引的位置上。
这就是开放地址法中的线性探测法
,遇到哈希冲突+1。但是这个方法性能并不高,因为如果有一片都冲突了,要不停的往下去寻找。
另一种就是平方探测法
,具体就是先+1去找如果+1冲突了直接就+4去找还冲突就是+9、+16。
还有再哈希法
等等,就是再用另一种哈希算法来算地址位置。