Map和Set,简单模拟实现哈希表以及哈希表部分底层源码的分析

目录

  • Map和Set的简单介绍
  • 降低哈希冲突发生的概率以及当冲突发生时如何解决哈希冲突
  • 简单模拟实现哈希表--1.key为整形;2.key为引用类型
  • 哈希表部分底层源码的分析

1.Map和Set的简单介绍

1.1.Map的说明

Map :Key-Value 模型,什么是key - value模型呢,就比如梁山好汉的江湖绰号:豹子头 - 林冲 等等。Map 中存储的就是 key-value的键值对, Map 是一个接口类,该类没有继承自 Collection ,该类中存储的是 <K,V> 结构的键值对,并且 K 一定是唯一的,不 能重复

1.2.Map方法的介绍

方法解释
V get (Object key)
返回 key 对应的 value
V getOrDefault (Object key, V defaultValue)
返回 key 对应的 value key 不存在,返回默认值
V put (K key, V value)
设置 key 对应的 value
V remove (Object key)
删除 key 对应的映射关系
Set<K> keySet ()
返回所有 key 的不重复集合
Collection<V> values ()
返回所有 value 的可重复集合
Set<Map.Entry<K, V>> entrySet ()
返回所有的 key-value 映射关系
boolean containsKey (Object key)
判断是否包含 key
boolean containsValue (Object value)
判断是否包含 value
Map的注意事项:
1. Map 是一个接口,不能直接实例化对象 ,如果 要实例化对象只能实例化其实现类 TreeMap 或者 HashMap。
2. Map 中存放键值对的 Key 是唯一的, value 是可以重复的(重复的情况,后面put的覆盖前面的)。
3. Map 中的 Key 可以全部分离出来,存储到 Set 来进行访问 ( 因为 Key 不能重复 )
4. Map 中的 value 可以全部分离出来,存储在 Collection 的任何一个子集合中 (value 可能有重复 )
5. Map 中键值对的 Key 不能直接修改, value 可以修改,如果要修改 key ,只能先将该 key 删除掉,然后再来进行重新插入。
TreeMap HashMap 的区别:
Map 底层结构
TreeMap
HashMap
底层结构
红黑树
哈希桶
插入 / 删除 / 查找时间
复杂度
O(log2^N)
O(1)
是否有序
关于key有序
无序
线程安全
不安全不安全
插入/删除/查找区别需要进行元素比较通过哈希函数计算哈希地址
比较与覆写
key必须能够比较,否则会抛出
ClassCastException异常
自定义类型需要覆写equals
hashCode方法
应用场景
需要 Key 有序场景下
Key 是否有序不关心,需要更高的
时间性能
其中 Set<Map.Entry<K, V>> entry Set() 这个方法非常复杂但也非常重要,所以要做一些具体的说明:
Map.Entry<K, V> Map 内部实现的用来存放  <key, value>  键值对映射关系的内部类 ,该内部类中主要提供了  <key, value> 的获取, value 的设置以及 Key 的比较方式。
如何理解????通俗来说就是:
Entry是Map里面的一个内部类,而 Map.Entry<key,val> 的作用就是把一个个map元素(key,val) 打包成一个整体,而这个整体的类型就是 Map.Entry<K,V>, 然后我们有一个Set集合,它里面存放的每个元素的类型就是 Map.Entry<K,V>。这里可以联想到我们的单链表的内部类ListNode,将 val,next 打包成一个整体,那么它的类型就是ListNode。

 所以下面这段代码运行起来一定会把Set集合中存放的map中的每一个元素都输出出来:

public static void main(String[] args) {
    Map<String, Integer> map = new HashMap<>();
    map.put("hello",2);
    map.put("world",1);
    map.put("bit",3);
    Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
    for (Map.Entry<String,Integer> entry:entrySet) {
        System.out.println("key: "+entry.getKey()+" val: "+entry.getValue());
    }
}

该内部类Entry提供的一些方法也是比较重要的:

方法
解释
K getKey ()
返回 entry 中的 key
V getValue ()
返回 entry 中的 value
V setValue(V value)
将键值对中的 value 替换为指定 value

1.3.Set的说明

Set Map 主要的不同有两点: Set 是继承自 Collection 的接口类, Set 中只存储了 Key

1.4.Set方法的介绍

方法
解释
boolean add (E e)
添加元素,但重复元素不会被添加成功
void clear ()
清空集合
boolean contains (Object o)
判断 o 是否在集合中
Iterator<E> iterator ()
返回迭代器
boolean remove (Object o)
删除集合中的 o
int size()
返回set 中元素的个数
boolean isEmpty()
检测 set 是否为空,空返回 true ,否则返回 false
Object[] toArray()
set 中的元素转换为数组返回
boolean containsAll(Collection<?> c)
集合 c 中的元素是否在 set 中全部存在,是返回 true ,否则返回false
boolean addAll(Collection<? extends
E> c)
将集合 c 中的元素添加到 set 中,可以达到去重的效果

Set的注意事项:

1. Set 是继承自 Collection 的一个接口类。
2. Set 中只存储了 key ,并且要求 key 一定要唯一。
3. Set 的底层是使用 Map 来实现的,其使用 key Object 的一个默认对象作为键值对插入到 Map 中的。
4. Set 最大的功能就是对集合中的元素进行去重。
5. 实现 Set 接口的常用类有 TreeSet HashSet ,还有一个 LinkedHashSet LinkedHashSet 是在 HashSet 的基础上维护了一个双向链表来记录元素的插入次序。
6. Set 中的 Key 不能修改,如果要修改,先将原来的删除掉,然后再重新插入。
7. Set 中不能插入 null key
TreeSet HashSet 的区别
Set 底层结构
TreeSet
HashSet
底层结构红黑树哈希桶
插入/删除/查找时间复杂度O(log2^N)O(1)
是否有序
关于 Key 有序
不一定有序
线程安全
不安全
不安全
插入/删除/查找区别
按照红黑树的特性来进行插入和删除
1. 先计算key哈希地址 2. 然后进行
插入和删除
比较与覆写
key必须能够比较,否则会抛出
ClassCastException异常
自定义类型需要覆写equals
hashCode方法
应用场景需要Key有序场景下
Key 是否有序不关心,需要更高的
时间性能

为什么HashMap和HashSet无序,而TreeMap和TreeSet有序??后面会解释到。


2.降低哈希冲突发生的概率以及当冲突发生时如何解决哈希冲突

2.1.概念

不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

 2.2.降低哈希冲突的发生的概率

两种解决方法

1.设计好的哈希函数;2.降低负载因子

2.2.1.设计好的哈希函数

哈希函数设计原则
  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
  • 哈希函数计算出来的地址能均匀分布在整个空间中。
  • 哈希函数应该比较简单。

常用的两种哈希函数

1. 直接定制法
取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
优点:简单、均匀。
缺点:需要事先知道关 键字的分布情况 使用场景:适合查找比较小且连续的情况。
力扣上这道题可以帮助我们理解: 字符串中第一个只出现一次字符

2. 除留余数法

设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址

 2.2.2.降低负载因子

下图是冲突率和负载因子的关系图:

 从图中我们可以直到要想降低冲突的概率,只能减小负载因子,而负载因子又取决于数组的长度。

公式:   负载因子 = 哈希表中元素的个数 / 数组的长度

因为哈希表中的已有的元素个数是不可变的,所以我们只能通过增大数组长度来降低负载因子。

2.3.当冲突发生时如何解决哈希冲突(简单介绍)

解决哈希冲突 两种常见的方法是: 闭散列 开散列
闭散列:有两种(线性探测法&&二次探测法)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 key 存放到冲突位置中的 下一个 空位置中去。
开散列:它的叫法有很多,也叫做哈希桶/链地址法/拉链法
开散列: 首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子
集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。 参照下图:


3.简单模拟实现哈希表

3.1.哈希表概念

我们之前学过的顺序结构和平衡树中,查找一个元素时,都要经过关键码的多次比较顺序查找的效率O(N),平衡树的查找效率O(logN)。这些都不是我们想要的搜索方法,我们想要的搜索方法是O(1),可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。向该结构中插入元素时以某种哈希函数插入,取元素的时候,也通过该哈希函数取出来,该方式即为哈希(散列)方法构造出来的结构称为哈希表(Hash Table)(或者称散列表)

但该种方法插入元素的时候,也有一定的缺陷,就是一定会存在哈希冲突,但是可以接受。

3.2.哈希表的简单实现

代码实现

public class HashBuck {
    static class Node {
        public int key;
        public int val;
        public Node next;
        public Node(int key, int val) {
            this.key = key;
            this.val = val;
        }
    }
    public Node[] array;
    public int usedSize;
    public static final double DEFAULT_LOAD_FACTOR = 0.75;//负载因子
    public static final int DEFAULT_SIZE = 8;
    public HashBuck() {
        this.array = new Node[DEFAULT_SIZE];
    }
    //插入数据
    public void put(int key, int val) {
        Node node = new Node(key, val);
        int index = key % array.length;
        Node cur = array[index];
        //检查桶里面有无相同key的元素,有则覆盖val,没有则头插
        while(cur != null) {
            if(cur.key == key) {
                cur.val = val;
                return;
            }
            cur = cur.next;
        }
        //没有return就进行头插,底层是尾插
        node.next = array[index];
        array[index] = node;
        this.usedSize++;
        //检查负载因子
        if(loadFactor() >= DEFAULT_LOAD_FACTOR) {
            reSize();
        }
    }
    private double loadFactor() {
        return this.usedSize * 1.0 / array.length;
    }
    //扩容
    private void reSize() {
        //申请一个两倍大小的数组
        Node[] newArray = new Node[2 * array.length];
        //重新哈希
        for (int i = 0; i < array.length; i++) {
            Node cur = array[i];
            while(cur != null) {
                //找每个下标中哈希桶里的每个结点重新哈希后的下标
                int index = cur.key % newArray.length;
                Node curNext = cur.next;//注意先保存
                cur.next = newArray[index];
                newArray[index] = cur;
                cur = cur.next;
            }
        }
        array = newArray;
    }
    //根据key获取val
    public int get(int key) {
        int index = key % array.length;
        Node cur = array[index];
        while(cur != null) {
            if(cur.key == key) {
                return cur.val;
            }
            cur = cur.next;
        }
        return -1;
    }
}

 说明:以上的代码只是简单的实现了两个重要的函数:插数据和取数据

并且只是简单的实现,底层的树化并没有实现。

问题--》

问题一:以上代码的key是整形,所以找地址的时候,可以直接用 key % array.length,如果我的key是一个引用类型呢???,我怎么找地址???

下面这段代码,两者的 id 都一样,运行结果却不一样,这就和我们刚刚的相同的key发生冲突就不一致了。

class Person {
    public String id;
    public Person(String id) {
        this.id = id;
    }
    @Override
    public String toString() {
        return "Person{" +
                "id=" + id +
                '}';
    }
}
public class Test {
    public static void main(String[] args) {
        Person person1 = new Person("10101");
        Person person2 = new Person("10101");
        System.out.println(person1.hashCode());
        System.out.println(person2.hashCode());
    }
}

正确的处理方法:重写hashCode()方法

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 id == person.id;
    }
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
public class Test {
    public static void main(String[] args) {
        Person person1 = new Person("10101");
        Person person2 = new Person("10101");
        System.out.println(person1.hashCode());
        System.out.println(person2.hashCode());
    }
}

1.为什么引用类型就要谈到 hashCode() ??

因为如果key是引用类型,就不能通过模上数组的长度来寻址了。而 hashCode() 作用就是返回对象的哈希代码值,简单来说,他就是一个整数

2.按道理来说,学号相同的两个对象应该是同一个人,为什么重写 hashCode(),返回对象的哈希代码值才会一样,不重写为什么会导致最终在数组中寻找的地址不相同??

因为底层的hashCode()是Object类的方法,底层是由C/C++代码写的,我们是看不到,但是因为它是根据对象的存储位置来返回的哈希代码值,这里就可以解释了,person1和person2本质上就是两个不同的对象,在内存中存储的地址也不同,所以最终返回的哈希代码值必然是不相同的,哈希代码值不同,那么在数组中根据 hash % array.length 寻找的地址也就不相同。而重写 hashCode() 方法之后,咱们根据 Person 中的成员变量 id 来返回对应的哈希代码值,这就相当于当一个对象,多次调用,那么返回的哈希代码值就必然相同。

 所以我们的哈希表的实现就可以相应的改写成这样:

public class HashBuck<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 = (Node<K,V>[]) new Node[10];
    public int usedSize;
    public static final double DEFAULT_LOAD_FACTOR = 0.75;

    public void put(K key, V val) {
        Node<K,V> node = new Node<>(key,val);
        int hash = key.hashCode();
        int index = hash % array.length;
        Node<K,V> cur = array[index];
        while(cur != null) {
            if(cur.key.equals(key)) {
                cur.val = val;
                return;
            }
            cur = cur.next;
        }
        //头插
        node.next = array[index];
        array[index] = node;
        this.usedSize++;
        if(loadFactor() >= DEFAULT_LOAD_FACTOR) {
            reSize();
        }
    }
    private double loadFactor() {
        return this.usedSize * 1.0 / array.length;
    }
    private void reSize() {
        Node<K,V>[] newArray = (Node<K, V>[]) new Node[2 * array.length];
        for (int i = 0; i < array.length; i++) {
            Node<K,V> cur = array[i];
            while (cur != null) {
                Node<K,V> curNext = cur.next;
                int hash = cur.key.hashCode();
                int index = hash % newArray.length;
                cur.next = newArray[index];
                newArray[index] = cur;
                cur = cur.next;
            }
        }
        array = newArray;
    }

    public V get(K key) {
        int hash = key.hashCode();
        int index = hash % array.length;
        Node<K,V> cur = array[index];
        while(cur != null) {
            if(cur.key == key) {
                return cur.val;
            }
            cur = cur.next;
        }
        return null;
    }
}
性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入 / 删除 / 查找时间复杂度是 O(1)

面试问题一:hashCode()和equals() 在HashMap中的作用分别是什么???

hashCode():用来找元素在数组中的位置;

equals():用来比较数组下链表中的每个元素的 key 与我的 key 是否相同。

equals也一样,如果不重写,上面的person1和person2的比较结果必然是不相同。

hashCode()和equals()就好比查字典,比如要查美丽,肯定要先查美字在多少页--hashCode(),然后它的组词有美景,美女,美丽,equals()就能找到美丽。

面试问题二:如果hashCode一样,那么equals一定一样吗? 如果equals一样,hashCode一定一样吗??

答案肯定是不一定,一定。

同一个地址下链表中的key不一定一样,就好比数组长度为10,4和14找到的都是4下标。

而equals一样,hashCode就一定一样,4和4肯定都在4下标。

所以这时候再回过头来看HashMap数据的打印时,就能明白HashMap和HashSet为什么无序了,它本身就不是一个顺序结构,,

 至于TreeMap和TreeSet为啥有序,这就和我们之前学过的优先级队列是一个道理了。(整形的key,输出时,自然而然就排好序了,如果key是引用类型,则需要实现Comparable接口,或者传比较器)


4.哈希表部分底层源码的分析

哈希表底层部分成员属性的分析: 

面试问题:以下两个桶的数组容量分别是多大?

HashMap<String,Integer> map = new HashMap<>(19); //桶1

HashMap<String,Integer> map = new HashMap<>(); //桶2

刚刚我们分析了成员属性和成员方法,桶的只是定义了,并没有看见给桶开辟大小??那我们如何put 进去元素呢?

首先可以确定的是桶 2 的大小为 0,至于为什么没开辟空间也可以 put 元素,我们就需要分析底层的 put 函数,接下来我们带着疑惑继续分析源码,,

  结论:

1.桶2的默认大小是0,但是在put进去第一个元素时,它的容量就扩容为了16.

2.我们可以看到底层寻址的方式不是 hash % array.length,而是 (n-1) & hash,因为 JDK规定数组的长度必须是 2 的某个次幂。因为当 n 是 2 的某个次幂时,hash % array.length 与(n-1) & hash 得到的值是一样的,并且位运算的效率高。所以桶1的容量就不是19,而是2的某个次幂向上取整,所以桶1大小为32,我们可以继续看带一个参数的构造方法的源码:


本期到此结束,谢谢观看!!!

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Master_hl

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值