概念
哈希表来源于数据的随机访问特性。在搜索的问题中,链表的查找只能从链表的头部遍历到结尾,时间复杂度为O(n)
,搜索树的查找(平衡搜索树)的时间复杂度为O(logn)
,于是人们开始不断探寻有没有查找比O(logn)
还快的结构。 在数组中,如果知道元素的索引,查找的时间复杂度就是O(1)
,那能不能利用数组的随机访问特性来查找元素?这个思想就是哈希表产生的背景。
不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它本身的元素值之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素,这种结构就称为哈希表,这种函数就称为哈希函数。
哈希函数
将任意的数据类型的值转变为正整数,这样转换后的数字就可以作为数组的下标。一般来说,哈希函数不需要自己设计,用现成的方案即可。
哈希函数的设计需要满足以下三个规则:
1.一致性:对于两个相同的数据x和y,通过哈希函数得到的两个哈希值也必须相等。
2.稳定性:对于相同的数字x,任何时候计算哈希值得到的结果均相同。
3.均匀性:不同的数据x和y,经过哈希函数计算之后的结果尽量分散。(评价哈希函数的好坏)
关于整型的哈希函数
取模运算:
原数组的内容都是整数,%n之后的结果在[0,n)范围之内,这样就可以把一组特别大的数据分散到一个可控的区间之内。
如上图所示,{10,20,30,40}这一组元素能在[0,8)这个区间都能表示,但是,当我们继续插入50这个元素时,发现数组中编号为2的位置已经被占用了,也就是说:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。 因此,在以取模运算作为哈希函数时,比较好的方式就是取一个素数作为因子,显著降低哈希冲突的几率。
关于字符串的哈希函数
业内有很多成熟的关于字符串的哈希函数,比如:MD3,MD4,MD5,SHA1,SHA256等等,以MD5为例:
特点:
1.定长:无论输入多长的字符串,经过MD5函数运算后得到的值是定长的(16位或者32位);
2.分散:原字符串只改动一点内容,得到的MD5值差距很大;
3.不可逆:从字符串得到MD5数值很容易,但是通过MD5值倒推原内容非常困难,基本不可能。
感兴趣的可以点击这里尝试一下。注意,在工程中,两个数据经过md5运算之后得到相同的值就可以认为两个值是相同的数据。 因为在工程中以md5作为哈希函数很难产生哈希冲突。
哈希冲突
前面已经提到了哈希冲突这个概念,那该如何解决这个问题呢?一般有两种方案。闭散列和开散列。
闭散列
闭散列也叫线性探测。当发生冲突时,找到冲突位置旁边的空闲位置放入冲突元素。
在闭散列方案中,假如我们要查找元素120在不在,先要对120取模算对应的索引,但发现此时索引为19的元素并不是120,此时向后遍历,一直等查到120位置,极端情况下遍历完之后都没查到,或者在最后一个位置才查到,这样就会将一个原本只有O(1)复杂度的查找问题变为O(n)。同样需要删除120这个元素时,先要查到这个元素。 因此闭散列方案好想,好放,但是难查,更难删。工程中很少采用这个方案。
开散列
开散列也叫链地址法,这是工程中普遍采用的方案。开散列的思路是当产生哈希冲突时,让冲突位置变为一个链表。以上图为例,要存储元素120时,由于19的位置已经有元素了,此时,开散列的方法是将120头插/尾插到19这个索引之后。这样元素120的查找和删除遍历链表即可。
试想一下,在开散列方法中,当元素个数不断变大,哈希冲突的概率也会越来越大,在某些数组中,某些链表的长度也会变得很长,这样查找效率又会从O(1)复杂度变为O(n)。此时通常有来两种解决方案:
1.针对整个数组扩容,扩容为原来的一倍,大概率原先冲突的元素再次哈希之后就不再冲突。(C++采用的方案)
2.将长度过长的链表转为BST/哈希表(数组+链表)。(JDK8+的方案)
负载因子
描述哈希表冲突的严重情况,一般来说,当哈希表的元素个数size>=哈希表的长度length * 负载因子factor就认为当前哈希表的冲突比较严重,需要进行处理。
举例:
若当前哈希表的长度为16,factor= 0.75,当哈希表保存的元素超过12时,就认为冲突严重;
若当前哈希表的长度为16,factor= 10,当哈希表保存的元素超过160时,就认为冲突严重。
结论:负载因子越大,冲突越严重,节省空间(保存的元素个数多);负载因子越小,冲突越轻微,浪费空间(保存元素少)。
JDK的HashMap默认负载因子就是0.75;阿里的实验室论证,在一般商用系统中,负载因子取10比较合适。
哈希表的实现
哈希表不考虑树化其实就是数组加链表的一个结构,数组的每个元素其实就是链表的头节点。
下面实现基于int开散列方案的哈希表。
扩容
//扩容
private void resize() {
//扩容为原来的一倍
this.M = data.length << 1;
Node[] newData = new Node[data.length<<1];
//搬移原数组的所有节点
for(int i =0;i< data.length;i++){
for(Node x = data[i];x!=null;){
Node next = x.next;
//将当前x搬移到新数组的对应位置
int newIndex = hash(x.key);
//头插到新数组的对应位置
x.next = newData[newIndex];
newData[newIndex] = x;
//继续搬移数组的下一个节点
x = next;
}
}
//更新data的指向
data = newData;
}
//哈希函数-取模
public int hash(int key){
return key % this.M;
}
添加元素
//在当前哈希表中添加一对新元素,返回添加前的值,若新元素,返回-1
public int put(int key, int value){
//1、首先计算出当前元素的下标
int index = hash(key);
//2、在当前子链表中判断key值是否存在,若存在,只需要更新value即可
for(Node x = data[index];x != null; x = x.next){
if(x.key == key){
//存在,更新value即可
int oldvalue = x.value;
x.value = value;
return oldvalue;
}
}
//3、若key值不存在,头插到当前的子链表中,返回-1
Node node = new Node(key,value);
node.next = data[index];
data[index] = node;
//4、判断当前哈希表的冲突情况,是否要扩容
if(size>=this.data.length * LOAD_FACTOR){
resize();
}
return -1;
}
判断是key&value是否存在
//判断当前哈希表中是否包含指定的key值
public boolean containsKey(int key){
int index = hash(key);
for(Node x =data[index]; x!=null;x = x.next){
if(x.key == key){
return true;
}
}
return false;
}
//判断当前哈希表中是否包含指定的value值
public boolean containsValue(int value){
//全表扫描
for(int i =0;i<data.length;i++){
//内层循环就是每个子链表的遍历
for(Node x =data[i];x!= null;x = x.next){
if(x.value == value){
return true;
}
}
}
return false;
}
删除元素
//在当前哈希表中删除指定的key值节点
public boolean removeKey(int key){
//1、先求索引,找到实在哪个子链表进行删除
int index = hash(key);
//先判空
if(data[index] == null){
return false;
}
//剩下就是链表的删除问题
if(data[index].key== key){
data[index] = data[index].next;
size--;
return true;
}
//此时头节点不是待删除的节点
Node prev = data[index];
while (prev.next !=null){
if(prev.next.key == key){
prev.next = prev.next.next;
size --;
return true;
}
}
//此时不存在指定的key值
return false;
}
继续加油努力!!!