目录
哈希表又称散列表,是一个天然的查找和搜索实际上是通过数组衍生出来的,它高效查找的奥秘就在于数组的随机访问特性。哈希表中的数组元素也称为哈希桶
例:假设现有一个数组 arr = [ 9,5,2,7,3,6,8],需要判断 3 这个元素是否存在。
这时我们创建一个 boolean 数组,这个数组的长度取决于原数组中最大值是谁。
arr中最大值是9,则创建一个长度为10的boolean数组,遍历原数组,出现一个元素,就在该元素值对应于新数组的位置记为true
这时要查询原数组是否有3,就直接返回新数组索引为3的值,若为true就代表有,若为false就为无,这样的时间复杂度就是O(1)
可以看出哈希表是一个典型的以空间换时间的数据结构。
哈希函数
所谓的哈希函数就是将任意的数据类型转为整型,变成整型之后就可以作为数组的索引了
假设现在的数据集是这样[101,3000,0,10,-2,10000]
这种集合元素跨度较大,有的元素的值本身也比较大,若采用一一对应的方式的话就得开辟长度至少100001的长度,显然非常浪费空间。因此大部分场景下,我们需要将原数组的元素和数据的索引|建立一个映射关系,这就是哈希函数。最常用的方法就是取模
- 可以看到,通过取模运算,将一个很大范围的数据集映射到一个小区间(区间的大小取决我们取模数的大小
- 有可能出现多个不同的key经过hash之后得到了相同的值,这就是哈希冲突(又叫做哈希碰撞,在数学上理论一定存在)
哈希函数的设计原则:
1.不同的key值经过hash函数运算后得到的结果分布越均匀(假设现在新数组的长度为n,得到的结果平均分布在新数组的各个位置)越好,一般来说模素数会得到一个比较均衡的值
最常用的哈希函数的设计就是取模。
2.稳定性:相同的key值经过N次哈希运算得到的值一定是相同的
hashCode( )
哈希函数一般不需要我们自己设计, 咱直接用现成的就可以,JDK中提供了。
public native int hashCode();
任意一个数据类型都可以通过hashCode方法转为int
public static void main(String[] args) {
String a = "张三";
Integer b = 123;
Double c = 1.2;
System.out.println(a.hashCode());
System.out.println(b.hashCode());
System.out.println(c.hashCode());
}
//输出:774889
123
213909504
【注意】hashCode相同的对象,equals不一定相同;反过来,equals相同的对象,hashCode一定是相同的
MD5
常见的哈希算法有MD5、MD3、MD3、SHA1、SHA256等等,以md5为例,md5一般给字符串进行hash运算,
md5的三大特点:
- 定长,无论输入的数据有多长,得到的MD5值是长度固定的。
- 分散,如果输入的数据稍微有点变化,得到的md5值相差非常大(一般若两个字符串的MD5值相同,我们就认为这两个字符串是相同的,工程上可以忽略MD5的哈希冲突)
- 不可逆,根据任意值计算出的MD5值很容易,但是MD5值还原为原数据(难如登天),基本不可能
MD5的应用
- 作为hash值
- 作为加密
- 对比文件内容:一般大文件都会有一个md5值,大文件在传输过程中有可能由于网络问题有的片段丢失了,要想知道下载后的文件内容是正确的,我们就拿着下载后的文件计算md5值,看下和源文件的MD5值是否相同
解决哈希冲突
上面我们已经知道了,不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一 个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率。把具有不同关键码而具有相同哈希地址的数据元素称为”同义词"。
常见的两种解决哈希冲突的方法:
闭散列-开放定址法
当发生冲突时,哈希表(也就是数组)没有被装满,就把冲突的key存到下一个空位置中。那如何寻找下一个空位置呢?有两种方法:
- 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 二次探测/再探测:再对冲突的key取一个不同的模
我们以线性探测为例来学习闭散列:
索引为19的位置已经有元素了,那么120就放在索引为20的地方,随后的121就顺延到索引21的位置。
如何查找元素呢?
1.以查询121为例,先对121取模找到她应该存放的位置20;
2.发现索引为20的位置的元素并不是121,继续向后查询(从20这个位置开始向后遍历)直到找到121为止,21这个位置就找到了121,存在。
3.如果一直走到数组末尾都没有找到,就说明我们找的元素不存在。
可以发现,闭散列好放、难取、更难删。若整个哈希表冲突比较严重,此时查找元素过程就相当于遍历数组,查找效率退化为O(N)
开散列
当发生冲突时,就让冲突位置变为链表,这种方式既简单又实用
可以看到,所谓的开散列方案实际上就是把单纯的数组转变为数组+链表的方式当发生哈希碰撞时,就将对应冲突位置的元素转换为链表,之后的查询和删除操作都是针对这单个链表来进行处理的。
我们来练习一个题比较一下两种方法:
已知一个线性表(38, 25, 74, 63, 52, 48) ,假定采用散列函数h (key) = key%7计算散列地址,并散列存储在散列表A[ 0...6]中。
若采用线性探测方法解决冲突,则在该散列表上进行等概率成功查找的平均查找长度为?
使用开散列的平均查找长度又是多少呢?
使用闭散列:
使用开散列:
可以看到开散列更加简单和方便,但是,若在开散列的方案下,某个下标对应的冲突非常严重,导致单个链表的长度过长,该如何解决呢?
- 针对整个哈希表进行扩容,假设以前%7(数组长度为7),现在扩容为原数组的一倍,现在% 14(数组长度变为14), 就可大大降低冲突的概率,减少链表的长度,这种思路是C++采用的思路
- 单个链表的长度过长,查询效率就会变为链表的遍历O(N),就针对这单个链表进行转换处理,可以把单个链表再转为哈希表或者变为搜索树(JDK8的HashMap就采用此方案,当某个
链表的长度> 6,且整个哈希表的元素个数> 64,把此链表转为RBTree) 这是Java使用的方法
避免冲突--负载因子
α = 填入表中的元素个数 / 散列表的长度
负载因子越大,发生哈希冲突的概率越大,数组的长度就会偏小,节省空间。
负载因子越小,发生哈希冲突的概率越小,数组的长度就会偏大,浪费空间。
负载因子到底取多大,要根据当前你的系统需求做实验来得知。JDK HashMap的负载因子为0.75。就是说当元素个数/负载因子>=数组长度,此时我们就认为冲突比较严重了,需要进行扩容
假设此时HashMap的哈希表的长度为16,负载因子为0.75,数组元素达到多少时需要扩容?
个数 / 0.75 >= 16 ,也就是个数 > 12时就会发生扩容
负载因子不是固定的,是可以动态调整的,阿里内部,对于负载因子的建议取值为10,允许每个链表的平均长度为10以内,可以接受。负载因子就是在空间和时间上求平衡。
哈希表的实现
package article2;
import java.util.NoSuchElementException;
//基于开散列方案下的哈希表实现
public class MyHashMap {
private class Node{
//对Key值进行哈希运算
int key;
//存储value
int value;
Node next;
public Node(int key, int value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
//实际存储元素的个数
private int size;
//默认哈希表的长度
private static final int DEFAULT_CAPACITY = 16;
//默认负载因子
private static final double LOAD_FACTOR = 0.75;
//取模数,用于取得key的索引
private int M;
//实际存储数据的数组
private Node[] data;
public MyHashMap() {
this(DEFAULT_CAPACITY);
}
public MyHashMap(int Capacity){
this.data = new Node[Capacity];
//对数组长度取模
this.M = Capacity;
}
//哈希函数
public int hash(int key){
return Math.abs(key) % M;
}
//在当前的哈希表中添加一个键值对 key = value
public int add(int key, int value){
//1.对key取模,得到存储的索引
int index = hash(key);
//2.遍历索引对应的链表,查看当前的key是否已经存在了
for (Node x = data[index]; x != null;x = x.next) {
if(x.key == key){
//此时key已经存在,更新value值
int oldVal = x.value;
x.value = value;
return oldVal;
}
}
//3.此时key对应的元素在当前哈希表中不存在,新建节点头插在哈希表中
//原先的链表头
Node head = data[index];
Node node = new Node(key,value,head);
data[index] = node;
//4.添加一个新元素后,查看是否需要扩容
if((size / LOAD_FACTOR) >= data.length){
resize();
}
size ++;
return value;
}
//扩容操作
private void resize() {
//新数组长度是原数组的一倍
Node[] newData = new Node[data.length << 1];
//新的取模数M变为新数组的长度
this.M = newData.length;
//遍历原数组,进行节点的搬移
for (int i = 0; i < data.length; i++) {
if(data[i] != null){
//进行链表遍历
for(Node x = data[i];x!=null;){
//暂存下一个节点
Node next= x.next;
int newIndex = hash(x.key);
//新数组的头插
x.next = newData[newIndex];
newData[newIndex] = x;
//继续进行下一个节点的搬移操作
x = next;
}
}else {
//当前位置没有节点,不用搬移
continue;
}
}
data = newData;
}
//查询key值
public boolean containKey(int key){
int index = hash(key);
//遍历index位置对应的链表,查看是否有节点的key值和查找的key相等
for(Node x = data[index];x != null ;x = x.next){
if(x.key == key){
return true;
}
}
return false;
}
//查询value值
public boolean containValue(int value){
//遍历整个哈希表
for (int i = 0; i < size; i++) {
for(Node x = data[i];x != null;x = x.next){
if(x.value == value){
return true;
}
}
}
return false;
}
//删除key对应的节点
public int remove(int key){
int index = hash(key);
//判断头节点是否是待删除的节点
Node head = data[index];
if(head.key == key){
int val = head.value;
data[index] = head.next;
head.next = head = null;
size --;
return val;
}
//要删除的不是头节点
Node prev = head;
while (prev.next != null){
if(prev.next.key == key){
//prev恰好是待删除节点的前驱
Node cur = prev.next;
int val = cur.value;
prev.next = cur.next;
cur.next = cur = null;
size--;
return val;
}
}
throw new NoSuchElementException("没有值为key的节点哦");
}
}
public class HashTest {
public static void main(String[] args) {
MyHashMap hashMap = new MyHashMap(4);
hashMap.add(1,10);
hashMap.add(2,20);
hashMap.add(5,55);
System.out.println(hashMap.containKey(1));
System.out.println(hashMap.remove(5));
}
}
JDK中Set和Map的源码分析
1.Set和Map集合有什么关系吗?(考验我们查看源码的能力)
其实Set集合下的子类就是Map集合下的子类,也就是说,HashSet 就是 HashSet,TreeSet 就是 TreeMap。我们查看源码就可以发现
调用Set集合的add方法实际上就是调用Map集合的put,将Set集合的元素放在key上,value都是同一个空的Object对象
2.讲讲JDK的HashMap大概的结构以及重点方法
JDK7之前的HashMap就是一个基于开散列的散列表实现,也就是数组+链表的结构
JDK8之后引入了红黑树,因为当链表的长度过长时,元素的查询退化为链表的遍历,时间复杂度由O(1)变成了O(n),于是将链表树化。
HashMap的属性解读:
hash方法实现:
该方法是将原哈希值使用key自带的hashCode方法运算后,得到的哈希值和取高16位的值做异或运算
为什么要这样计算呢?以我们的18位身份证号为例,我们用它 % 1000,其实只有最后的四位数字参与了运算,哪怕我们%100万,依然不是全部数字参与运算,而且我们的数组长度也会是100万,很浪费空间。将数据取高16位,使全部数据都参与了运算,保证了均衡性
我们得到的hash不是最终索引,只是得到了一个比较均匀的hash后的整数。
这里为什么使用与运算呢?
public static void main(String[] args) {
int hash = 120;
//16 = 2^4
int n = 16;
System.out.println(hash % n);
System.out.println((n - 1) & hash);
}
//输出:8
8
可以看到,模和与运算的结果是一样的,因为与运算速度更快,所以使用与运算得到了最终索引。