【Java】Java中的HashMap和HashSet


一、哈希表

1.1 什么是哈希表

哈希表(Hash Table),也称为散列表,是一种常见的数据结构,用于实现键值对的存储和查找。它通过哈希函数将键映射到数组中的位置,从而实现快速的插入、删除和查找操作。

哈希表的核心思想是使用哈希函数将键转换成数组中的索引位置。哈希函数将键映射到一个固定大小的数组中,每个数组位置称为桶(bucket)。当需要插入或查找一个键值对时,通过哈希函数计算键的哈希值,然后将哈希值映射到数组索引上,从而快速定位到键值对应的桶位置。

哈希表的主要优势是具有快速的插入、删除和查找操作。在理想情况下,哈希函数能够将键均匀地映射到数组的不同位置,使得每个桶中的键值对数量尽可能均匀分布。这样可以使得插入和查找的时间复杂度接近常数级别(O(1)),即不受数据规模的影响。

然而,在实际应用中,哈希函数可能会出现冲突,即不同的键计算得到相同的哈希值,导致键值对被映射到同一个桶中。为了解决冲突,常见的方法是使用链表或红黑树来存储冲突的键值对。当桶中的链表长度超过一定阈值时,链表可能会转化为红黑树,以提高查找性能。

1.2 哈希冲突

哈希冲突指的是不同的键通过哈希函数计算得到相同的哈希值,从而被映射到哈希表中的同一个桶(bucket)位置。由于哈希表的存储空间是有限的,当不同的键映射到同一个桶时,就会产生冲突。

哈希冲突可能会导致以下问题:

  1. 数据丢失:当两个不同的键经过哈希函数计算得到相同的哈希值,并被映射到同一个桶中时,会发生键值对的覆盖,导致其中一个键值对丢失。

  2. 查找性能下降:在发生哈希冲突的桶中,需要遍历链表或红黑树来查找目标键值对,这会导致查找操作的性能下降,时间复杂度从常数级别变为线性级别(O(n))。

1.3 冲突避免

冲突避免(Collision Avoidance)是在哈希表中解决哈希冲突的一种策略,目的是尽量减少冲突的发生,以提高哈希表的性能和效率。

冲突避免的常见方法包括以下几种:

  1. 良好的哈希函数设计:选择一个良好的哈希函数可以尽量减少冲突的发生。好的哈希函数能够将输入数据均匀地映射到哈希值的范围内,减少相同或相似的输入数据产生相同哈希值的概率。

  2. 增加哈希表容量:通过增加哈希表的容量(桶的数量),可以分散键值对的分布,降低发生冲突的可能性。当哈希表负载因子(Load Factor)较高时,考虑进行扩容操作。

  3. 均匀分布键值对:在插入键值对时,尽量保持键值对的分布均匀。例如,可以随机插入键值对,或者根据键的特性选择插入位置等。

  4. 拉链法(Chaining):在链表法中,通过链表将冲突的键值对串联在一起。在哈希函数设计和哈希表容量合理的情况下,链表法可以有效地避免冲突。

  5. 开放地址法(Open Addressing):开放地址法尝试将冲突的键值对存储在其他空闲的桶位置,而不是使用链表。常见的开放地址法包括线性探测、二次探测、双重哈希等。

  6. 二次哈希法(Secondary Hashing):二次哈希法是一种开放地址法,使用多个哈希函数来处理冲突。每个哈希函数都可以将键映射到不同的桶位置,以减少冲突的可能性。

冲突避免的策略取决于具体的应用场景和需求。在设计哈希表时,需要根据数据特性、负载因子、性能要求等因素综合考虑,并选择合适的冲突避免方法。

1.4 哈希函数

哈希函数(Hash Function)是一种将输入数据映射到固定大小的哈希值(Hash Value)或哈希码(Hash Code)的函数。它接受任意长度的输入数据,并输出一个固定长度的哈希值。哈希函数的设计目标是使得不同的输入数据产生不同的哈希值,且相同的输入数据产生相同的哈希值。

哈希函数的特性:

  1. 一致性:相同的输入数据应该始终产生相同的哈希值。即对于相同的输入 x,多次应用哈希函数应该得到相同的结果。

  2. 高效性:哈希函数的计算应该快速,以便能够在常数时间内完成。

  3. 均匀性:哈希函数应该将输入数据均匀地映射到哈希值的范围内,以尽可能减少冲突的发生。

  4. 雪崩效应:输入数据的微小变化应该导致哈希值的巨大变化。这样可以确保即使输入数据有微小的改变,其哈希值也会有较大的差异。

1.5 闭散列

闭散列(Closed Hashing),也称为开放定址法(Open Addressing),是一种解决哈希冲突的方法,其中冲突的键值对被存储在哈希表中的其他空闲桶位置,而不是使用链表或其他数据结构。

闭散列的基本原理是,在发生冲突时,通过一定的算法在哈希表中查找下一个可用的桶位置,直到找到一个空闲的桶或达到哈希表的末尾。常见的闭散列算法包括线性探测、二次探测和双重哈希等。

  1. 线性探测(Linear Probing):当发生冲突时,线性探测会依次检查下一个桶位置,直到找到一个空闲的桶或哈希表的末尾。探测的步长是常量,通常为 1。

  2. 二次探测(Quadratic Probing):二次探测会使用二次函数来计算下一个探测的桶位置。当发生冲突时,探测的步长会按照二次函数的规律逐渐增加,直到找到一个空闲的桶或哈希表的末尾。

  3. 双重哈希(Double Hashing):双重哈希使用两个不同的哈希函数来计算下一个探测的桶位置。当发生冲突时,双重哈希会计算出一个新的探测步长,并继续查找下一个桶位置,直到找到一个空闲的桶或哈希表的末尾。

在闭散列中,当哈希表的负载因子(Load Factor)较高时,即填充因子接近或超过 1 时,性能可能会受到影响,因为冲突的概率增加。因此,通常需要根据实际情况定期进行哈希表的扩容,以保持较低的负载因子,以提高闭散列的性能。

1.6 开散列

开散列(Open Hashing),也称为链表法(Chaining),是一种解决哈希冲突的方法,其中冲突的键值对被存储在哈希表中的同一个桶位置的链表中。

开散列的基本原理是,在发生冲突时,将冲突的键值对添加到桶位置的链表中。每个桶都存储一个链表,链表中的每个节点包含一个键值对。当发生冲突时,新的键值对会被追加到链表的末尾。

开散列的优点是简单易实现,适用于处理冲突较多的情况。由于每个桶都存储了一个链表,可以容纳多个键值对,因此不会发生键值对被覆盖的情况。

开散列的操作包括:

  1. 插入操作:当插入一个键值对时,首先计算键的哈希值,然后根据哈希值找到对应的桶位置。如果桶位置为空,则创建一个新节点存储键值对;如果桶位置已经有链表存在,则将新节点添加到链表的末尾。

  2. 查找操作:当查找一个键值对时,同样需要计算键的哈希值,然后根据哈希值找到对应的桶位置,再在桶位置的链表中进行线性查找,直到找到目标键值对或链表结束。

  3. 删除操作:删除操作类似于查找操作,首先计算键的哈希值,找到对应的桶位置,然后在桶位置的链表中找到目标键值对并删除。

但是,对于开散列,当哈希表的负载因子(Load Factor)较高时,即链表的长度较长时,查找的性能可能会下降。因此,通常需要根据实际情况定期进行哈希表的扩容,以保持较低的负载因子,以提高开散列的性能。

二、HashMap的使用

2.1 常用方法

HashMap 提供了许多常用的方法,以下是一些常见的方法及其示例:

  1. put(key, value):向 HashMap 中插入键值对。
HashMap<String, Integer> map = new HashMap<>();
map.put("apple", 5);
map.put("banana", 3);
map.put("orange", 8);
  1. get(key):根据键获取对应的值。
int count = map.get("apple");
System.out.println("The count of apples: " + count);
  1. remove(key):根据键移除对应的键值对。
map.remove("banana");
  1. containsKey(key):检查 HashMap 是否包含指定的键。
if (map.containsKey("orange")) {
    System.out.println("The map contains orange.");
}
  1. containsValue(value):检查 HashMap 是否包含指定的值。
if (map.containsValue(5)) {
    System.out.println("The map contains a value of 5.");
}
  1. size():获取 HashMap 中键值对的数量。
int size = map.size();
System.out.println("The size of the map: " + size);
  1. keySet():获取 HashMap 中所有键的集合。
Set<String> keys = map.keySet();
for (String key : keys) {
    System.out.println("Key: " + key);
}
  1. values():获取 HashMap 中所有值的集合。
Collection<Integer> values = map.values();
for (int value : values) {
    System.out.println("Value: " + value);
}

2.2 Map.Entry

Map.Entry 是 Java 中用于表示键值对的接口。它是 Map 接口中的一个嵌套接口,定义了表示键值对的方法和属性。

Map.Entry 接口提供了以下常用的方法:

  1. getKey():获取当前键值对的键。

  2. getValue():获取当前键值对的值。

  3. setValue(V value):设置当前键值对的值为指定的值。

Map.Entry 接口通常与迭代器(Iterator)一起使用,用于遍历 Map 中的键值对。通过迭代器的方式可以逐个访问 Map 中的键值对,并使用 Map.Entry 的方法获取键和值。

以下是一个使用 Map.Entry 遍历 Map 的示例:

Map<String, Integer> map = new HashMap<>();
map.put("apple", 5);
map.put("banana", 3);
map.put("orange", 8);

// 使用迭代器遍历 Map
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Integer> entry = iterator.next();
    String key = entry.getKey();
    int value = entry.getValue();
    System.out.println("Key: " + key + ", Value: " + value);
}

在上述示例中,通过 map.entrySet() 获取到 Map 中所有键值对的集合,然后使用迭代器遍历集合,每次迭代得到一个 Map.Entry 对象,通过该对象的 getKey()getValue() 方法获取键和值。

Map.Entry 接口提供了一种方便的方式来遍历和操作 Map 中的键值对,特别适用于需要同时访问键和值的场景。

2.3 底层结构

HashMap 的底层实现使用了数组和链表(或红黑树),具体取决于元素的数量和哈希冲突的情况。

在 Java 8 及之前的版本中,HashMap 的底层实现是数组加链表的结构。具体来说,HashMap 内部维护了一个 Entry(条目)数组,每个 Entry 是一个键值对的结构。当插入元素时,HashMap 根据键的哈希值确定它在数组中的索引位置,如果发生哈希冲突,即多个键的哈希值映射到了同一个索引位置,那么这些键值对将以链表的形式连接在一起,形成一个链表。通过链表的方式解决了哈希冲突问题。

但在 Java 8 中,为了提高性能,当链表长度达到一定阈值(默认为 8)时,HashMap 会将链表转换为红黑树,这是一种自平衡的二叉搜索树。使用红黑树可以在最坏情况下将查找和插入的时间复杂度从 O(n) 降低到 O(log n),进一步提高了查找效率。

在 JDK 8 及以后的版本中,HashMap 使用链表来解决哈希冲突。当链表长度达到一定阈值时,默认为 8,HashMap 会将链表转换为红黑树,以提高查找性能。

转变为红黑树的条件包括以下两个:

  1. 链表长度达到阈值:当链表的长度达到一定阈值(默认为 8)时,HashMap 会判断是否将链表转换为红黑树。这是因为当链表较长时,使用链表进行查找的性能可能较低,而红黑树的查找性能更好。

  2. 数组长度达到最小树化阈值:HashMap 内部维护了一个数组,当数组长度小于最小树化阈值(默认为 64)时,不会进行树化操作。这是为了避免在初始阶段或容量较小时频繁进行树化操作,以保持较好的性能。

需要注意的是,HashMap 在进行树化操作时,并不是直接将整个链表转换为红黑树,而是先将链表的一部分元素(至少是 8 个)提取出来,形成一个新的红黑树节点,然后将剩余的链表继续保留在原位置。

在 JDK 8 中,红黑树的树化操作是通过 TreeNode 类来实现的。当链表转换为红黑树后,HashMap 会使用 TreeNode 对象来代替原先的链表节点,以支持红黑树的操作。

树的退化:

在 HashMap 中,当红黑树节点的链表长度变小于一定阈值(默认为 6)时,会触发树的退化操作。

树的退化过程如下:

  1. 当红黑树节点的链表长度小于退化阈值(默认为 6)时,HashMap 会将红黑树重新转换为链表。

  2. 在退化操作中,HashMap 会将红黑树节点上的键值对按照原先的顺序重新连接成一个链表。

  3. 转换后的链表会取代原先的红黑树节点,存储在相同的桶(bucket)位置。

2.4 扩容机制

HashMap 的扩容机制是为了保持较低的负载因子(Load Factor)和较高的性能。负载因子是指 HashMap 中实际存储的键值对数量与当前容量的比值。

当 HashMap 中的键值对数量超过负载因子与当前容量的乘积时,即超过了负载因子的阈值,就会触发扩容操作。

HashMap 的默认负载因子为 0.75,这是一个经验上的权衡值,既保证了较高的查找性能,又减少了空间的浪费。当实际存储的键值对数量超过容量乘以负载因子时,HashMap 会自动进行扩容。

扩容操作主要包括以下几个步骤:

  1. 创建一个新的容量更大的数组(通常是当前容量的两倍)。

  2. 将原来数组中的键值对重新分配到新的数组中。这涉及到重新计算键的哈希值和确定在新数组中的位置。

  3. 更新 HashMap 的容量和阈值,以反映新的数组大小和负载因子。

  4. 扩容完成后,原来的数组会被丢弃,成为可被垃圾回收的对象。

扩容过程可能会耗费一定的时间和计算资源,但它保证了 HashMap 在负载因子较低的情况下维持了较好的性能,避免了哈希冲突的增加和查找效率的下降。

需要注意的是,由于扩容涉及到重新计算键的哈希值和重新分配位置,所以在扩容期间可能会导致一些操作的性能略有下降。因此,在预知大量数据插入的情况下,可以通过构造函数或使用 HashMap(int initialCapacity) 方法来初始化 HashMap,并指定一个适当的初始容量,以减少扩容的次数和性能影响。

2.5 使用案例

例如:前K个高频词汇

  • 给定一个单词列表 words 和一个整数 k ,返回前 k 个出现次数最多的单词。
  • 返回的答案应该按单词出现频率由高到低排序。如果不同的单词有相同出现频率, 按字典顺序排序。
class Solution {
    public List<String> topKFrequent(String[] words, int k) {
        HashMap<String, Integer> map = new HashMap<>();
        //1. 统计每个单词频率
        for (String word : words) {
            if(map.get(word) == null){
                map.put(word, 1);
            } else {
                Integer val = map.get(word);
                map.put(word, val + 1);
            }
        }

        //2. 遍历map当中的每个 entry, 建立大小为K的小根堆
        PriorityQueue<Map.Entry<String, Integer>> priorityQueue = new PriorityQueue<>(k, new Comparator<Map.Entry<String, Integer>>() {
            @Override
            public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                if(Objects.equals(o1.getValue(), o2.getValue())){
                    return o2.getKey().compareTo(o1.getKey());
                }

                return o1.getValue() - o2.getValue();
            }
        });

        Set<Map.Entry<String, Integer>> entries = map.entrySet();


        for (Map.Entry<String, Integer> entry : entries) {
            if(priorityQueue.size() < k) {
                priorityQueue.offer(entry);
            } else {
                Map.Entry<String, Integer> top = priorityQueue.peek();
                // 当前元素的频率大于堆顶元素
                if (entry.getValue().compareTo(top.getValue()) > 0) {
                    priorityQueue.poll();
                    priorityQueue.offer(entry);
                } else {
                    // 两个元素频率相同
                    if(entry.getValue().compareTo(top.getValue()) == 0){
                        if(top.getKey().compareTo(entry.getKey()) > 0){
                            priorityQueue.poll();
                            priorityQueue.offer(entry);
                        }
                    }
                }
            }
        }

        List<String> list = new ArrayList<>();
        while (priorityQueue.size() != 0){
            Map.Entry<String, Integer> entry = priorityQueue.poll();
            list.add(0, entry.getKey());
        }

        // System.out.println(priorityQueue);

        return list;
    }
}

三、HashSet的使用

HashSet 是 Java 中的一个集合类,它实现了 Set 接口,用于存储一组唯一的元素,不允许重复。HashSet 是基于哈希表的数据结构实现的,具有快速的插入、删除和查找操作。

3.1 常用方法

HashSet 提供了一系列常用的方法,以下是一些常见的方法示例:

  1. 添加元素:
Set<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("orange");
  1. 判断元素是否存在:
boolean contains = set.contains("apple");
System.out.println("Set contains 'apple': " + contains);
  1. 删除元素:
set.remove("banana");
  1. 获取集合大小:
int size = set.size();
System.out.println("Set size: " + size);
  1. 判断集合是否为空:
boolean isEmpty = set.isEmpty();
System.out.println("Set is empty: " + isEmpty);
  1. 清空集合:
set.clear();
  1. 遍历集合:
for (String item : set) {
    System.out.println(item);
}

需要注意的是,HashSet 中的元素不保证按照特定的顺序存储,元素的顺序可能会发生变化。HashSet 使用元素的哈希值来确定元素的存储位置,因此添加到 HashSet 中的元素必须正确实现 hashCode()equals() 方法,以确保元素的唯一性。

除了上述方法之外,HashSet 还继承了 Set 接口和 Collection 接口中定义的其他方法,例如添加多个元素的 addAll() 方法、移除多个元素的 removeAll() 方法、判断是否包含指定集合的 containsAll() 方法等。

3.2 底层结构

HashSet 的底层结构是基于 HashMap 实现的

  • HashSet 内部维护了一个 HashMap 对象,实际上它是使用 HashMap 的键(Key)来存储元素,而将 HashMap 的值(Value)设置为一个常量对象。

  • 在 HashSet 中,所有元素都被存储为 HashMap 的键,而值则为一个固定的 Object(常量 PRESENT),这个 Object 并不会被使用,只是起到占位的作用。

  • 具体来说,HashSet 利用了 HashMap 的键值对结构,将元素作为 HashMap 的键存储在内部的 HashMap 对象中。这样可以借助 HashMap 的去重机制,确保 HashSet 中的元素唯一,因为 HashMap 的键是不允许重复的。

  • 在实际使用时,当调用 HashSet 的 add() 方法时,实际上是将元素作为 HashMap 的键,将 PRESENT 对象作为对应的值存储在 HashMap 中。当调用 contains() 方法时,HashSet 会通过 HashMap 的 containsKey() 方法来判断元素是否存在。

因为 HashSet 的底层实现依赖于 HashMap,所以 HashSet 具有与 HashMap 相似的特性,例如快速的插入、删除和查找操作。但需要注意的是,HashSet 并不保证元素的顺序,因为它是基于哈希表实现的。

3.3 使用案例

例如:宝石与石头

  • 给一个字符串 jewels 代表石头中宝石的类型,另有一个字符串 stones 代表拥有的石头。 stones 中每个字符代表了一种拥有的石头的类型,求拥有的石头中有多少是宝石。
  • 字母区分大小写,因此 “a” 和 “A” 是不同类型的石头。
class Solution {
    public int numJewelsInStones(String jewels, String stones) {
        HashSet<Character> set = new HashSet<>();
        int count = 0;
        for (int i = 0; i < jewels.length(); i++) {
            set.add(jewels.charAt(i));
        }

        for (int i = 0; i < stones.length(); i++) {
            if(set.contains(stones.charAt(i))){
                count++;
            }
        }

        return count;
    }
}
  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

求知.

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

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

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

打赏作者

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

抵扣说明:

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

余额充值