第8课:哈希表
学习目标
通过本课学习,你将能够:
- 理解哈希表的基本工作原理与核心优势(快速查找)。
- 掌握两种主要的哈希冲突解决策略:链地址法与开放地址法。
- 实现一个基本的哈希表类,支持插入、查找和删除操作。
- 了解哈希表在现实编程中的典型应用场景。
核心概念
1. 什么是哈希表?
想象一个图书馆。如果我们想根据书名快速找到一本书,一种笨办法是从头到尾扫描所有书架。而聪明的做法是使用“索引系统”:先通过一个算法(如书名首字母)计算出一个“书架编号”,然后直接去对应书架上找。哈希表(Hash Table) 就是编程世界里的“索引系统”。
它是一种基于键-值(Key-Value)对 存储数据的数据结构。核心思想是使用一个哈希函数(Hash Function),将任意大小的键(Key)转换为一个固定范围的整数(称为哈希值或索引),然后直接用这个索引去访问对应的值(Value),从而实现近乎 O(1) 时间复杂度的插入、删除和查找。
2. 哈希函数与冲突
- 哈希函数:一个好的哈希函数应该尽量将不同的键均匀地映射到不同的索引上。例如,一个简单的哈希函数可以是
hash(key) = len(key) % table_size。 - 冲突(Collision):然而,不同的键可能会计算出相同的哈希值。例如,在上述函数中,
“cat”和“dog”的长度都是3,它们的哈希值相同。这种情况称为“冲突”。冲突是哈希表设计的核心挑战。
3. 冲突解决策略
如何处理多个键被映射到同一个索引的情况?
-
链地址法(Chaining): 每个哈希表的“槽位”不直接存储一个值,而是存储一个链表。所有哈希到同一索引的键值对,都被存储在这个链表中。查找时,先计算索引找到链表,再在链表中遍历找到对应的键。 优点:实现简单,对负载因子不敏感。 缺点:需要额外空间存储链表指针,如果冲突严重,链表过长,查找会退化为 O(n)。
-
开放地址法(Open Addressing): 所有键值对都直接存储在哈希表数组中。当发生冲突时,按照某种探测序列(如线性探测、二次探测)在数组中寻找下一个空闲槽位,直到找到为止。 优点:数据都存储在数组中,缓存友好。 缺点:对负载因子敏感,容易产生“聚集”现象,删除操作较复杂。
4. 负载因子
负载因子(Load Factor) = 表中已存元素的数量 / 表的大小。它是衡量哈希表“装满”程度的指标。当负载因子过高时(例如超过0.7),冲突概率急剧增加,性能下降。此时通常需要扩容:创建一个更大的新表,并将所有旧数据重新哈希插入到新表中(称为再哈希,Rehashing)。
代码示例
下面是一个使用链地址法实现的简单哈希表。它支持插入(put)、查找(get)和删除(remove)操作。
class Node:
"""链表节点,用于存储键值对"""
def __init__(self, key, value):
self.key = key
self.value = value
self.next = None
class SimpleHashTable:
"""一个简单的哈希表,使用链地址法解决冲突"""
def __init__(self, capacity=10):
# 初始化一个固定大小的数组(桶),每个桶是一个链表的头节点
self.capacity = capacity
self.size = 0
self.buckets = [None] * self.capacity # 初始所有桶为空
def _hash(self, key):
"""一个简单的哈希函数:将键转换为索引"""
# 使用内置的hash函数,并用容量取模确保索引在数组范围内
return hash(key) % self.capacity
def put(self, key, value):
"""插入或更新键值对"""
index = self._hash(key)
node = self.buckets[index]
# 遍历当前索引对应的链表
# 1. 如果键已存在,则更新其值
while node:
if node.key == key:
node.value = value # 更新值
return
node = node.next
# 2. 如果键不存在,则创建一个新节点插入到链表头部(头插法)
new_node = Node(key, value)
new_node.next = self.buckets[index] # 新节点指向原来的头节点
self.buckets[index] = new_node # 新节点成为新的头节点
self.size += 1
# 简单的扩容逻辑(可选):当负载因子>1时,进行扩容
# 注意:实际应用中负载因子阈值通常更低(如0.75)
if self.size / self.capacity > 1:
self._resize()
def get(self, key):
"""根据键查找值"""
index = self._hash(key)
node = self.buckets[index]
# 在链表中查找键
while node:
if node.key == key:
return node.value # 找到,返回值
node = node.next
return None # 未找到
def remove(self, key):
"""根据键删除键值对"""
index = self._hash(key)
node = self.buckets[index]
prev = None # 记录前一个节点,用于链表操作
# 在链表中查找要删除的节点
while node:
if node.key == key:
if prev:
prev.next = node.next # 跳过当前节点(中间或尾部删除)
else:
self.buckets[index] = node.next # 删除的是头节点
self.size -= 1
return True # 删除成功
prev = node
node = node.next
return False # 键不存在,删除失败
def _resize(self):
"""当负载因子过高时,进行扩容和再哈希"""
old_buckets = self.buckets
self.capacity *= 2 # 容量翻倍
self.buckets = [None] * self.capacity
self.size = 0 # size会在重新put时重新计算
# 将旧表中的所有数据重新插入新表
for head_node in old_buckets:
node = head_node
while node:
self.put(node.key, node.value) # 重新哈希并插入
node = node.next
# 测试我们的哈希表
if __name__ == "__main__":
ht = SimpleHashTable()
ht.put("apple", 5)
ht.put("banana", 3)
ht.put("orange", 8)
ht.put("pear", 2)
ht.put("grape", 6) # 可能引起冲突,取决于哈希函数和容量
print(f"苹果的价格: {ht.get('apple')}") # 输出: 5
print(f"香蕉的价格: {ht.get('banana')}") # 输出: 3
print(f"葡萄的价格: {ht.get('grape')}") # 输出: 6
ht.put("apple", 10) # 更新苹果的价格
print(f"更新后苹果的价格: {ht.get('apple')}") # 输出: 10
ht.remove("orange")
print(f"删除橙子后查找: {ht.get('orange')}") # 输出: None
print(f"哈希表当前大小: {ht.size}") # 输出: 4
实践练习
练习 1:基础哈希函数
实现一个简单的哈希函数 my_hash(key, table_size),要求:
key是一个字符串。- 返回值为
key所有字符 ASCII 码之和对table_size取模。 - 测试:
my_hash("cat", 10)应该返回(99 + 97 + 116) % 10 = 2。
练习 2:开放地址法(线性探测)
修改上面的哈希表类,将冲突解决策略从链地址法改为开放地址法中的线性探测。
put操作:当目标槽位已被占用时,向后线性探测下一个空槽位。get操作:从计算出的索引开始线性探测,直到找到键或遇到空槽位。- 提示:你需要用一个特殊的标记(如
"__DELETED__")来标记被删除的槽位,以保证探测序列的连续性。
练习 3:应用 - 统计单词频率
编写一个函数 count_word_frequency(text),利用哈希表统计一段英文文本(字符串)中每个单词出现的次数。忽略大小写和标点符号。
- 输入:
"The cat sat on the mat. The cat is fat." - 预期输出:一个字典(或打印),显示每个单词及其频率,例如:
{'the': 3, 'cat': 2, 'sat': 1, 'on': 1, 'mat': 1, 'is': 1, 'fat': 1}。
常见错误
- 哈希函数设计不当:哈希函数将所有键都映射到少数几个索引上,导致严重冲突,哈希表退化为链表,失去 O(1) 的优势。
- 忘记处理冲突:假设没有冲突,直接覆盖数据,导致数据丢失。
- 负载因子过高不扩容:为了节省空间而拒绝扩容,当数据量大时,性能急剧下降。
- 开放地址法中的删除问题:简单地将被删除的槽位置空,会切断后续元素的探测路径,导致它们永远无法被找到。必须使用“墓碑”标记。
- 混淆哈希表与字典:在Python中,
dict就是用哈希表实现的。但在其他语言(如Java的HashMap、C++的std::unordered_map)中,其底层实现和性能特性也需要了解。
小结
- 哈希表通过哈希函数将键直接映射到数组索引,实现快速的键值对存取。
- 冲突是不可避免的,解决方法主要有链地址法(用链表)和开放地址法(用探测)。
- 负载因子是衡量哈希表性能的关键指标,过高时需要再哈希扩容。
- 哈希表是字典、集合等高级数据结构的基础,在缓存、数据库索引、符号表等场景中无处不在。
- 在实际编程中,应优先使用语言内置的、经过高度优化的哈希表实现(如Python的
dict),但理解其内部原理对于优化性能和调试问题至关重要。