土法炼钢 · 系统与基础设施

哈希表内部:链式、线性探测、Robin Hood 与生产实现的取舍

文章导航

分类入口
algorithms
标签入口
#hash-table#open-addressing#linear-probing#robin-hood#tombstone#swiss-table#cpython-dict#java-hashmap#go-map

目录

关于哈希表,流传着几句很顺口的话:“哈希表是 \(O(1)\) 的”;“Robin Hood 把最长探测距离从 \(O(\log n)\) 降到 \(O(\log\log n)\)”;“backward shift 删除是 Robin Hood 独有的优势,tombstone 只是负担”;“Go 的 map 是链式哈希”。这几句话要么缺了前提,要么已经过时。\(O(1)\) 是期望值,常数由负载因子决定,线性探测在负载 0.95 时一次未命中查找平均要看约 200 个槽位;\(\log\log n\) 的结论来自随机探测模型,放到线性探测上不成立;普通线性探测同样可以不用 tombstone 删除,而 tombstone 在理论上还有”反聚集”的一面;Go 从 1.24 起默认改用 SwissTable。

本文按”模型 → 链式 → 线性探测 → Robin Hood → 删除 → SwissTable → 生产实现 → 争论”的顺序展开。文中所有探测次数都来自同目录 reproduce/ 下的模拟程序,指标全部是计数,与时钟无关;所有实现细节都钉在具体版本的源码上。

一、问题模型:负载因子与”期望 \(O(1)\)”

哈希函数 \(h\) 把键映射到 \(\{0, 1, \ldots, m-1\}\),表里有 \(n\) 个键,负载因子(load factor)\(\alpha = n/m\)。经典分析假设简单均匀哈希:每个键的哈希值独立、均匀地落在 \(m\) 个位置上。这个假设在后文第八节会被重新审视。

冲突来得比直觉早。插入 \(k\) 个键后至少发生一次冲突的概率约为 \(1 - e^{-k(k-1)/(2m)}\),令它等于 \(1/2\) 得 \(k \approx \sqrt{2m\ln 2} \approx 1.18\sqrt{m}\):1024 个桶插入约 38 个键、65536 个桶插入约 302 个键时,冲突就已经比不冲突更可能。所以冲突处理不是边角情况,而是哈希表的主体。

处理冲突有两大流派。下图用同样 5 个键对比两种内存布局:

同样五个键在两种布局下的对比:左边是链式哈希,8 个桶指针中桶 0 指向 C 再指向 A、桶 2 指向 B、桶 3 指向 E 再指向 D,节点分散在堆上;右边是线性探测,A、C、B、D、E 依次存放在同一个数组的槽 0 到 4,C 和 E 因冲突各偏移一格,虚线标出每 4 个 16 字节槽组成一条 64 字节缓存行

这两条路线的学术谱系大致如下:Peterson 在 1957 年的 IBM Journal of Research and Development 上发表了最早的开放寻址检索代价分析之一;Knuth 在 1963 年的未发表手稿中给出线性探测的精确分析,后来写进 TAOCP 第 3 卷 6.4 节;Celis、Larson 和 Munro 在 FOCS 1985 提出 Robin Hood 哈希;Yao 在 JACM 1985 证明均匀探测在一类方案中最优,并留下一个猜想,直到 2024 年才被推翻(第八节)。工程上的分叉点在 2017 年前后:Google 公开 SwissTable 的设计,之后 Rust(1.36)和 Go(1.24)的标准实现先后迁移过去。

二、链式哈希

代价

链式哈希在均匀假设下的代价很好算:成功查找平均检查 \(1 + \frac{n-1}{2m} \approx 1 + \frac{\alpha}{2}\) 个节点,未命中查找平均检查 \(\alpha\) 个节点。负载因子可以超过 1,删除只是摘链,不需要任何标记。最长链的长度在 \(\alpha\) 固定时约为 \(\log n / \log\log n\)(Gonnet 1981,转引自 Devroye 等 2004 年论文的引言),比开放寻址的最长探测序列短得多,第三节的实测也是如此。

真正的代价在内存访问上。每个节点单独分配,next 指针指向哪里由分配器决定,下一次读取必须等上一次读取完成。第三节用”触及多少条缓存行”近似这件事。

Java HashMap:链表加红黑树(JDK 21)

java.util.HashMap 是链式哈希。以 OpenJDK jdk-21-ga 的 src/java.base/share/classes/java/util/HashMap.java 为准:默认容量 16(DEFAULT_INITIAL_CAPACITY),默认负载因子 0.75(DEFAULT_LOAD_FACTOR),桶下标用 (n - 1) & hash,其中 hash() 先把 hashCode() 的高 16 位异或到低 16 位((h = key.hashCode()) ^ (h >>> 16)),让容量较小时高位也能参与定位。

Java 8 起,单个桶过长时会从链表转成红黑树,动机见 JEP 180(Handle Frequent HashMap Collisions with Balanced Trees):大量键落进同一个桶时,查找从 \(O(n)\) 退化到 \(O(\log n)\),而不是一直线性扫描。三个常量共同决定何时转换:

// OpenJDK jdk-21-ga, java/util/HashMap.java(摘录)
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // ... 把链表节点换成 TreeNode 并建树
    }
}
flowchart TD
    A["putVal appends a node to a bin"] --> B{"bin now has more than 8 nodes?"}
    B -- no --> L["keep the linked list"]
    B -- yes --> C{"table length below 64?"}
    C -- yes --> R["resize: double the table instead"]
    C -- no --> T["treeifyBin: convert the bin to a red-black tree"]
    T --> S{"during resize split: a half has at most 6 nodes?"}
    S -- yes --> L
    S -- no --> T

几个容易说错的细节:

Redis dict:链式加渐进式 rehash(Redis 7.4.2)

链式哈希的另一个工程问题是扩容:全量 rehash 是一次 \(O(n)\) 的停顿。Redis 的 dict 用两张表把这个停顿摊开。以 Redis 7.4.2 的 src/dict.h、src/dict.c 为准:

flowchart TD
    A["dictAdd / dictFind / dictDelete"] --> B{"rehashidx != -1 ?"}
    B -- no --> N["use ht_table[0] only"]
    B -- yes --> C["migrate one bucket from ht_table[0] to ht_table[1]"]
    C --> D["search both tables; new keys go to ht_table[1]"]
    D --> E{"ht_used[0] == 0 ?"}
    E -- yes --> F["free old table, ht_table[1] becomes ht_table[0], rehashidx = -1"]
    E -- no --> G["continue on later operations"]

代价是 rehash 期间每次查找可能要看两张表。站内 Redis 字典与渐进式 rehash 对这部分有更完整的源码拆解。

三、线性探测:Knuth 公式与实测

探测序列

开放寻址的三种经典探测序列(第 \(i\) 次探测,\(i = 0, 1, 2, \ldots\)):

\[ \text{linear: } h(k) + i, \qquad \text{quadratic: } h(k) + c_1 i + c_2 i^2, \qquad \text{double: } h_1(k) + i \cdot h_2(k) \pmod m. \]

一次聚集

线性探测的问题是一次聚集(primary clustering):连续被占用的一段槽位(下文称 run)会越长越快。

线性探测一次聚集的逐步演示:8 个槽的表中先有 A 在槽 2;B 的家是 2,被挤到 3;C 的家是 3,被挤到 4;D 的家是 5,没有冲突,但 run 已经连成 2 到 5;E 的家是 2,要探测 5 次才落到槽 6;F 的家是 1,直接放下,使 run 扩展为 1 到 6

图中紫色条是 run。D 没有发生任何冲突,只因为落在 run 的紧邻位置,就把 run 延长了;E 的家在 run 的开头,要走过整个 run 才能落脚。一般地,一个长为 \(L\) 的 run 两侧都是空槽时,新键的家落在 run 内部或两侧那两个空槽上都会让它变长,概率是 \((L+2)/m\):run 越长,越容易继续变长。

Knuth 在 1963 年的手稿中给出了这个正反馈的精确代价(TAOCP 第 3 卷 6.4 节,Algorithm L 的分析)。在均匀哈希假设下,\(n, m \to \infty\)、\(\alpha\) 固定时,成功查找与未命中查找的平均探测次数为

\[ C_n \approx \frac{1}{2}\left(1 + \frac{1}{1-\alpha}\right), \qquad C'_n \approx \frac{1}{2}\left(1 + \frac{1}{(1-\alpha)^2}\right). \]

作为对照,均匀探测(每个键的探测序列是一个随机排列,双重哈希近似如此)的对应结果是 \(\frac{1}{\alpha}\ln\frac{1}{1-\alpha}\) 和 \(\frac{1}{1-\alpha}\)。未命中查找的差别最大:线性探测是 \((1-\alpha)^{-2}\) 量级,均匀探测是 \((1-\alpha)^{-1}\)。

实测:公式、最长探测与缓存行

实验环境:Intel Core i9-12900K,WSL2 内核 6.6.87.2,GCC 16.1.1。程序 reproduce/probe_sim.c,编译运行:

cd reproduce
gcc -O2 -Wall -Wextra -o probe_sim probe_sim.c -lm
./probe_sim > results/probe_sim.txt

口径:\(m = 2^{20}\) 个槽,键是 splitmix64 生成的随机 64 位整数,哈希函数是 MurmurHash3 的 64 位 finalizer(fmix64,一个双射),家为哈希值的低 20 位;未命中查找用 20 万个不在表中的随机键。每个配置跑 5 个种子,平均值取 5 次的均值,最长位移取 5 次的中位数。输出是确定的,连续运行结果逐字节相同;程序也在 -fsanitize=address,undefined 下跑过,输出一致。缓存行按”每槽 16 字节(键和值各 8 字节)、每行 64 字节、表按行对齐”计数;链式哈希按”桶数组 1 行 + 每个节点 1 行”计数。这是一个访存模型,不是计时。

\(\alpha\) 线性:成功(实测 / 公式) 线性:未命中(实测 / 公式) 线性:最长位移 双重哈希:成功 / 未命中 双重哈希:最长位移 成功查找触及缓存行:线性 / 双重 / 链式
0.50 1.500 / 1.500 2.507 / 2.500 33 1.386 / 2.002 16 1.125 / 1.386 / 2.250
0.70 2.166 / 2.167 6.069 / 6.056 120 1.720 / 3.336 27 1.291 / 1.720 / 2.350
0.80 2.998 / 3.000 13.017 / 13.000 264 2.011 / 5.005 45 1.500 / 2.011 / 2.400
0.875 4.499 / 4.500 32.345 / 32.500 624 2.376 / 8.016 79 1.875 / 2.376 / 2.437
0.90 5.493 / 5.500 49.883 / 50.500 856 2.557 / 10.021 90 2.123 / 2.557 / 2.450
0.95 10.460 / 10.500 198.691 / 200.500 3286 3.155 / 20.002 179 3.365 / 3.155 / 2.475
平均探测次数随负载因子的变化:左图是成功查找,右图是未命中查找(对数纵轴);线性探测的实测点落在 Knuth 公式曲线上,双重哈希的实测点落在均匀探测公式曲线上,右图还画出 Robin Hood 提前终止后的未命中探测次数

从表和图里能读出四件事:

  1. 公式成立。所有负载下实测与 Knuth 公式的差距都在 1% 以内(\(\alpha = 0.95\) 的未命中差约 0.9%,是有限表的效应)。双重哈希与均匀探测公式同样吻合。
  2. 未命中查找是线性探测的软肋。从 0.8 到 0.95,成功查找从 3 次涨到 10.5 次,未命中查找从 13 次涨到约 199 次。插入一个新键先要确认它不存在,代价与未命中查找相同。
  3. 探测次数多不等于访存多。按缓存行计,线性探测在 \(\alpha \le 0.9\) 时都比双重哈希少碰缓存行(0.9 时 2.12 对 2.56),到 0.95 才反超。链式哈希的”探测”最少(成功查找只看 1.25 到 1.48 个节点),但每次都要多读一次桶数组,而且节点之间是依赖读取。
  4. 最长探测序列很长。\(\alpha = 0.9\) 时线性探测平均只需 5.5 次,最长却有 856 次;同一张表若用链式哈希,最长链只有 8 个节点。

四、Robin Hood 哈希

规则

Celis、Larson 和 Munro 在 FOCS 1985 提出 Robin Hood 哈希,Celis 的博士论文(Waterloo,1986,技术报告 CS-86-14)给出了完整分析。规则只有一条:插入时沿探测序列前进,如果手里这个键的位移大于当前槽位住户的位移,就把住户换出来,带着它继续往前走。“劫富济贫”指的是离家近的键(富)把位置让给离家远的键(穷)。

Robin Hood 插入 X 的过程:插入前 A、B、C、D 占据槽 0 到 3,位移分别为 0、1、0、1;X 的家是 1,在槽 1 遇到位移为 1 的 B,B 更穷,不交换;在槽 2 时 X 的位移为 1,大于 C 的 0,于是 X 占据槽 2,改为携带 C;在槽 3 与 D 位移相等,不交换;C 最终落在空槽 4,位移为 2。下方对比普通线性探测:X 落在槽 4,位移为 3

对照图中两种结果:占用的槽位完全相同(0 到 4),位移总和都是 5,Robin Hood 只是把位移重新分配了,最大位移从 3 降到 2,方差从 1.2 降到 0.4。

它改变了什么、没改变什么

Janson(ACM Transactions on Algorithms,2005)研究了线性探测的三种插入策略:先来先得(普通线性探测)、后来先得、Robin Hood。论文指出(并引述 Carlsson 等人与 Poblete、Viola 的结果),同一组键无论用哪种策略插入,被占用的槽位集合相同,因此位移总和相同;Robin Hood 在所有线性探测算法中使位移的方差最小。由此可得:

probe_sim 对同一批键分别用两种策略建表,结果如下(\(m = 2^{20}\),5 个种子):

\(\alpha\) 平均成功探测(两者相同) 位移方差:线性 / Robin Hood 最长位移:线性 / Robin Hood 未命中探测:扫到空槽 / Robin Hood 提前终止
0.50 1.500 1.58 / 0.67 33 / 10 2.507 / 1.751
0.70 2.166 9.44 / 2.24 120 / 16 6.069 / 2.517
0.80 2.998 35.48 / 5.42 264 / 27 13.017 / 3.397
0.875 4.499 153.93 / 14.66 624 / 42 32.345 / 4.935
0.90 5.493 302.55 / 23.17 856 / 50 49.883 / 5.941
0.95 10.460 2488.17 / 94.05 3286 / 86 198.691 / 10.923

提前终止的收益在高负载下最明显:\(\alpha = 0.9\) 时,未命中查找从约 50 次降到约 6 次,与成功查找处在同一量级。这也是 Robin Hood 能跑到高负载的主要原因。

\(\log\log n\) 的出处

常见的说法是”Robin Hood 的最长位移是 \(O(\log\log n)\),普通线性探测是 \(O(\log n)\)“。\(\log\log n\) 这个结论确实存在:Devroye、Morin 和 Viola(SIAM Journal on Computing,2004)证明最长查找时间以趋于 1 的概率落在 \(\log_2\log n + O(1)\) 附近。但他们的模型是随机探测:每个键的探测序列由独立、均匀分布在全表上的位置组成,更接近双重哈希,而不是线性探测。Celis 论文里的最长探测分析,按 Devroye 等人的引述,也是随机探测模型。

线性探测下没有这样的结论。实测中,Robin Hood 的最长位移随 \(\log_2 m\) 近似线性增长:

负载 0.9 时最长位移随表大小的变化:表从 2 的 10 次方增长到 2 的 22 次方,普通线性探测的最长位移从 133 增长到 1181,Robin Hood 从 14 增长到 54,两者在对数坐标上都持续上升

\(m\) 每扩大 4 倍,Robin Hood 的最长位移增加 4 到 9;若是 \(\log\log m\) 增长,从 \(2^{10}\) 到 \(2^{22}\) 只应增加 1 左右。Robin Hood 在线性探测上的收益是常数倍的:\(m \ge 2^{16}\) 时最长位移约为普通线性探测的 \(1/15\) 到 \(1/22\),但增长趋势并没有变成 \(\log\log n\)。Janson 的论文还顺带否定了另一个直觉:在随机探测下,“从平均位移附近开始双向查找”(centered probing)能加速,在线性探测下不能。

Rust 旧版 HashMap(1.0 到 1.35)

Rust 标准库在 1.36 之前用的就是 Robin Hood 线性探测。以 Rust 1.35.0 的 src/libstd/collections/hash/map.rs 为准:

1.36.0(2019-07-04)的发布说明写明:“HashMap’s implementation has been replaced with hashbrown::HashMap implementation”,也就是 SwissTable(第六节)。

实现

reproduce/rh_map.c 是一个完整的 Robin Hood 表(uint64_t 到 uint64_t,负载上限 0.7,容量为 2 的幂)。psl[i] 为 0 表示空槽,为 \(d+1\) 表示位移为 \(d\),一个数组同时编码”空不空”和”离家多远”;用 uint32_t 存,位移不可能超过容量,不存在溢出。核心三个函数如下(摘自 reproduce/rh_map.c,省略了创建、扩容与校验函数):

/* reproduce/rh_map.c(摘录) */
static int rh_place(RHMap *m, uint64_t key, uint64_t value)
{
    size_t mask = m->capacity - 1;
    size_t idx = rh_hash(key) & mask;
    uint32_t dist = 1;

    for (;;) {
        if (m->psl[idx] == 0) {
            m->keys[idx] = key;
            m->values[idx] = value;
            m->psl[idx] = dist;
            m->size++;
            return 1;
        }
        if (m->psl[idx] == dist && m->keys[idx] == key) {
            m->values[idx] = value;
            return 0;
        }
        if (m->psl[idx] < dist) {
            uint64_t tk = m->keys[idx], tv = m->values[idx];
            uint32_t tp = m->psl[idx];
            m->keys[idx] = key;
            m->values[idx] = value;
            m->psl[idx] = dist;
            key = tk;
            value = tv;
            dist = tp;
        }
        dist++;
        idx = (idx + 1) & mask;
    }
}

uint64_t *rh_lookup(const RHMap *m, uint64_t key)
{
    size_t mask = m->capacity - 1;
    size_t idx = rh_hash(key) & mask;
    uint32_t dist = 1;

    for (;;) {
        if (m->psl[idx] < dist)
            return NULL;
        if (m->psl[idx] == dist && m->keys[idx] == key)
            return &m->values[idx];
        dist++;
        idx = (idx + 1) & mask;
    }
}

int rh_delete(RHMap *m, uint64_t key)
{
    uint64_t *v = rh_lookup(m, key);
    if (!v) return 0;
    size_t mask = m->capacity - 1;
    size_t idx = (size_t)(v - m->values);

    for (;;) {
        size_t next = (idx + 1) & mask;
        if (m->psl[next] <= 1) {
            m->psl[idx] = 0;
            m->size--;
            return 1;
        }
        m->keys[idx] = m->keys[next];
        m->values[idx] = m->values[next];
        m->psl[idx] = m->psl[next] - 1;
        idx = next;
    }
}

三处细节:

reproduce/rh_test.c 对它做差分测试:200 万次随机插入、更新、删除、查找,与直接寻址数组逐次比对,每 65536 步检查一次不变量(每个键的位移与家一致;相邻槽位 psl 最多加 1;空槽之后的键必须在家)。

gcc -O2 -Wall -Wextra -o rh_test rh_test.c rh_map.c && ./rh_test
gcc -O1 -g -fsanitize=address,undefined -Wall -Wextra -o rh_test_san rh_test.c rh_map.c && ./rh_test_san

两种编译方式都输出 ok: 2000000 ops, final size 6013, capacity 16384。把删除的停止条件故意改成 psl[next] == 0 后,测试在第 549 步报错,说明它能抓住后移逻辑的错误。

五、删除:tombstone、Algorithm R 与 backward shift

为什么不能直接清空

开放寻址里,查找遇到空槽就停。把被删的槽直接标成空,会切断经过它的探测路径:

从线性探测表中删除 B 的三种做法:删除前 A、B、C、D 占据槽 0 到 3,C 和 D 的家都是 1;直接标记为空时,查找 D 在它的家槽 1 遇到空槽就停止,D 丢失;用 tombstone 时,查找 D 跳过槽 1 的墓碑,走 3 步找到 D,未命中查找要走 4 步;backward shift 把 C、D 各往前移一格并在槽 3 留下空槽,查找 D 只需 2 步

图中给出两种正确做法:

往回移动不是 Robin Hood 独有的。Knuth 在 TAOCP 6.4 节给出过普通线性探测的删除算法 Algorithm R:从空洞 \(i\) 往后扫,对槽 \(j\) 中家为 \(r\) 的键,只要 \(i\) 落在它的探测路径 \(r, r+1, \ldots, j\) 上,就把它移到 \(i\),空洞随之移到 \(j\),直到遇见空槽。Robin Hood 的版本更简单:由于”位移每步最多加 1”的不变量,后面的键可以无条件整体前移一格,遇到空槽或位移为 0 的键就停。两者都依赖”探测序列是连续的”,所以双重哈希和 SwissTable 只能用墓碑。

实测:墓碑的两面

reproduce/churn_sim.c 把一张 \(m = 2^{18}\) 的线性探测表填到 \(\alpha = 0.75\),然后执行 \(8n\) 对”随机删除一个已有键、插入一个新键”,键数保持不变。比较四种删除策略:

每 \(n/32\) 对操作采样一次:未命中查找(必须扫到真正的空槽)的平均探测数,以及插入找到落脚点(第一个空槽;墓碑策略下是第一个空槽或墓碑)的平均探测数。各用 2 万个随机新键,3 个种子取中位数。

gcc -O2 -Wall -Wextra -o churn_sim churn_sim.c && ./churn_sim > results/churn_sim.txt
python3 plot_churn.py results/churn_sim.txt ../deletion-churn.svg   # 需要 matplotlib
稳态负载 0.75 下反复删除和插入时探测次数的变化:左图是未命中查找,墓碑不重建时从约 8.5 次一路涨到约 278 次,按 7/8 重建时在约 8.4 到 20 次之间呈锯齿,Algorithm R 与 Robin Hood 始终约 8.5 次,Robin Hood 提前终止约 2.9 次;右图是插入找到落脚点的探测次数,两种墓碑策略都降到约 6.2 次,低于无墓碑的约 8.5 次
策略 墓碑占比 未命中查找探测数 插入落脚探测数
墓碑,不重建(\(0.5n\) 对后) 17.5% 32.5 6.19
墓碑,不重建(\(1.72n\) 对后停止) 24.5% 277.6 6.20
墓碑,7/8 重建(\(n\) 对之后的全部采样) 0 到 12.4% 8.4 到 20.2,均值 14.2 6.25 到 8.30,均值 6.80
Algorithm R 0 8.2 到 8.7,均值 8.46 同左
Robin Hood backward shift 0 8.46;提前终止 2.87 同左

几点观察:

  1. Algorithm R 与 Robin Hood 的前两列逐点相同。这不是巧合:线性探测下占用集合只由键的集合决定,两种方法删完之后的占用集合都等于”只插入现存键”的结果,所以一直停在 Knuth 公式的稳态(\(\alpha = 0.75\) 时未命中 8.5 次)。Robin Hood 相对 Algorithm R 的额外收益来自提前终止,而不是来自”没有墓碑”。
  2. 不重建的墓碑会耗尽空槽。插入只会消耗空槽或墓碑,删除只会制造墓碑,空槽数单调不增;空槽越少,未命中查找越长。\(1.72n\) 对操作后空槽只剩 0.5%,未命中查找已经要 278 次。
  3. 墓碑让插入落脚更快。两种墓碑策略下,插入找到落脚点只要约 6.2 次,比无墓碑的 8.5 次少。墓碑散布在 run 中间,新键更早遇到可以复用的位置,这正是 Bender、Kuszmaul 和 Kuszmaul(FOCS 2021)所说的墓碑”反聚集”效应。
  4. 两者的代价落在不同操作上:插入新键若不能事先确定键不存在,仍然要先做一次未命中查找,那一步会被墓碑拖慢。本实验的插入之所以能直接落脚,是因为新键由随机数生成,已知不在表中。

生产实现怎样对付墓碑

六、SwissTable:先比元数据,再比键

设计

SwissTable 由 Google 的 Matt Kulukundis 等人设计,在 CppCon 2017 的报告 “Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step” 中公开,随后作为 Abseil 的 absl::flat_hash_map / absl::node_hash_map 开源,设计说明见 abseil.io 的 “Swiss Tables Design Notes”。它仍是开放寻址,关键变化是为每个槽维护 1 字节控制字节(control byte),先在控制字节上批量筛选,只对少数候选比较完整的键。

SwissTable 在一个 16 槽分组中的查找:64 位哈希拆成高 57 位的 H1 和低 7 位的 H2;H1 决定探测起点,之后按 16、48、96 的三角数偏移跳到下一组;16 个控制字节中,满槽存该键的 H2,空槽为 0x80,已删除为 0xFE;第一步把 H2 = 0x3A 广播后与 16 个字节同时比较,得到掩码 0x0011,只需比较槽 0 和槽 4 的完整键;第二步用同样方法找空槽,只要本组有空槽且没有命中,就可以断定键不存在

以 Abseil 20240722.0 的 absl/container/internal/raw_hash_set.h 为准:

一次组内查找就是图中两步:用 _mm_set1_epi8、_mm_cmpeq_epi8、_mm_movemask_epi8 得到 H2 匹配掩码,逐个比较候选键;再求空槽掩码,本组有空槽而键没找到,就可以停止。kDeleted 不会让查找停下,所以上一节的”能不写墓碑就不写”很重要。Rust 的 hashbrown 与 Abseil 的思路相同,但控制字节编码不同(空槽 0xFF、删除 0x80),不要把两者混为一谈。组内细节、SIMD 抽象与 Abseil 源码拆解见 SwissTable 原理。

分组探测的计数模型

probe_sim 的实验 C 模拟一个简化的 SwissTable:\(m = 2^{20}\) 个槽,按组对齐(Abseil 的探测窗口可以从任意槽开始,Go 1.24 按组对齐),组间三角数探测,7 位 H2。统计每次查找访问的组数,以及 H2 误匹配导致的多余键比较次数。

\(\alpha\) 16 路:成功 / 未命中访问组数 16 路:成功 / 未命中多余键比较 8 路:成功 / 未命中访问组数 8 路:成功 / 未命中多余键比较
0.50 1.001 / 1.009 0.031 / 0.063 1.009 / 1.061 0.016 / 0.034
0.70 1.016 / 1.146 0.045 / 0.102 1.058 / 1.385 0.024 / 0.062
0.80 1.048 / 1.457 0.055 / 0.148 1.127 / 1.915 0.031 / 0.099
0.875 1.106 / 2.125 0.066 / 0.237 1.232 / 2.942 0.039 / 0.165
0.95 1.263 / 4.960 0.089 / 0.597 1.481 / 7.075 0.056 / 0.426

在 \(7/8\) 的负载上限处,16 路分组的命中查找平均只访问 1.1 组,未命中 2.1 组,多余的键比较不到 0.25 次;同样负载下,线性探测的未命中查找要逐个检查约 32 个槽。未命中查找一旦遇到”本组有空槽”就能停,这起到的作用与 Robin Hood 的提前终止相同,但不需要在插入时搬动键。

这张表比较的是计数,单位也不同:一次”访问组”包含一次 16 字节加载和若干条 SIMD 指令,一次线性探测是一次 8 字节比较。两者在真实硬件上谁快,要看键的大小、哈希函数和表是否在缓存里,本文不给计时结论。

七、生产实现对照

实现 版本 结构 扩容阈值 删除 源码
CPython dict 3.13 索引数组上的开放寻址 + 按插入顺序的条目数组,perturb 探测 条目数达到 \(\frac{2}{3}\) 容量(USABLE_FRACTION) DKIX_DUMMY 墓碑 Objects/dictobject.c
Java HashMap JDK 21 链式,长桶转红黑树 0.75 摘链 java/util/HashMap.java
Go map 1.23 8 槽 bucket + overflow 链,渐进式搬迁 平均每 bucket 6.5 个键 清空槽位 src/runtime/map.go
Go map 1.24 SwissTable(8 槽 group)+ 可扩展哈希目录 每张表 \(7/8\) 组内有空槽则标空,否则墓碑 src/internal/runtime/maps/
absl::flat_hash_map 20240722.0 SwissTable(SSE2 下 16 槽 group) \(7/8\) 能标空则标空,否则墓碑,必要时原地清理 absl/container/internal/raw_hash_set.{h,cc}
Rust HashMap 1.0 到 1.35 Robin Hood 线性探测 \(10/11\) backward shift src/libstd/collections/hash/map.rs
Rust HashMap 1.36 起 hashbrown(SwissTable) 见 hashbrown 源码 墓碑 hashbrown crate
Redis dict 7.4.2 链式,双表渐进 rehash 负载 1;有子进程时为 4 摘链 src/dict.c

CPython dict:紧凑布局与 perturb 探测

Objects/dictobject.c 文件开头写明 “As of Python 3.6, this is compact and ordered”,思路来自 Raymond Hettinger 2012 年在 python-dev 上的提议;3.7 起插入顺序成为语言规范的一部分。

CPython 紧凑字典的两层结构:dk_indices 是 8 个 int8 的稀疏数组,-1 表示空、-2 表示已删除、非负数是条目下标;dk_entries 是按插入顺序追加的 5 个条目,其中第 2 个条目因删除 dave 而键为空,第 4 个条目尚未使用;迭代时顺序扫描 dk_entries 并跳过空键,所以得到插入顺序

探测序列不是线性也不是二次,而是 perturb 递推:

/* CPython v3.13.0, Objects/dictobject.c, lookdict_index() */
static Py_ssize_t
lookdict_index(PyDictKeysObject *k, Py_hash_t hash, Py_ssize_t index)
{
    size_t mask = DK_MASK(k);
    size_t perturb = (size_t)hash;
    size_t i = (size_t)hash & mask;

    for (;;) {
        Py_ssize_t ix = dictkeys_get_index(k, i);
        if (ix == index) {
            return i;
        }
        if (ix == DKIX_EMPTY) {
            return DKIX_EMPTY;
        }
        perturb >>= PERTURB_SHIFT;
        i = mask & (i*5 + perturb + 1);
    }
    Py_UNREACHABLE();
}

PERTURB_SHIFT 为 5。源码里那段长注释解释了原因:CPython 的整数哈希就是整数本身,hash(i) 对连续整数是连续的,用低位直接定位时连续整数完全不冲突,这比随机哈希还好;但像 [i << 16 for i in range(20000)] 这样的键,低 15 位全相同,全部落到同一个槽。递推 \(j \leftarrow 5j + 1 \bmod 2^k\) 能遍历所有槽,再把 perturb 逐步右移加进去,让高位也参与探测;perturb 最终变成 0,退化为纯 \(5j+1\),仍然保证能找到空槽。str 和 bytes 的哈希是带随机种子的 SipHash:3.13 的 Include/pyhash.h 中默认算法为 Py_HASH_SIPHASH13。

Go:从 bucket 链到 SwissTable

Go map 在 1.24 前后的内存布局:上半部分是 Go 1.23,hmap 指向 2 的 B 次方个 bucket,每个 bucket 依次存 8 字节 tophash、8 个键、8 个值和 overflow 指针,满了挂 overflow bucket;下半部分是 Go 1.24,Map 通过按哈希高位索引的目录指向多张表,多个目录项可以指向同一张表,每张表至多 1024 个槽,由若干 group 组成,每个 group 是 8 字节控制字加 8 个键值槽

Go 1.23(src/runtime/map.go):

Go 1.24(src/internal/runtime/maps/,发布说明称可用 GOEXPERIMENT=noswissmap 关闭):

发布说明把新 map 列为 Go 1.24 运行时平均 2% 到 3% CPU 开销下降的原因之一(与小对象分配、运行时互斥锁的改进合计),没有单独给出 map 的收益。

八、争论与开放问题

争论一:线性探测需要多”随机”的哈希函数

Knuth 公式假设哈希值完全随机,生产系统用的是固定的快速函数。Pagh、Pagh 和 Ružić(STOC 2007,SIAM Journal on Computing 2009)证明:只用两两独立(pairwise independent)的哈希族,线性探测的期望代价可能是对数级;用 5 阶独立(5-wise independent)就能保证每次操作期望常数时间。Pătraşcu 和 Thorup(ICALP 2010)给出匹配的下界:他们构造了一个 4 阶独立的哈希函数,使某些键的期望查找时间为对数级,并指出很快的 2 阶独立 multiply-shift 在这类应用中会严重失效。Pătraşcu 和 Thorup 在 STOC 2011 又证明,只有 3 阶独立的简单 tabulation 哈希也能让线性探测达到期望常数时间,说明”独立阶数”并不是唯一的判据。

工程上的做法各不相同。CPython 有意让整数哈希保持规则,把打散的工作交给 perturb 探测;Abseil、Rust、Go 都用强混合的哈希函数并加入随机种子,Rust 默认的 SipHash-1-3 还以抵御哈希洪泛攻击(hash flooding)为设计目标。第三节的实验用的是 fmix64 这种双射混合函数和随机键,这正是”接近理想”的情形;键有规律、混合函数又弱时,线性探测的退化比第三节的表严重得多。哪一类哈希函数在真实键分布上”足够好”,目前主要靠经验和测试,理论与实践之间仍有空隙。

争论二:墓碑是负担还是资产

教科书把墓碑当作必须定期清理的负担,第五节的未命中查找曲线也支持这一点。Bender、Kuszmaul 和 Kuszmaul(FOCS 2021)从另一面论证:删除留下的墓碑有反聚集(anti-clustering)作用,可以抵消一次聚集。经典分析认为负载 \(1 - 1/x\) 时插入的期望代价是 \(\Theta(x^2)\);他们证明,只要删除的实现细节做对,即使表一直处在 \(1 - \Theta(1/x)\) 的负载,每次操作的期望均摊代价也只有 \(\tilde{O}(x)\)。他们还提出 graveyard hashing,在任意操作序列上完全消除一次聚集:当前负载为 \(1 - 1/x\) 时,每次操作的期望代价为 \(O(x)\)。

第五节的实验在两边都给出了证据:墓碑让插入落脚从 8.5 次降到约 6.2 次,同时让必须扫到空槽的未命中查找从 8.5 次涨到 14 次(有重建)乃至 278 次(无重建)。结论取决于负载里”插入已知的新键”和”查找不存在的键”各占多少。Abseil 的 WasNeverFull() 和 Go 的”组内有空就不留墓碑”选择尽量少留墓碑,graveyard hashing 目前还停留在论文里。

争论三:Robin Hood 为什么在工程上让位

Robin Hood 在理论上很漂亮:线性探测里方差最小、未命中查找可以提前终止、删除不用墓碑。但 Rust 在 1.36 换成了 hashbrown,Go 1.24、Abseil 选的也都是 SwissTable。第四、六节的计数给出了一种解释:两者都把未命中查找压到了常数量级(\(7/8\) 负载下,Robin Hood 约 5 个槽,SwissTable 约 2 个组),但 SwissTable 靠元数据筛选做到这一点,插入时不需要搬动键,也就不需要为每次插入付出交换的写入代价。插入搬动元素还带来实现上的约束,例如 Rust 旧实现要用 DISPLACEMENT_THRESHOLD 防范长位移。这是本文基于计数的推断;要确认代价的真实比例,需要在同一硬件上对两种生产级实现计时,本文没有这样的数据。

开放问题

九、工程陷阱与选型

陷阱 后果 做法
开放寻址表负载设得过高 线性探测在 0.95 时未命中查找约 199 次(第三节),插入新键也要付这个代价 线性探测把上限放在 0.7 到 0.8;需要高负载时用 Robin Hood 的提前终止或 SwissTable 的分组元数据
线性探测配弱哈希或规则的键 run 快速拉长,理论上两两独立的哈希族可导致对数级代价 用强混合函数(如 fmix64);面向不可信输入时用带随机种子的 SipHash 一类函数,见 密码学哈希与非密码学哈希
用墓碑删除却从不重建 空槽单调减少,未命中查找无限变长(第五节:\(1.72n\) 次删改后 278 次) 以”有效键加墓碑”计负载并触发重建;线性探测可改用 Algorithm R 或 Robin Hood 的后移删除
把删除直接写成空槽 截断探测路径,已存在的键查不到 墓碑、后移删除,或 SwissTable 的”组内从未满过才标空”
保存开放寻址表里元素的指针 插入触发扩容、Robin Hood 交换或后移删除后指针悬空 不跨修改保存指针;需要地址稳定时用节点式容器(如 absl::node_hash_map、std::unordered_map)
以为 Java HashMap 链长到 8 就会建树 表长小于 64 时只会扩容 知道 MIN_TREEIFY_CAPACITY = 64;键实现 Comparable 能让树内查找更有效
在延迟敏感的服务里全量 rehash 大表 一次 \(O(n)\) 的停顿 预分配容量;或用 Redis 式双表渐进迁移、Go 1.24 式分表扩容,把停顿切碎
依赖 Go map 的遍历顺序 遍历起点是随机的,结果不稳定 需要顺序时先取键再排序

选型上,按约束来选:

场景 常见选择 决定性约束
通用内存哈希表,键值小、追求吞吐 SwissTable 类(Abseil、hashbrown、Go 1.24) 高负载下的未命中查找代价、缓存行利用
元素地址必须稳定,或键值很大 链式或节点式开放寻址 扩容与重排不能移动元素
需要插入顺序 CPython 式紧凑字典(索引数组 + 条目数组) 迭代顺序、迭代时的访存
单线程服务里的大字典,要求无长停顿 链式 + 渐进 rehash(Redis) 扩容停顿必须摊到每次操作
教学或需要最坏查找可控的简单实现 Robin Hood 线性探测 实现短、方差小、删除无墓碑

十、参考资料

源码与文档

核心论文

其他论文

工程资料

实验


系列导航: - 上一篇:排序基准测试:12 种排序算法在不同数据分布下的实测 - 下一篇:Cuckoo Hashing:用两个位置换取最坏情况常数查找

相关阅读: - SwissTable 原理:控制字节与 SIMD 分组探测 - 密码学哈希与非密码学哈希 - 并发哈希表 - Redis 字典与渐进式 rehash

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2025-07-15 · algorithms

Cuckoo Hashing:用两个位置换取最坏情况常数查找

从 Pagh–Rodler 的两表插入与 cuckoo 图出发,用可复现实验核对失败概率、stash、d-ary 与分桶的负载阈值和两种插入搜索的代价,再对照 MemC3、libcuckoo、DPDK、OVS 源码说明并发读写怎样避免假未命中。


By .