🔍 哈希表 (Hash Table)
哈希表是一种使用哈希函数将键映射到存储位置的数据结构,以实现快速数据查找。
1️⃣ 特点
- 快速的查找、插入和删除 由于直接访问内存位置,常数时间复杂度内可以完成操作。
- 键值对存储 存储数据的形式为键值对。
- 冲突处理 当两个键的哈希值相同时,需要一种机制来处理这种冲突,常见的方法有链地址法和开放地址法等。
- 动态扩容 为了保持操作的效率,当哈希表的装载因子超过一定阈值时,通常需要进行扩容。
2️⃣ Java中有哪些常用哈希表,属于那种哈希表
-
HashMap
- 描述
HashMap
是 Java 中最常用的哈希表实现。 - 特点
- 允许
null
键和null
值。 - 内部实现为数组+链表/红黑树结构。
- 不保证映射的顺序,特别是不保证该顺序恒久不变。
- 允许
- 示例
HashMap<String, Integer> hashMap = new HashMap<>(); hashMap.put("one", 1); Integer value = hashMap.get("one");
- 描述
-
Hashtable
- 描述
Hashtable
是一个古老的哈希表实现。 - 特点
- 不允许
null
键和null
值。 - 线程安全,所有的公有方法都使用了
synchronized
修饰。 - 性能上较
HashMap
略慢,因为它同步所有操作。
- 不允许
- 示例
Hashtable<String, Integer> hashtable = new Hashtable<>(); hashtable.put("one", 1);
- 描述
-
LinkedHashMap
- 描述
LinkedHashMap
是HashMap
的一个子类,维护了访问的顺序。 - 特点
- 可以按照插入顺序或访问顺序排序。
- 通常用于实现LRU缓存策略。
- 示例
LinkedHashMap<String, Integer> lhm = new LinkedHashMap<>(16, 0.75f, true); lhm.put("three", 3); lhm.get("three");
- 描述
-
ConcurrentHashMap
- 描述
ConcurrentHashMap
是一个线程安全的哈希表实现。 - 特点
- 采用分段锁技术,允许多个修改操作并发进行。
- 在 Java 8 中,分段锁技术已被废弃,转而使用了红黑树。
- 支持高并发的读写操作。
- 示例
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>(); chm.put("four", 4);
- 描述
3️⃣ Java中哈希表的使用
- 初始化
HashMap<String, Integer> map = new HashMap<>();
- 插入
map.put("key", 1);
- 查找
int value = map.get("key");
- 删除
map.remove("key");
4️⃣ Java中的常用场景
- 数据去重 使用HashSet或HashMap来快速去除重复数据。
- 缓存实现 使用HashMap或LinkedHashMap来实现简单的缓存。
- 快速查找 在需要快速查找数据是否存在的场景中使用。
5️⃣ 工具类的使用
Apache Commons Collections
提供了一些增强的哈希表实现和相关的工具类。Google Guava
提供了多种强大的哈希表实现,如Multimap
、BiMap
等。
⚠️ 注意: 在使用哈希表时,应考虑哈希函数的选择、装载因子和扩容策略,以确保性能。
📌 哈希取模
哈希取模是在哈希表中分配键值对的常用方法。
-
过程:
- 首先,使用哈希函数计算给定键的哈希值。
- 使用取模操作确定该键值对应该存储在哈希表的哪个位置。这通常是通过将哈希值模上哈希表的大小来实现的。
-
用途:
- 通过取模操作,可以确保分配的哈希位置在哈希表的大小范围内。
- 这种方法也有助于在哈希表中均匀地分配键值对,减少碰撞。
-
示例:
int hash = key.hashCode(); int index = hash % tableSize;
📌 数学归纳法
数学归纳法是一种数学证明方法,用于证明一系列语句中的每一条都是真的。
-
过程:
- 基础步骤 (Base Step): 首先证明该语句对于最小的值(通常是1或0)是成立的。
- 归纳假设 (Inductive Hypothesis): 假设该语句对于某个特定的值k是成立的。
- 归纳步骤 (Inductive Step): 在归纳假设的基础上证明该语句对于k+1也是成立的。
-
用途:
- 数学归纳法经常用于证明序列或数列中的性质或属性。
-
示例:
假设我们想要证明对于所有非负整数n,有 (1 + 2 + 3 + … + n = \frac{n(n + 1)}{2}) 成立:- 基础步骤: 当n=1时,左边的和为1,右边为(\frac{1(1+1)}{2})。两边相等。
- 归纳假设: 假设该公式对于某个特定的值k成立。
- 归纳步骤: 基于上述假设,证明该公式对于k+1也成立。
经过这三个步骤,我们可以得出结论:该公式对于所有非负整数n都成立。
📌 哈希码与哈希冲突
-
📜 哈希码的定义
- 在Java中,每个对象都有一个与之关联的哈希码,可以通过对象的
hashCode()
方法获取。 - 哈希码通常用作快速访问数据结构(如哈希表)中的对象的方法,因为哈希码的计算通常比完整的对象比较要快。
- 在Java中,每个对象都有一个与之关联的哈希码,可以通过对象的
-
📎 哈希冲突的原因
- 哈希函数是将大范围的输入映射到较小范围的输出的函数。
- 由于输出范围的大小是有限的,而输入范围可能是无限的或非常大的,因此不同的输入可能会被映射到同一个输出,导致哈希冲突。
-
📎 哈希函数的设计与冲突
- 哈希函数的目标是均匀地分布输出,以减少冲突。
- 但实际的函数可能并不完美,有些函数可能更容易导致冲突。
- 在Java中,对象的
hashCode()
方法提供了对象的哈希值,但很多类都重写了这个方法以提供更有意义的哈希值。
📌 对象的唯一性
-
📜 在Java中,对象的唯一性通常由两个因素共同确定:
- 哈希码(通过
hashCode()
方法得到) - 相等性(通过
equals()
方法确定)
- 哈希码(通过
-
📎 为什么需要哈希码和相等性
- 哈希码可能会冲突,因此我们不能仅仅依赖哈希码来确定对象的唯一性。
- 当我们在哈希表中查找一个键时,首先会使用其哈希码来定位可能的位置。找到具有相同哈希码的键后,会使用
equals()
方法进一步确认这是我们要找的确切键。
-
📌
hashCode()
和equals()
关系:- 📎 当两个对象按照
equals()
方法比较是相等的,那么它们的hashCode()
必须相同。 - 📎 两个对象的
hashCode()
相同,并不意味着它们一定相等。
- 📎 当两个对象按照
-
📝 为什么会这样?:
- 📍
hashCode()
方法返回的是一个int
值,这意味着只有 (2^{32}) 个可能的哈希码值。 - 📍 可能存在的对象数量是无限的,因此许多不同的对象可能会有相同的哈希码。
- 📍
⚠️ 注意: 当重写 equals()
方法时,为了维护这种关系,通常也需要重写 hashCode()
方法。
📌 常见的冲突处理方法
-
📎 链地址法 (
Separate Chaining
)- 每个表项不再是单一记录,而是一个链表。
- 当新的记录的哈希值与已存在的记录的哈希值冲突时,将其添加到相应的链表中。
- 需要额外的内存来存储链表结构。
public class HashTable<K, V> { private LinkedList<Entry<K, V>>[] table; public void put(K key, V value) { int index = hash(key); if (table[index] == null) { table[index] = new LinkedList<>(); } table[index].add(new Entry<>(key, value)); } }
⚠️ 注意: 如果链表过长,查找效率可能会降低。为了保持效率,当链表长度超过某个阈值时,可能需要调整哈希表的大小。
-
📎 开放地址法 (
Open Addressing
)- 当发生冲突时,寻找下一个可用的位置。
- 常见的探查策略有线性探查、二次探查和双重哈希等。
- 所有的记录都存储在哈希表本身,无需额外的数据结构。
public class HashTable<K, V> { private Entry<K, V>[] table; public void put(K key, V value) { int index = hash(key); while (table[index] != null) { index = (index + 1) % table.length; // 线性探查 } table[index] = new Entry<>(key, value); } }
⚠️ 注意: 为了保持查找效率,通常当哈希表被填满到一定程度时,会进行扩容。
📌 LRU (Least Recently Used) 缓存策略
LRU
(Least Recently Used,最近最少使用)缓存是一种常用的缓存策略。它根据数据项的访问顺序来确定哪些数据项是最近最少使用的,从而进行缓存的替换。
📍 实现步骤
1️⃣ 定义缓存的容量
- 确定缓存可以存储的数据项数量上限。
- 当缓存达到容量上限时,需要进行替换。
2️⃣ 使用数据结构支持快速访问和删除操作
- 使用双向链表和哈希表的组合。
- 双向链表用于保持数据项的访问顺序。
- 哈希表用于实现快速的数据项查找。
3️⃣ 数据项访问时的操作
- 检查数据项是否在缓存中存在。
- 如果存在,将数据项移到链表的头部。
- 如果不存在,将其添加到缓存中。如果缓存已满,删除链表尾部的数据项并添加新数据项到链表头部。
4️⃣ 数据项删除操作
- 当缓存达到容量上限时,删除链表尾部的数据项。
- 在哈希表中快速找到要删除的数据项,并更新链表的前后指针。
⚠️ 注意 在实际编码中,可以使用Java提供的数据结构如 LinkedHashMap
或 LinkedHashSet
。这些数据结构已经提供了LRU缓存的特性,简化代码实现。在多线程环境下,缓存的并发访问需要同步操作,保证线程安全。