0Pricing
DSA Interview Prep · 课时

哈希函数原理与冲突处理

理解 Python 如何计算对象哈希值,开放寻址和链式法如何解决冲突,以及平均 O(1) 为何可能退化为 O(n)。

哈希函数原理与冲突处理 是 CoddyKit 上的免费 DSA Interview Prep 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 DSA Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 DSA Interview Prep 课程共包含 4 节课。

什么是哈希映射?

哈希映射(Python 中的字典)使用哈希函数将键映射到一个底层数组中的整数索引。理想的哈希函数会将键均匀地分布在数组中,从而使查找、插入和删除的平均时间复杂度达到 O(1)。这个底层数组称为哈希表或桶数组。

在 Python 中,dict 是高度优化的哈希映射。了解其内部机制有助于您分析最坏情况的行为,并选择合适的键。

# Python dict is a hash map
hm = {}
hm['alice'] = 95
hm['bob']   = 87
hm['carol'] = 91

print(hm['alice'])          # O(1) lookup: 95
print('bob' in hm)          # O(1) membership: True
del hm['bob']               # O(1) deletion
print(hm)                   # {'alice': 95, 'carol': 91}

哈希函数与 __hash__ 方法

Python 会调用 __hash__(key),根据键计算出一个整数,然后对表大小取模,以确定桶索引。int、str 和 tuple 等内置类型都有快速的内置哈希实现。list 和 dict 不可哈希(它们是可变的,而修改它们会使已存储的哈希值失效)。

良好的哈希函数应均匀分布键、具有确定性,并且计算速度快。Python 的字符串哈希值会在不同运行之间随机化(这是一项安全特性)——在测试中使用 PYTHONHASHSEED=0 可将其禁用,以保证结果可复现。

# Built-in hash in Python
print(hash(42))           # integer hashes to itself (CPython)
print(hash('hello'))      # string hash (randomised per run)
print(hash((1, 2, 3)))    # tuple hash: depends on contents

# Unhashable types
try:
    hash([1, 2, 3])       # lists are mutable -> not hashable
except TypeError as e:
    print('Error:', e)

# Custom class: define __hash__ and __eq__
class Point:
    def __init__(self, x, y): self.x = x; self.y = y
    def __hash__(self): return hash((self.x, self.y))
    def __eq__(self, other): return self.x == other.x and self.y == other.y

points = {Point(1, 2): 'A', Point(3, 4): 'B'}
print(points[Point(1, 2)])  # 'A'

冲突:两个键哈希到同一个桶时

当两个不同的键产生相同的桶索引时,就会发生冲突。冲突不可避免(鸽巢原理:键有无限多个,而桶的数量有限)。两种标准的解决策略是链式法和开放寻址法。Python 使用一种带伪随机探测的开放寻址法变体。

链式法会在每个桶中存储一个链表(或动态数组);哈希到同一个桶的所有键会组成一条链。开放寻址法则按照探测序列寻找下一个空桶。

# Simplified chaining hash map
class ChainingHashMap:
    def __init__(self, capacity=8):
        self.capacity = capacity
        self.buckets  = [[] for _ in range(capacity)]

    def _idx(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        bucket = self.buckets[self._idx(key)]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, val)
                return
        bucket.append((key, val))

    def get(self, key):
        for k, v in self.buckets[self._idx(key)]:
            if k == key:
                return v
        return None

hm = ChainingHashMap()
hm.put('a', 1); hm.put('b', 2)
print(hm.get('a'))  # 1
print(hm.get('c'))  # None

开放寻址法:线性探测

在线性探测中,如果索引 i 处发生冲突,映射会依次检查 i+1、i+2……(绕回数组开头),直到找到空槽位。查找时必须按照相同的序列进行探测,才能找到目标键。删除时需要使用“墓碑”标记,而不是清空槽位,以免破坏探测链。

聚集是线性探测的主要缺点:一旦形成一组连续的已填槽位,之后对该区域的插入就会不断延长这个聚集,从而使性能下降到接近 O(n)。

class LinearProbingHashMap:
    DELETED = object()  # tombstone sentinel

    def __init__(self, capacity=8):
        self.capacity = capacity
        self.keys  = [None] * capacity
        self.vals  = [None] * capacity
        self.size  = 0

    def _probe(self, key):
        idx = hash(key) % self.capacity
        while self.keys[idx] is not None and self.keys[idx] != key:
            idx = (idx + 1) % self.capacity
        return idx

    def put(self, key, val):
        idx = self._probe(key)
        if self.keys[idx] is None:
            self.size += 1
        self.keys[idx] = key
        self.vals[idx] = val

    def get(self, key):
        idx = self._probe(key)
        if self.keys[idx] == key:
            return self.vals[idx]
        return None

hm = LinearProbingHashMap()
hm.put('x', 10); hm.put('y', 20)
print(hm.get('x'))  # 10

负载因子与扩容

负载因子是已存储条目数与总容量的比值:α = n/m。随着 α 增大,发生冲突的概率会上升,性能也会下降。当负载因子超过约 2/3 时,Python 的字典会扩容(容量翻倍)。扩容会将所有现有条目重新计算哈希并放入新的更大表中,这是一个 O(n) 操作,但它并不频繁,因此可以将摊销插入成本保持在 O(1)。

import sys

d = {}
prev_size = sys.getsizeof(d)
for i in range(30):
    d[i] = i
    new_size = sys.getsizeof(d)
    if new_size != prev_size:
        print(f'Resized at n={i+1}: {prev_size} -> {new_size} bytes')
        prev_size = new_size

平均 O(1) 与最坏情况 O(n)

在良好的哈希函数下,冲突很少,预期链长与 n 无关且保持为常数。因此,查找、插入和删除的平均时间复杂度都是O(1)。但是,最坏情况下,例如刻意构造的对抗性输入将所有键映射到同一个桶,所有操作的复杂度都会下降到 O(n)。Python 的随机化哈希种子可以缓解这种攻击,但从理论上讲无法消除最坏情况。

在面试分析中,请说明:“平均 O(1),由于冲突,最坏情况下为 O(n)。”

# Python randomised hash seed prevents worst-case hash-flooding
import os
print('PYTHONHASHSEED:', os.environ.get('PYTHONHASHSEED', 'random'))
# By default Python randomises the hash of strings each run
# This prevents an attacker from crafting keys that all collide
# To reproduce results in testing: PYTHONHASHSEED=0 python script.py

Python 字典、默认字典与计数器

Python 提供了三种值得了解的哈希映射变体。dict 是通用映射;访问缺失的键会引发 KeyError。defaultdict(factory) 在访问缺失键时返回默认值(适合收集列表或进行计数)。Counter 是用于统计可哈希对象的专用子类;它还支持计数器之间的算术运算。

from collections import defaultdict, Counter

# defaultdict for grouping
groups = defaultdict(list)
for word in ['apple', 'ant', 'banana', 'bee', 'avocado']:
    groups[word[0]].append(word)
print(dict(groups))
# {'a': ['apple','ant','avocado'], 'b': ['banana','bee']}

# Counter for frequency
c = Counter('abracadabra')
print(c.most_common(3))  # [('a',5),('b',2),('r',2)]
print(c['a'] - Counter('aa')['a'])  # counter subtraction

哈希映射与哈希集合

哈希集合只存储键(不存储关联的值),支持 O(1) 的成员测试、插入和删除。Python 的 set 就是哈希集合。如果您只需要回答“这个元素是否存在?”,而不需要存储关联数据,请使用集合。如果您需要将值(计数、结果等)与键关联起来,请使用字典。

# set for membership testing
visited = set()
for node in [1, 3, 5, 3, 7, 1]:
    if node not in visited:
        print('New node:', node)
        visited.add(node)

# Set operations: union, intersection, difference
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print('Union:', A | B)         # {1,2,3,4,5,6}
print('Intersection:', A & B)  # {3,4}
print('Difference:', A - B)    # {1,2}

从头实现哈希映射(面试版本)

面试官有时会要求您实现一个基本的哈希映射。关键组成部分包括:一个固定大小的桶数组(使用 16 或 1024),每个桶是一个由(键,值)对组成的列表,用于链式处理;一个哈希函数(使用 Python 内置的哈希函数对容量取模);以及在负载因子超过 0.7 时进行扩容。主动提及扩容和负载因子,可以体现您对相关知识的深入理解。

class HashMap:
    def __init__(self, capacity=16):
        self.capacity = capacity
        self.size     = 0
        self.buckets  = [[] for _ in range(capacity)]

    def _hash(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        b = self.buckets[self._hash(key)]
        for i, (k, v) in enumerate(b):
            if k == key:
                b[i] = (key, val)
                return
        b.append((key, val))
        self.size += 1
        if self.size / self.capacity > 0.7:
            self._resize()

    def get(self, key, default=None):
        for k, v in self.buckets[self._hash(key)]:
            if k == key:
                return v
        return default

    def _resize(self):
        old = self.buckets
        self.capacity *= 2
        self.buckets = [[] for _ in range(self.capacity)]
        self.size = 0
        for bucket in old:
            for k, v in bucket:
                self.put(k, v)

hm = HashMap()
for i in range(20):
    hm.put(i, i * 2)
print(hm.get(10))   # 20
print(hm.capacity)  # should have resized

哈希映射失效时:不可哈希的键

只有可哈希的对象才能作为字典键。在 Python 中,如果对象具有 __hash__ 方法和 __eq__ 方法,并且其哈希值在整个生命周期内不会改变,那么该对象就是可哈希的。列表、集合和字典都是可变的,因此不可哈希。当需要将列表或集合用作键时,可以使用元组或冻结集合作为可哈希的替代方案。

一个常见的面试陷阱是:对变位词进行分组时,必须使用排序后的元组(而不是排序后的列表)作为字典键。

from collections import defaultdict

def groupAnagrams(strs):
    groups = defaultdict(list)
    for s in strs:
        key = tuple(sorted(s))  # tuple is hashable; list is not
        groups[key].append(s)
    return list(groups.values())

print(groupAnagrams(['eat','tea','tan','ate','nat','bat']))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]

总结:哈希映射的复杂度

哈希映射为插入、删除和查找提供平均 O(1) 的时间复杂度,是许多最优面试解法的基础。其关键假设包括:良好的哈希函数会均匀分布键,负载因子保持有界(扩容可以维持这一点),并且键对象是不可变且可哈希的。在这些假设成立时,哈希映射可以将 O(n) 的线性扫描转换为 O(1) 的查找,从而能够以 O(n) 而不是 O(n²) 的复杂度解决两数之和等问题。

快速检查

请测试您对本课中“数据结构与算法——编程面试准备”概念的理解。

课程回顾

在本课中,您学到了:哈希映射使用哈希函数将键映射到桶索引,并实现平均 O(1) 的操作;冲突通过链式法(每个桶一个链表)或开放寻址法(探测下一个空槽)解决;以及只有不可变且可哈希的对象才能作为字典键——需要使用序列作为键时,应使用元组而不是列表。接下来,我们将解决两数之和及其众多面试变体。

常见问题解答

「哈希函数原理与冲突处理」课时是免费的吗?

是的 — 「哈希函数原理与冲突处理」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 DSA Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 DSA Interview Prep 课程共包含 4 节课。

「哈希函数原理与冲突处理」这节课中我会学到什么?

理解 Python 如何计算对象哈希值,开放寻址和链式法如何解决冲突,以及平均 O(1) 为何可能退化为 O(n)。 你通过在浏览器中直接运行的动手代码来练习 DSA Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 DSA Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 DSA Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「哈希函数原理与冲突处理」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 DSA Interview Prep 课中编写并运行代码吗?

能。每节 DSA Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 哈希函数原理与冲突处理
  2. 两数之和及其多种变体
  3. 频率统计与分组
  4. 最长连续序列与 LRU Cache
← 返回 DSA Interview Prep