目录
一、什么是哈希表
1.1 哈希函数和哈希冲突
1.2 解决哈希冲突的思路
1.2.1基于闭散列方案的思路
1.2.2基于开散列方案的思路
1.2.3开散列方案中存在的问题和解决方法
1.2.4JDK8之后的HashMap处理哈希冲突的方法:
1.3 负载因子
二、手动实现一个基于拉链法的哈希表
2.1先定义好哈希表中的节点类
2.2实现哈希表的构造方法以及哈希函数的定义
2.3将一对键值对保存在当前的哈希表中,put方法的实现
2.4 扩容操作,resize方法的实现
2.5 查找方法的实现
2.6 根据key值删除对应的键值对
哈希表是一种基于数组的随机访问特性衍生出来的数据结构(用空间换时间的思想)
例如:
如果存在一组数据{9,5,2,7,3,6,8},要查询某个特定的元素是否在数组中,可以将原数组的元素映射成为新的布尔数组的下标。要看某个元素是否存在,只需在对应的布尔值数组中查看该索引是true还是false即可。
但是,这种方法的缺陷也很明显
当数组的元素跨度较大,例如{10w, 1, 99, 1000w...}, 就得根据元素的最大值来开辟新数组,浪费大量的空间。同时,当数组中存在负数的时候,无法将负数映射为新数组的下标。
由此引申出了哈希函数和哈希冲突
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
哈希函数:将任意的Key值(所有的数据类型)映射为数字下标 --- (Object -->int)
哈希冲突:原本两个不同的Key值,经过哈希函数运算后得到两个相同的val值
ex: 在处理整型映射时,最常用的手段就是进行取模运算,将一组很大的数组映射为很小的数字
nums[1,10,101,1w,100w....] % 10==> 将nums数组的每个元素映射为arr[0.....9]
虽然将数组内的数字通过哈希函数映射为了很小的一组数字,但也存在问题
例如1和11进行%10运算,得到的值都为1 ======》这就是哈希冲突
当发生哈希冲突时,找冲突附近是否存在空闲位置,如果有就放入冲突元素
ex:假设现在有一组数据[1,2,3,19,120,121],通过哈希函数对101取模后放入新数组arr中,19和120对101取模都为19,所以我们只能在arr[19]附近找空位置来存放120,例如此时arr数组中下标为20的位置就是空的,所以将120存入arr[20]中。
在这个哈希表中查找一个元素的流程:要查找120是否存在,首先对120%101 = 19,找19这个索引对应的值是不是120,如果不是,就向后继续遍历查找,如果在后面找到,则120这个元素存在,否则说明该元素不存在!
缺点:
当整个哈希表出现大量的哈希冲突时,查找某个元素基本上就等同于遍历数组,退化为O(n)
如果出现哈希冲突,就在对应的位置上将数组元素变为一个链表(拉链法)
ex:[1,2,3,19,120] 通过哈希函数对101取模后放入新数组arr中
19和120发生了哈希冲突,取模后都为19,此时,将120作为链表的节点尾插(头插)到19对应的元素之后。
当要查找某个元素x是否存在,就x%101取模拿到对应链表的头结点,只需要遍历这个链表即可。
因此
基于开散列的方案,哈希表的数组其实储存的就是每个链表的头节点!
整体的哈希表就是一个数组+链表的结构
基于这种方案解决的哈希冲突就将整张表的哈希冲突问题转为某几个链表的冲突问题
若当前开散列方案下,某个链表的冲突非常严重,该链表的长度过长,查找元素就又会退化为链表的遍历O(n)
方案1:针对整个哈希表进行扩容(对原数组扩容),冲突链表上的元素放到新数组上时就会重新进行取模运算,降低冲突概率 (c++采取方案1进行大量冲突的处理)
方案2:将某些冲突的链表再次变为哈希表或者树化(二分平衡搜索树),哈希表中其他的位置不影响,不改动,只处理这个冲突严重的链表。
当整个哈希表元素个数<64时采用整表扩容(第一种方案)
当整个哈希表元素个数>64且某个链表的长度≥8时,会将此链表转为RBtree处理(树化),此时不再进行整表扩容
1.3.1 负载因子(load factor):描述一个哈希表冲突的情况
load factor = 哈希表中实际存储的元素个数/数组长度
负载因子越大说明在当前哈希表中冲突概率越大,查找效率就越低,比较节省空间(数组长度比较小)
负载因子越小说明在当前哈希表中冲突概率越小,查找效率就越高,比较浪费空间(数组长度比较大)
触发扩容(树化)的机制为:数组元素 >= 哈希表 * 负载因子
负载因子的大小如何确定?
阿里经过大量的试验论证,哈希表的负载因子取10(在当前哈希表中平均每个链表的长度不超过10)
JDK的HashMap的负载因子默认设置的是0.75
Q: 在JDK的HashMap中,若此时数组长度为16,实际存储元素的个数为多少时会触发扩容操作?
数组元素 >= 哈希表 * 负载因子 = 16 * 0.75 = 12
1.3.2 关于哈希函数的设计
这是离散数学家研究的话题
一般来说,如果是对整型做哈希运算,模一个素数冲突概率较低
如果是对字符串做哈希运算,可以利用md5算法(主要给字符串计算哈希值)
md5的三个特点:
1. 定长,无论输入多大的字符串,得到的md5值长度固定
2.分散,原数据变动一点点,得到的md5值差异很大
3.不可逆,通过原数据得到md5值很容易,反之通过md5值得到原数据内容很难
哈希表就是由N个子链表共同组成的结构,其中每个子链表的头节点存储在哈希表的数组中
private static class Node{
//key值不重复
int key;
//val可以重复
int val;
Node next;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
//当前哈希表中有效元素的个数
private int size;
//负载因子,默认取JDK的0.75
private static final double LOAD_FACTOR = 0.75;
//实际存储元素的数组,数组中实际放的是一个个链表的头结点
private Node[] hashTable;
//简单起见,取模数保持和hashTable一致
private int M;
public MyHashMap(){
this(10);
}
public MyHashMap(int capacity){
this.hashTable = new Node[capacity];
this.M = capacity;
}
//哈希函数,取模运算,也要考虑存入的key值为负数的可能性,先求key的绝对值
public int hash(int key){
return Math.abs(key)%M;
}
public int put(int key, int val){
//1.先对key值取模,才知道当前key值应该放在哪个链表中
int index = hash(key);
//2.遍历这个链表,查看key值是否存在
for(Node x = hashTable[index]; x != null; x = x.next){
if(x.key == key){
//说明key值已经存在,只需要更新value值
int oldVal = x.val;
x.val = val;
return oldVal;
}
}
//走到这说明整个链表中没有key值,则构造一个新节点插入这个链表中
Node newNode = new Node(key,val);
newNode.next = hashTable[index];
hashTable[index] = newNode;
size++;
//增加完元素之后判断是否需要扩容
if(size >= hashTable.length * LOAD_FACTOR){
resize();
}
return val;
}
private void resize() {
//1.产生一个新数组,扩大一倍
Node[] newTable = new Node[hashTable.length << 1];
//更新取模数为新数组长度
this.M = newTable.length;
//2.进行原哈希表的数据搬移操作
for(int i = 0; i < hashTable.length; i++){
for(Node x = hashTable[i]; x != null;){
//暂存下一个节点的地址
Node next = x.next;
//进行X节点的搬移
int newIndex = hash(x.key);
x.next = newTable[newIndex];
newTable[newIndex] = x;
x = next;
}
}
hashTable = newTable;
}
//判断哈希表中是否有key值
public boolean containsKey(int key){
int index = hash(key);
for(Node x = hashTable[index]; x != null; x = x.next){
if(x.key == key){
return true;
}
}
return false;
}
//判断哈希表中是否有val值
public boolean containsVal(int val){
//不知道val在那个链表,只能全表扫描
for(int i = 0; i < hashTable.length; i++){
for(Node x = hashTable[i]; x != null; x = x.next){
if(x.val == val){
return true;
}
}
}
return false;
}
//根据key值来找val值
public int get(int key){
int index = hash(key);
for(Node x = hashTable[index]; x != null; x = x.next){
if(x.key == key){
return x.val;
}
}
throw new NoSuchElementException("hashtable has not this key!");
}
在哈希表中由于有负载因子的存在,不可能存在某个单链表长度过长的情况,因此在哈希表中根据key值查询元素的时间复杂度近似于O(1),但是如果根据val值来找,需要走全表扫描,至少是O(n)的时间复杂度。因此在哈希表中都使用key去查询键值对
//根据key值删除对应的键值对
public boolean remove(int key) {
int index = hash(key);
Node head = hashTable[index];
//如果这个索引内没有保存任何节点,则说明向删除的键值对根本就不存在
if (head == null) {
return false;
}
// 如果头结点就是待删除的结点
if (head.key == key) {
hashTable[index] = head.next;
head = head.next = null;
size --;
return true;
}
//如果头节点不是待删除的节点,向后遍历
Node prev = head;
while (prev.next != null) {
if (prev.next.key == key) {
// cur是待删除节点
Node cur = prev.next;
prev.next = cur.next;
cur = cur.next = null;
size --;
return true;
}
}
// 走到这说明不存在要删除的键值对
return false;
}
重点总结:
1.了解哈希运算和哈希冲突是什么
2.resize扩容方法的实现逻辑
3.hash方法到底怎么对key值做hash运算
4.哈希表的增删查改方法的手动实现