目录
哈希表
其它结构:顺序结构以及平衡树中,元素的值与其存储位置之间没有对应的关系因此在查找一个元素时,必须要经过多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log 2 n ),搜索的效率取决于搜索过程中 元素的比较次数。
哈希表:理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。本质就是数组 +哈希函数
哈希表一定存在一个数组,同时可以根据元素和hash函数得到数组中一个合法的下标。相比较搜索树来说,哈希表的效率更高,平均o(1),实现简单,但容易出现冲突。
哈希冲突
有k1 !=k2 ,但有:Hash(k1) == Hash(k2),即:不同关键字通过相同哈
希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
两个值不相等,但是两个经过hash函数之后的下标是相等的,就产生了hash冲突。
解决hash冲突
解决哈希冲突两种常见的方法是:闭散列和开散列(链地址法),其他的就是再哈希法和建立公共溢出区。
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把key存放到冲突位置中的“下一个” 空位置中去。
寻找空位置的方法
- 线性探测再散列
某一个元素的比较次数就是相当于在hash表中查找需要比较多少次
比如,对于这样一组数据
int [] arr = new int[11]{7,31,5,8,9,6,20,13}
hash 函数: 元素 % 11
那么对于下标和元素的存储位置有如下的关系
线性探测法常问的问题
- 对所有在哈希表中的元素进行线性探测,平均的比较次数是多少?
目前hash表中有的元素是7,31,5,8,9,6,20,13
那么如果我对31进行查找 hash后的下标是9,到hash表的9号索引去查找,一比较发现找到了,那么31的查找次数是1次
如果对20 进行查找 hash后下标是9 然后在hash表的9号下标去寻找,发现9号位置不是20,那么就继续往后探测,到10号所以发现还不是20 ,加u到0号索引发现是20,所以为了找到20,比较的次数是3次
分母是每一个元素的比较次数,分子是一共有多少个有效元素。
- 对所有不在hash 表中的元素进行查找,需要比较的次数是多少
本质上,问题是想问,对于一个元素比较多少次才能确定不存在。
比如我想对数字16进行下标查找,那么hash后的下标是5 ,然后到hash表的5号索引去找,发现不是16,那么就能确定16不在hash表中吗?当然不可以(可能16是一个线性冲突的元素),还需要继续进行线性探测。探测6号索引,7号索引…直到探测到1号索引的时候,发现为空了还没有找到,所以hash后下标为5的元素的查找次数是8次。
分母是每一个下标的比较次数,分子式下标总的个数
- 平方探测再散列
di=12,-12,22,-22,…,k2,-k2 ( k<=m/2 )
这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。
开散列
解决-开散列/哈希桶
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子
集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
拉链法(Java中的HashMap选用此种方案)
底层实现是数组+链表的方式
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
使用链表来解决冲突的元素。拉链法又叫链地址法,Java中的HashMap在存储数据的时候就是用的拉链法来实现的。有的时候也叫它哈希桶。
{11,7,8,9,2,3,21}数据元素,之前的线性探测法里面,数组保存的是一个个的元素,现在数组里面保存的是一个个的链表。
hash函数 元素% 5
上面举例的元素全都是int类型,如果不是int类型怎么办,比如一个Person对象。
所有的Object类型都有一个方法
int hashCode() ;返回值是int 作用是: 把一个对象变成int类型。
但是转换成的int类型有可能数据比较大,不是一个合法下标。
目前我们采用的方法是
int类型转换的合法下标 = hashValue % arr.length
再哈希法
这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
建立公共溢出区
这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
冲突是无法避免的,但一定不是一个好事情!我们的目标就是尽可能的减少冲突
如何减少冲突
通过降低负载因子而减少冲突
什么是负载因子:加载因/装载因子?
用于表示哈希表中元素填满的程度。冲突的机会越大,则查找的成本越高。
负载因子的计算方式: 元素个数/数组的长度
负载因子值的大小,对HashMap有什么影响?(面试题)
- 负载因子的大小决定了HashMap的数据密度。
- 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
- 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。
一般情况下对于冲突率有一个预先设定的值,那对应产生一个负载因子(元素个数)。
那么如果要想降低负载因子,实现的原理就是对数组扩容
HashMap中的加载因子
① new HashMap();初始容量是16,加载因子loadFactor=0.75,默认容量是16*0.75=12 也就是说,当容量达到了12的时候就会进行扩容操作。
②new HashMap(int initialCapacity);
给定自定义的loadFactor.
③new HashMap(int initialCapacity, fload loadFactor);
自定义初始容量,自定义加载因子。
HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
1. **
/****
2. ** Constructs a new, empty hashtable with a default initial capacity (11)*
3. ** and load factor (0.75).*
4. **/*
5. public Hashtable() {
6. this(11, 0.75f);
7. }
8.
9. **/****
10. ** Constructs an empty <tt>HashMap</tt> with the default initial capacity*
11. ** (16) and the default load factor (0.75).*
12. **/*
13. public HashMap() {
14. this.loadFactor = DEFAULT_LOAD_FACTOR; *// all other fields defaulted*
15. }
当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
冲突严重的时候的解决办法:
根据泊松分布的证明,每一个链表,也就是每一个hash桶的的长度不会查过8,但一旦超过8单纯用链表的搜索方式性能就不高了。
java中采用的方式是当和一个链表中元素个数超过8的时候,就单纯的把这条链表转换成红黑树。
良好的设计hash函数
让hashCode尽可能的均匀
数组大小尽量用素数(),但java中没有怎么用这个。
性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,
也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是
O(1) 。
哈希表的java实现
纯key模型:HashSet
key-value 模型:HashMap
- 在保存元素不是固定的时候,hash冲突可以避免吗?
不能避免,因为当存储的元素范围远远大于数组长度的时候是一定有出现的
hash表的实现,Node里面只有key就是HashSet,key-value都有就是HashSet
public class MyHashTable {
//1.需要一个数组
private Node[] arr = new Node[11];
private int size ;
//true :key之前不在hash表中
//flase:key之前已经有了
public boolean insert (Integer key) {
//1.把对象转换从int类型得到一个下标
//hashCode() 核心
int hashValue = key.hashCode();
//2.hashValue 可能不是一个合法下标
int index = hashValue % arr.length;
//3.遍历index对于的链表,确定key是否存在在元素中
Node cur = arr[index];
while (cur != null){
if(key.equals(cur.key)){ // equals核心
return false;
}
cur = cur.next;
}
//循环退出的时候,就是key不在链表中,所以执行插入的逻辑
//4. 把key装入结点中,并插入index所对应的链表里面(头插尾插都可以目前来说)
//这里使用头插 arr[index] 里面的元素类型是结点,所以arr[index] 里面的值是所有
//hash后为index 的链表的头结点
Node node = new Node(key);
node.next = arr[index];
arr[index] = node;
//5.维护元素个数
size++;
//6.通过维护负载因子进而维护较低的冲突率
if(size / arr.length*100 >= 75){
扩容();
}
return true;
}
//搬运的时间复杂度是o(n)
//搬原来的元素
//同时更改对象里面的成员属性的指向关系
//不能单纯的把原来的元素搬出来,因为下标是和数组长度有关系的
//数组长度变了,下标也会变
// 所以需要重写计算他的下标重新插入
private void 扩容() {
Node[] newArray = new Node[arr.length *2];
//外层遍历每一个数组
for(int i =0 ;i < arr.length;i++){
//每一个数组上面是一个链表,所以还需要遍历链表
Node cur = arr[i];
while (cur != null){
//高效的做法是搬运结点,但写起来复杂
//采用的是复制元素
Integer key = cur.key;
int hashValue = key.hashCode();
int index = hashValue % newArray.length;
Node node = new Node(key);
//然后采用头插入的办法/尾插
node.next = newArray[index];
newArray[index] = node;
cur = cur.next;
}
}
arr = newArray;
}
//如果有并且删除成功了,就返回true
//如果没有那么就返回false
public boolean remove(Integer key){
//1.将对象转换成int类型的下标
int hashValue = key.hashCode();
//2.下标合法化
int index = hashValue % arr.length;
//3.遍历
Node cur = arr[index];
Node parent = null;
while (cur != null ){
if(cur.key.equals(key)){//注意这里一定要判断parent
//删除
if(parent != null){
parent.next = cur.next;
}else { // 什么结点没有前驱,只有头节点没有说明链表的头节点是要删除的结点
arr[index] = cur.next;
}
size--;
return true;
}
parent = cur;
cur = cur.next;
}
return false;
}
public boolean contains(Integer key){
//1.将对象转换成int类型的下标
int hashValue = key.hashCode();
//2.下标合法化
int index = hashValue % arr.length;
Node cur = arr[index];
while (cur != null) {
if(cur.key.equals(key)){
return true;
}
cur = cur.next;
}
return false;
}
}
总结哈希表和java类的关系
java 类集的关系
- HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
- java 中使用的是哈希桶方式解决冲突的
- java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
- java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方
法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方
法,而且要做到 equals 相等的对象,hashCode 一定是一致的
关于重写hashcode 和 重写equals
hashCode、equals在自定义类的使用里面要非常的小心!
我们先来看一段代码
public class Person {
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
Person p1 =new Person("gb",18);
Person p2 =new Person("gb",18);
HashSet<Person> set = new HashSet<>();
set.add(p1);
System.out.println(set.contains(p1)); // true
System.out.println(set.contains(p2)); //false
首先new两个对象,他们的name 和age一样。
建立了一个hashSet 里面的key,也就是元素的类型是Person类型,现在往里面加入p1
对象,那么set里面自然包含p1,问是否包含p2?
结果是不包含!
当然我们很容易理解,这是两个完全不一样的对象,那如何做才能返回true呢?
第一个反应重写equals 方法,让两个对象比较的时候,不再是比较对象的引用,而是对象的值是否相等。
包装数据类型比如Integer 已经重写了equals 方法,所以调用的时候,默认就是比较值。
引用数据类型String 也重写了equals方法,但注意stringBuilder 没有
重写equals后的效果
System.out.println(set.contains(p1)); // true
System.out.println(set.contains(p2)); //false
发现还是false 为什么会这样呢?
还记得上面的代码我们有自己手动实现了一个MyHashTable吗,里面自己实现了contains方法。其实我们自己实现的本质,就是为了更好的理解HashMap到底在干什么。
这里需要提一下,java源码里面set的contains方法本质上是调用了map的containsKey方法。
我们现在把它拿出来看一下,这是前面自己实现出现过的代码,把重要的地方标出来,分析为什么重写equals而没有重写hashCode做不到返回true
其实底层的HashMap的contains源码逻辑和自己实现的逻辑基本类似,所以就看自己实现的,比较简洁。
hashCode()方法底层是运用对象的内存地址通过哈希函数来计算,可为其生成一个整形值(散列码)。
由于hashCode是object类里面的方法,所有的自定义类型默认继承object类,所以一个自定义类型Person 也有hashCode。 之前把p1对象存入hashCode的时候计算出了一个下标。 判断contains 首先要把通过p2.hashCode() 计算出一个下标 因为我们没有重写hashCode,所以计算的下标肯定不一样,那么我们去hash表里面查找,首先找的索引都不一样当然找不到了。
简言之:没有找到存储位置正确的下标
为什么重写HashCode而没有重写equals 方法也返回false
还是之前的逻辑,把p1对象存入hashCode的时候计算出了一个下标,这个对象就存在下标对应的链表里面。 判断contains 首先要把通过p2.hashCode() 计算出一个下标 我们重写hashCode,所以计算的下标一样,也就是说我们找到了正确的链表。但是这样并不能做到找到了,因为找到了正确下标对应的链表以后还要查找链表里面的每一个Person 对象,利用equals方法判断是否相等,我们没用重写equals,自然比较的是两个对象的引用,所以还是找不到。Object中默认的equals方法,内部还是使用==来比较对象在内存中的地址,
简言之:达到的效果就是找到了正确的下标对应的链表,但是没有复写equals方法,和链表里面的元素比较出现了问题。
因为contains 方法里面,出现了hashCode 和 equals方法,所以对于自定义类的使用一定要两个都重写,才能达到让set里面包含p2。
System.out.println(set.contains(p1)); // true
System.out.println(set.contains(p2)); //true
那么关于重写的理由已经说清楚了,为了加深印象我们再来看一看代码
使用HashMap
HashMap<String,Person> map1 = new HashMap<>();
map1.put("gb",p1);
System.out.println(map1.get("gb"));
现在我们用HashMap ,里面key的类型是String ,value的类型是Person (并且Person 没有重写hashCode 和 equals方法)
结果返回的结果是可以找到对应的value,为什么会这样?
本质的原因是map的get是只和key的类型有关既然String 类型本身已经复写了hashCode 和 equals,那么就算Person没有写,和结果有什么关系呢?
现在我们把key的类型换成Person类型,显然,很清楚,如果没有重写,那么map里面自然没有,会返回null,如果重写了两个方法,map就会通过对应的key找到value。
Person p1 =new Person("gb",18);
Person p2 =new Person("gb",18);
HashMap<Person,String> map2 = new HashMap<>();
map2.put(p1,"gb");
System.out.println(map2.get(p2)); // 没有复写的时候 是null
总结
- java中规定:两个对象的equals()相同,hashCode一定相同。hashCode相同,但equals不一定相同,所以在重写equals时,一定需要重写hashCode
- 自定义类中使用HashSet或者HashMap的key,需要注意
- 必须重写hashCode和equals方法
- 如果你认为两个对象相等,那么必须保证hashCode的值相等,并且equals返回true
- 如果p1.equals(p2)返回true,p1.hashCode == p2.hasnCode
- p1.hashCode == p2.hasnCode,则不一定要 p1.equals(p2)返回true。出现这样的情况是哈希值相等,但equals不相等的情况就是哈希冲突。
- 最后一个底层知识的扩充
来看一下Object.hashCode的通用约定(摘自《Effective Java》第45页)
在一个应用程序执行期间,如果一个对象的equals方法做比较所用到的信息没有被修改的话,那么,对该对象调用hashCode方法多次,它必须始终如一地返回
同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不同,即这个应用程序这次执行返回的整数与下一次执行返回的整数可以不一致。
如果两个对象根据equals(Object)方法是相等的,那么调用这两个对象中任一个对象的hashCode方法必须产生同样的整数结果。
如果两个对象根据equals(Object)方法是不相等的,那么调用这两个对象中任一个对象的hashCode方法,不要求必须产生不同的整数结果。然而,程序员应该意识到这样的事实,对于不相等的对象产生截然不同的整数结果,有可能提高散列表(hashtable)的性能。
如果只重写了equals方法而没有重写hashCode方法的话,则会违反约定的第二条:相等的对象必须具有相等的散列码(hashCode)。