开始前,会这些就够了
- 会使用字典保存键值对,理解同一个键再次赋值是更新。
- 先读 哈希表、树与堆,认识哈希碰撞与定位。
一个小表把 4、9、14 都散列到槽位 4。后来的键只能往后找,绕回数组开头。删掉中间的 9 后,如果把那个位置恢复成“从未用过”,查找 14 就可能过早停止。
下面实现一个固定容量、整数键的教学映射。它不是 Python dict 的复刻;Python 字典只作为独立的内容核对工具。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- demo.py:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B demo.py解压后,在含 demo.py 的目录打开终端。命令分别回车执行。固定容量整数键映射;9331条最多5步的碰撞操作序列对照dict;覆盖绕回、越墓碑更新、满表、全墓碑及负键,无扩缩容/并发/性能结论。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
删掉碰撞链中间一项,后面的键还找得到吗?
正在准备实验控件…
容量固定为 5,hash(key)=key mod 5,逐槽线性探测,最多访问 5 个槽。这里只演示整数键与普通值;“清空槽”模式故意破坏查找约定,用来观察错误,不是推荐的删除方式。
五个槽位,三个键从同一位置出发
容量为 5,起点取 key % 5。4、9、14 的起点都是 4:4 留在槽 4,9 顺着探测到槽 0,14 再走到槽 1。到数组末尾后用取余回到开头,这就是本例的线性探测。
查找也必须从相同起点沿相同路径走。要找 14,就依次看槽 4、0、1。遇到别人占着的槽位不等于不存在,得继续判断。
“从未用过”和“以前用过”不能混成一种空
程序用两个不同的 object 作为 EMPTY 和 DELETED 标记,正常槽位保存 (key, value)。EMPTY 表示当前这张表里尚未被插入路径越过的真正空槽;DELETED 表示旧记录被删除,但后面的探测路径仍可能有有效记录。
删除 9 时把槽 0 改为 DELETED。找 14 经过这里仍继续,于是在槽 1 读到 C。如果把槽 0 改成 EMPTY,正常的查找停止规则就会误判 14 不存在。
“遇到 EMPTY 可以停止”不是魔法,而是插入和删除共同维护的约定:在这份实现中,普通删除从不把一个用过的槽直接恢复成 EMPTY。
见到删除标记先记下来,还不能马上插入
现在更新 14 为 C2。探测先遇到槽 0 的 DELETED,但 14 已经在后面的槽 1。如果立即复用槽 0,就会制造两个相同键,映射不再满足一个键只有一个值的规则。
正确过程先记住第一个删除槽,继续沿路找旧键。找到旧键就原地更新;遇到真正 EMPTY 且之前没找到旧键时,才使用记住的删除槽,或直接使用当前空槽。
因此更新 14 后槽 0 仍是 DELETED,槽 1 变成 14:C2。接着插入新键 19,确认路径上没有它,才把槽 0 复用为 19:D。程序会打印这两次操作后不同的槽位状态。
没有 EMPTY 时,最多绕一圈也必须结束
容量 3 的表放入 0、3、6 后,每个槽都被占用。删除 3 后只有一个 DELETED,没有 EMPTY。此时更新 6 仍应找到原键;随后插入 9 可以绕完一圈,确认不存在后复用那个删除槽。
若整张表都占用,再插入新键就抛 OverflowError。本例对一次查找、插入或删除最多探测 capacity 个槽,因此没有空槽、全是删除标记、查找不存在的键时都不会无限循环。
把所有键删完后,整张表全是 DELETED。程序再插入负键 -3,核对它能复用槽位。取余在这里用来定位,不要求输入键必须非负。
运行五槽现场,再核对 9,331 条操作序列
保存下面完整代码为 demo.py,执行 python demo.py。2026-10-23 在 Windows、CPython 3.14.0 实际运行,只用标准库。default 轨迹之外,还执行容量 3 的满表、更新越过墓碑、仅墓碑可复用和全部已删除用例。
穷举长度 0–5 的操作序列,每步可以对 0、3、6 中的一个键执行 put 或 delete,因此共 9,331 条序列。put 的值使用操作步数,让重复写入是否真正更新也可观察。每一步与 Python dict 比较内容、条目数和查询结果。
同时核对条目数很重要:只把槽位转成 dict 再比较,可能让重复键被字典覆盖,从而掩盖错误。本例先比较原始有效槽位数量,再比较映射内容。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Fixed-capacity integer-key map teaching deletion, not Python dict internals."""
from itertools import product
EMPTY, DELETED = object(), object()
def require(condition, message):
if not condition:
raise AssertionError(message)
class LinearMap:
def __init__(self, capacity):
if type(capacity) is not int or capacity < 1:
raise ValueError("positive integer capacity required")
self.slots = [EMPTY] * capacity
def probes(self, key):
if type(key) is not int:
raise TypeError("integer keys required")
for offset in range(len(self.slots)):
yield (key + offset) % len(self.slots)
def get(self, key):
for index in self.probes(key):
slot = self.slots[index]
if slot is EMPTY:
break
if slot is not DELETED and slot[0] == key:
return slot[1]
raise KeyError(key)
def put(self, key, value):
first_deleted = None
for index in self.probes(key):
slot = self.slots[index]
if slot is DELETED:
if first_deleted is None:
first_deleted = index
elif slot is EMPTY:
target = index if first_deleted is None else first_deleted
self.slots[target] = (key, value)
return
elif slot[0] == key:
self.slots[index] = (key, value)
return
if first_deleted is not None:
self.slots[first_deleted] = (key, value)
return
raise OverflowError("table is full")
def delete(self, key):
for index in self.probes(key):
slot = self.slots[index]
if slot is EMPTY:
return False
if slot is not DELETED and slot[0] == key:
self.slots[index] = DELETED
return True
return False
def items(self):
return [slot for slot in self.slots if slot is not EMPTY and slot is not DELETED]
def state(self):
return ["EMPTY" if x is EMPTY else "DELETED" if x is DELETED
else f"{x[0]}:{x[1]}" for x in self.slots]
def check_map(table, expected):
items = table.items()
require(len(items) == len(expected), "duplicate key or missing entry")
require(dict(items) == expected, "contents differ from oracle")
for key in (-3, 0, 3, 6, 9):
try:
value = table.get(key)
except KeyError:
require(key not in expected, "lost reachable key")
else:
require(key in expected and value == expected[key], "wrong lookup")
def main():
table = LinearMap(5)
for key, value in [(4, "A"), (9, "B"), (14, "C")]:
table.put(key, value)
print(f"insert={table.state()}")
require(table.delete(9), "delete existing")
print(f"delete9={table.state()}; get14={table.get(14)}")
table.put(14, "C2")
require(len(table.items()) == 2, "update must not create duplicate")
print(f"update14={table.state()}")
table.put(19, "D")
print(f"insert19={table.state()}")
full = LinearMap(3)
for key in (0, 3, 6):
full.put(key, str(key))
full.delete(3)
full.put(6, "updated")
require(len(full.items()) == 2, "full-table update crosses tombstone")
full.put(9, "reused")
print(f"no_empty_slots={full.state()}")
try:
full.put(12, "overflow")
except OverflowError:
print("full_insert=OverflowError")
else:
raise AssertionError("full table accepted new key")
require(not full.delete(12), "absent delete must terminate")
for key in (0, 6, 9):
full.delete(key)
require(all(slot is DELETED for slot in full.slots), "all tombstones")
full.put(-3, "negative")
require(full.get(-3) == "negative", "negative key and all tombstones")
print(f"all_deleted_then_insert={full.state()}")
cases = 0
actions = [(op, key) for op in ("put", "delete") for key in (0, 3, 6)]
for length in range(6):
for sequence in product(actions, repeat=length):
current, expected = LinearMap(3), {}
for step, (op, key) in enumerate(sequence):
if op == "put":
current.put(key, step)
expected[key] = step
else:
existed = key in expected
require(current.delete(key) == existed, "delete result")
expected.pop(key, None)
check_map(current, expected)
cases += 1
print(f"oracle_sequences={cases}; maximum_length=5")
print("PASS")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。固定容量整数键映射;9331条最多5步的碰撞操作序列对照dict;覆盖绕回、越墓碑更新、满表、全墓碑及负键,无扩缩容/并发/性能结论。
运行后对照结果
insert=['9:B', '14:C', 'EMPTY', 'EMPTY', '4:A']
delete9=['DELETED', '14:C', 'EMPTY', 'EMPTY', '4:A']; get14=C
update14=['DELETED', '14:C2', 'EMPTY', 'EMPTY', '4:A']
insert19=['19:D', '14:C2', 'EMPTY', 'EMPTY', '4:A']
no_empty_slots=['0:0', '9:reused', '6:updated']
full_insert=OverflowError
all_deleted_then_insert=['-3:negative', 'DELETED', 'DELETED']
oracle_sequences=9331; maximum_length=5
PASS
内容少了,不代表探测路就短了
有效键数量和非 EMPTY 槽位数量是两回事。删除很多键后,表里可能只剩很少内容,却仍有许多 DELETED;查找不存在的键还需要经过它们。
实际结构常通过重建把有效键重新放入新表,让删除标记消失。这里没有自动扩容、缩容或重建,故每次操作的最坏成本是 O(capacity),存储也是 O(capacity)。不能把一般哈希表的期望常数时间直接套到故意制造全碰撞的示例。
教材讨论的负载阈值、随机散列前提与本例的简单 key % capacity 不同。本项目只验证槽位状态和映射语义;不测试散列质量、并发访问、持久化或抗恶意输入。
检查删除逻辑,要连着查找和插入一起看
单独看 delete 函数,很容易觉得把数组位置清空最自然。但一个槽位还承担着“后面是否可能有记录”的含义,必须与 get 的停止条件保持一致。
再看 put 是否先完成查重,再复用第一个删除槽;最后看探测有没有上限。重复键更新、绕回和没有 EMPTY 的状态,分别针对这三个容易遗漏的条件。
本篇与已有的布隆过滤器删除问题不同:那里关注共享位可能造成误判,这里存的是实际键值对,问题是错误中断了精确查找的探测路径。
记住这些要点
- 删除标记允许查找继续,同时给以后插入留下可复用位置。
- 先遇到墓碑不能跳过后续查重,否则更新可能变成重复插入。
- 没有 EMPTY 的表也要有明确的结束条件。
- 有效记录数量少不代表探测路径短。
别把例子用过头
- 删除时恢复成 EMPTY,切断后续键的查找路径。
- 一碰到 DELETED 就写入,漏掉后面的同键记录。
- 只比较转成 dict 后的内容,没有检查原槽位里的重复键。
- 把这个固定容量教学模型说成 Python dict 的内部实现。
自己试一小步
给固定容量映射增加 compact():只清理删除标记、保持容量不变,你会怎样证明没有丢键或重复键?
- 设计包含绕回、连续墓碑和重复更新的操作序列,记录重建前槽位。
- 列出
compact()前后必须保持的映射性质。 - 加入空表、全部墓碑、满表和负键输入。
- 把 compact 作为新操作加入有限序列核对,并保留失败时可重放的最短序列。
做到这些,就算完成
- 比较真实有效槽位数,不能只比较转成字典后的结果。
- 所有已有键的值保持不变,缺失键仍然缺失。
- 明确重建过程中是否允许其他线程访问,以及当前实验的边界。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Open Data Structures,5.2 LinearHashTable: Linear Probing (新标签页)↗
EMPTY 与删除标记的不同含义、查找停止条件、查重后插入,以及重建时清除删除标记。
- OpenDSA,Hashing Deletion (新标签页)↗
删除不能切断探测路径;遇墓碑后仍需检查后续重复键,确认不存在后才能复用。