文章目录
一、哈希表
1.1 什么是哈希表
哈希表(Hash Table),也称为散列表,是一种常见的数据结构,用于实现键值对的存储和查找。它通过哈希函数将键映射到数组中的位置,从而实现快速的插入、删除和查找操作。
哈希表的核心思想是使用哈希函数将键转换成数组中的索引位置。哈希函数将键映射到一个固定大小的数组中,每个数组位置称为桶(bucket)。当需要插入或查找一个键值对时,通过哈希函数计算键的哈希值,然后将哈希值映射到数组索引上,从而快速定位到键值对应的桶位置。
哈希表的主要优势是具有快速的插入、删除和查找操作。在理想情况下,哈希函数能够将键均匀地映射到数组的不同位置,使得每个桶中的键值对数量尽可能均匀分布。这样可以使得插入和查找的时间复杂度接近常数级别(O(1)),即不受数据规模的影响。
然而,在实际应用中,哈希函数可能会出现冲突,即不同的键计算得到相同的哈希值,导致键值对被映射到同一个桶中。为了解决冲突,常见的方法是使用链表或红黑树来存储冲突的键值对。当桶中的链表长度超过一定阈值时,链表可能会转化为红黑树,以提高查找性能。
1.2 哈希冲突
哈希冲突指的是不同的键通过哈希函数计算得到相同的哈希值,从而被映射到哈希表中的同一个桶(bucket)位置。由于哈希表的存储空间是有限的,当不同的键映射到同一个桶时,就会产生冲突。
哈希冲突可能会导致以下问题:
-
数据丢失:当两个不同的键经过哈希函数计算得到相同的哈希值,并被映射到同一个桶中时,会发生键值对的覆盖,导致其中一个键值对丢失。
-
查找性能下降:在发生哈希冲突的桶中,需要遍历链表或红黑树来查找目标键值对,这会导致查找操作的性能下降,时间复杂度从常数级别变为线性级别(O(n))。
1.3 冲突避免
冲突避免(Collision Avoidance)是在哈希表中解决哈希冲突的一种策略,目的是尽量减少冲突的发生,以提高哈希表的性能和效率。
冲突避免的常见方法包括以下几种:
-
良好的哈希函数设计:选择一个良好的哈希函数可以尽量减少冲突的发生。好的哈希函数能够将输入数据均匀地映射到哈希值的范围内,减少相同或相似的输入数据产生相同哈希值的概率。
-
增加哈希表容量:通过增加哈希表的容量(桶的数量),可以分散键值对的分布,降低发生冲突的可能性。当哈希表负载因子(Load Factor)较高时,考虑进行扩容操作。
-
均匀分布键值对:在插入键值对时,尽量保持键值对的分布均匀。例如,可以随机插入键值对,或者根据键的特性选择插入位置等。
-
拉链法(Chaining):在链表法中,通过链表将冲突的键值对串联在一起。在哈希函数设计和哈希表容量合理的情况下,链表法可以有效地避免冲突。
-
开放地址法(Open Addressing):开放地址法尝试将冲突的键值对存储在其他空闲的桶位置,而不是使用链表。常见的开放地址法包括线性探测、二次探测、双重哈希等。
-
二次哈希法(Secondary Hashing):二次哈希法是一种开放地址法,使用多个哈希函数来处理冲突。每个哈希函数都可以将键映射到不同的桶位置,以减少冲突的可能性。
冲突避免的策略取决于具体的应用场景和需求。在设计哈希表时,需要根据数据特性、负载因子、性能要求等因素综合考虑,并选择合适的冲突避免方法。
1.4 哈希函数
哈希函数(Hash Function)是一种将输入数据映射到固定大小的哈希值(Hash Value)或哈希码(Hash Code)的函数。它接受任意长度的输入数据,并输出一个固定长度的哈希值。哈希函数的设计目标是使得不同的输入数据产生不同的哈希值,且相同的输入数据产生相同的哈希值。
哈希函数的特性:
-
一致性:相同的输入数据应该始终产生相同的哈希值。即对于相同的输入 x,多次应用哈希函数应该得到相同的结果。
-
高效性:哈希函数的计算应该快速,以便能够在常数时间内完成。
-
均匀性:哈希函数应该将输入数据均匀地映射到哈希值的范围内,以尽可能减少冲突的发生。
-
雪崩效应:输入数据的微小变化应该导致哈希值的巨大变化。这样可以确保即使输入数据有微小的改变,其哈希值也会有较大的差异。
1.5 闭散列
闭散列(Closed Hashing),也称为开放定址法(Open Addressing),是一种解决哈希冲突的方法,其中冲突的键值对被存储在哈希表中的其他空闲桶位置,而不是使用链表或其他数据结构。
闭散列的基本原理是,在发生冲突时,通过一定的算法在哈希表中查找下一个可用的桶位置,直到找到一个空闲的桶或达到哈希表的末尾。常见的闭散列算法包括线性探测、二次探测和双重哈希等。
-
线性探测(Linear Probing):当发生冲突时,线性探测会依次检查下一个桶位置,直到找到一个空闲的桶或哈希表的末尾。探测的步长是常量,通常为 1。
-
二次探测(Quadratic Probing):二次探测会使用二次函数来计算下一个探测的桶位置。当发生冲突时,探测的步长会按照二次函数的规律逐渐增加,直到找到一个空闲的桶或哈希表的末尾。
-
双重哈希(Double Hashing):双重哈希使用两个不同的哈希函数来计算下一个探测的桶位置。当发生冲突时,双重哈希会计算出一个新的探测步长,并继续查找下一个桶位置,直到找到一个空闲的桶或哈希表的末尾。
在闭散列中,当哈希表的负载因子(Load Factor)较高时,即填充因子接近或超过 1 时,性能可能会受到影响,因为冲突的概率增加。因此,通常需要根据实际情况定期进行哈希表的扩容,以保持较低的负载因子,以提高闭散列的性能。
1.6 开散列
开散列(Open Hashing),也称为链表法(Chaining),是一种解决哈希冲突的方法,其中冲突的键值对被存储在哈希表中的同一个桶位置的链表中。
开散列的基本原理是,在发生冲突时,将冲突的键值对添加到桶位置的链表中。每个桶都存储一个链表,链表中的每个节点包含一个键值对。当发生冲突时,新的键值对会被追加到链表的末尾。
开散列的优点是简单易实现,适用于处理冲突较多的情况。由于每个桶都存储了一个链表,可以容纳多个键值对,因此不会发生键值对被覆盖的情况。
开散列的操作包括:
-
插入操作:当插入一个键值对时,首先计算键的哈希值,然后根据哈希值找到对应的桶位置。如果桶位置为空,则创建一个新节点存储键值对;如果桶位置已经有链表存在,则将新节点添加到链表的末尾。
-
查找操作:当查找一个键值对时,同样需要计算键的哈希值,然后根据哈希值找到对应的桶位置,再在桶位置的链表中进行线性查找,直到找到目标键值对或链表结束。
-
删除操作:删除操作类似于查找操作,首先计算键的哈希值,找到对应的桶位置,然后在桶位置的链表中找到目标键值对并删除。
但是,对于开散列,当哈希表的负载因子(Load Factor)较高时,即链表的长度较长时,查找的性能可能会下降。因此,通常需要根据实际情况定期进行哈希表的扩容,以保持较低的负载因子,以提高开散列的性能。
二、HashMap的使用
2.1 常用方法
HashMap 提供了许多常用的方法,以下是一些常见的方法及其示例:
- put(key, value):向 HashMap 中插入键值对。
HashMap<String, Integer> map = new HashMap<>();
map.put("apple", 5);
map.put("banana", 3);
map.put("orange", 8);
- get(key):根据键获取对应的值。
int count = map.get("apple");
System.out.println("The count of apples: " + count);
- remove(key):根据键移除对应的键值对。
map.remove("banana");
- containsKey(key):检查 HashMap 是否包含指定的键。
if (map.containsKey("orange")) {
System.out.println("The map contains orange.");
}
- containsValue(value):检查 HashMap 是否包含指定的值。
if (map.containsValue(5)) {
System.out.println("The map contains a value of 5.");
}
- size():获取 HashMap 中键值对的数量。
int size = map.size();
System.out.println("The size of the map: " + size);
- keySet():获取 HashMap 中所有键的集合。
Set<String> keys = map.keySet();
for (String key : keys) {
System.out.println("Key: " + key);
}
- 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 接口提供了以下常用的方法:
-
getKey():获取当前键值对的键。
-
getValue():获取当前键值对的值。
-
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 会将链表转换为红黑树,以提高查找性能。
转变为红黑树的条件包括以下两个:
-
链表长度达到阈值:当链表的长度达到一定阈值(默认为 8)时,HashMap 会判断是否将链表转换为红黑树。这是因为当链表较长时,使用链表进行查找的性能可能较低,而红黑树的查找性能更好。
-
数组长度达到最小树化阈值:HashMap 内部维护了一个数组,当数组长度小于最小树化阈值(默认为 64)时,不会进行树化操作。这是为了避免在初始阶段或容量较小时频繁进行树化操作,以保持较好的性能。
需要注意的是,HashMap 在进行树化操作时,并不是直接将整个链表转换为红黑树,而是先将链表的一部分元素(至少是 8 个)提取出来,形成一个新的红黑树节点,然后将剩余的链表继续保留在原位置。
在 JDK 8 中,红黑树的树化操作是通过 TreeNode
类来实现的。当链表转换为红黑树后,HashMap 会使用 TreeNode
对象来代替原先的链表节点,以支持红黑树的操作。
树的退化:
在 HashMap 中,当红黑树节点的链表长度变小于一定阈值(默认为 6)时,会触发树的退化操作。
树的退化过程如下:
-
当红黑树节点的链表长度小于退化阈值(默认为 6)时,HashMap 会将红黑树重新转换为链表。
-
在退化操作中,HashMap 会将红黑树节点上的键值对按照原先的顺序重新连接成一个链表。
-
转换后的链表会取代原先的红黑树节点,存储在相同的桶(bucket)位置。
2.4 扩容机制
HashMap 的扩容机制是为了保持较低的负载因子(Load Factor)和较高的性能。负载因子是指 HashMap 中实际存储的键值对数量与当前容量的比值。
当 HashMap 中的键值对数量超过负载因子与当前容量的乘积时,即超过了负载因子的阈值,就会触发扩容操作。
HashMap 的默认负载因子为 0.75,这是一个经验上的权衡值,既保证了较高的查找性能,又减少了空间的浪费。当实际存储的键值对数量超过容量乘以负载因子时,HashMap 会自动进行扩容。
扩容操作主要包括以下几个步骤:
-
创建一个新的容量更大的数组(通常是当前容量的两倍)。
-
将原来数组中的键值对重新分配到新的数组中。这涉及到重新计算键的哈希值和确定在新数组中的位置。
-
更新 HashMap 的容量和阈值,以反映新的数组大小和负载因子。
-
扩容完成后,原来的数组会被丢弃,成为可被垃圾回收的对象。
扩容过程可能会耗费一定的时间和计算资源,但它保证了 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 提供了一系列常用的方法,以下是一些常见的方法示例:
- 添加元素:
Set<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("orange");
- 判断元素是否存在:
boolean contains = set.contains("apple");
System.out.println("Set contains 'apple': " + contains);
- 删除元素:
set.remove("banana");
- 获取集合大小:
int size = set.size();
System.out.println("Set size: " + size);
- 判断集合是否为空:
boolean isEmpty = set.isEmpty();
System.out.println("Set is empty: " + isEmpty);
- 清空集合:
set.clear();
- 遍历集合:
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;
}
}