Bootstrap

【Java 数据结构】Map和Set

🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!

欢迎志同道合的朋友一起加油喔🦾🦾🦾


目录

1.概念

1.1概念及场景

1.2 模型

1.3 Map的说明

1.4 Map方法的介绍

1.5 Set的说明

1.6 Set方法的介绍

2、哈希表

2.1 什么是哈希表

3. 哈希冲突

3.1 概念

 3.2 降低哈希冲突的发生的概率

3.2.1 设计好的哈希函数

3.2.2 降低负载因子

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

闭散列:有两种(线性探测法&&二次探测法)

线性探测

二次探测

开散列:它的叫法有很多,也叫做哈希桶/链地址法/拉链法

 ③若遇到负载因子过大,要扩容,那么存入的数据又该怎么进行处理???(链表中的每一个数要进行重新哈希),以下为二倍扩容后的图​编辑实现一个哈希表

 重写hashCode()方法

性能分析

小结

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

5. 一些有关 Map 和 Set 的题目

1. 统计一组数据中的每个数据出现了多少次

2. 将一组数据中多余的数据去重

3. 找出第一个重复出现的数据

4. 只出现一次的数字

5. 复制带随机指针的链表

6. 宝石与石头

7. 键盘坏了的键

8. 前K个高频单词



1.概念

1.1概念及场景

①Map和Set的作用:

一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关 。

②Map和Set相比于其他类型的优点:

之前我们学过的常见搜索方式有:  直接遍历, 二分查找等

上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如:

1. 根据姓名查询考试成绩

2. 通讯录,即根据姓名查询联系方式

3. 不重复集合,即需要先搜索关键字是否已经在集合中

可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的 Map 和 Set 是 一种适合 动态查找的集合容器 

1.2 模型

1. 纯 key 模型:

eg.有一个英文词典,快速查找一个单词是否在词典中 ;快速查找某个名字在不在通讯录中

2.Key-Value 模型

eg.统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:<单词,单词出现的次数 > ;梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号

而 Map中存储的就是key-value的键值对,Set中只存储了Key

1.3 Map的说明

Map 中存储的是 key-value的键值对, Map 是一个接口类,该类没有继承自 Collection ,该类中存储的是 <K,V> 结构的键值对,并且 K 一定是唯一的,不 能重复 。

1.4 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 是否有序不关心,需要更高的

时间性能

遍历map的几种方式:

public static void main(String[] args) {
    Map<String,Integer> map = new HashMap<>();
    map.put("小诗诗",18);
    map.put("小甜甜",19);
    map.put("小红红",20);

    //使用entrySet方法和增强for循环遍历map集合
    Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
    for (Map.Entry<String, Integer> entry: entrySet) {
        System.out.println("key = " + entry.getKey()+" value = " + entry.getValue());
    }

    //使用keySet方法和增强for循环遍历map集合
    for (String key : map.keySet()) {
        Integer value = map.get(key);
        System.out.println("key = " +key+" value = "+ value);
    }
    
    //使用 values() 来遍历所有的值(但这种方法无法获取对应的键
    System.out.println("==========");
    for(Integer value : map.values()) {
        System.out.print(value+" ");
    }
}

其中 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.5 Set的说明

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

1.6 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 什么是哈希表

最理想的搜索方法 , 即就是在查找某元素时 , 不进行任何比较的操作 , 一次直接查找到需要搜索的元素 , 可以达到这种要求的方法就是哈希表.

哈希表就是通过构造一种存储结构 , 通过某种函数使元素存储的位置与其关键码位形成一 一映射的关系 , 这样在查找元素的时候就可以很快找到目标元素.

哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

例如:

存在一个数组集合 {1,7,6,4,5,9}.
哈希函数设置为:hash(key) = key % capacity;
capacity 为存储元素底层空间总的大小。

如图所示: 这样存储数据更加便于查找

 采取上面的方法,确实能避免多次关键码的比较,搜索的效率也提高的,但是问题来了,拿上述图的情况来举例子的话,我接着还要插入一个元素 14,该怎么办呢?

这个就是我们本章的重点,哈希冲突,4%10 = 4;14%10 = 4,此时发生了哈希冲突。

3. 哈希冲突

3.1 概念

首先我们得知道,哈希冲突是必然的,无论怎么插入,插入多少都无法杜绝,哪怕就插入两个元素4,14都发生了哈希冲突,我们能做的就是尽量避免哈希冲突的发生。

这也就是我们哈希表这种结构存在的问题。

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

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

 3.2 降低哈希冲突的发生的概率

两种解决方法

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

3.2.1 设计好的哈希函数

哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。

  • 哈希函数计算出来的地址能均匀分布在整个空间中。

  • 哈希函数应该比较简单。

常用的两种哈希函数

1. 直接定制法

取关键字的某个线性函数为散列地址: Hash  Key  = A*Key + B

优点:简单、均匀。

缺点:需要事先知道关 键字的分布情况 使用场景:适合查找比较小且连续的情况。

力扣上这道题可以帮助我们理解: 字符串中第一个只出现一次字符

2. 除留余数法

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

3.2.2 降低负载因子

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

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

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

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

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

解决哈希冲突 两种常见的方法是: 闭散列 和 开散列

闭散列:有两种(线性探测法&&二次探测法)

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以  key 存放到冲突位置中的  下一个 ” 空位置中去。

线性探测

①什么是线性探测:

从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

②线性探测的相关操作:

当插入操作时,通过哈希函数获取待插入元素在哈希表中的位置 ;如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 ;下一个空位置,插入新元素

简而言之就是寻找下一个空的地方

③弊端:(可能会导致冲突元素均被放在一起) 

二次探测

①如何进行二次探测:

利用这个公式进入插入。其中:i = 1,2,3…,Hi是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

对于上述线性探测中的问题如果要插入44,产生冲突,使用解决后的情况为:

②重要结论:

当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情 况,但在插入时必须确保表的装载因子a 不超过 0.5 ,如果超出必须考虑增容。

因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

开散列:它的叫法有很多,也叫做哈希桶/链地址法/拉链法

①什么是哈希桶???

开散列法又叫链地址法 ( 开链法 ) , 首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。 开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。 参照下图:

 ②哈希桶如何进行存储???(链式存储法)

 ③若遇到负载因子过大,要扩容,那么存入的数据又该怎么进行处理???(链表中的每一个数要进行重新哈希),以下为二倍扩容后的图实现一个哈希表

代码如下:

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 HashBuck() {
        this.array = new Node[10];
    }
 
    /**
     * put函数
     * @param key
     * @param val
     */
    public void put(int key,int val) {
        //1、找到Key所在的位置
        int index = key % this.array.length;
        //2、遍历这个下标的链表,看是不是有相同的key。有 要更新val值的
        Node cur = array[index];
        while (cur != null) {
            if(cur.key == key) {
                cur.val = val;//更新val值
                return;
            }
            cur = cur.next;
        }
        //3、没有这个key这个元素,头插法
        Node node = new Node(key, val);
        node.next = array[index];
        array[index] = node;
        this.usedSize++;
        //4、插入元素成功之后,检查当前散列表的负载因子
        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) {
                int index = cur.key % newArray.length;//获取新的下标 11
                //就是把cur这个节点,以头插/尾插的形式 插入到新的数组对应下标的链表当中
                Node curNext = cur.next;
                cur.next = newArray[index];//先绑定后面
                newArray[index] = cur;//绑定前面
                cur = curNext;
            }
        }
        array = newArray;
    }
 
    private double loadFactor() {
        return 1.0*usedSize/array.length;
    }
 
    /**
     * 根据key获取val值
     * @param key
     * @return
     */
    public int get(int key) {
        //1、找到Key所在的位置
        int index = key % this.array.length;
        //2、遍历这个下标的链表,看是不是有相同的key。有 要更新val值的
        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("134");
        Person person2 = new Person("134");
        System.out.println(person1.hashCode());
        System.out.println(person2.hashCode());
    }
}

但是这个时候直接输出他们的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("134");
        Person person2 = new Person("134");
        System.out.println(person1.hashCode());
        System.out.println(person2.hashCode());
    }
}

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

  • 在引用类型中,比较对象的相等性通常需要使用 equals() 方法。但是在某些情况下,我们需要将对象用作集合(如 HashSetHashMap)的键或进行哈希表操作时,还需要使用 hashCode() 方法。
  • 在 Java 中,对象的哈希码(hash code)在哈希表等数据结构中用于确定对象的存储位置。哈希表通过使用对象的哈希码来快速查找和操作元素,以实现高效的插入、查找和删除操作。因此,为了在哈希表中正确地处理对象,我们需要重写 hashCode() 方法。
  • 在哈希表中,首先会根据对象的哈希码计算出存储位置(存储桶),然后再使用 equals() 方法来进一步比较键的相等性。如果不重写 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中的作用分别是什么???

  1. hashCode() 方法用于确定对象在哈希表中的存储位置。HashMap 使用哈希码来计算键的存储位置,以便在查找、插入和删除元素时能够快速定位到对应的存储桶(bucket)。每个键对象的 hashCode() 方法返回的哈希码将被用作该键在哈希表中的索引。

  2. equals() 方法用于检查两个键对象是否相等。当两个键的哈希码相同时,HashMap 会使用 equals() 方法来进一步比较键的内容是否相等。这是为了解决哈希冲突的情况,当不同的键具有相同的哈希码时,需要通过 equals() 方法进行确切的比较来确定它们是否相等。

具体来说,当我们向 HashMap 中插入键值对时,它会根据键的哈希码找到对应的存储桶,并在该存储桶中进行查找或插入操作。在这个过程中,会使用键对象的 hashCode() 方法来计算哈希码,并使用 equals() 方法来检查键的相等性。

在使用自定义类作为键时,我们通常需要重写 hashCode()equals() 方法,以确保它们的正确性和一致性。重写 hashCode() 方法是为了根据对象的属性计算哈希码,以便在哈希表中能够正确定位到存储位置。而重写 equals() 方法是为了根据对象的属性比较相等性,以确保在哈希表中进行查找、插入和删除操作时的正确性。

总结而言,hashCode() 方法在 HashMap 中用于确定键的存储位置,而 equals() 方法用于检查键的相等性。这两个方法在 HashMap 中共同工作,确保正确的键查找和存储。

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

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

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

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

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

  1. 如果两个对象的 hashCode() 相等,不一定意味着它们的 equals() 一定相等。这是因为哈希码的计算可能会发生冲突,不同的对象可能具有相同的哈希码。这种情况称为哈希冲突。当两个对象的哈希码相等时,equals() 方法会被调用来进一步比较它们的内容以确定它们是否相等。

  2. 如果两个对象的 equals() 相等,那么它们的 hashCode() 一定相等。这是因为根据 equals() 的定义,相等的对象必须具有相同的哈希码,以满足哈希表等数据结构的要求。如果两个对象的 equals() 返回 true,则它们的哈希码应该相等,以便正确地处理它们在哈希表中的存储和查找。

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

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

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

所以这时候再回过头来看HashMap数据的打印时,就能明白HashMap和HashSet为什么无序了,它本身就不是一个顺序结构,至于TreeMap和TreeSet为啥有序,这就和我们之前学过的优先级队列是一个道理了。(整形的key,输出时,自然而然就排好序了,如果key是引用类型,则需要实现Comparable接口,或者传比较器)

小结

1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
2. java 中使用的是哈希桶方式解决冲突的
3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。

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,我们可以继续看带一个参数的构造方法的源码:

5. 一些有关 Map 和 Set 的题目

1. 统计一组数据中的每个数据出现了多少次

/**
 * 统计一组数据中的每个数据出现了多少次
 */
 
import java.util.HashMap;
import java.util.Map;

public class Test {
    public static void main(String[] args) {
        char[] arr = {'a','c','e','c','b','d','f','c','d'};
        Map<Character,Integer> map = computer(arr);
        System.out.println(map);
    }

    public static Map<Character,Integer> computer(char[] arr){
        Map<Character,Integer> map = new HashMap<>();
        
        //如果在 map 中没有数组对应的 Key,那么就添加 1个 Key 进去
        //如果在 map 中包含了和数组对应的 Key,且 Key 对应 count个 Val,
        //那么就添加 count + 1个 Key 进去
        for (char x :arr) {
            if(map.containsKey(x)){
                int count = map.get(x);
                map.put(x, count+1);
            }else {
                map.put(x,1);
            }
        }
        return map;
    }
}

2. 将一组数据中多余的数据去重

/**
 * 将一组数据中多余的数据去重
 */
 
import java.util.HashSet;
import java.util.Set;

public class Test {
    public static void main(String[] args) {
        int[] arr = {1,3,5,7,9,3,2,4,6,8,5,4,6};

        Set<Integer> set = new HashSet<>();
        for (int x : arr) {
            set.add(x);
        }
        System.out.println(set);
    }
}

3. 找出第一个重复出现的数据

/**
 * 在一组数据中,找出第一个重复出现的数据
 */
 
import java.util.HashSet;
import java.util.Set;

public class Test6 {
    public static void main(String[] args) {
        int[] arr = {1,3,5,7,9,3,2,4,6,8,5,4,6};
        
        Set<Integer> set = new HashSet<>();
        int i = 0;
        //如果在添加元素到 set 之前,就已经发现了 set 中包含某个数组的元素,
        //那么就 break
        for (i = 0; i < arr.length; i++) {
            if(set.contains(arr[i])){
                break;
            }
            set.add(arr[i]);
        }
        System.out.println(arr[i]); //拿到的就是第一次出现的重复值
    }
}

4. 只出现一次的数字

leetcode 136

方法一:

/**
 * 利用两两元素之间异或,最终得出的就是单独元素
 */
class Solution {
    public int singleNumber(int[] nums) {
        int x = nums[0];
        for(int i= 1; i<nums.length; i++){
            x = x^nums[i];
        }
        return x;
    }
}

方法二:

/**
 * 利用集合 Set
 * 如果集合中有相同元素,就移除,反之,就添加
 */
class Solution {
    public int singleNumber(int[] nums) {
        Set<Integer> set = new HashSet<>();
        for(int i=0; i<nums.length; i++){
            if(!set.contains(nums[i])){
                set.add(nums[i]);
            }else{
                set.remove(nums[i]);
            }
        }

        for(int i=0; i<nums.length; i++){
            if(set.contains(nums[i])){
                return nums[i];
            }
        }
        return -1;
    }
}

5. 复制带随机指针的链表

leetcode 138

本题需要详细掌握 Map 的操作,与其对应的映射关系,才能将本题理解到位。

/**
 * 通过 Map 的映射关系
 * 知道了 Key 的状态,实现 Value 的状态
 * 即通过旧节点之间的关系,实现新节点之间的关系
 */
class Solution {
    public Node copyRandomList(Node head) {
        if(head == null) return null;
        
        //Key - 旧节点,Value - 新节点
        Map<Node,Node> map = new HashMap<>(); 
        Node cur = head;
        
        //1. 创建新的节点
        while(cur != null){
            map.put(cur, new Node(cur.val)); 
            cur = cur.next;
        }

        cur = head;
        //2. 连接各个节点
        while(cur != null){
            map.get(cur).next = map.get(cur.next);
            map.get(cur).random = map.get(cur.random);
            cur = cur.next;
        }

        return map.get(head);
    }
}

6. 宝石与石头

leetcode 771

方法一

/**
 * 方法一
 * 1. 将 jewels 字符串中的字符放入集合 set 中
 * 2. 查看集合 set 中是否包含 stones 字符数组中字符
 * 3. 用 count 计数,最后返回
 */
class Solution {
    public int numJewelsInStones(String jewels, String stones) {
    
        Set<Character> set = new HashSet<>();
        for(char x:jewels.toCharArray()){ //1.
            set.add(x);
        }

        int count = 0;
        for(char j:stones.toCharArray()){ //2.
            if(set.contains(j)){
                count++;
            }
        }
        return count; //3.
    }
}

方法二

/**
 * 方法二
 * 两层 for 循环  
 */
class Solution {
    public int numJewelsInStones(String jewels, String stones) {
        int count = 0;
        for(int i=0; i<jewels.length(); i++){
            for(int j=0; j<stones.length();j++){
                if( jewels.charAt(i) == stones.charAt(j) ){
                    count++;
                }
            }
        }
        return count;
    }
}

7. 键盘坏了的键

牛客网链接

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Scanner;
import java.util.Set;


/**
 * 1. 将实际输入的文字 str2 放在集合 set 中
 * 2. for 循环遍历应该输入的文字 str1, 
 * 若集合 set 不包含某个字符,且线性表之前也未出现该字符,就输出打印
 * 3. 将步骤 2 中不包含的字符放入顺序表中,以便下一次判断
 */
public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        String str1 = scanner.nextLine(); //应该输入的文字
        String str2 = scanner.nextLine(); //实际输入的文字
        out(str1,str2);
    }

    public static void out(String str1, String str2){
        Set<Character> set = new HashSet<>();
        ArrayList<Character> list = new ArrayList<>();

        for (char x:str2.toUpperCase().toCharArray()) {
            set.add(x); //1.
        }

        for(char x:str1.toUpperCase().toCharArray()){ //2.
            if(!set.contains(x) && !list.contains(x)){ 
                System.out.print(x);
            }
            list.add(x); //3.
        }
    }
}

8. 前K个高频单词

    力扣链接

class Solution {
    public List<String> topKFrequent(String[] words, int k) {
        Map<String,Integer> hashMap =new HashMap<>();
        for(String s: words) {
            if(hashMap.get(s) == null) {
                hashMap.put(s,1);
            }else {
                hashMap.put(s,hashMap.get(s)+1);
            }
        }
        //2、建立小根堆
        PriorityQueue<Map.Entry<String,Integer>> minHeap = new PriorityQueue<>(
                k, new Comparator<Map.Entry<String, Integer>>() {
            @Override
            public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                if(o1.getValue().compareTo(o2.getValue()) == 0) {
                    return o2.getKey().compareTo(o1.getKey());
                }
                return o1.getValue().compareTo(o2.getValue());
            }
        }
        );
        //3、遍历hashMap 把里面 的数据 放到小根堆
        for(Map.Entry<String,Integer> entry : hashMap.entrySet()) {
            if(minHeap.size() < k) {
                minHeap.offer(entry);
            }else {
                //小根堆放满了K个,下一个entry和堆顶元素比较
                Map.Entry<String,Integer> top = minHeap.peek();
                //堆顶的频率小于当前entry的频率 就出队 然后入队entry
                if(top.getValue().compareTo(entry.getValue()) < 0) {
                    minHeap.poll();
                    minHeap.add(entry);
                }else {
                    //频率相同的情况
                    if(top.getValue().compareTo(entry.getValue()) == 0) {
                        if(top.getKey().compareTo(entry.getKey()) > 0) {
                            minHeap.poll();
                            minHeap.add(entry);
                        }
                    }
                }
            }
        }
        //4、 此时小根堆当中已经有了结果
        //System.out.println(minHeap);
        List<String> ret = new ArrayList<>();
        for (int i = 0; i < k; i++) {
            String key = minHeap.poll().getKey();
            ret.add(key);
        }
        Collections.reverse(ret);
        //System.out.println("ret: "+ret);
        return ret;
    }
}

;