目录
1、什么是Hash表
2、理解关键码与其存储位置之间的映射关系
3、冲突概念
4、冲突是必然的
5、哈希函数的设计(冲突的避免)
6、常见的哈希函数
1、直接定制法(常用)
2、除留余数法(常用)
3、 平方取中法(了解)
4、折叠法 (了解)
5、随机数法 (了解)
6、数学分析法 (了解)
7、冲突的避免——负载因子调节(重点)
8、解决冲突(闭散列)
1、线性探测
2、二次探测
9、解决冲突(开散列/哈希桶)【重点掌握】
10、冲突严重时的解决办法
11、实现数组+链表的结构 (哈希桶)【简单版本】
11.1、创建hashBuck类
11.2、给哈希表中插入数据(头插法实现)
11.3、根据key值查找,返回value值
11.4、问题:怎样将引用类型的数据放入哈希桶中
12、实现数组+链表的结构 (哈希桶)【泛型类型】
12.1、创建HashBuck类
12.2、给哈希表中插入数据(头插法实现)
12.3、通过查找key,返回value
12.4、测试
12.5、思考问题?
13、性能分析
14、和Java类集的关系
15、练习
1、有10个数据,并且数据有重复的,去重
2、有10个数据,并且数据有重复的,找到第一个重复的数据
3、统计10个数据当中,每个数据出现的次数
想要了解Hash表,首先我们要了解一下Hash函数。
在顺序结构以及平衡树中,元素关键码(数据)与其存储位置之间没有对应的关系,顺序表,需要遍历一遍循序表,它查找的时间复杂度为O(N);平衡树则需要从根节点开始进行查找,从结点取出数据或者索引与查找的值进行比较。它的时间复杂度为树的高度,即O(logN),搜索的效率取决于搜索过程中元素比较的次数。
理想的搜索方法:可以不经过任何比较,依次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找是通过该函数可以很快找到该元素。
向该结构中插入或者搜索元素:
- 插入元素:
根据待插入元素的关键码,以次函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素:
对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在构造中按此位置取元素比较,若关键码相等,则搜索成功
该方法称为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列函数),构造出来的结构称为哈希表(HashTable)(或者称为散列表)。
将哈希函数设置为:hash(key) = key%capacity;capacity为存储元素底层空间总的大小。
这样存储的数据就会与它存储的位置之间存在映射关系。当要查找某个数据的时候,我们就可以通过这个哈希函数,找出我们要查找的数据(key)所映射的位置,然后将所映射位置的值,与key比较,相等则搜索成功。
这里以数据集合{1,7,6,4,5,9}为例
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。
❓❓❓但是他还存在问题,当我们向集合中插入元素15的时候,会出现数组下标5的位置,会有两个值,一个5和15,那这样关键码(key)和其所存储的位置就不是一一映射的关系了。
那么这里我们就要了解一下冲突这个概念了!!!
不同关键码通过相同的哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或者哈希碰撞。
- 哈希表的冲突是必然的,哈希表底层数组的容量往往是小于实际要存入的数据的(为了提高效率),这就导致冲突必然会发生。
- 比如:数组长度为10,我们要存入12个数据,数组长度不够,我们需要扩容,但是我们无法预估要存入的数据,每个数据映射的存储位置之间都是不冲突,当我们存入的数据只有两个的时候(5和15),数组长度为10,他们之间还是会冲突。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1 之间(举例:我们给定了10个数据,那么其值域必须在0到9之间)。
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
当然我们在这里说哈希函数的设计,并不是说我们要设计哈希函数,我们在平时使用的时候,库当中的哈希函数已经够用。我们只是在这里理解一下冲突产生的各种原因。
取关键字的某个线性函数为散列地址:Hash(key) = A*key + B
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况
- 使用场景:适合查找比较小且连续的情况。由于这样的限制,在现实应用中,并不常用
举例:如果我们现在要对 0~100 岁的人口数字统计表,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f( key) =key。
地址 | 年龄 | 人数 |
00 | 0 | 300万 |
01 | 1 | 600万 |
02 | 2 | 450万 |
…… | …… | …… |
20 | 20 | 1500万 |
…… | …… | …… |
- 设散列表中允许的地址数(数组长度)为m,函数:Hash(key) = key%p(p<=m),按照哈希函数,将关键码转换成为哈希地址。
- 很显然,如何选取p是个关键问题。
举例:
当我们存储3, 6, 9这三个数时,p就不能取3
因为3%3 = 6%3 = 9%3
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对 它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和, 并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数 函数。
通常应用于关键字长度不等时采用此法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某 些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据 散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
例如: 假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以 选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如 1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方 法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均 匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
散列表的载荷因子定义为: 填入表中的元素个数 / 散列表的长度
Java的系统库当中限制了载荷因子为0.75.
由上图可以看出当负载因子越大,冲突率就越高,为了降低冲突率,只有将负载因子控制在一定的范围内。要将负载因子控制在一定的范围内,填入表中的数据我们不能控制,这个由用户决定,我们能做的只有给数组扩容,来降低负载因子的值。达到控制冲突率的效果。
闭散列:也叫开放地址法,当发生哈希冲突时,如果哈希表未被填满,那么可以把key存放到冲突位置中的"下一个"空位置中去。如何找下一个位置?
比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该 位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 下一个空位置,插入新元素。
注意:
虽然这种方式在一定程度上可以降低冲突,但是这种方法在极端的情况下,会使冲突的数据集中在一起。以上图为例,将4删除之后,我们就找不到14,34,44,14等数字,因为这些数字本应该在4下标位置,在通过哈希函数查找这些数字时,在4下标位置没有这些数字,4下标元素为空,表示在哈希表当中没有这些数字,实际上是有的。
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨 着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: = ( + )% m, 或者: = ( - )% m。
i:表示冲突次数
m:是表的大小
二次探测也存在问题:它的空间利用率不高,假设负载因子为0.75,那么以上图为例,当放到第8个元素的时候,数组就会发生扩容。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
开散列是数组,链表+红黑树的组织
- 桶的理解:我们这里可以将数组的每个下标位置的空间,认为是桶。因为每个下标位置都有可能发生冲突,当冲突发生的时候,会以链表的形式,将数据存在冲突位置的空间。
以上图为例:
- 链表的体现:数据在发生冲突的时候,将数据通过单链表链接起来。这里就是链表的体现
- 红黑树的体现:当一个桶当中链表的长度大于等于8并且数组的长度大于64的时候,桶当中的链表就会以红黑树的结构存储。
❗❗❗总结:开散列,可以认为是把一个大集合中的搜索问题转化为在小集合中搜索的问题。
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味 着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1. 每个桶的背后是另一个哈希表
2. 每个桶的背后是一棵搜索树
这里创建key-value模型
//Key - value模型
public class HashBuck {
//结点
static class Node{
public int key;
public int val;//key的个数
public Node next;
public Node(int key,int val) {
this.key = key;
this.val = val;
}
}
public Node[] array;//定义一个node类型的数组
public int usedSize;//计算放入的元素
public static final double loadFactor = 0.75;//定义哈希表的负载因子
public HashBuck() {
array = new Node[8];//给数组初始化
}
❗❗❗扩容要注意的是,数组在扩容之后,原本数组当中的数据,根据哈希函数计算的时候,会变化位置,以数组长度为10,插入4,14,24,34为例,当数组扩容之后长度变为20,那么14就不会在4下标的链表当中,在新的数组当中会在14的位置。所以在数组扩容之后,要重新进行哈希,计算数据新的位置。
//向哈希表中插入数据(头插法)
public void put(int key,int val){
int index = key % array.length;//计算出当前数据的在数组当中的位置
Node cur = array[index];//当index位置是一个链表的时候,定义一个cur结点遍历链表
//当cur为空的时候,不进入循环
while(cur != null){
if(cur.key == key){//在链表当中找到于插入数据相同的数据时
cur.val = val;//由于是key-value模型,所以更新value的值为插入结点的value值
return ;
}
//如果没有找到,则继续向下找,知道将链表遍历完
cur = cur.next;
}
//当链表当中没有与插入数据相同的数据,则建立一个结点,它的值为要插入的数据,value同样
Node node = new Node(key,val);
node.next = array[index];//先将链表的头节点链接在新结点的地址域
array[index] = node;//然后将node结点放在数组index位置。
usedSize++;//数组当中数据个数+1
if(calculateLoadFactor() >= loadFactor){
//扩容
resize();
}
}
//计算负载因子
private double calculateLoadFactor(){
return usedSize*1.0 /array.length;//乘以1.0的原因是,usedSize是整数类型的,计算出来之后是整数,这里我们需要小数。
}
private void resize(){
Node[] newArray = new Node[2*array.length];
for (int i = 0; i < array.length; i++) {
Node cur = array[1];
while(cur != null){
Node curNext = cur.next;//创建一个curNode类型的变量,用来记录cur遍历到的结点的下一个位置
int index = cur.key % newArray.length;//计算出cur遍历到的结点的在新数组中的位置
cur.next = newArray[index];//采用头插法,将新数组的index位置的结点链接在新节点的地址域
newArray[index] = cur;//再将结点链接在新的数组的index位置。
cur = curNext;//将cur遍历到的结点放到新的数组当中之后,cur回到原来的数组的链表当中,遍历下一个结点
}
}
array = newArray;//重新计算哈希之后,将数据全部插入新的数组之后,再将newArray
}
//通过查找key,返回value
public int get(int key){
int index = key % array.length;//计算key在哈希表当中的位置
Node cur = array[index];//定义cur遍历index位置的链表
while(cur != null){//将链表遍历完,不进入
if(cur.key == key){//当cur遍历到的结点的值等于key
return cur.val;//返回cur结点的value值
}
//若不相同,则继续向后遍历,直到将链表遍历完成
cur = cur.next;
}
//将链表遍历完成还是没有找到,则返回-1
return -1;
}
11.3、测试
可以通过hashCode方法将引用类型的数据,转换为哈希码,用key记录哈希码,然后就可以放在刚刚我们写的哈希桶当中。
❓❓❓ 这里有一个新的问题
当我们创建两个student对象,他们的id一样,在我们的印象中两个人身份证号一样,我们就认为他们同一个人,生成的哈希码也是一样的。
但是在编译器中,他们是两个对象。
这里我们可以重写在student类当中重写hashCode()方法和equals()方法
我们可以通过hashCode方法将引用类型的对象放入哈希桶当中,还可以通过写一个泛型类型的哈希桶。
public class HashBuck2 {
static class Node{
public K key;
public V value;
public Node next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
}
public Node[] array = (Node[])new Node[10];
public int usedSize;
public static final double LOAD_FACTOR = 0.75;
//给哈希表中插入数据(头插法)
public void put(K key,V value){
int hash = key.hashCode();//将引用类型的数据使用hashCode方法转换为哈希码
int index = hash % array.length;//使用哈希码计算数据的下标
Node cur = array[index];//定义一个Node类型的变量,遍历index位置的链表
while(cur != null){
if(cur.key.equals(key)){
cur.value = value;
return;
}
cur = cur.next;
}
Node node = new Node<>(key,value);
node.next = array[index];
array[index] = node;
usedSize++;
if(calculateLoadFactor() >= LOAD_FACTOR){//负载因子大于规定的负载因子,进行扩容
//扩容
resize();
}
}
//计算负载因子
private double calculateLoadFactor(){
return usedSize*1.0 /array.length;//乘以1.0的原因是,usedSize是整数类型的,计算出来之后是整数,这里我们需要小数。
}
//扩容
private void resize(){
Node[] newArray = (Node[])new Node[2*array.length];
for (int i = 0; i < array.length; i++) {
Node cur = array[1];
while(cur != null){
Node curNext = cur.next;//创建一个curNode类型的变量,用来记录cur遍历到的结点的下一个位置
int hash = cur.key.hashCode();//重新计算每个元素的哈希码
int index = hash % newArray.length;//计算出cur遍历到的结点的在新数组中的位置
cur.next = newArray[index];//采用头插法,将新数组的index位置的结点链接在新节点的地址域
newArray[index] = cur;//再将结点链接在新的数组的index位置。
cur = curNext;//将cur遍历到的结点放到新的数组当中之后,cur回到原来的数组的链表当中,遍历下一个结点
}
}
array = newArray;//重新计算哈希之后,将数据全部插入新的数组之后,再将newArray
}
public V get(K key){
int hash = key.hashCode();//将引用类型的对象转化为哈希码
int index = hash % array.length;//计算key在哈希表当中的位置
Node cur = array[index];//定义cur遍历index位置的链表
while(cur != null){//将链表遍历完,不进入
if(cur.key.equals(key)){//当cur遍历到的结点的值等于key
return cur.value;//返回cur结点的value值
}
//若不相同,则继续向后遍历,直到将链表遍历完成
cur = cur.next;
}
//将链表遍历完成还是没有找到,则返回-1
return null;
}
这里我们以student这个引用类型的对象为例,当这两个对象的id相同的时候,我们认为他们是同一个对象,所以Student类当中重写了equals()方法和hashCode()方法。这样两个对象id相同的情况下,在put和get方法中,会生成相同的哈希码。
当我们将student1用put方法放入哈希表当中,用get方法查看哈希表当中有无student2数据的时候,会返回zhangsan。这时候编译器认为两个对象是同一个。
- 当hashCode一样的时候,equals一定一样吗?
- 当equals一样,hashCode一定一样吗?
- 第一个,equals不一定一样。当在调用get方法查看哈希表当中是否有引用类型的key 时,当hashCode一样时,不过是生成的哈希码相同,两个数据在哈希表的同一个下标位置,但是在链表当中是否有相同的key不一定。
- 第二个,hashCode一定相同。当在调用get方法查看哈希表当中是否有引用类型的key 时。当equals都相同的情况下,他们在同一个链表当中,所以他们肯定在哈希表的同一个下标位置,所以他们的哈希码一定相同。
虽然哈希表一直在和冲突作斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1).
- HashMap和HashSet即Java中利用哈希表实现的Map和Set
- Java中使用的是哈希桶的方式解决冲突的
- Java会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
- Java中计算哈希值实际上是调用的类的hashCode方法,进行key的相等性比较是调用key的equals方法。所以如果要用自定义类作为HashMap的key或者HashSet的值,必须重写hashCode和equals方法,而且要做到equals相等的对象,hashCode 一定是一致的。
//set具有去重的效果
public static void main10(String[] args) {
int[] array = {1,2,3,3,2,1,5,9,6,7};
Set set = new HashSet<>();
for (int x:array) {
set.add(x);
}
System.out.println(set);
}
public static void main(String[] args) {
int[] array = {1,2,3,3,2,1,5,9,6,7};
Set set = new HashSet<>();
for (int x:array) {
//contains方法判断元素x是否在集合中
if(!set.contains(x)){//x没在集合中
set.add(x);//添加这个元素
}else{//x在集合中
System.out.println(x);//将x输出
return;//返回
}
}
}
代码思路:
Map当中的元素使用K-V模型
首先遍历数组,将遍历到的值放在Map中,Map中有就加1,没有就是1
遍历Map就可以的
public static void main(String[] args) {
int[] array = {1,2,3,3,2,1,5,9,6,7};
Map map = new HashMap();
for (int i : array) {
if (!map.containsKey(i)){//map中没有这个数据
map.put(i,1);
}else{//map中已经存在这个数据
int val = map.get(i);//得到当前数据的val
map.put(i,++val);//给当前数据的val+1
}
}
for (Map.Entry entry : map.entrySet()){
if(entry.getValue()>1){
System.out.println("key: "+entry.getKey()+" val: "+entry.getValue());
}
}
}