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

完美哈希:从 FKS 两级表到 gperf 与现代 MPHF

文章导航

分类入口
algorithms
标签入口
#perfect-hashing#mphf#fks#bdz#chd#pthash#recsplit#gperf#hypergraph-peeling

目录

键集合事先完全已知、之后不再变化时,能不能造一个在这组键上没有任何冲突的哈希函数?答案是能,而且有两种很不一样的造法,对应两个不同的问题:

常见的误解有三个:以为 GCC 用 gperf 识别 C 关键字(GCC 15.1 用的是普通的标识符哈希表,第七节);以为 MPHF 可以直接回答”在不在”(它对集合外的键会返回一个合法下标,第一节);以及把不同论文、不同条件下的 bits/key 放进一张表直接排名(第六节)。本文依次讲 FKS、空间下界、基于随机超图的 CHM/BDZ、hash-and-displace 一族(CHD、PTHash)、BBHash 与 RecSplit,最后回到 gperf 3.3 的实际算法。文中所有自测数字来自同目录 reproduce/ 下的程序(环境见第四节开头),全部是与时钟无关的计数;外部数据都注明出处。

一、定义:PHF、MPHF 与静态字典

设全集为 \(U\),\(|U| = u\),键集合 \(S \subseteq U\),\(|S| = n\)。函数 \(h: U \to \{0, \ldots, m-1\}\) 若在 \(S\) 上是单射,就称为 \(S\) 的完美哈希函数(Perfect Hash Function,PHF);若 \(m = n\),就是 MPHF。\(\alpha = n/m\) 叫负载因子。

两点容易混淆:

目标 存不存键 典型空间 典型用途
静态字典(FKS) 存 \(O(n)\) 个机器字 最坏 \(O(1)\) 的成员查询
PHF,\(\alpha < 1\) 不存 低于 \(\log_2 e\) bits/key(第三节) 给键分配槽位,允许少量空槽
MPHF,\(\alpha = 1\) 不存 \(\ge \log_2 e \approx 1.443\) bits/key 把字符串键映射成紧凑的数组下标

二、FKS:两级表与 \(\sum b_i^2\)

从 Tarjan–Yao 到 FKS

Tarjan 和 Yao(CACM 1979,“Storing a sparse table”)已经用”行位移”把稀疏表压成 \(O(n)\) 空间,但查找时间依赖全集大小 \(u\) 与 \(n\) 的关系,只在 \(u\) 相对 \(n\) 不太大时才是常数。Fredman、Komlós 和 Szemerédi 的 “Storing a Sparse Table with \(O(1)\) Worst Case Access Time” 先在 FOCS 1982 发表,再刊于 JACM 31(3),1984。它对任意全集给出 \(O(n)\) 空间、最坏常数时间的静态字典,思路是两级哈希。

下面按教科书常用的参数(CLRS 第 11.5 节)叙述,并配合 Carter–Wegman(JCSS 1979)的全域哈希族:

\[ h_{a,b}(x) = \big((a x + b) \bmod p\big) \bmod m,\qquad 1 \le a < p,\ 0 \le b < p, \]

\(p\) 为大于所有键的素数。该族满足:对任意 \(x \ne y\),\(\Pr[h(x) = h(y)] \le 1/m\)。

为什么不能一级搞定

把 \(n\) 个键扔进 \(m\) 个槽,冲突对数的期望是

\[ E[\#\text{collisions}] \le \binom{n}{2} \frac{1}{m}. \]

取 \(m = n^2\) 时它小于 \(1/2\),由 Markov 不等式,随机抽一个 \(h\) 就有至少 \(1/2\) 的概率无冲突。反过来,要让随机抽的函数以常数概率无冲突,\(m\) 必须是 \(\Theta(n^2)\) 量级(生日问题)。注意这是对”随机抽”的要求,不是”不存在更小的完美函数”:第三节会看到,只要允许存几个 bit 的描述,\(m = n\) 也能做到。

两级结构

FKS 的办法是只在小范围内付平方代价:

  1. 第一级用 \(h\) 把键分进 \(m = n\) 个桶,桶 \(i\) 有 \(b_i\) 个键;若 \(\sum_i b_i^2 \ge 4n\) 就换一个 \(h\)。
  2. 桶 \(i\) 拥有 \(s_i = b_i^2\) 个槽的子表和自己的 \(h_i\),换 \(h_i\) 直到它在桶内无冲突。

第一级的期望:\(\sum_i b_i^2 = n + 2\cdot\#\text{collisions}\),所以

\[ E\Big[\sum_i b_i^2\Big] \le n + 2\binom{n}{2}\frac{1}{n} = 2n - 1, \]

由 Markov 不等式 \(\Pr[\sum b_i^2 \ge 4n] < 1/2\),期望不到 2 次就能选到合格的 \(h\)。第二级每个桶的成功概率至少 \(1/2\)。总空间是 \(n\) 个桶头加不到 \(4n\) 个槽,即 \(O(n)\) 个字。

FKS 两级表的实际布局:8 个键 11、23、38、47、52、66、71、95 经第一级哈希分到 8 个桶,其中桶 1、2、3 各一个键,桶 5 有 23、38、52 三个键,桶 6 有 66、95 两个键,其余为空;每个非空桶指向全局槽数组中属于自己的一段,桶 5 占偏移 3 起的 9 个槽,桶 6 占偏移 12 起的 4 个槽;下方演示查找 38 时先读桶 5 的参数,再读槽 6 并比较键

图中的数据来自 ./fks --demo 3 的真实输出。有三点需要看:

实现与实测

reproduce/fks.c 取 \(p = 2^{61}-1\),用 128 位乘法和 Mersenne 素数的折叠求模。构造的核心是两个重试循环(摘自该文件 fks_build(),有删减):

do {
    f->level1_tries++;
    f->h1 = cw_random(&rng);
    memset(cnt, 0, (size_t)n * sizeof *cnt);
    for (uint32_t i = 0; i < n; i++)
        cnt[cw_hash(f->h1, keys[i], n)]++;
    sumsq = 0;
    for (uint32_t j = 0; j < n; j++)
        sumsq += (uint64_t)cnt[j] * cnt[j];
} while (sumsq >= 4ULL * n);
...
for (;;) {
    tries++;
    bk->h = cw_random(&rng);
    for (uint32_t s = 0; s < bk->size; s++)
        sub[s] = EMPTY;
    uint32_t k;
    for (k = 0; k < b; k++) {
        uint64_t x = grouped[start[j] + k];
        uint32_t pos = cw_hash(bk->h, x, bk->size);
        if (sub[pos] != EMPTY)
            break;
        sub[pos] = x;
    }
    if (k == b)
        break;
}

对每个规模用 5 个种子构造,并验证全部键可查到、另取 \(10^6\) 个随机非键全部查不到(中位数):

\(n\) 第一级尝试次数 \(\sum b_i^2 / n\) 第二级平均尝试次数/非空桶 最大桶 \(b_i\)(5 次中最大)
\(10^3\) 1 2.0100 1.149 5
\(10^5\) 1 1.9994 1.155 8
\(10^6\) 1 1.9993 1.156 9

\(\sum b_i^2/n\) 紧贴理论值 \(2 - 1/n\),15 次构造里第一级都是一次成功,远低于 \(4n\) 的门槛;第二级平均 1.16 次,比”至少 1/2 成功率”推出的 2 次上界好,因为大多数桶只有一个键,必然一次成功。

空间要按字算。本实现每个桶头 24 字节,槽约 \(2n\) 个、每个 8 字节,合计约 40 字节/键,而且其中 16 字节是键本身。FKS 解决的是”最坏两次访存的字典”,不是”最小的函数描述”;把它和 MPHF 的 bits/key 放进同一列比较没有意义。

三、空间下界:为什么是 \(\log_2 e\)

MPHF 不存键,那它最少要多少 bit?计数论证来自 Mehlhorn(FOCS 1982,“On the program size of perfect and universal hash functions”)和 Fredman、Komlós(SIAM J. Algebraic Discrete Methods 5(1),1984),2025 年的综述(Lehmann 等,见第六节)给了简明版本:

  1. 固定一个函数 \(f: U \to [n]\)。它对某个 \(n\) 元集合是 MPHF,当且仅当该集合在每个原像 \(f^{-1}(i)\) 里恰好取一个元素,这样的集合有 \(\prod_i |f^{-1}(i)|\) 个,原像大小都为 \(u/n\) 时取最大值 \((u/n)^n\)。
  2. 可能的输入集合共有 \(\binom{u}{n}\) 个,所以至少需要 \(\binom{u}{n} / (u/n)^n\) 个不同的函数,描述它至少要

\[ \log_2 \frac{\binom{u}{n}}{(u/n)^n} \xrightarrow{u \to \infty} \log_2 \frac{n^n}{n!} = n \log_2 e - \tfrac{1}{2}\log_2(2\pi n) - o(1) \]

bit。按精确式计算(reproduce/plot_space.py),每键下界在 \(n = 16\) 时是 1.234 bit,\(n = 1000\) 时 1.436 bit,\(n = 10^6\) 时 1.4427 bit。

允许空槽时下界会降低。负载因子为 \(\alpha\) 的 PHF,每键至少

\[ \log_2 e + \Big(\frac{1}{\alpha} - 1\Big)\log_2(1 - \alpha) \]

bit。\(\alpha = 0.5\) 时约 0.443,\(\alpha = 0.81\) 时 0.881,\(\alpha = 0.99\) 时 1.376。CHD 论文图 1 标注的下界 0.88、1.07、1.38、1.44(对应 \(\alpha = 0.81, 0.90, 0.99, 1.00\))与此式一致。

PHF 空间下界随负载因子变化的曲线:从 α=0.3 时约 0.24 bit 上升到 α=1 时的 1.443 bit;图中还标出 CHD 论文图 1 报告的 λ=5 时 1.40、1.65、1.98、2.07 bits/key 四个点,以及 BPZ 在 α=0.81 与 1.0 时的 1.97 和 2.61 bits/key

图中的点是论文数据,不是本站实测。实际方案离曲线还有 0.5 到 1 bit 左右的距离,而且越靠近 \(\alpha = 1\),下界本身越陡。

四、随机超图:CHM 与 BDZ

实验环境

以下各节的自测都在同一环境下完成:Intel Core i9-12900K,WSL2 内核 6.6.87.2,GCC 16.1.1,编译参数 -O2 -Wall -Wextra(另用 -fsanitize=address,undefined 跑过小规模输入);Python 绘图用 matplotlib。程序都使用固定种子,指标是成功率、尝试次数和 bit 数,与时钟无关,不报告任何耗时。

cd reproduce
gcc -O2 -Wall -Wextra -o fks fks.c && ./fks
gcc -O2 -Wall -Wextra -o graphs graphs.c -lm && ./graphs > results/graphs.txt
gcc -O2 -Wall -Wextra -o hd hd.c -lm && ./hd 1000000
gcc -O2 -Wall -Wextra -o bbhash_sim bbhash_sim.c -lm && ./bbhash_sim
python plot_peel.py && python plot_space.py
./run_gperf.sh

CHM:无环随机图

Czech、Havas 和 Majewski(IPL 43(5),1992)把每个键看成一条边 \(\{f_1(x), f_2(x)\}\),顶点数 \(m = cn\),\(c > 2\)(常用 2.09)。如果这张随机图无环,就可以沿着每棵树给顶点赋值 \(g\),使

\[ h(x) = \big(g(f_1(x)) + g(f_2(x))\big) \bmod n \]

等于任意事先指定的值,所以 CHM 天然是保序的。代价是 \(g\) 的每个值要 \(\log_2 n\) bit,总空间约 \(2.09\, n \log_2 n\) bit(综述 Table 1)。\(c > 2\) 的门槛来自随机图理论:平均度数 \(2n/m = 2/c\) 小于 1 时,环的数目近似服从 Poisson 分布,图以常数概率无环。

Botelho、Pagh 和 Ziviani(WADS 2007)对二分图版本(两个哈希各占一半顶点)给出无环概率 \(\sqrt{1 - (2/c)^2}\),\(c = 2.09\) 时为 0.29,他们在 \(n = 10^7\) 上实验得到 0.294。graphs.c 的 E1 用 \(n = 10^5\) 复现:

\(c\) 次数 实测无环比例 \(\sqrt{1-(2/c)^2}\)
2.09 2000 0.301 0.290
2.50 1000 0.586 0.600
3.00 1000 0.763 0.745

三组差异都在两个标准差以内(\(c = 2.09\) 时标准差约 0.010)。\(c = 2.09\) 意味着平均要试 3 次多才能得到一张可用的图。

MWHC/BDZ:三部超图与剥离

Majewski、Wormald、Havas 和 Czech(The Computer Journal 39(6),1996)把边换成 3 个顶点的超边。三部超图不再谈”环”,而是看能否剥离(peeling):反复找一个度为 1 的顶点,删掉它唯一所在的边;若最后删光所有边,就称可剥离。每条边被删时都带走一个”自由顶点”,此后不再有边碰它。

BDZ 剥离过程:5 个键对应 5 条三元超边,9 个顶点分成 3 部;左侧画出超边 x0={0,3,6}、x1={0,4,7}、x2={1,4,6}、x3={2,5,7}、x4={1,5,8} 与各顶点初始度数,顶点 2、3、8 初始度数为 1;右侧按栈顺序列出 5 步:经顶点 8 删 x4、经 5 删 x3、经 7 删 x1、经 4 删 x2、经 6 删 x0,最终全部删光

图中的例子与 ./graphs --demo 的输出一致。实现用”度数 + 相邻边编号异或”找出度为 1 的顶点所在的唯一边,整个剥离是线性时间。

Botelho、Pagh、Ziviani 的 BDZ(论文和综述里也叫 BPZ)在剥离之后不再存 \(\log_2 n\) bit 的值,每个顶点只存 \(g[v] \in \{0,1,2,3\}\),查询时

\[ h(x) = v_i(x),\qquad i = \big(g[v_0] + g[v_1] + g[v_2]\big) \bmod 3, \]

也就是用 2 bit/顶点选出三个候选顶点中的一个。赋值按逆剥离顺序进行:处理某条边时,它的自由顶点还没被任何已处理的边碰过,可以自由设值,让和的余数恰好指向自己;未被使用的顶点保持 3(模 3 等于 0,同时标记”未用”)。上例的赋值过程:

处理次序 边 自由顶点(所在部 \(j\)) 另两个顶点的 \(g \bmod 3\) \(g[v] = (j - \text{和}) \bmod 3\) \(h(x)\)
1 x0 6(\(j=2\)) \(g_0 = 3 \to 0\),\(g_3 = 3 \to 0\) 2 6
2 x2 4(\(j=1\)) \(g_1 = 3 \to 0\),\(g_6 = 2\) \((1-2) \bmod 3 = 2\) 4
3 x1 7(\(j=2\)) \(g_0 \to 0\),\(g_4 = 2\) 0 7
4 x3 5(\(j=1\)) \(g_2 \to 0\),\(g_7 = 0\) 1 5
5 x4 8(\(j=2\)) \(g_1 \to 0\),\(g_5 = 1\) 1 8

5 个键落到 5 个不同的顶点,得到 PHF;再对”\(g \ne 3\) 的顶点”做 rank,顶点 4、5、6、7、8 依次映射为 0 到 4,就得到 MPHF。对应代码(reproduce/graphs.c):

static void bdz_assign(const graph_t *g, uint8_t *lab)
{
    memset(lab, 3, g->m);
    for (uint32_t i = g->n; i-- > 0;) {
        uint32_t e = g->order[i];
        int j = g->free_pos[i];
        const uint32_t *ev = &g->v[(size_t)e * 3];
        int sum = 0;
        for (int k = 0; k < 3; k++)
            if (k != j)
                sum += lab[ev[k]] % 3;
        lab[ev[j]] = (uint8_t)(((j - sum) % 3 + 3) % 3);
    }
}

static uint32_t bdz_phf(const graph_t *g, const uint8_t *lab, uint32_t e)
{
    const uint32_t *ev = &g->v[(size_t)e * 3];
    int i = (lab[ev[0]] + lab[ev[1]] + lab[ev[2]]) % 3;
    return ev[i];
}

1.23 从哪里来

3 部超图可剥离的阈值约为 \(n/m = 0.8185\),即 \(c = m/n \approx 1.222\);MWHC 1996 年推测了这个阈值,约十年后 Molloy(Random Structures & Algorithms 27(1),2005)证明了随机超图 2-core 出现的阈值,与推测值吻合(据上述综述)。graphs.c 的 E2 扫描 \(c\) 从 1.15 到 1.31:

三部超图可剥离比例随 c=m/n 的变化:n=1000、10000、100000 三条曲线都在 c≈1.222 附近从 0 跳到 1,n 越大跳变越陡;n=100000 时 c=1.22 的成功率为 0.11,c=1.23 时为 1.0

E3 用 \(n = 10^6\)、\(c = 1.23\) 构造 5 次,每次都是第一次尝试就可剥离,PHF 与 rank 后的 MPHF 都通过了双射校验。标签部分 \(2 \times 1.23 = 2.46\) bits/key;BDZ 论文 Table 3 报告加上 rank 结构的 MPHF 为 2.62 bits/key,把 3 值标签按 \(\log_2 3\) 打包的 PHF(\(m = 1.23n\))为 1.95 bits/key。

五、hash-and-displace:从 Pagh 到 CHD 与 PTHash

谱系

“先分桶、再给每个桶找一个位移”这条线同样可以追溯到 Tarjan–Yao 1979。Pagh(WADS 1999,“Hash and Displace”)用 \(h(x) = (f(x) + d_{g(x)}) \bmod n\),桶按大小从大到小处理,查询只需两次访存;按 CHD 论文的总结,Pagh 的分析要求桶数大于 \(n\),空间约 \((2+\epsilon)\log_2 n\) bits/key。Fox、Chen、Heath(SIGIR 1992)的 FCH 更早用了”倾斜”的分桶:60% 的键进 30% 的桶,让先处理的大桶更容易放。

Belazzougui、Botelho、Dietzfelbinger 的 CHD(ESA 2009,“Hash, Displace, and Compress”)做了两个改动:

  1. 桶 \(B_i\) 里的键放到 \((f_1(x) + d_0 f_2(x) + d_1) \bmod m\),按 \((0,0), (0,1), \ldots, (0,m-1), (1,0), \ldots\) 的固定顺序尝试位移对,只存第一个成功的对在序列中的下标;
  2. 从小序号开始试,序号序列的熵就低,再用 simple dense coding 压缩,同时保留 \(O(1)\) 随机访问。

它的摘要给出:\(m = 1.23n\) 时 1.4 bits/key,\(m = 1.01n\) 时 1.98 bits/key;MPHF 则是在 PHF 之上加一个支持 rank/select 的简洁结构,论文图 1(d) 中 \(\lambda = 5\) 时为 2.07 bits/key,同图中的 BPZ 为 2.61。\(\lambda\) 是平均桶大小,桶数 \(r = \lceil n/\lambda \rceil\)。

Pibiri 和 Trani 的 PTHash(SIGIR 2021)回到 FCH 的倾斜分桶,把位移改成 XOR:\(\big(h(x, s) \oplus h(v_i, s)\big) \bmod m\),并允许为 pilot 数组换用不同的编码(固定宽度、字典、Elias–Fano 等)。\(\alpha < 1\) 时,落在 \([n, m)\) 的位置经一个用 Elias–Fano 存的 \(m - n\) 项表重映射回 \([0, n)\)。论文摘要称查询比同等空间的方案快 2 到 4 倍。

构造循环用下图表示(CHD 与 PTHash 共用这个骨架,区别在分桶函数、位移形式和编码):

flowchart TD
    A["hash every key: bucket g(x), fingerprint h(x)"] --> B["sort buckets by size, largest first"]
    B --> C["take next bucket B_i"]
    C --> D["pilot l = 0"]
    D --> E["p(x) = slot(h(x), l) for all x in B_i"]
    E --> F{"all slots free and distinct?"}
    F -- no --> G["l = l + 1"]
    G --> E
    F -- yes --> H["pilot[i] = l, mark slots taken"]
    H --> I{"buckets left?"}
    I -- yes --> C
    I -- no --> J["encode pilot[]; if m > n build remap for slots >= n"]

大桶先放,因为此时表还空;轮到小桶时表已接近满,单个键找空位要试 \(1/(1-\text{load})\) 次左右,但小桶的”所有键同时有空位”概率下降得慢。平均桶越大,pilot 越少、每个 pilot 的熵越低,但构造越费力;综述 Table 5 把这类方法的构造时间写成 \(O(n e^{\lambda}/\lambda)\)。

实测:\(\lambda\) 与 \(\alpha\) 的代价

reproduce/hd.c 实现了这个骨架:CHD 式均匀分桶,pilot 从 0 起递增,槽位用 PTHash 式的 XOR 位移。输出三种空间估计:pilot 序列的零阶经验熵 \(H_0\)(理想熵编码的估计,不含模型本身)、Elias-gamma 前缀码长度(具体可算、但不支持随机访问),以及 \(\alpha < 1\) 时重映射表的 Elias–Fano 大小。\(n = 10^6\),3 个种子取中位数,每次都校验了双射:

\(\alpha\) \(\lambda\) 尝试次数/键 \(H_0\) bits/key gamma bits/key 重映射 EF bits/key 最大 pilot
1.00 1 13.87 2.955 3.014 0 926,817
1.00 2 15.81 2.380 2.604 0 637,280
1.00 3 23.66 2.136 2.560 0 2,617,115
1.00 4 56.81 2.016 2.585 0 1,584,112
1.00 5 214.64 1.939 2.623 0 1,999,561
0.99 1 4.47 2.835 2.897 0.091 710
0.99 2 5.78 2.271 2.484 0.091 690
0.99 3 11.86 2.041 2.433 0.091 2,479
0.99 4 33.77 1.932 2.456 0.091 9,871
0.99 5 119.96 1.861 2.492 0.091 55,464

几个结论:

实现时遇到一个坑:若槽位直接取 \(h(x) \oplus H(\ell)\) 的高位(multiply-shift 规约),同桶两个键的哈希值高位相同时,XOR 同一个 \(H(\ell)\) 不改变这个事实,于是对所有 \(\ell\) 都冲突,构造永远停不下来。\(n = 5000\)、\(\lambda = 4\) 时就触发了。hd.c 在 XOR 之后再做一次混合,PTHash 论文用的则是对 \(m\) 取模。

六、BBHash、RecSplit 与近年的空间竞赛

BBHash:分层指纹

Limasset、Rizk、Chikhi、Peterlongo 的 BBHash(SEA 2017)思路最简单:第 \(i\) 层把剩下的 \(n_i\) 个键哈希进 \(\gamma n_i\) 个 bit,独占一个位置的键就在这一层定下来,其余进下一层。查询时找到第一个”自己那一位为 1”的层,再对所有层拼起来的位图做 rank。

每个位置接收的键数近似 Poisson\((1/\gamma)\),一个键独占位置的概率是 \(e^{-1/\gamma}\),于是 \(n_{i+1} = n_i (1 - e^{-1/\gamma})\),总 bit 数

\[ \gamma n \sum_{i \ge 0} \big(1 - e^{-1/\gamma}\big)^i = \gamma e^{1/\gamma} n. \]

reproduce/bbhash_sim.c(\(n = 10^6\),3 个种子中位数,不含 rank 结构):

\(\gamma\) 层数 实测 bits/key \(\gamma e^{1/\gamma}\) 第 0 层安放比例
1.0 30 2.718 2.718 36.81%
1.5 19 2.920 2.922 51.30%
2.0 15 3.297 3.297 60.71%
5.0 8 6.104 6.107 81.90%

\(\gamma = 1\) 空间最省,但层数最多,更多的键要到深层才能定位;\(\gamma\) 增大时第 0 层就能安放更多键,代价是每键 bit 数上升。BBHash 论文摘要报告:\(10^{10}\) 个键、8 线程、不到 7 分钟、5 GB 内存,结果 3.7 bits/key。

RecSplit 与之后

Esposito、Graf、Vigna 的 RecSplit(ALENEX 2020)把键先分成大桶,桶内递归地找”把集合按固定比例劈开”的种子,直到叶子只有 \(\ell\) 个键,再对叶子暴力搜一个双射种子。摘要写明:构造是期望线性时间,查询期望常数时间,可做到 1.56 bits/key(距下界 8.3%),代价是每键构造时间不到 2 ms。换句话说,RecSplit 以极高的构造常数换空间,而不是渐进复杂度更差。

之后的工作继续压空间:ShockHash(Lehmann、Sanders、Walzer,ALENEX 2024)把叶子的暴力搜索换成”找一个让叶子里的键能放进两选 cuckoo 表的种子,再用每键 1 bit 的 retrieval 结构记下选了哪个位置”,需要尝试的种子数从 \(e^\ell\) 量级降到约 \((e/2)^\ell\);Consensus-RecSplit(Lehmann 等,arXiv:2502.05613,预印本)在综述的测量中达到 1.444 bits/key。

同口径数据

不同论文的 bits/key 用的数据集、编码和硬件都不同,直接放进一张表会误导。Lehmann、Mueller、Pagh、Pibiri、Sanders、Vigna、Walzer 的综述 “Modern Minimal Perfect Hashing: A Survey”(arXiv:2506.06536 v3,预印本,未经同行评审)在同一台机器上测了主要实现。以下摘自其 Table 6:\(n = 10^8\) 个长度 10 到 50 的随机字符串,单线程,Intel i7-11700 锁定 2.5 GHz,GCC 14.2 -O3 -march=native,实现版本截至 2025-10-12(引用数据,未在本站复现):

方法与配置 bits/key 构造 ns/key 查询 ns
FCH,\(c = 7\) 7.000 1,100 97
CHD,\(\lambda = 6\) 2.007 18,336 346
PTHash,\(\lambda = 6\),\(\alpha = 0.95\),EF 2.150 1,125 119
PtrHash,\(\lambda = 3\) 2.990 141 45
PHast,\(S = 7\),\(\lambda = 3.7\) 1.998 750 43
BBHash,\(\gamma = 1.5\) 3.293 273 173
RecSplit,\(\ell = 8\),\(b = 100\) 1.793 1,629 212
RecSplit,\(\ell = 14\),\(b = 2000\) 1.584 290,374 252
ShockHash-RS,\(\ell = 40\),\(b = 2000\) 1.551 4,128 320
Consensus,\(k = 32768\),\(\varepsilon = 0.0005\) 1.444 71,301 445

同一行里空间、构造、查询三个量此消彼长:最接近下界的 Consensus 查询最慢、构造也很贵;查询最快的 PHast、PtrHash 空间在 2 到 3 bits/key;CHD 在这台机器上构造和查询都不占优。BBHash 的 3.293 比上表模拟的 2.920 多出的部分,是 rank 结构和实现开销。

七、gperf:给几十个字符串造一个函数

真实算法(gperf 3.3)

GNU gperf 由 Douglas C. Schmidt 编写(论文 “GPERF: A Perfect Hash Function Generator”,Second USENIX C++ Conference,1990),其思路受 Keith Bostic 约 1984 年发到 net.sources 的程序启发,Bruno Haible 后来增强并优化了搜索算法(手册 Contributors 一节)。它解决的是另一个尺度的问题:几十到上千个关键字,生成一段可直接编译的 C/C++ 代码。它的哈希函数形态与 Cichelli(CACM 1980,“Minimal Perfect Hash Functions Made Simple”,gperf 手册参考文献 [3])的”长度加若干字符的关联值”同源。gperf 3.3 源码 src/search.cc 开头的注释给出了一般形式:

\[ \text{hash}(w) = \text{len}(w) + \sum_{i \in \text{Pos}} \text{asso\_values}\big[w[i] + \text{alpha\_inc}[i]\big] \]

长度项默认计入,可用 -n 关闭。搜索分三步,每一步都要求一个在关键字集合上单射的投影:前两步保证选出的字符多重集互不相同,第三步再给字符赋值:

flowchart TD
    K["keyword set"] --> P["step 1: pick byte positions Pos so that the tuples differ"]
    P --> A["step 2: pick alpha_inc so that the multisets differ"]
    A --> S["step 3: order the characters into steps, each step refines the partition"]
    S --> V["choose asso_values for this step's characters"]
    V --> T{"keywords in each equivalence class have distinct partial sums?"}
    T -- no --> N["next value: + jump; if range exhausted, double asso_value_max"]
    N --> T
    T -- yes --> M{"more steps?"}
    M -- yes --> V
    M -- no --> O["emit hash(), wordlist[], lookup()"]

第三步是关键。find_asso_values() 的注释写明,思路是”每次确定一个或几个字符的关联值,使已做的选择以后永远不需要撤销”:把”剩余未定字符的多重集相同”的关键字归为一个等价类,同一类里的关键字将来会加上完全相同的值,所以只要它们当前的部分和互不相同,最终哈希值就互不相同。每一步按 initial + iter * jump 枚举候选值(默认 jump 为 5,须为奇数),取值范围是 2 的幂;枚举完还找不到,就把范围 asso_value_max 加倍,而不是回溯。常见的”gperf 基于图着色”一说,与 3.3 的源码不符。

实跑一次

reproduce/keywords.gperf 定义了 16 个 C 关键字,run_gperf.sh 下载 gperf 3.3(校验 SHA-256)、生成 keywords.h,再用 test_keywords.c 检查 16 个关键字全部命中、24 个近似词(iff、While、brake、do 等)全部被拒:

GNU gperf 3.3
16 keywords found, 24 non-keywords rejected, 0 errors

生成的哈希函数如下(gperf 3.3 输出,删去了 256 项 asso_values 表的数值和 fallthrough 属性的宏判断):

#define TOTAL_KEYWORDS 16
#define MIN_WORD_LENGTH 2
#define MAX_WORD_LENGTH 8
#define MIN_HASH_VALUE 2
#define MAX_HASH_VALUE 21
/* maximum key range = 20, duplicates = 0 */

static unsigned int
keyword_hash (register const char *str, register size_t len)
{
  static const unsigned char asso_values[] = { /* 256 entries */ };
  register unsigned int hval = len;

  switch (hval)
    {
      default:
        hval += asso_values[(unsigned char)str[2]];
      /*FALLTHROUGH*/
      case 2:
        hval += asso_values[(unsigned char)str[1]];
        break;
    }
  return hval;
}

要点:

手册的 “Known Bugs and Limitations” 一章说明了适用规模:约 1000 个关键字的中小集合很快,3.0 起也能处理超过 15,000 个关键字,但生成的表和目标代码可能很大。百万级键集合应该换用第五、六节的方法。

谁在用,谁没用

gperf 手册 Introduction 至今写着它为 GNU C、GNU C++ 等编译器生成关键字识别器,这是 1990 年前后的描述。GCC 15.1 的现状:

/* GCC 15.1, gcc/c/c-parser.cc, c_parse_init(),有删减 */
  for (i = 0; i < num_c_common_reswords; i++)
    {
      /* If a keyword is disabled, do not enter it into the table
     and so create a canonical spelling that isn't a keyword.  */
      if (c_common_reswords[i].disable & mask)
    continue;

      id = get_identifier (c_common_reswords[i].word);
      C_SET_RID_CODE (id, c_common_reswords[i].rid);
      C_IS_RESERVED_WORD (id) = 1;
      ridpointers [(int) c_common_reswords[i].rid] = id;
    }

关键字表 c_common_reswords[] 在 gcc/c-family/c-common.cc,启动时把每个关键字插入普通的标识符表,并在标识符节点上打标记。词法分析器本来就要把每个标识符插入 libcpp 的哈希表(libcpp/symtab.cc 的 ht_lookup_with_hash(),开放寻址加双重哈希),查到节点时顺带读出关键字标记,不需要单独的完美哈希。GCC 仍在用 gperf 的地方是 C++ 前端:gcc/cp/Make-lang.in 里有两条维护者规则,从 cfns.gperf(标准 C 库函数名,供 nothrow_libfn_p 判断)和 std-name-hint.gperf(std:: 名字到头文件的提示)生成头文件。

其他可核对的例子:

八、争论与开放问题

争论一:空间还是查询速度

RecSplit 一线以空间为首要目标(摘要:1.56 bits/key,距下界 8.3%),ShockHash、Consensus 沿着这条线把空间压到 1.444。PTHash 论文则主张查询时间同样关键,它的摘要强调比同等空间的方案快 2 到 4 倍;综述 Table 6 里 PHast、PtrHash 的查询只有 43 到 45 ns,空间在 2 到 3 bits/key,而 1.444 bits/key 的 Consensus 查询是 445 ns。两边的数据都来自同一份测量,结论取决于应用:键数到 \(10^9\) 以上、MPHF 本身就有数百 MB 并且要常驻内存或随数据分发时,0.5 bit/key 是实打实的收益;查询在热路径上时,一次多出的缓存未命中可能更贵。综述在同一台机器上给出了 Pareto 前沿,但它是预印本,各实现也在持续更新,这张表只代表 2025 年 10 月的状态。

争论二:到底要不要”最小”和”不存键”

gperf 手册认为非最小的 PHF 往往更快。当应用本来就要存键(为了拒绝集合外的查询)时,函数本身 2 bit 与键的几十字节相比可以忽略,这时 FKS 式的”多留空槽、查询直达”或者普通的开放寻址表可能更划算。MPHF 的优势场景是键不必存、或者只存短指纹:把字符串映射成稠密 ID 再索引外部数组,例如 BBHash 论文面向的海量 k-mer 集合。

开放问题

九、工程选型

场景 常见选择 决定因素
几十到几千个编译期已知的字符串,需要成员判定 gperf,或像 Go 那样手写小表 生成代码可读、无运行时依赖;不需要追求最小
已有全局标识符表(编译器、解释器) 在表节点上打关键字标记 查找已经发生,不必再多一个结构(GCC)
静态键值表,要最坏两次访存,愿意存键 FKS 式两级表 约 \(2n\) 个槽加桶头;构造期望线性
大规模字符串到稠密 ID,查询在热路径 PTHash、PtrHash、PHast 一类 查询一次左右的访存,2 到 3 bits/key
大规模、空间最敏感、可离线构造 RecSplit、ShockHash、Consensus 一类 1.5 bits/key 附近,构造和查询都更贵
构造要快、实现要短 BBHash 一类分层指纹方法 3 bits/key 左右或以上;层数随 \(\gamma\) 变化

几个陷阱:

十、参考资料

规范与文档

源码

核心论文

其他论文

实验


系列导航: - 上一篇:SwissTable:SIMD 加速的开放寻址哈希表 - 下一篇:一致性哈希:哈希环、Jump Hash 与 Maglev

相关阅读: - 哈希表内部:冲突处理与负载因子 - Cuckoo Hashing:两个候选位置与踢出路径 - Bloom Filter 家族 - 哈希函数设计:密码学与非密码学哈希

读完这篇,下一步读什么

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

2025-07-15 · algorithms / database

外部排序:从 I/O 下界到 PostgreSQL 与 GNU sort

从 Aggarwal–Vitter 的 I/O 下界出发,用可复现实验比较替换选择与快排生成 run、败者树与堆的比较次数、多阶段与平衡归并的搬运量,再对照 PostgreSQL 18 与 GNU sort 9.11 源码说明今天为何多用快排、平衡归并和堆。


By .