目录
4. 关于哈希桶的数量必须为2^n的说明——HashMap的构造方法
Hash表
哈希表中需要一种方法将任意的数据类型转为数组的索引,这样的一种方法我们称之为哈希函数。
哈希表的高效查找秘诀就在于数组的随机访问能力,在数组中若知道索引,可以在O(1)获取到该元素,用空间换取时间。
问题:在数组[9,5,2,7,3,6,8]中查找元素是否存在?
就建立一个长度为10的boolean数组,遍历原数组,若该元素在原数组中存在,boolean对应的位置置为true。
boolean[] hash = new boolean[10];
hash[9] = true;
hash[5] = true;
....直到扫描完整个集合。查询元素3是否在原集合存在,判断hash[3] == true?时间复杂度:O(1)。
任意数字对应一个索引,数字值为多少,对应boolean数组的索引就为多少。
此时开辟的新哈希数组的大小是按照原数组最大数值+1
若此时原数组中的数字跨度非常之大,而且包含负数,上述方式就不再适用。
[9,100001,-2,800,55,30000000]没法创造一个一一对应的索引。
若数字本身的值较大,就需要让原数字和下标建立一个映射关系(hash函数)。让跨度较大的一组数据转为跨度很小的一组数据。从而高效利用有效空间。
哈希函数
哈希函数:将任意的数据 key 转为索引
如:字符c,f(c) = 'c' - 'a' 转为索引;
一个班的学号[1...30],直接将学号的数值作为索引值;身份证号18位数,将大整数映射为小整数;
String 转为 int ,字符串内部就是字符数组,因此按照 char 转 int 的方式转换;
其他类型 转为 int ,任意类型都有toString()方法,先转 String 再转 int 。
一般来说,我们将任意的正整数映射为小区间数字,最常用做法为“取模”。
在理论上,数学中的任意函数f(x),两个不相同的 x 都一定有可能会映射到相同的 y ,因此产生哈希冲突。
哈希冲突:不同的 key 经过 hash 函数的计算得到了相同的 value 值(索引)。
解决冲突的一种办法:模一个素数
哈希函数的设计:哈希冲突在数学领域理论上一定存在。
哈希函数最核心的原则:尽可能在空间和时间上求平衡,利用最少的空间获取一个较为平均的数字分布。哈希表:是哈希函数的设计和哈希冲突的解决。
将任意的key 转换为一个索引,映射得到的索引按照理论一定会冲突,冲突之后如何解决。
1. 将任意的 key 经过哈希函数的运算转为相应的索引值。
2. 若得到的索引在当前哈希表中没有元素保存,直接保存。
3. 若得到的索引在当前哈希表中已经保存了元素,处理哈希冲突后保存。
处理哈希冲突的两种方案
1. 闭散列
当发生冲突时,找到冲突位置的旁边是否存在空闲位置,直到找到第一个空闲位置放入元素。(好存、难查、更难删,工程中很少使用)
查找元素
若整个哈希表冲突非常严重,此时查找一个元素,时间复杂度从O(1)——>遍历数组O(n)。
2. 开散列
若出现hash冲突,就让这个位置变为链表。开散列方案下的哈希表:数组+链表。
查找元素
若当前哈希表中某个位置,图中索引19这个位置冲突非常严重。
恰好每个元素取模后都是19,某个数组对应的链表的长度过长,查找效率降低。解决方案:
1. 针对整个数组进行扩容(例如:现在数组长度101,扩容到202)由原先%101=> % 202,很多原来同一个链表上的元素均分到其他新的位置,降低哈希冲突。C++ STL的Map采用此方案
2. 将这个冲突严重的链表再次变为新的哈希表 / 二分搜索树将O(n)——>O(logn),不用整张哈希表进行处理,只处理冲突严重的链表。
JDK采用此方案。
问题线性探测方法,即闭散列方案,冲突之后在冲突位置之后寻找下一个为空的位置。
等概率成功查找的平均查找长度:当前表中所有元素的查找次数 / 表中有效的元素个数。
答:使用闭散列方法
得出答案 12 / 6 = 2,选择C。
若使用开散列方法
总共查找次数:8
等概率成功查找的平均查找长度:8 / 6 = 4 / 3 = 1.3
冲突概率变小,查找次数降低。
哈希算法
对于一般场景下的哈希函数的设计:
一般来说不用自己写,用现成的即可。
方案1:MD5,MD4,MD3
方案2:SHA1,SHA256
MD5一般用在字符串计算hash值
MD5的特点:
1. 定长。无论输入的数据有多长,得到的MD5值长度固定(16或32位)。
2. 分散。如果输入的数据稍有偏差,得到的MD5值相差很大(冲突概率非常低,工程领域忽略不计)。3. 不可逆。根据字符串计算MD5容易,想通过得到的MD5还原字符串非常难(基本不可能)。
4. 稳定性。根据相同的数据计算的MD5值是稳定的,不会发生变化。稳定性是所有哈希函数都要满足的特点。
MD5的用途非常广泛:
1. 作为hash运算;
2. 用于加密;
3. 对比文件内容(内容稍有修改,得到的md5值天差地别)。例如:我给小明发送大小为2G的文件源文件,计算MD5。小明在收到之后,如何知道这个文件内容是否有变化,传输是否成功?
小明在收到之后,再把收到的文件计算一个MD5若原MD5 == 新MD5,则传输成功。
基于开散列方式实现的哈希表(重点)
添加操作
代码实现
/** * 对key值求哈希 */ public int hash(int key) { return Math.abs(key) % M; } /** * 将一对键值对保存到当前hash表中 * * @param key * @param value * @return 若key存在,此时修改原来的键值对,返回修改前的元素 */ public int put(int key, int value) { // 1.先对key值取模 int index = hash(key); // 2.遍历这个index对应的链表,查看key值是否存在 for (Node x = hashTable[index]; x != null; x = x.next) { if (x.key == key) { int oldValue = x.value; x.value = value; return oldValue; } } // 3.此时整个列表中不包括相应key的节点,头插到当前位置 Node node = new Node(key, value); // 当前链表的头节点 hashTable[index] node.next = hashTable[index]; hashTable[index] = node; size++; // 添加元素后判断是否扩容 if (size >= hashTable.length * LOAD_FACTOR) { resize(); } return value; }
扩容(引入负载因子)
采用整表扩容方式
什么时候需要对数组扩容?哈希表冲突严重。
如何判断是否冲突严重?引入负载因子。
负载因子 loadFactor = 哈希表有效元素个数 / 哈希表长度
这个值越大,就说明冲突越严重。
这个值越小,说明冲突越小,数组利用率越低。
扩容与否就根据负载因子来决定
数组长度 * 负载因子 <= 有效元素个数,就需要扩容。
假设此时数组长度16,负载因子 = 0.75(JDK HashMap的默认负载因子为0.75)16 * 0.75 = 12,当保存的元素个数 >= 12,就需要扩容了。
基本不冲突,每个链表长度1左右。最高效查询
假设此时数组长度16,负载因子 = 10(阿里巴巴实验数据)
16*10= 160,保存的元素个数 >= 160(每个子链表平均长度为10)需要扩容。
冲突较上面比较严重,每个链表平均都有10个节点。空间利用率较高负载因子就是空间和时间取平衡,负载因子的取舍需要根据现实的需求去做实验。
代码实现
/** * hash表的扩容,新数组的长度变为原来的2倍 */ private void resize() { // 1.产生一个新数组且新数组长度变为原来的2倍 Node[] newTable = new Node[hashTable.length << 1]; // 2.进行元素的搬移操作,将原数组中的所有元素搬移到新数组中, // 此时取模数变为新数组的长度 this.M = newTable.length; // 3.进行元素搬移 for (int i = 0; i < hashTable.length; i++) { for (Node x = hashTable[i]; x != null;) { Node next = x.next; // 将x搬移到新数组的位置 int index = hash(x.key); // 新数组的头插 x.next = newTable[index]; newTable[index] = x; // 继续遍历原数组的后继节点 x = next; } } hashTable = newTable; } }
查找操作
查找key
/** * 判断当前key是否在表中存在 */ public boolean containsKey(int key) { int index = hash(key); for (Node x = hashTable[index]; x != null; x = x.next) { if (key == x.key) { return true; } } return false; }
查找value
/** * 判断当前value是否在表中存在 */ public boolean containsValue(int value) { // 全表扫描 for (int i = 0; i < hashTable.length; i++) { for (Node x = hashTable[i]; x != null; x = x.next) { if (value == x.value) { return true; } } } return false; }
查找(key,value)键值对
/** * 判断(key,value)存在 */ public boolean containsKeyAndValue(int key, int value) { int index = hash(key); for (Node x = hashTable[index]; x != null; x = x.next) { if (value == x.value) { return true; } } return false; }
删除操作
/** * 在哈希表中删除指定的键值对(key,value) */ public boolean remove(int key, int value) { int index = hash(key); // 判断头节点是否是待删除的节点 Node head = hashTable[index]; if (head.key == key && head.value == value) { // 此时头节点是待删除的节点 hashTable[index] = head.next; head = head.next = null; size--; return true; } Node prev = head; while (prev.next!=null) { if (prev.next.key == key && prev.next.value == value) { // prev恰好是待删除节点的前驱 Node x = prev.next; prev.next = x.next; x = x.next = null; size--; return true; } else { prev = prev.next; } } // 当前hash表中没有这个节点 throw new NoSuchElementException("no such element!remove error!"); }
Object中的hashCode()和equals()方法
1. hashCode()
将任意的对象转为数组索引,只要是不同的对象原则上都会返回不同的整数。
Object提供的hashCode()可以将任意对象转为int,不同的对象(地址不同)原则上一定转为不同的int。
原则上自定义的类若需要保存到HashMap哈希表中,不能直接使用Object提供的hashCode(),需要覆写这个方法。原因是,hashCode返回的整型太大,数组开辟的空间就会过大。
2. equals()
判断两个对象是否是"相同"内容。
class Student { private String name; private int age; }
此时要将Student对象存储到HashMap的key上。
1. 计算Student对象的哈希值,得到一个数组的索引下标。hashCode()计算哈希值。
2. 判断当前这个Student对象是否已经在哈希表中"存在"。equals()是否是相同的key值。问题:equals相同的两个对象,其hashCode是否相同?
必须相同。equals相同的两个对象,就认为是同一个对象。哈希表中这个对象有且只能有一个。经过哈希函数运算后,两个对象保存的索引也应该相同。
问题:hashCode相同的两个对象,其equals是否相同?不一定相同。hashCode相同,说明此时发生了哈希冲突,不一定就是相同的对象,到底是否相同还要取决于equals方法。
在哈希表中,只有equals和hashCode都相同的对象,才称为唯一对象。
class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } @Override public int hashCode() { return 0; } public boolean equals(Object obj) { if (this == obj) { return true; } if (obj instanceof Student) { Student stu = (Student) obj; return this.age == stu.age && this.name.equals(stu.name); } return false; } @Override public String toString() { return "Student{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
输出结果
此时设置equal()的返回值为0,将三个对象都放在索引为0处。因为stu1和stu3这两个对象的equals相同,此时在HashMap只会存储一份。认为是相同的一个对象。equals相同的对象,就认为是同一个对象。
自定义对象作为key的唯一性,就是通过equals方法保证的。
JDK的源码分析——HashMap的源码
主要问题:
1. HashMap的哈希函数是如何设计的2. put方法的逻辑,到底是如何存储元素的当发生冲突时如何解决的
3. 哈希表冲突较严重,如何扩容的,resize操作。
1. JDK8之后HashMap的结构如下
数组+链表+红黑树(冲突严重的链表会被"树化",将链表转为红黑树,提高冲突严重的链表的查询效率)
JDK8之前,JDK7以及更老版本,HashMap就是数组+链表
2. 关于HashMap源码中属性的解读
若某个链表长度>=8,此时哈希桶的数量不足64,则只是简单的哈希表扩容而已。
3. put方法解读
3.1 hash函数计算索引
ctrl + alt+鼠标左键,选择实现子类的方法而不是接口方法
首先计算一下当前key的哈希值,哈希Map的哈希方法。
高低16位都参与哈希函数的运算,尽可能保证不同key映射到比较均衡的状态。
问题汇总
1. 为何不采用Object类提供的hashCode方法计算出来的key值作为桶下标?
基本不会发生碰撞,哈希表就和普通数组基本没有区别。
2. 为何h >>> 16?
为何取出key值得高16位右移参与hash运算?
这样高低16位都参与运算,尽量保证数据均匀分布。
3. 为何HashMap中容量均为2^n ?
(n - 1) & hash:当n为2^n,此时的位运算就相当于 hash % (n - 1)。
3.2 put方法
put方法核心流程小结
I. 若HashMap还未初始化,先进行哈希表的初始化操作(默认初始化为16个桶)。
II. 对传入的key值做hash,得出要存放该元素的桶编号。
a. 若没有发生碰撞,即头结点为空,将该节点直接存放到桶中作为头结点。
b. 若发生碰撞
1. 此桶中的链表已经树化,将节点构造为树节点后加入红黑树。
2. 链表还未树化,将节点作为链表的最后一个节点入链表。
III. 若哈希表中存在key值相同的元素,替换最新的value值。
IV. 若桶满了(size++ 是否大于threshold),调用resize()扩容哈希表。
thresholed = 容量(默认16) * 负载因子 (默认0.75)。
问题:重写了hashcode还需要重写equals方法吗?
4. 关于哈希桶的数量必须为2^n的说明——HashMap的构造方法
(n - 1)& hash == hash % n (n为2^n)
4.1 使用无参构造,内部数组还没有初始化,只有第一次调用put方法时才初始化内部哈希桶数组。(懒加载模式)第一次使用(添加)时才初始化相应的内存。
4.2 使用有参构造
检查传入的哈希桶大小是否是2^n,若不是,调整为最接近的2^n的数(大于传递的参数)
5. 关于resize方法
即是扩容方法又是初始化方法,在resize方法中进行哈希桶数组的初始化操作。
关于Set集合和Map集合的关系
Set集合的子类实际上在存储元素时就是放在了Map集合的Key中,这也是为什么Set是不可重复的。
HashSet其实就使用HashMap保存的
TreeSet其实就使用TreeMap保存的Set就是用的Map的子类来存储元素,Set的不可重复就是因为元素保存在了Map的Key中,因此Set保存的元素不可重复。
HashSet能否保存null?可以,因为HashMap的key可以为null。
TreeSet能否保存null?不可以,因为TreeMap的key不能为null。