文章目录
一、冲突——概念
不同关键字通过相同的哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或者哈希碰撞。
首先,我们要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,我们能做的是尽量降低冲突率。
二、冲突——避免——哈希函数设计
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
1.哈希函数设计原则
哈希函数的定义域必须包括需要存储的全部关键词,如果散列表允许有m个地址时,其值域必须在0~m-1之间
哈希函数计算出来的地址能均匀分布在整个空间中
哈希函数应该比较简单
2.常见哈希函数
1.直接定制法
取关键字的某个线性函数为散列地址:Hash(Key)=A*Key+B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2.除留余数法
设置列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key)=key%p(p<=m),将关键码转换成哈希地址。
还有平方取中法、折叠法、随机数法和数学分析法等几种不常用的方法,这里不再作过多阐述。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
二、冲突——解决——闭散列
1.概念
当发生哈希冲突时,从发生哈希冲突的位置开始按照某种方式找“下一个”空位置。
1.通过哈希函数计算哈希地址
2.插入元素----注意:可能会产生哈希冲突
找“下一个”空位置的方式:
1.线性探测
从发生哈希冲突的位置开始,逐个挨着依次往后查找,如果走到空间末尾再从头开始。
那么我们这里要插入36,求出哈希地址为6,但此位置已经被占据,我们就挨着往后进行查找,走到8号位置发现是空的,插入36即可。
那么这里有一个问题:我们怎么知道哪个位置有元素,哪个位置没有元素呢?
所以这里我们要给哈希表格中的每个空间进行状态标记:
有元素:EXIST 无元素:EMPTY
查找要插入的位置:
但是当删除表格内元素的时候,就会出现一些意外的情况:
当我们要删除6时,先要计算6的哈希地址,算出来为6号位置,直接将该位置状态修改成EM,但是如果把该位置修改了,就会造成我们后边的36找不到,因为我们要删除36求其哈希地址为6,但是6号位置已经为空了,就不会再往后找了,这样就会导致后期有些元素找不到。
所以在删除元素时,不能直接将其状态改为EM,得需要再给一个状态DELETE,表明该位置上的元素被删除了,所以把6删完,将其状态改为DELETE,说明该位置之前有元素,只是后来被删除了而已,我们就可以往后查找,就可以找到36。
但是我们会发现,上面写的程序最终会进入死循环,其实前面这种事有很大问题的,当表格快要存满时,哈希表的效率就会降低,发生冲突的概率也会升高
比如下图:当我们要在此基础上插入44时,我们需要从四号位置一直走,知道走到3号位置,效率很低了
因此哈希表格当中是不会将元素存满的,那么存多少个才算合适呢?
存的少了空间利用率不高,存的多了冲突的概率就大。
负载因子调节:
散列表中负载因子的定义为:α=填入表中的元素个数 / 散列表的长度
负载因子越小:存的元素越少,发生冲突的概率越低
负载因子的冲突率的关系粗略演示:
负载因子应严格限制在0.7~0.8以下,在线性探测中,我们一般情况下规定负载因子为0.75。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中数组的大小。
优点:
处理哈希冲突的方式简单—逐个挨着依次往后找
缺陷:
容易产生数据的堆积—一旦发生冲突,数据就容易连成一片。
解决方式: 二次探测
2.二次探测
线性探测的缺陷就是产生冲突的数据堆积在一块,这与其找下一个空位置有关,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:Hi=(H0+i2)%m,或者:Hi=(H0-i2)%m。 其中:i=1,2,3…,H0是通过散列函数Hash(x)计算出来的哈希地址,m是表的大小。
如果index越界,接着取模运算
优点:
解决了线性探测数据堆积的问题。
缺陷:
当表格中的元素不断增多,二次探测可能要找的次数更多
所以二次探测的负载因子可能会被放得很低,比如α=0.5。
三、冲突——解决——开散列
1.概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头节点存储在哈希表中。数组+链表
2.开散列的模拟实现
1.创造一个不带头的单链表: public static class ListNode{
private int key;
private int value;
ListNode next;
public ListNode(int key,int value){
this.key=key;
this.value=value;
}
}
2.创造一个数组用来存放链表的头结点并定义初始容量
ListNode[] table;
int size;
public HashBucket(int initCapacity){
initCapacity=initCapacity<=0?16:initCapacity;
table=new ListNode[initCapacity];
}
private int size(){
return size;
}
3.写哈希函数
private int hashFunc(int key){
return key%table.length;
}
4.V put(K key,V value) :插入元素 O(1)
根据哈希函数找到对应的桶号
遍历该链表,如果找到相等的就替换value并返回旧的value
没有相等的则将新的头插在链表中
public int put(int key,int value){
//求初始哈希地址
int hash=hashFunc(key);
//找到对应的链表
ListNode cur=table[hash];
while(cur!=null){
//相等则替换
if(key==cur.key){
int oldValue=cur.value;
cur.value=value;
return oldValue;
}
cur=cur.next;
}
//不相等则头插
ListNode newNode=new ListNode(key,value);
newNode.next=table[hash];
table[hash]=newNode;
size++;
return value;
}
5.V get(Object key) :获取key对应的value O(1)
根据哈希函数找到对应的桶号
遍历该桶号所对应的链表,如果key相等返回对应的value,如果没有找到则返回null
public Integer get(int key){
int hash=hashFunc(key);
ListNode cur=table[hash];
while(cur!=null){
if(cur.key==key){
return cur.value;
}
cur=cur.next;
}
return null;
}
6.V getOrDefault(Object key,V defaultValue):获取key对应的value,如果没有返回设置的默认值
根据get方法来写
如果找到则返回get方法的结果
未找到则返回默认值
public int getOrDefault(int key,int defaultValue){
Integer ret=get(key);
return ret!=null?ret:defaultValue;
}
7.V remove(Object key):移除对应元素
根据哈希函数计算key所在的桶号找到对应链表
如果链表的第一个就是要删除的,直接将下一个的地址放入对应桶中
如果是后面的,设置一个prev来标记前一个结点,prev.next=cur.next
cur.next==null
public int remove(int key){
int hash=hashFunc(key);
ListNode cur=table[hash];
int oldValue=0;
//第一个就是要删除的
if(key==cur.key){
oldValue=cur.value;
//直接将下一个的地址放在哈希桶中
table[hash]=cur.next;
cur.next==null;
}
//普遍情况
//设置prev来标记前一个
ListNode prev=cur;
cur=cur.next;
while(cur!=null){
if(cur.key==key){
oldValue=cur.value;
prev.next=cur.next;
cur.next=null;
}
prev=cur;
cur=cur.next;
}
size--;
return oldValue;
}
8.boolean containsKey(Object key):判断是否包含key对应元素 O(1)
得到key所对应的桶号
利用之前写过的get(key)方法
为空说明没找到返回false
不为空找到,返回true
public boolean containsKey(int key){
int hash=hashFunc(key);
return get(key)==null?false:true;
}
9.boolean containsValue(Object value) :判断是否包含value对应元素 O(N)
因为桶中所有元素是根据key联系起来的,与value无关
所以这里查找value只能采用全部遍历的方法
public boolean containsValue(int value){
//遍历桶中所有元素
for(int i=0;i<table.length;i++){
ListNode cur=table[i];
while(cur!=null){
if(cur.value==value){
return true;
}
cur=cur.next;
}
}
//遍历所有后没找到
return false;
}
10.主方法:
public static void main(String[] args) {
HashBucket hb=new HashBucket(10);
int[] array={1,7,6,4,5,9};
for(int i=0;i<array.length;i++){
hb.put(array[i],array[i]);
}
System.out.println(hb.size());
hb.put(7,8);
System.out.println(hb.size());
System.out.println(hb.get(7));
System.out.println(hb.getOrDefault(7,7));
System.out.println(hb.getOrDefault(10,10));
System.out.println(hb.containsKey(7));
System.out.println(hb.containsValue(7));
hb.remove(7);
System.out.println(hb.containsKey(7));
}
}
输出结果:
由于我们在查找key时,最多也就是遍历完一个链表,其中中每个链表又不会很长,所以时间复杂度为O(1)
而在查找value时需要对所有元素都遍历一遍,时间复杂度为O(N)
哈希桶什么情况下达到最优?
空间利用率尽可能高:每个桶都将其利用上
这种状态一旦出现,再继续插入元素,必然会发生冲突
因此我们可以设置当哈希桶中的元素个数和桶的个数相同时,进行扩容,例如下图:
一般扩容都是按照两倍的方式来的
代码实现:
private void checkCapacity(){
if(size>= table.length){
int newCapacity=table.length*2;
ListNode[] newTable=new ListNode[newCapacity];
//逐个桶进行搬移---逐条链表进行搬移
for(int i=0;i<table.length;i++){
ListNode cur=table[i];
while(cur!=null){
//将cur先从table[i]桶中移除---类似头删
table[i]=cur.next;
//将cur往newTable中插入
int bucketNo=cur.key%newTable.length;
newTable[bucketNo]=cur;
cur=table[i];
}
}
table=newTable;
}
}
同时在put方法中添加第0步:检查是否需要扩容。