引言
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。比方说,你需要在一个长度为100 的整型数组中,去查找数字 5 ,那么你就得去和数组中这些 100 个元素进行比较,若相等,就找到了,返回 true;若不想等,就返回 false. 而在链表或树形结构中查找我们想要的元素时,道理是一样的,都得去挨个比较。
顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。
而有一种理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
1. 理解散列表查找
比方说,我们在一组数据中查找一个关键字,我可以将这一组数据存储在某个容器中,
而这个容器的所有数据都符合一个函数,也就是说,数据和存储位置有一一映射的关系。
所以我们就可以不经过比较,很快就能找到我们想要的关键字
这个方法为散列表查找,即哈希表:
存储位置 = f(关键字)
值得注意的是:
散列并不像线性表、树等结构存在某种逻辑关系,散列只与关键字有关联,因此散列主要是一种用来查找的存储结构。它既可以做到存储,又可以做到查找。
2. 散列表查找步骤(先存后找)
① 当向该结构中插入元素时,根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
② 当向该结构中搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置进行元素比较,若关键码相等,则查找成功。
上述的方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
你可以将散列表想象成高等数学中的一元一次函数,给你一个表达式,让你往这个表达式输入一个值,得出来的就是我们需要的值。例如:
f(x) = 2x + 1
//输入 x = 2
//输出 f(x) = 5
在上面的代码块中,f(x) = 5,就是我们通过 x = 2,所在散列表中查找出来的值。
说白了,散列表查找就是给你一个值,通过某一个事先设计好的查找规则,
来得出这个值对应的另一个值。
一、哈希函数
假设我们需要查找一个数据 key,我们知道了搜寻容器的大小 capacity
我们就可以精确定位到这个值
哈希函数:hash(key) = key % capacity
转换成数组逻辑
index = key % array.length
// index 表示数组下标
// length 表示数组长度
// key 表示我们需要查找的值
二、哈希冲突
不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,因为这会造成它们在存放至哈希表时,不知道怎么存放(一个位置只能放一个数据)。
1. 避免冲突
首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率。
2. 负载因子调节
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。而已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。
负载因子和冲突率的关系粗略演示
3. 使用链地址法来处理哈希冲突
创建一个数组,数组元素对应的类型是引用类型,我们将 index 相等的 Key 都放入数组的一个下标中,但很显然,数组的一个下标只能放一个元素,所以我们将这些 index 相等的 Key 合并起来,构成一个单链表。
一个单链表中,有三个域,[ Key, Value, next ]。其中, Key 和 Value 可以根据我们的逻辑来定义类型,可以是整型、浮点型、字符型。。。而 next 也就是链表中的指针域,它必须为引用类型。
这样一来,我们数组就可以存储链表的节点了,也就是说,每个数组下标存储的都是当前单链表的第一个节点,也即是头结点,之后通过 next,将链表中后面的节点串联起来即可。
哈希函数:
key % array.length == index //数组下标
例如:
arr.length = 10,那么:
4 % 10 = 4;
14 % 10 = 4;
24 % 10 = 4;
以上的 4,14,24 这三个元素都放入数组下标为 4 的数组中,通过单链表串起来
4. 图解链地址法思想
在下图中,我把数组下标为 4 的链表横过来了,而每个节点对应的三个域我给它竖过来了,但思想是相通的。
5. 代码实现1
/**
* 模拟链地址法 / 哈希桶
*/
public class HashBucket {
static class Node{
public int key;
public String val;
public Node next;
public Node(int key, String val){
this.key = key;
this.val = val;
}
}
public Node[] array; //数组中的每个元素为链表的头结点
public int usedSize; //当前哈希表的元素个数
public static final double DEFAULT_LOAD_FACTOR = 0.75;//默认的负载因子
public HashBucket() {
this.array = new Node[10]; //初始数组的元素全部为 null
}
/**
* 向哈希表中添加 key 和 val
*/
public void put(int key, String val){
//1. 通过哈希函数, 找到对应的 index
int index = key % array.length;
//2. 查找 key 对应的节点
Node cur = array[index];
while (cur != null){
if(key == cur.key){
cur.val = val; // key 重复,更新 val
return;
}
cur = cur.next;
}
//3. 程序走到这里,说明我们没有找到重复的 key, 那我们可以利用单链表的头插法进行操作
Node node = new Node(key,val);
node.next = array[index];
array[index] = node; //更新头节点
usedSize++; //添加一个不重复的 key, 需要将哈希表的元素个数 +1
double loadFactor = 1.0 * usedSize / array.length;
//负载因子越大,产生的哈希冲突的可能性就越大,
//那么我们需要增大哈希表长度来减小哈希冲突
if(loadFactor >= DEFAULT_LOAD_FACTOR){
resize();
}
}
/**
* 将旧数组的每个元素,往新数组中放,并按照哈希函数重新调整
*/
private void resize() {
Node[] newArray = new Node[array.length*2];
for (int i = 0; i < array.length; i++) {
Node cur = array[i];
while (cur != null){
Node curNext = cur.next; //先记录一下旧数组 i 下标的某节点的后继节点
//1. 通过重新哈希, 找到新数组对应的 index
int index = cur.key % newArray.length;
//2. 开始往新的数组调整
// ① 调整前
// 4 % 10 = 4
// 14 % 10 = 4
// ② 调整后
// 4 % 20 = 4
// 14 % 20 = 14
cur.next = newArray[index];
newArray[index] = cur;
cur = curNext;
}
}
array = newArray; //让旧数组引用新数组,那么旧数组最终将被回收
}
/**
* 通过 key, 找出对应的 val
*/
public String get(int key){
//1. 通过哈希函数, 找到对应的 index
int index = key % array.length;
//2. 查找 key 对应的 val 并返回
Node cur = array[index];
while (cur != null){
if(key == cur.key){
return cur.val; //找到了
}
cur = cur.next;
}
return null;
}
public static void main(String[] args) {
HashBucket hashBucket = new HashBucket();
hashBucket.put(4,"a"); //1
hashBucket.put(14,"b");
hashBucket.put(24,"c");
hashBucket.put(1,"d");
hashBucket.put(2,"e");
hashBucket.put(3,"j");
hashBucket.put(5,"k");
hashBucket.put(6,"l"); //2
hashBucket.put(7,"m");
hashBucket.put(8,"z");
System.out.println(hashBucket.get(14)); //输出: "b"
}
}
6. 调试结果
结果1:在上面主函数的测试中,注释1到注释2之间的调试结果,此时的负载因子 < 0.75,那么数组长度为10。原先 14 % 10 = 4,所以key = 14 在数组下标为 4之中。
结果2:在上面主函数的测试中,注释2之后的调试结果,此时的负载因子 > 0.75,那么数组长度为更新为20。现在 14 % 20 = 14,所以 key = 14 在数组下标为 14 之中。
7. 图解分析
三、理解hashcode ( ) 方法
两个引用指向相同的地址,那么这两个引用通过 hashcode( ) 方法可以转换成两个相同的整数。(前提:必须重写 hashCode 和 equals 方法)
import java.util.Objects;
class Person{
public String ID;
public Person(String ID){
this.ID = ID;
}
@Override
public String toString() {
return "Person{" +
"ID='" + ID + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(ID, person.ID);
}
@Override
public int hashCode() {
return Objects.hash(ID);
}
}
public class Test {
public static void main(String[] args) {
Person person1 = new Person("123abc");
Person person2 = new Person("123abc");
System.out.println(person1.hashCode());
System.out.println(person2.hashCode());
}
}
输出结果:
1. 解决哈希冲突时,K,V 为引用类型
import java.util.Objects;
class Person{
public String ID;
public Person(String ID){
this.ID = ID;
}
@Override
public String toString() {
return "Person{" +
"ID='" + ID + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(ID, person.ID);
}
@Override
public int hashCode() {
return Objects.hash(ID);
}
}
public class HashBucket2 <K,V>{
static class Node<K,V>{
public K key;
public V val;
public Node<K,V> next;
public Node(K key, V val){
this.key = key;
this.val = val;
}
}
public Node<K,V>[] array; //数组中的每个元素为链表的头结点
public int usedSize; //当前哈希表的元素个数
public static final double DEFAULT_LOAD_FACTOR = 0.75;//默认的负载因子
public HashBucket2() {
this.array = (Node<K,V>[])new Node[10];
}
/**
* 向哈希表中添加 key 和 val
*/
public void put(K key, V val){
//1. 通过哈希函数, 找到对应的 index
//key 这个引用要通过 hashcode()方法转换成整数
int index = key.hashCode() % array.length;
//2. 查找 key 对应的节点
Node<K,V> cur = array[index];
while (cur != null){
if(key.equals(cur.key)){ //引用类型比较使用 equals
cur.val = val;
return;
}
cur = cur.next;
}
//3. 程序走到这里,说明我们没有找到重复的 key, 那我们可以利用单链表的头插法进行操作
Node<K,V> node = new Node<K,V>(key,val);
node.next = array[index];
array[index] = node; //更新头节点
usedSize++; //添加一个不重复的 key, 需要将哈希表的元素个数 +1
double loadFactor = 1.0 * usedSize / array.length;
//负载因子越大,产生的哈希冲突的可能性就越大,
//那么我们需要增大哈希表长度来减小哈希冲突
if(loadFactor >= DEFAULT_LOAD_FACTOR){
resize();
}
}
/**
* 将旧数组的每个元素,往新数组中放,并按照哈希函数重新调整
*/
private void resize() {
Node<K,V>[] newArray = (Node<K,V>[])new Node[array.length*2];
for (int i = 0; i < array.length; i++) {
Node<K,V> cur = array[i];
while (cur != null){
Node<K,V> curNext = cur.next; //先记录一下旧数组 i 下标的某节点的后继节点
//1. 通过重新哈希, 找到新数组对应的 index
int index = cur.key.hashCode() % newArray.length;
//2. 开始往新的数组调整
cur.next = newArray[index];
newArray[index] = cur;
cur = curNext;
}
}
array = newArray; //让旧数组引用新数组,那么旧数组最终将被回收
}
/**
* 通过 key, 找出对应的 val
*/
public V get(K key){
//1. 通过哈希函数, 找到对应的 index
int index = key.hashCode() % array.length;
//2. 查找 key 对应的 val 并返回
Node<K,V> cur = array[index];
while (cur != null){
if(key.equals(cur.key)){
return cur.val; //找到了
}
cur = cur.next;
}
return null;
}
public static void main(String[] args) {
Person person1 = new Person("123abc");
Person person2 = new Person("123abc");
//Person 是我们自定义的类
HashBucket2<Person,String> hashBucket2 = new HashBucket2<>();
hashBucket2.put(person1,"hello world");
System.out.println(hashBucket2.get(person2));
}
}
输出结果:
四、注意
hashcode 一样,equals 不一定一样
equals 一样,hashcode 一定一样
- HashMap 和 HashSet 即 Java 中利用哈希表实现的 Map 和 Set
- Java 中是使用哈希桶方式来解决哈希冲突的。
- Java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
- Java 中计算哈希值实际上是调用的类的 hashCode( ) 方法,进行 key 的相等比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须重写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。
五、 一些关于 HashMap 的问题
1. 如果 new HashMap(19),bucket 数组多大?
19 <= buckect.length <= 最接近19的一个2的2次幂
= (2^5 = 32)
2. HashMap 什么时候开辟 bucket 数组占用内存?
第一次 put 的时候,(哈希容量)数组长度为 16
3. HashMap 什么时候开始扩容?
超过负载因子 loadFactor 的情况下,HashMap 进行2倍扩容
4. 当两个对象的 hashCode( ) 相同,会发生什么?
发生哈希冲突
5. 如果两个 Key 的 hashCode( ) 相同,如何获取值的对象?
使用 equals( ) 方法获取值的对象,
遍历 hashCode 值相等时相连的链表,直到相等或者 null
6. 重新调整 HashMap 的大小存在什么问题?
重新调整 HashMap 的大小,会使原来的 Key 放置的位置不同。
所以我们需要根据新哈希桶的容量来重新计算存储位置,将 Key 放到新数组中的新链表中。