散列表
散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。
存储原理
散列表的存储过程可以分为两步:
- 计算哈希值 :当要插入一个键值对
(key, value)时,首先使用散列函数f(key)计算出key的哈希值。 - 确定存储位置 :将计算出的哈希值通过取模运算(
%)映射到散列表数组的有效索引范围内,然后将value存储在该索引位置。
例如:如果散列表大小为 10,键 "apple" 的哈希值是 1452,那么它最终会被存储在索引 1452 % 10 = 2 的位置。
哈希冲突
冲突是散列表中不可避免的问题。它指的是两个不同的关键字 key1 和 key2,经过散列函数计算后,得到了相同的哈希地址。
影响冲突的因素
- 散列函数的设计:一个好的散列函数应尽可能地将关键字均匀地分布在地址空间中。
- 装填因子 (Load Factor) :定义为
α = 表中填入的记录数 / 散列表的长度。α越大,表越满,发生冲突的概率就越高。
处理方法
链地址法 (Separate Chaining)
散列表的每个"桶"(即数组的每个位置)都维护一个链表(或其他数据结构)。所有哈希到同一位置的元素都会被添加到这个链表中。
- 优点:实现简单,对高装填因子不敏感,删除操作方便。
- 缺点:需要额外的空间存储指针,缓存局部性较差。
开放地址法 (Open Addressing)
当发生冲突时,按照某种探测序列(如线性探测、二次探测)在表中寻找下一个空的槽位来存放元素。
- 优点:所有数据都存储在数组中,缓存局部性好,没有额外的指针开销。
- 缺点:删除操作复杂,对装填因子敏感,当表快满时性能会急剧下降。
操作
插入 (Insert/Put)
- 计算键的哈希值并定位到数组索引。
- 如果该位置为空,直接插入。
- 如果该位置不为空(发生冲突),则根据冲突处理方法(如遍历链表或线性探测)找到合适位置插入。如果键已存在,则更新其值。
查找 (Search/Get)
- 计算键的哈希值并定位到数组索引。
- 如果该位置为空,说明键不存在,返回空。
- 如果该位置不为空,则在该位置(或对应的冲突链/探测序列)中查找目标键,找到则返回其值。
删除 (Remove/Delete)
- 计算键的哈希值并定位到数组索引。
- 在该位置(或对应的冲突链/探测序列)中找到目标键并执行删除。
- 对于开放地址法,删除不能简单地将位置置空,通常会使用一个特殊的"已删除"标记,以保证后续查找的正确性。
时间复杂度
| 操作 | 平均情况 | 最坏情况 |
|---|---|---|
| 插入 | O(1) | O(n) |
| 查找 | O(1) | O(n) |
| 删除 | O(1) | O(n) |
优缺点
优点
- 高效的平均性能:插入、查找、删除的平均时间复杂度为 O(1),速度极快。
- 灵活的键类型:支持多种键类型(整数、字符串、对象等),只要该类型是可哈希的。
- 实用性强:在需要快速查找的工程场景中被广泛使用。
缺点
- 最坏情况性能差:如果哈希函数设计不佳或装填因子过高,性能会退化为 O(n)。
- 无序性:元素在表中是无序存储的,无法直接按顺序遍历。
- 内存开销:链地址法需要存储指针,开放地址法可能会浪费一些桶空间。
- 键必须不可变:键的哈希值在其生命周期内不能改变,否则无法再找到它。因此,像列表这样的可变对象通常不能作为键。
应用场景
HashMap
JDK1.7中HashMap使用一个table数组来存储数据,用key的hashcode取模来决定key会被放到数组里 的位置,如果hashcode相同,或者hashcode取模后的结果相同,那么这些key会被定位到Entry数组的 同一个格子里,这些key会形成一个链表,在极端情况下比如说所有key的hashcode都相同,将会导致 这个链表会很长,那么put/get操作需要遍历整个链表,那么最差情况下时间复杂度变为O(n)。 扩容死链 针对JDK1.7中的这个性能缺陷,JDK1.8中的table数组中可能存放的是链表结构,也可能存放的是红黑 树结构,如果链表中节点数量不超过8个则使用链表存储,超过8个会调用treeifyBin函数,将链表转换 为红黑树。那么即使所有key的hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要 O(logn)的开销。
字典
Redis字典dict又称散列表(hash),是用来存储键值对的一种数据结构。Redis整个数据库是用字典来存储的。(K-V结构) 对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。 Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。
布隆过滤器
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机 hash映射函数。
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器的原理是:当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K 个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如 果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的 基本思想。
代码示例
java
import java.util.Objects;
public class MySimpleHashMap<K, V> {
// 1. 基础配置
private static final float LOAD_FACTOR = 0.75f; // 负载因子:默认0.75
private static final int DEFAULT_CAPACITY = 16; // 初始容量:必须是2的幂次方
// 2. 底层数据结构
private Node<K, V>[] table; // 桶数组
private int size; // 实际元素个数
private int threshold; // 扩容阈值 = capacity * loadFactor
// 3. 节点类 (模拟 JDK 的 Entry/Node)
private static class Node<K, V> {
int hash; // 缓存 Key 的哈希值
K key; // 键
V value; // 值
Node<K, V> next; // 下一个节点(解决冲突用)
Node(int hash, K key, V value) {
this.hash = hash;
this.key = key;
this.value = value;
}
}
// 构造方法
public MySimpleHashMap() {
this.table = new Node[DEFAULT_CAPACITY];
this.threshold = (int) (DEFAULT_CAPACITY * LOAD_FACTOR);
this.size = 0;
}
// ==========================================
// 核心方法:Put (插入/更新)
// ==========================================
public void put(K key, V value) {
// 1. 计算哈希值 (处理 null 键)
int hash = (key == null) ? 0 : hash(key.hashCode());
// 2. 计算数组索引 (位运算优化取模)
int index = indexFor(hash, table.length);
// 3. 检查该位置是否已有节点 (处理冲突 & 更新)
Node<K, V> current = table[index];
while (current != null) {
// 如果 Key 已存在,更新 Value 并直接返回
if (current.hash == hash && Objects.equals(key, current.key)) {
current.value = value;
return;
}
current = current.next;
}
// 4. Key 不存在,执行插入 (头插法)
Node<K, V> newNode = new Node<>(hash, key, value);
newNode.next = table[index]; // 新节点指向旧的头节点
table[index] = newNode; // 新节点成为新的头节点
size++;
// 5. 检查是否需要扩容
if (size >= threshold) {
resize();
}
}
// ==========================================
// 核心方法:Get (查询)
// ==========================================
public V get(K key) {
// 1. 计算哈希和索引
int hash = (key == null) ? 0 : hash(key.hashCode());
int index = indexFor(hash, table.length);
// 2. 遍历链表查找
Node<K, V> current = table[index];
while (current != null) {
// 先比对哈希值(快),再比对 equals(准)
if (current.hash == hash && Objects.equals(key, current.key)) {
return current.value;
}
current = current.next;
}
return null; // 没找到
}
// ==========================================
// 辅助方法:哈希扰动 & 索引计算
// ==========================================
// 简单的哈希扰动函数:让高位也参与运算,减少碰撞
private int hash(int h) {
return h ^ (h >>> 16);
}
// 计算索引:(n - 1) & hash 等价于 hash % n (前提是 n 是 2 的幂)
private int indexFor(int h, int length) {
return h & (length - 1);
}
// ==========================================
// 核心方法:Resize (扩容)
// ==========================================
private void resize() {
Node<K, V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 1. 容量翻倍
int newCapacity = oldCapacity << 1; // 左移一位 = 乘以 2
@SuppressWarnings("unchecked")
Node<K, V>[] newTable = new Node[newCapacity];
// 2. 重新哈希 (Rehash)
for (int i = 0; i < oldCapacity; i++) {
Node<K, V> node = oldTable[i];
while (node != null) {
Node<K, V> next = node.next; // 暂存下一个节点
// 3. 重新计算新位置
// 利用位运算特性:扩容后,节点要么在原位置,要么在 原位置+旧容量 的位置
int newIndex = (node.hash & (newCapacity - 1));
// 4. 插入新数组 (头插法)
node.next = newTable[newIndex];
newTable[newIndex] = node;
node = next;
}
}
table = newTable;
threshold = (int) (newCapacity * LOAD_FACTOR);
}
// ==========================================
// 测试主函数
// ==========================================
public static void main(String[] args) {
MySimpleHashMap<String, Integer> map = new MySimpleHashMap<>();
// 1. 插入数据
map.put("Apple", 10);
map.put("Banana", 20);
map.put("Orange", 30);
// 2. 查询数据
System.out.println("Apple: " + map.get("Apple")); // 输出: 10
// 3. 更新数据
map.put("Apple", 100);
System.out.println("Apple (Updated): " + map.get("Apple")); // 输出: 100
// 4. 触发扩容测试 (插入大量数据)
for (int i = 0; i < 20; i++) {
map.put("Key" + i, i);
}
System.out.println("当前容量: " + map.table.length); // 输出: 32 (16->32)
System.out.println("当前大小: " + map.size); // 输出: 23
}
}