8·基础概念进阶

哈希表

hashcollisionchainingopen-addressing

第8课:哈希表

学习目标

通过本课学习,你将能够:

  1. 理解哈希表的基本工作原理与核心优势(快速查找)。
  2. 掌握两种主要的哈希冲突解决策略:链地址法与开放地址法。
  3. 实现一个基本的哈希表类,支持插入、查找和删除操作。
  4. 了解哈希表在现实编程中的典型应用场景。

核心概念

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}

常见错误

  1. 哈希函数设计不当:哈希函数将所有键都映射到少数几个索引上,导致严重冲突,哈希表退化为链表,失去 O(1) 的优势。
  2. 忘记处理冲突:假设没有冲突,直接覆盖数据,导致数据丢失。
  3. 负载因子过高不扩容:为了节省空间而拒绝扩容,当数据量大时,性能急剧下降。
  4. 开放地址法中的删除问题:简单地将被删除的槽位置空,会切断后续元素的探测路径,导致它们永远无法被找到。必须使用“墓碑”标记。
  5. 混淆哈希表与字典:在Python中,dict就是用哈希表实现的。但在其他语言(如Java的HashMap、C++的std::unordered_map)中,其底层实现和性能特性也需要了解。

小结

  • 哈希表通过哈希函数将键直接映射到数组索引,实现快速的键值对存取。
  • 冲突是不可避免的,解决方法主要有链地址法(用链表)和开放地址法(用探测)。
  • 负载因子是衡量哈希表性能的关键指标,过高时需要再哈希扩容。
  • 哈希表是字典、集合等高级数据结构的基础,在缓存、数据库索引、符号表等场景中无处不在。
  • 在实际编程中,应优先使用语言内置的、经过高度优化的哈希表实现(如Python的dict),但理解其内部原理对于优化性能和调试问题至关重要。

练习编辑器

rust
Loading...

继续学习

完成本课后,建议继续学习下一课「字符串基础操作」 以巩固所学知识。