qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

哈希表删掉一个键,为什么后面的键也会突然查不到?

用五个槽位观察碰撞、绕回和删除标记,特别检查更新旧键时为什么不能见到空位就写。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 五个槽位,三个键从同一位置出发
  4. “从未用过”和“以前用过”不能混成一种空
  5. 见到删除标记先记下来,还不能马上插入
  6. 没有 EMPTY 时,最多绕一圈也必须结束
  7. 运行五槽现场,再核对 9,331 条操作序列
  8. 内容少了,不代表探测路就短了
  9. 检查删除逻辑,要连着查找和插入一起看
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 会使用字典保存键值对,理解同一个键再次赋值是更新。
  • 先读 哈希表、树与堆,认识哈希碰撞与定位。
遇到不熟悉的词,先看这 2 个解释
线性探测
散列起点被占用时,沿数组逐格找位置,到末尾再绕回开头。
删除标记
告诉查找程序这里曾有记录,现在没有了,但后面仍可能有要找的键。
打开完整术语小抄 ↗

一个小表把 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

删掉碰撞链中间一项,后面的键还找得到吗?

正在准备实验控件…

槽 09: B
槽 114: C
槽 2空槽
槽 3空槽
槽 44: A

本次探测路径:尚未操作

已依次插入 4、9、14。它们都从槽 4 开始探测,绕回槽 0、1。

读者任务:先删除 9,再更新 14。正确模式应该越过删除标记找到旧键,而不是立即插入第二个 14。

容量固定为 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 再比较,可能让重复键被字典覆盖,从而掩盖错误。本例先比较原始有效槽位数量,再比较映射内容。

展开收起代码 · 小实验python · 136 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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():只清理删除标记、保持容量不变,你会怎样证明没有丢键或重复键?

  1. 设计包含绕回、连续墓碑和重复更新的操作序列,记录重建前槽位。
  2. 列出 compact() 前后必须保持的映射性质。
  3. 加入空表、全部墓碑、满表和负键输入。
  4. 把 compact 作为新操作加入有限序列核对,并保留失败时可重放的最短序列。

做到这些,就算完成

  • 比较真实有效槽位数,不能只比较转成字典后的结果。
  • 所有已有键的值保持不变,缺失键仍然缺失。
  • 明确重建过程中是否允许其他线程访问,以及当前实验的边界。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Open Data Structures,5.2 LinearHashTable: Linear Probing (新标签页)↗Pat Morin / Open Data Structures · 英文

    EMPTY 与删除标记的不同含义、查找停止条件、查重后插入,以及重建时清除删除标记。

    2026-10-23 实际打开并阅读:5.2 从三类槽位到 find/add/remove/resize 的完整相关正文和代码;另核读 5.2.1–5.2.3 的期望成本前提。仅声明该范围,不表示通读整部资料。

  • OpenDSA,Hashing Deletion (新标签页)↗Virginia Tech / OpenDSA Project · 英文

    删除不能切断探测路径;遇墓碑后仍需检查后续重复键,确认不存在后才能复用。

    2026-10-23 实际打开并阅读:10.9.1 Deletion 的两条要求、tombstone、重复键以及重新散列段落;没有把页面练习组件加载状态当作实验结果。仅声明该范围,不表示通读整部资料。