Py算法与数据结构

08 哈希法

冲突、拉链、探测、扩容与 Python 容器

搜索的目标,是在一批数据中根据某个关键字找到对应的元素。

如果数据保存在普通列表中,最直接的方法是从头到尾检查,复杂度通常是 O(n)。哈希法则尝试把关键字转换成一个数组下标,让程序直接跳到可能的位置。

Python 中的 dict 和 set 就是哈希法最常见的应用。

哈希法的核心思想是:用哈希函数把“关键字”转换成“存储位置”,从而把平均搜索成本降到接近 O(1)。

1. 从线性搜索开始

假设有一组用户名和分数:

records = [
    ("alice", 95),
    ("bob", 82),
    ("carol", 91),
]


def linear_search(records, username):
    for name, score in records:
        if name == username:
            return score
    return None


print(linear_search(records, "carol"))  # 91

如果有 n 条记录,最坏情况下需要检查全部记录,时间复杂度是 O(n)。

当数据量很大、并且程序经常按照用户名查找时,每次从头扫描会比较慢。哈希法会为每个用户名计算一个位置。

2. 哈希函数和哈希值

哈希函数接收一个关键字,输出一个整数:

关键字 → 哈希函数 → 哈希值 → 数组下标

Python 可以使用内置的 hash 函数:

print(hash("alice"))
print(hash(2026))
print(hash((1, 2, 3)))

哈希值可能是很大的整数,所以不能直接把它当数组下标。假设哈希表容量为 capacity,常用的下标计算方式是:

index = hash(key) % capacity

例如:

key = "alice"
capacity = 8
index = hash(key) % capacity
print(index)  # 一定在 0 到 7 之间

哈希函数希望满足以下特点:

  • 相同的关键字,计算出的哈希值必须相同;
  • 不同关键字最好尽量分散;
  • 计算速度应该较快;
  • 哈希值分布不应该集中在少数几个位置。

注意:哈希函数不是加密函数。哈希值不能用来还原原始数据,也不应该直接当作密码保存方案。

3. Python 中哪些对象可以作为键?

字典的键和集合中的元素必须是可哈希的。简单来说,对象在作为键使用期间,参与比较的内容不能发生变化。

可以作为键的常见对象包括:

data = {
    "name": "Alice",
    2026: "year",
    (1, 2): "tuple",
}

print(data["name"])
print(data[(1, 2)])

列表和字典不能直接作为键,因为它们是可变对象:

# 下面的代码会抛出 TypeError
# data[[1, 2]] = "list"
# data[{"x": 1}] = "dict"

元组只有在其中所有元素都可哈希时,才能作为键:

valid_key = (1, "two", (3, 4))
data[valid_key] = "valid"

4. 哈希冲突

不同关键字可能得到相同的数组下标:

hash("alice") % 8 = 3
hash("bob")   % 8 = 3

这种情况叫作哈希冲突。冲突不是哈希法失败,而是哈希表必须处理的正常情况。

常见的冲突处理方法有两类:

4.1 拉链法

每个数组槽位不只保存一个元素,而是保存一个小链表或列表:

槽位 0:空
槽位 1:空
槽位 2:[key_a, value_a] → [key_b, value_b]
槽位 3:空

查找时,先根据哈希值找到槽位,再在槽位中的小链表里逐个比较关键字。

4.2 开放地址法

所有元素都直接放在哈希表数组中。如果目标位置被占用,就按照某种规则继续尝试其他位置:

第一次尝试:index
第二次尝试:index + 1
第三次尝试:index + 2
...

这种“依次向后寻找”的方法叫线性探测。还可以使用二次探测或再次哈希等方法。

两种方法的比较:

方法 元素存放位置 优点 代价
拉链法 槽位对应的链表或列表 删除相对直接,实现直观 有额外节点或列表空间
开放地址法 哈希表数组内部 内存连续,缓存局部性较好 删除和探测规则更复杂

5. 用 Python 实现拉链法哈希表

下面实现一个简化版的哈希映射。每个槽位保存一个列表,列表中的每一项是 key 和 value:

class ChainedHashMap:
    def __init__(self, capacity=8):
        if capacity <= 0:
            raise ValueError("容量必须大于 0")

        self._buckets = [[] for _ in range(capacity)]
        self._size = 0

    def _index(self, key):
        return hash(key) % len(self._buckets)

    def put(self, key, value):
        bucket = self._buckets[self._index(key)]

        for index, (stored_key, _) in enumerate(bucket):
            if stored_key == key:
                bucket[index] = (key, value)
                return

        bucket.append((key, value))
        self._size += 1

        if self._size / len(self._buckets) > 0.75:
            self._resize(len(self._buckets) * 2)

    def get(self, key, default=None):
        bucket = self._buckets[self._index(key)]

        for stored_key, value in bucket:
            if stored_key == key:
                return value

        return default

    def delete(self, key):
        bucket = self._buckets[self._index(key)]

        for index, (stored_key, _) in enumerate(bucket):
            if stored_key == key:
                bucket.pop(index)
                self._size -= 1
                return

        raise KeyError(key)

    def _resize(self, new_capacity):
        old_items = [
            item
            for bucket in self._buckets
            for item in bucket
        ]

        self._buckets = [[] for _ in range(new_capacity)]
        self._size = 0

        for key, value in old_items:
            self.put(key, value)

    def __contains__(self, key):
        bucket = self._buckets[self._index(key)]
        return any(stored_key == key for stored_key, _ in bucket)

    def __len__(self):
        return self._size

可以这样使用:

table = ChainedHashMap()

table.put("alice", 95)
table.put("bob", 82)
table.put("alice", 98)  # 更新 alice 的值

print(table.get("alice"))  # 98
print(table.get("unknown"))  # None

table.delete("bob")
print(len(table))  # 1

上面的实现展示了哈希表的主要过程:

  1. 计算关键字的哈希值;
  2. 用取模得到槽位;
  3. 在槽位中搜索相同关键字;
  4. 找到则更新,找不到则插入;
  5. 装载因子太高时扩容并重新哈希。

如果哈希表允许存储 None 作为值,contains 不能通过 get 的返回值判断,而应该直接检查键是否存在:

table = ChainedHashMap()
table.put("empty", None)
print("empty" in table)  # True

教学实现可以帮助我们理解原理;实际项目中通常直接使用 Python 内置的 dict。

6. Python 的 dict

Python 字典提供了成熟的哈希表实现:

scores = {
    "alice": 95,
    "bob": 82,
}

scores["carol"] = 91
scores["alice"] = 98

print(scores["alice"])            # 98
print(scores.get("unknown"))      # None
print(scores.get("unknown", 0))   # 0
print("bob" in scores)            # True

常见操作的平均复杂度:

操作 平均复杂度 说明
根据键读取 O(1) 计算哈希并定位
插入或更新 O(1) 平均情况
删除 O(1) 平均情况
判断是否存在 O(1) 平均情况
遍历全部键值 O(n) 必须访问所有元素

最坏情况下,如果大量关键字发生冲突,单次操作可能退化到 O(n)。现代实现会通过优秀的哈希函数、扩容和冲突策略降低这种可能性。

7. Python 的 set

集合 set 只保存关键字,不保存对应的值,也使用哈希表实现:

visited = set()

visited.add("home")
visited.add("about")
visited.add("home")  # 重复添加不会产生第二个元素

print("home" in visited)  # True
visited.remove("about")

集合特别适合做去重和快速存在性检查:

numbers = [3, 1, 3, 2, 1, 5]
unique_numbers = set(numbers)

print(unique_numbers)

如果需要保持首次出现的顺序,可以使用字典的键:

numbers = [3, 1, 3, 2, 1, 5]
unique_in_order = list(dict.fromkeys(numbers))

print(unique_in_order)  # [3, 1, 2, 5]

8. 装载因子与扩容

哈希表容量为 m,已经存放 n 个元素时,装载因子可以写成:

α = n / m

装载因子越高,冲突通常越多,搜索一个槽位中的元素可能需要比较更多关键字。

因此哈希表通常不会一直填满。当装载因子超过某个阈值时,会进行扩容:

旧容量:8
新容量:16
重新计算每个元素的位置

扩容需要把已有元素重新放入新表,单次成本可能是 O(n)。但扩容不是每次插入都发生,所以插入操作的长期平均成本仍然可以接近 O(1)。

这和动态数组的摊销复杂度很相似:

  • 某一次操作可能很贵;
  • 大部分操作很快;
  • 长期平均成本较低。

9. 自定义对象作为字典键

自定义类如果希望作为字典键,需要正确实现相等判断和哈希:

class UserKey:
    def __init__(self, user_id):
        self.user_id = user_id

    def __eq__(self, other):
        return (
            isinstance(other, UserKey)
            and self.user_id == other.user_id
        )

    def __hash__(self):
        return hash(self.user_id)


users = {
    UserKey(1): "Alice",
}

print(users[UserKey(1)])  # Alice

这里两个 UserKey 对象不是同一个对象,但它们的 user_id 相同,因此被视为相等的键。

重要规则是:

如果两个对象相等,它们的哈希值必须相等。

反过来,哈希值相等的两个对象不一定相等,因为哈希冲突是允许的。

不要让参与哈希的属性在对象作为键期间发生变化:

class MutableKey:
    def __init__(self, value):
        self.value = value

    def __hash__(self):
        return hash(self.value)

    def __eq__(self, other):
        return isinstance(other, MutableKey) and self.value == other.value


key = MutableKey("before")
mapping = {key: "data"}

key.value = "after"

# 此时再用 key 查找,结果可能不符合直觉

修改键的哈希相关内容后,原来的槽位和新的哈希值可能不再对应。

10. 哈希法适合什么时候?

哈希法适合:

  • 根据唯一关键字快速查找;
  • 统计频次;
  • 去重;
  • 判断元素是否访问过;
  • 缓存计算结果;
  • 建立对象编号到对象的映射;
  • 处理“两数之和”等需要快速查找的问题。

例如,两数之和可以用集合或字典把复杂度从 O(n²) 降到平均 O(n):

def two_sum(numbers, target):
    seen = {}

    for index, value in enumerate(numbers):
        complement = target - value

        if complement in seen:
            return seen[complement], index

        seen[value] = index

    return None


print(two_sum([2, 7, 11, 15], 9))  # (0, 1)

哈希法不适合:

  • 需要按照大小排序;
  • 需要查找某个范围内的所有元素;
  • 需要频繁获得“下一个更大”或“前一个更小”的键;
  • 需要稳定的有序遍历而又不额外维护顺序。

这些场景可以考虑排序数组、二叉搜索树或其他有序结构。

11. 常见错误

误以为哈希查找永远是 O(1)

O(1) 通常指平均复杂度。冲突严重时,最坏情况可能退化到 O(n)。

把哈希值当作固定编号

Python 的字符串哈希可能因为进程级随机化而变化,不应该把 hash("name") 的具体数值写死或保存到文件中。

使用可变对象作为键

列表、字典等可变对象不能直接作为键。自定义对象也不应该在作为键期间改变参与哈希的属性。

误把哈希法当排序结构

哈希表擅长“是否存在”和“根据键取值”,但不擅长按照大小关系组织键。

忽略空间成本

哈希表需要额外数组、槽位和冲突处理结构。用时间换空间是哈希法的重要特点。

12. 小结

  • 哈希法通过哈希函数把关键字映射到存储位置;
  • 冲突是正常现象,需要使用拉链法或开放地址法处理;
  • Python 的 dict 和 set 都建立在哈希表思想之上;
  • 字典和集合的查找、插入、删除平均为 O(1);
  • 装载因子过高时需要扩容和重新哈希;
  • 键必须是可哈希的,参与哈希的内容不能在使用期间改变;
  • 哈希法适合快速查找和去重,不适合范围查询和有序操作。