目录
一、上集回顾
我们已经了解了Set和Map的基本性质,并且我们使用二叉搜索树实现了简单的TreeSet的功能,其实TreeMap的实现方法和TreeSet是类似的,我们不再介绍。接下来我们会引入一个新的数据结构哈希表(HashTable),并以此来实现HashMap类,同样,HashSet类似,就不介绍了。
二、基本原理
什么是哈希表呢?哈希表是我们构造的一种通过某种
函数(hashFunc)
使元素的存储位置与它的关键码之间能够
建立一 一映射的关系
的数据结构,那么在查找时通过该函数可以很快找到该元素。
举个例子,我有好多件衣服,每天出门的时候需要挑一件来穿,那么我要怎样放衣服才能比较快的找到我要穿的衣服呢?我可以买几个衣柜,然后按照衣服的类别(T恤、外套等)将衣服放进不同的衣柜里。这样我在找衣服的时候就可以快速定位到衣服在哪个衣柜里,这样效率提高了。所以,关键在于
建立一种元素(Key)到位置的关系
。
于是,这种建立映射的函数称为哈希函数,构造出来的结构称为哈希表(HashTable)。这个函数往往需要经过特殊的设计,保证每个衣柜中的衣服尽可能的均匀。但是我们发现,”衣柜“的数量总会比”衣服“少,这样两件不同的衣服就有可能放在同一个衣柜内。这种hash(k1)=h1、hash(k2)=h2并且h1==h2的现象我们称为
哈希冲突(哈希碰撞)
。
显然,我们知道哈希冲突这种现象会影响到我们查找效率,并且现实中这种冲突几乎不可避免,于是我们应当做如下思考:
1、尽可能的把冲突率降低
2、真的冲突了,应该怎么办?
对于问题1:
1、我们应当让hash函数的结果尽可能的均匀
2、我们可以通过适当的扩容来降低冲突率
对于问题2:
解决办法这里介绍两种比较今典的方法:
1、闭散列方法
2、开散列方法(哈希桶)
三、闭散列方法
我们首先明确hash函数,我们采用比较经典的
除留余数法
。即hash(Key)=key % P。此处我们选择p为存储元素底层空间总的大小。
存在数据集合{1,7,6,5,9,4,15}。现在将该集合插入到储存空间中
1%10 等于1,将1放在下标为1处
7%10 等于7,将1放在下标为7处
…….
15%10等于5,下标5处已经存在元素5,
于是我们往后移
,发现6和7也有元素,继续后移,下标为8处为空,于是将15填入。
查找
的时候按照哈希hash函数算出所在下标,然后查看是否相等,如果相等则我们查找到了该元素,否则我们往后查找,如果在查找到该元素之前遇到空格,说明我们查找失败,并没有找到该Key。
删除
的时候,我们首先要进行查找,如果查找到该元素?我们能否直接删除该元素呢?很明显
不能直接删除
。例如上面的例子我们要删除6元素,如果直接进行删除,当我们要找到15的时候,就会发现,下标为6的地方为空,直接返回查找失败,但是15 还在集合中。所以直接删除可能会影响到集合。我们只能给每个下标处加一个状态,空为一个状态,已经删除为一个状态,存在元素为一个状态。(同理,查找的时候也需要根据这种方法进行变动)
上面这种遇到冲突情况就往后找一个的方法称为
线性探测法
。同样还有二次方探测、随机探测等方法。以上的方法其实并不常用。
四、开散列方法(哈希桶)
同样我们的hash函数还是选择hash(Key)=key % P。我们的思路还可以再换一换,为什么有冲突就要换地方呢?我们直接就在原地想办法不可以吗?于是就有开散列方法(哈希桶)。
我们使用一个链表来存放产生冲突的元素,此时已经不存在什么冲突换址的问题,无论有多少个冲突都只是在当前位置给单链表增加结点的问题。该方法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然这也就带来了查找时需要遍历单链表的性能损耗。
五、HashMap的手动实现
思路:get方法,获取下标之后,根据下标取得头结点,然后遍历链表,进行查找。put方法同理,但是我们需要考虑扩容,扩容需要
size/array.length大于某个阈值
。remove方法需要考虑特殊形况,也就是头结点为空的情况。主要方法已经基本完成:
package set_map.map.hashMap;
import java.util.*;
public class MyHashMap {
private static final double THRESHOLD=0.75;
private Node[] array;//链表头结点插在此处
private int size;
public MyHashMap() {
this.array = new Node[7];
this.size = 0;
}
public Integer get(String key){ //查找
int n=key.hashCode();
int index=n%array.length;
Node head = array[index];
//遍历整个链表,查找元素
for(Node cur=head;cur!=null;cur=cur.next){
if (key.equals(cur.Key)){
return cur.value;
}
}
return null; //遍历结束没有查询到Key说明没有找到
}
public Integer put(String key ,int value){
int n=key.hashCode();
int index=n%array.length;
Node head=array[index];
for(Node cur=head;cur!=null;cur=cur.next){
if (key.equals(cur.Key)){
//此时找到了对应的元素,我们进行更新的操作
int oldValue = cur.value;
cur.value=value;
return oldValue;
}
}
Node node=new Node(key,value);
//此处使用头插尾插都行,为了代码简单,我们使用头插法
node.next=array[index];
array[index]=node;
size++;
// TODO: 2022/3/26 扩容
if((1.0*size/array.length)>THRESHOLD){
ensureCapacity();
}
return null; //插入成功返回null
}
private void ensureCapacity() {
Node[] newArray=new Node[array.length*2];
for (int i = 0; i < array.length; i++) { //遍历数组中的每个元素
Node next;//记录下一个节点
for (Node cur=array[i];cur!=null ; cur=next) {//遍历链表的每个节点
int n=cur.Key.hashCode();
int index=n%newArray.length;
next=cur.next; //记录下一个节点
//头插操作
cur.next=newArray[index];
newArray[index]=cur;
}
}
array=newArray;
}
public Integer remove(String key){
int n=key.hashCode();
int index=n%array.length;
Node cur=array[index];
Node prev=null;
if(array[index]!=null&&array[index].Key.equals(key)){
Node head=array[index];
array[index]=cur.next;
return head.value;
}
while(cur!=null){
if(cur.Key.equals(key)){
prev.next=cur.next;
return cur.value;
}
prev=cur;
cur=cur.next;
}
return null;
}
public boolean remove(String key ,int value){
int n=key.hashCode();
int index=n%array.length;
Node cur=array[index];
Node prev=null;
if(array[index]!=null&&array[index].Key.equals(key)&&array[index].value==value){
array[index]=array[index].next;
return true;
}
while(cur!=null){
if(cur.Key.equals(key)&&cur.value==value){
prev.next=cur.next;
return true;
}
prev=cur;
cur=cur.next;
}
return false;
}
public void clear(){
Arrays.fill(array, null);
}
public int size(){
return size;
}
public boolean isEmpty(){
return size==0;
}
public Set <String> keySet(){
Set<String> set=new HashSet<>();
Set<Map.Entry<String,Integer>> nodes = this.entrySet();
for (Map.Entry<String, Integer> node : nodes) {
set.add(node.getKey());
}
return set;
}
public Collection<Integer> values(){
List<Integer> list=new ArrayList<>();
Set<Map.Entry<String,Integer>> nodes = this.entrySet();
for (Map.Entry<String, Integer> node : nodes) {
list.add(node.getValue());
}
return list;
}
public Set<Map.Entry<String,Integer>> entrySet(){
Set<Map.Entry<String,Integer>> set=new HashSet<>();
for (Node node : array) {
Node cur;
for (cur = node; cur != null; cur = cur.next) {
set.add(cur);
}
}
return set;
}
public static void main(String[] args) {
MyHashMap map=new MyHashMap();
System.out.println(map.put("aa", 1));
System.out.println(map.put("ab", 2));
System.out.println(map.put("ac", 3));
System.out.println(map.put("ad", 4));
System.out.println(map.put("ae", 3));
System.out.println(map.put("af", 2));
System.out.println(map.put("ag", 1));
System.out.println("==============================");
System.out.println(map.put("aa", 100));
System.out.println("==============================");
System.out.println(map.get("am"));
System.out.println(map.get("aa"));
System.out.println("==============================");
System.out.println(map.remove("aa", 1));
System.out.println(map.remove("aa"));
System.out.println(map.remove("aa"));
System.out.println(map.remove("af", 2));
System.out.println("==============================");
System.out.println(map.keySet());
System.out.println(map.values());
Set<Map.Entry<String, Integer>> set = map.entrySet();
for (Map.Entry<String, Integer> entry : set) {
System.out.println(entry.getKey()+"=>"+entry.getValue());
}
}
}
六、进一步分析
可以看看知乎用户的解答:
https://zhuanlan.zhihu.com/p/458305988
以上图片是从Java的HashMap源码中的截图,该图的意思是说链表有第一个节点的概率大约为0.60,第二个节点的概率大约是0.3…..。也就是说这个概率分布符合泊松分布,一般来说,某个链表的长度超过8了,说明该泊松分布的假设失效了。这种情况往往是受到了恶意攻击。Java采取的做法就是使用红黑树来替换链表(退化成O(log n)而不是O(n))。