目录
基本概念
哈希表(hash table),又称散列表,它通过建立键key 与值value 之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键key ,则可以在𝑂(1) 时间内获取对应的值value 。
‧ 添加元素:仅需将元素添加至数组(链表)的尾部即可,使用𝑂(1) 时间。
‧ 查询元素:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用𝑂(𝑛) 时间。
‧ 删除元素:需要先查询到元素,再从数组(链表)中删除,使用𝑂(𝑛) 时间。
数组 链表 哈希表
查找元素 𝑂(𝑛) 𝑂(𝑛) 𝑂(1)
添加元素 𝑂(1) 𝑂(1) 𝑂(1)
删除元素 𝑂(𝑛) 𝑂(𝑛) 𝑂(1)
观察发现,在哈希表中进行增删查改的时间复杂度都是𝑂(1) ,非常高效。
哈希表常用操作
import java.util.HashMap;
import java.util.Map;
public class HashTableExample {
public static void main(String[] args) {
// 创建一个空的哈希表
HashMap<String, Integer> hashTable = new HashMap<>();
// 插入键值对
hashTable.put("one", 1);
hashTable.put("two", 2);
hashTable.put("three", 3);
// 查找键值对
Integer value = hashTable.get("two");
System.out.println("Value for 'two': " + value);
// 更新键值对
hashTable.put("two", 22);
// 删除键值对
hashTable.remove("two");
// 清空哈希表
hashTable.clear();
// 检查是否包含键
boolean containsTwo = hashTable.containsKey("two");
System.out.println("Contains 'two': " + containsTwo);
// 获取键集合
Map<String, Integer> map = new HashMap<>(hashTable);
System.out.println("Keys: " + map.keySet());
// 获取值集合
System.out.println("Values: " + map.values());
// 获取哈希表大小
int size = hashTable.size();
System.out.println("Size of the hash table: " + size);
}
}
输出结果
Value for 'two': 2
Contains 'two': false
Keys: []
Values: []
Size of the hash table: 0
说明
HashMap<String, Integer> hashTable = new HashMap<>(); 创建了一个新的空哈希表。
hashTable.put("key", value) 用于插入键值对。
hashTable.get("key") 用于获取键对应的值,如果键不存在则返回 null。
hashTable.remove("key") 用于删除键值对。
hashTable.clear() 清空哈希表。
hashTable.containsKey("key") 检查键是否存在。
hashTable.keySet() 和 hashTable.values() 分别返回键和值的集合。
hashTable.size() 返回哈希表中键值对的数量。
基本实现
用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到key 对应的桶,并在桶中获取value 。
那么,如何基于key 定位对应的桶呢?
这是通过哈希函数(hash function)实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有key ,输出空间是所有桶(数组索引)。换句话说,输入一个key ,我们可以通过哈希函数得到该key 对应的键值对在数组中的存储位置。输入一个key
哈希函数的计算过程分为以下两步。
1. 通过某种哈希算法hash() 计算得到哈希值。
2. 将哈希值对桶数量(数组长度)capacity 取模,从而获取该key 对应的数组索引index 。
index = hash(key) % capacity
我们可以利用index 在哈希表中访问对应的桶,从而获取value 。设数组长度capacity = 100、哈希算法hash(key) = key ,易得哈希函数为key % 100。
哈希冲突与扩容
哈希冲突
哈希冲突指的是两个不同的键经过哈希函数计算后得到了相同的哈希值,从而导致它们被映射到了同一个数组索引位置上。哈希冲突是不可避免的,尤其是在哈希表逐渐填满时更为常见。
解决哈希冲突的方法
常见的解决哈希冲突的方法有以下几种:
- 链地址法:每个数组位置存储一个链表或更复杂的数据结构(如红黑树),当发生冲突时,新元素被添加到对应的链表或数据结构中。
- 开放寻址法:当发生冲突时,使用某种策略(如线性探测、二次探测)来寻找下一个可用的空槽位。
- 再哈希法:使用第二个哈希函数来计算另一个索引位置。
扩容
原因
随着哈希表中元素数量的增加,哈希冲突的概率也会增加,这会导致性能下降。因此,通常会在负载因子(即哈希表中元素的数量除以哈希表的大小)达到一定阈值时对哈希表进行扩容。在 Java 中,HashMap 类默认的负载因子是 0.75。
扩容步骤
- 创建一个新的更大的数组:通常是原数组大小的两倍。
- 重新哈希所有元素:将旧数组中的所有元素重新计算哈希值,并放入新的数组中。
- 更新引用:让哈希表指向新的数组。
- 在 Java HashMap 中,扩容时会先创建一个新的更大的数组,然后遍历旧数组中的每一个链表或红黑树,并将这些元素重新哈希到新的数组中。如果链表过长,则会转换成红黑树以提高查找效率。
关于先插入还是先扩容
对于 HashMap 来说,当尝试插入一个元素时,如果发现当前容量已经达到负载因子的限制,则会触发扩容操作。也就是说,扩容发生在插入之前,确保有足够的空间来放置新元素。这样可以避免频繁的扩容操作带来的性能开销。
哈希冲突
链式地址
定义
在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。
基于链式地址实现的哈希表的操作方法发生了以下变化:
- 查询元素:输入
key
,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比key
以查找目标键值对。- 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
- 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
链式地址存在以下局限性:
- 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
- 查询效率降低:因为需要线性遍历链表来查找对应元素。
注意要点
- 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
- 以下实现包含哈希表扩容方法。当负载因子超过 23 时,我们将哈希表扩容至原先的 2 倍。
- 值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至 O(logn) 。
开放地址
定义:
开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
1. 线性探测
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
- 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
- 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回
value
即可;如果遇到空桶,说明目标元素不在哈希表中,返回None
。
缺点:
线性探测容易产生“聚集现象”:解释:数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
注意:
我们不能在开放寻址哈希表中直接删除元素:这是因为删除元素会在数组内产生一个空桶
None
,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
解决方案:
我们可以采用懒删除 lazy deletion机制:它不直接从哈希表中移除元素,而是利用一个常量
TOMBSTONE
来标记这个桶。在该机制下,None
和TOMBSTONE
都代表空桶,都可以放置键值对。但不同的是,线性探测到TOMBSTONE
时应该继续遍历,因为其之下可能还存在键值对。方案缺点:
懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着
TOMBSTONE
的增加,搜索时间也会增加,因为线性探测可能需要跳过多个TOMBSTONE
才能找到目标元素。方案解决:
考虑在线性探测中记录遇到的首个
TOMBSTONE
的索引,并将搜索到的目标元素与该TOMBSTONE
交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
2. 平方探测
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即 1,4,9,… 步。
优势:
- 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。
- 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
缺点:
- 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
- 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。
3. 多次哈希
顾名思义,多次哈希方法使用多个哈希函数 f1(x)、f2(x)、f3(x)、… 进行探测。
- 插入元素:若哈希函数 f1(x) 出现冲突,则尝试 f2(x) ,以此类推,直到找到空位后插入元素。
- 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回
None
。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
注意
开放寻址(线性探测、平方探测和多次哈希)哈希表都存在“不能直接删除元素”的问题。
哈希算法
定义:
哈希算法是一种将任意长度的数据(通常称为“消息”)转换为固定长度的输出(通常称为“摘要”或“哈希值”)的过程。这种转换是单向的,意味着从哈希值很难(理论上是不可能的)逆向推导出原始数据。哈希算法在密码学和计算机科学中有广泛的应用,例如用于数据完整性检查、密码存储、数字签名等。
关键特性
- 确定性:相同的输入总是产生相同的输出。
- 唯一性:不同的输入应该尽可能产生不同的输出。
- 不可逆性:从哈希值难以推算出原始输入数据。
- 抗碰撞性:找到两个不同输入产生相同输出的情况非常困难。
常见的哈希算法
- MD5 : 生成128位的哈希值。由于安全性问题,不推荐用于安全敏感应用。
SHA家族 :
- SHA-1: 生成160位的哈希值,已经不再被认为是安全的。
- SHA-2: 包括多个版本,如SHA-256, SHA-512等,分别生成256位和512位的哈希值。
- SHA-3: 是一个新的标准,包括多种变体,例如Keccak算法。
其他哈希算法
GOST (GOST R 34.11-94): 俄罗斯标准的哈希算法,生成256位哈希值。
SM3: 中国国家密码管理局发布的密码杂凑算法标准,生成256位哈希值。
MurmurHash: 一种快速非加密哈希函数,常用于非安全目的。
在Java中的实现
在Java中,可以通过java.security.MessageDigest类来实现哈希算法。下面是一个简单的例子,演示如何使用SHA-256算法生成一个字符串的哈希值:
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashExample {
public static void main(String[] args) {
try {
// 创建一个SHA-256 MessageDigest实例
MessageDigest digest = MessageDigest.getInstance("SHA-256");
// 需要哈希的字符串
String input = "Hello, world!";
// 将字符串转换为字节数组
byte[] inputBytes = input.getBytes();
// 计算哈希值
byte[] hashBytes = digest.digest(inputBytes);
// 将哈希值转换为十六进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
// 输出哈希值
System.out.println("SHA-256 hash of '" + input + "': " + hexString.toString());
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}