Cuckoo Hashing:最坏 O(1) 查找的优雅设计

0 阅读2分钟

2001 年,Rasmus Pagh 和 Flemming Friche Rodler 发表了一篇只有 10 页的论文:Cuckoo Hashing。这篇论文用一个极其简洁的想法,解决了哈希表领域一个长期未解的问题:如何实现最坏情况 O(1) 的查找?

传统的开放寻址(线性探测、Robin Hood)只能保证期望 O(1)——在高负载因子或对抗性输入下,查找可能退化。链式哈希更不用说,最坏情况是 O(n)。

而 Cuckoo Hashing 的查找是确定性 O(1):查看最多 2 个位置,要么找到,要么确定不存在。

这个保证对某些场景至关重要:网络交换机的精确匹配表(ACL、路由表)需要硬实时的查找——不能接受"大部分时候 O(1),偶尔 O(n)"。DPDK 的 rte_hash 就是基于 Cuckoo Hashing 实现的。

Cuckoo Hashing:最坏 O(1) 查找的优雅设计

一、开放寻址的痛点

在进入 Cuckoo Hashing 之前,先回顾一下已有的开放寻址方案为什么不够好。

线性探测(Linear Probing)

线性探测是最简单的开放寻址策略:冲突时沿数组往后一个一个找空位。它的优点是 cache 友好——连续内存访问。但致命缺点是聚簇效应(clustering):一旦某个区域出现连续的已占 slot,后续哈希到这个区域的元素只会让连续段更长,形成正反馈。

期望情况下,负载因子 α\alpha 时的平均探测长度为 12(1+1(1α)2)\frac{1}{2}(1 + \frac{1}{(1-\alpha)^2})。当 α=0.9\alpha = 0.9 时,平均探测长度约 50 步。更关键的是最坏情况:在对抗性输入下,线性探测的查找可以退化到 O(n)O(n)——攻击者构造一组 key,让它们全部哈希到同一个小区域,形成一条超长的聚簇链。

Robin Hood 哈希

Robin Hood 哈希改进了线性探测:插入时,如果当前元素已经"偏移"了很远(探测序列很长),而新元素偏移更短,就把新元素让位给老元素——"劫富济贫"。这使得所有元素的探测长度的方差大幅降低。

Robin Hood 哈希的期望最大探测长度是 O(logn)O(\log n)——远好于线性探测。但它仍然不是 O(1)O(1)。对于需要硬实时保证的场景(如网络交换机每秒处理数十亿个包,每个包需要查一次流表),O(logn)O(\log n)O(1)O(1) 之间的差距就是"能用"和"不能用"的差距。

我们需要什么

理想的哈希表应该满足:

  1. 查找是确定性 O(1)O(1)——不是期望,不是摊还,是最坏情况。
  2. 内存访问次数有明确上界——最好是 2 次或 3 次。
  3. 空间利用率不能太差——50% 以上的负载因子。

Cuckoo Hashing 完美地回答了前两个需求,第三个需求通过后续的桶化和 d-ary 扩展也得到了解决。

二、Cuckoo Hashing 核心思想

名字的由来

Cuckoo(布谷鸟)是一种巢寄生鸟类——它不自己筑巢,而是把蛋下在其他鸟的巢里,让"房东"鸟帮它孵蛋。更过分的是,布谷鸟幼鸟孵化后会把巢中其他蛋推出去。

Cuckoo Hashing 的插入操作与此类似:新元素到达时,如果它的"家"(哈希位置)已经被别人占了,它会把现有元素踢出去,自己占据位置。被踢出的元素则去找自己的另一个"家"——如果那里也有人,继续踢。

两个表、两个哈希函数

准备两个哈希函数 h1h_1h2h_2,以及两个大小为 mm 的表 T1T_1T2T_2

每个元素 xx 恰好只能存在于两个位置之一:T1[h1(x)]T_1[h_1(x)]T2[h2(x)]T_2[h_2(x)]。这个约束使得查找变得极其简单。

查找:恰好 2 次访存

lookup(x):
  if T1[h1(x)] == x: return FOUND
  if T2[h2(x)] == x: return FOUND
  return NOT_FOUND

无论表中有多少元素,无论负载因子多高,查找永远只需要 2 次内存访问。没有探测链,没有链表遍历,没有任何条件分支。这就是确定性 O(1)O(1) 的来源。

对比一下:链式哈希表在最坏情况下需要遍历整条链(O(n)O(n));线性探测在最坏情况下需要扫描整个数组(O(n)O(n));Robin Hood 在最坏情况下也需要 O(logn)O(\log n) 次访问。Cuckoo Hashing 彻底消除了查找路径的不确定性。

删除:同样是 O(1)

delete(x):
  if T1[h1(x)] == x: T1[h1(x)] = EMPTY; return
  if T2[h2(x)] == x: T2[h2(x)] = EMPTY; return

删除和查找一样简单——检查两个位置,找到就清除。不需要像线性探测那样处理"墓碑"标记,也不需要像链式哈希那样维护链表指针。

三、插入过程详解

插入是 Cuckoo Hashing 中唯一复杂的操作——也是它名字的来源。

踢人机制

insert(x):
  if T1[h1(x)] is empty:
    T1[h1(x)] = x; return SUCCESS

  // 位置被占,踢出现有元素
  y = T1[h1(x)]
  T1[h1(x)] = x

  if T2[h2(y)] is empty:
    T2[h2(y)] = y; return SUCCESS

  // 继续踢
  z = T2[h2(y)]
  T2[h2(y)] = y

  // z 需要回到 T1 找位置...可能继续踢...
  // 如果踢出链太长(超过阈值),rehash

这个"踢出链"(eviction chain)是 Cuckoo Hashing 的核心机制。

下图展示了踢出链的工作过程,以及桶化 Cuckoo Hashing 如何提高负载因子:

Cuckoo Hashing 踢出链与桶化设计转存失败,建议直接上传图片文件

踢人链的具体过程

用一个具体例子说明。假设 m=4m = 4,要插入元素 A、B、C、D、E:

初始状态:T1 = [_, _, _, _]   T2 = [_, _, _, _]

插入 A: h1(A)=0, T1[0] 空 → T1[0]=A
  T1 = [A, _, _, _]   T2 = [_, _, _, _]

插入 B: h1(B)=1, T1[1] 空 → T1[1]=B
  T1 = [A, B, _, _]   T2 = [_, _, _, _]

插入 C: h1(C)=0, T1[0]A 占据
  踢出 AT1[0]=C
  AT2: h2(A)=2, T2[2] 空 → T2[2]=A
  T1 = [C, B, _, _]   T2 = [_, _, A, _]

插入 D: h1(D)=0, T1[0]C 占据
  踢出 CT1[0]=D
  CT2: h2(C)=2, T2[2]A 占据
  踢出 AT2[2]=C
  AT1: h1(A)=0, T1[0]D 占据
  踢出 DT1[0]=A
  DT2: h2(D)=3, T2[3] 空 → T2[3]=D
  T1 = [A, B, _, _]   T2 = [_, _, C, D]

注意第四次插入时,踢出链经过了 4 步才找到空位。链的长度取决于表的拥挤程度和哈希函数的分布质量。

下图以第四次插入(D)为例,展示踢出链的每一步:

flowchart TD
    start(["插入 D: h1(D)=0"])
    s1["T1[0] 被 C 占据\n踢出 C → T1[0] ← D"]
    s2["C 去 h2(C)=T2[2]\nT2[2] 被 A 占据\n踢出 A → T2[2] ← C"]
    s3["A 去 h1(A)=T1[0]\nT1[0] 被 D 占据\n踢出 D → T1[0] ← A"]
    s4["D 去 h2(D)=T2[3]\nT2[3] 为空 ✓"]
    done(["T2[3] ← D  踢出链结束,共 3 次搬移"])

    start --> s1 --> s2 --> s3 --> s4 --> done
    style done fill:#1a4a1a,stroke:#3fb950,color:#e6edf3
    style start fill:#1a2a4a,stroke:#388bfd,color:#e6edf3

环检测

踢出链可能形成——元素 A 踢出 B,B 踢出 C,C 又踢回 A。一旦出现环,DFS 风格的踢出将永远无法终止。

检测环的最简单方法是设置最大踢出次数(MAX_KICKS)。当踢出次数超过阈值时,停止并触发 rehash。经验值是 500 到 1024 次——在正常负载因子下,踢出链长度极少超过 O(logn)O(\log n),所以超过 500 次几乎可以确定遇到了环或者表太满。

另一种更精确的方法是维护一个访问标记:记录踢出过程中访问过的 bucket,如果再次访问同一个 bucket 就说明有环。但这增加了额外的空间开销,实践中很少使用。

何时触发 rehash

Rehash 意味着重新选择哈希函数的 seed,并将所有元素重新插入新表。触发条件通常有两种:

  1. 踢出链超长:单次插入的踢出次数超过 MAX_KICKS。这说明当前的哈希函数组合导致了局部拥堵或环。
  2. 负载因子过高:当总负载因子超过阈值(对于基本 2-way cuckoo,通常是 45%-49%;对于桶化版本,通常是 85%-90%)时,主动 rehash 并扩容。

Rehash 的期望时间是 O(n)O(n),并且由于新 seed 是随机选择的,rehash 后的表结构几乎一定更好。理论上,rehash 失败的概率是 O(1/n)O(1/n),所以极少需要连续 rehash 两次。

四、数学分析

Cuckoo Graph

Cuckoo Hashing 的正确性可以用随机图理论精确分析。构造一个二部图(bipartite graph),称为 Cuckoo 图

  • 左顶点集 = T1T_1mm 个位置
  • 右顶点集 = T2T_2mm 个位置
  • 每个元素 xx 对应一条边 (h1(x),h2(x))(h_1(x), h_2(x))

在这个模型下,nn 个元素在 2m2m 个顶点上产生 nn 条随机边。

树、单环和多环

Cuckoo 图中每个连通分量的结构决定了插入是否成功:

  • (tree):连通分量中边数 = 顶点数 - 1。这意味着每个位置最多被一个元素"需要",插入必定成功。
  • 单环(unicyclic):连通分量中边数 = 顶点数。存在恰好一个环,但通过沿着环"旋转"(每个元素移到它的另一个位置),仍然可以安置所有元素。
  • 多环(multicyclic):连通分量中边数 > 顶点数。此时踢出链会陷入死循环——某些元素无处安放。

因此,插入成功 \Leftrightarrow Cuckoo 图中不存在多环连通分量

下图展示三种连通分量结构。树型组件(边数 = 顶点数-1)可直接安置所有元素;单环组件(边数 = 顶点数)通过沿环旋转仍可安置;多环组件(边数 > 顶点数)时某些元素无处安放,必须 rehash:

graph LR
    subgraph tree ["树型 — 可插入"]
        direction LR
        T1a(["T1[0]"]) -- "A" --- T2a(["T2[1]"])
        T1b(["T1[2]"]) -- "B" --- T2b(["T2[3]"])
        T1c(["T1[1]"]) -- "C" --- T2c(["T2[0]"])
    end
    subgraph unicyc ["单环 — 旋转可安置"]
        direction LR
        U1(["T1[0]"]) -- "X" --- V1(["T2[2]"])
        U1 -- "Y" --- V2(["T2[3]"])
        U2(["T1[1]"]) -- "Z" --- V1
    end
    subgraph multicyc ["多环 — 需 Rehash"]
        direction LR
        P1(["T1[0]"]) -- "P" --- Q1(["T2[0]"])
        P1 -- "Q" --- Q2(["T2[1]"])
        P2(["T1[1]"]) -- "R" --- Q1
        P2 -- "S" --- Q2
    end

阈值 c 约等于 0.5

随机图理论中有一个经典结论:当一个随机二部图有 2m2m 个顶点和 nn 条边时:

  • n/2m<1/2n/2m < 1/2 时(即 n<mn < m),图几乎确定只包含树和单环分量。
  • n/2m=1/2n/2m = 1/2 时(即 n=mn = m),巨分量(giant component)出现,多环概率急剧上升。
  • n/2m>1/2n/2m > 1/2 时,多环几乎必然存在。

所以基本 Cuckoo Hashing 的负载因子上限是 n/(2m)<1/2n/(2m) < 1/2,即 α<0.5\alpha < 0.5

更精确地说,Devroye 和 Morin(2003)证明了:当 n=m(1ϵ)n = m(1 - \epsilon) 时,插入全部 nn 个元素成功的概率为:

1O(1/ϵ2m)1 - O(1/\epsilon^2 m)

ϵ\epsilon 固定且 mm \to \infty 时,成功概率趋近于 1。但当 ϵ0\epsilon \to 0(负载因子趋近 0.5)时,需要 m=Ω(1/ϵ2)m = \Omega(1/\epsilon^2) 才能保证高概率成功。

期望踢出链长度

α<0.5\alpha < 0.5 的前提下,单次插入的期望踢出链长度是 O(1)O(1)。更精确地,Pagh 和 Rodler 证明了踢出链长度超过 tt 的概率为 O(1/2t)O(1/2^t)——指数衰减。这意味着插入的期望时间是 O(1)O(1),只是最坏情况可能触发 rehash(O(n)O(n)),但 rehash 的频率足够低,摊还下来仍然是 O(1)O(1)

五、d-ary Cuckoo Hashing:突破 50% 限制

更多的哈希函数

2004 年,Fotakis 等人提出了 d-ary Cuckoo Hashing:使用 d>2d > 2 个哈希函数。每个元素有 dd 个可能的位置。

理论结果令人惊讶:

哈希函数数 d最大负载因子查找访问次数
2~50%2
3~91%3
4~97%4
5~99%5

从 2 到 3 个哈希函数,负载因子从 50% 跳到 91%——收益巨大。但查找时间从 2 次增加到 3 次内存访问。

直觉上为什么 d=3d = 3 的效果这么好?回到随机图模型。d=2d = 2 时,每个元素对应 Cuckoo 图中的一条边;d=3d = 3 时,每个元素对应一个超边(hyperedge)。随机超图的 peelability 阈值远高于随机二部图——因为超边之间"竞争"资源的概率更低,图的局部结构更稀疏。

Dietzfelbinger 和 Weidling(2007)给出了精确阈值的计算方法,本质上归结为以下递推:对于 dd-uniform 随机超图,nn 个超边和 mm 个顶点,当 n/m<cdn/m < c_d^*dd 阶 peeling 阈值)时,图几乎确定可以完全 peel。

桶化(Bucketized)Cuckoo Hashing

另一种提高负载因子的方法:每个位置存储多个元素(bucket)。这不增加查找的内存访问次数——因为同一个 bucket 内的元素在同一个 cache line 上。

如果每个 bucket 存 kk 个元素:

配置 (d, k)最大负载因子cache-line 友好性
(2, 1)~50%2 次随机访问
(2, 4)~95%2 次随机访问(bucket 对齐 cache line)
(2, 8)~98%2 次随机访问(bucket = 1-2 cache lines)
(3, 4)~99.9%3 次随机访问

这就是 DPDK rte_hash 的选择:2 个哈希函数,每个 bucket 存 8 个元素,负载因子可达 95%。

桶化为什么效果这么好?因为一个 kk-slot 的 bucket 相当于给每个位置增加了 kk 个"替补"。元素不再需要精确落入一个 slot,而是落入一个范围——这大幅降低了冲突概率。

踢出链的路径搜索:DFS vs BFS

基本 Cuckoo Hashing 的踢出使用简单的交替踢出(先踢 T1,被踢的去 T2,被踢的再去 T1...)。这本质上是 DFS(深度优先搜索)。

DFS 的问题是:它可能找到很长的踢出路径,即使存在更短的路径。长路径意味着更多的内存写入和更高的 cache 失效。

BFS 可以找到最短踢出路径,减少搬移次数:

// BFS 踢出路径搜索(简化伪代码)
int bfs_evict(CuckooHash *ht, uint64_t key, uint64_t value) {
    // 队列中每个元素记录:bucket_idx, slot_idx, parent
    Queue q;
    enqueue(&q, (Node){h1(key), -1, NULL});
    enqueue(&q, (Node){h2(key), -1, NULL});

    while (!queue_empty(&q) && q.depth < MAX_BFS_DEPTH) {
        Node cur = dequeue(&q);
        Bucket *b = &ht->buckets[cur.bucket];

        for (int i = 0; i < BUCKET_SIZE; i++) {
            if (!b->slots[i].occupied) {
                // 找到空位!沿 parent 链回溯,反向搬移
                backtrack_and_place(&q, cur, i, key, value);
                return 1;
            }
            // 该 slot 的元素可以被踢到它的另一个 bucket
            size_t alt = alternate_bucket(ht, b->slots[i].key, cur.bucket);
            enqueue(&q, (Node){alt, i, &cur});
        }
    }
    return 0;  // BFS 深度超限,需要 rehash
}

BFS 的额外空间开销(队列)在实践中可以接受——DPDK 限定 BFS 最大深度为 1024 步,队列大小约几 KB。

哈希函数的要求

Cuckoo Hashing 的理论分析假设哈希函数是完全随机的(每个 key 的哈希值独立均匀分布)。但实际的哈希函数(如 MurmurHash、xxHash)不是完全随机的。

理论上,Cuckoo Hashing 需要 O(logn)O(\log n)-wise 独立的哈希函数。Pagh 和 Rodler 的原始论文假设完全随机,后续工作(如 Patrascu 和 Thorup 2012)证明了 5-wise 独立就够用。

实践中,简单的做法是使用两个独立的哈希函数(不同的 seed),例如:

uint64_t h1(uint64_t key) { return murmurhash64(key, seed1); }
uint64_t h2(uint64_t key) { return murmurhash64(key, seed2); }

Patrascu 和 Thorup 还证明了 simple tabulation hashing 就足以让 Cuckoo Hashing 正确工作。Simple tabulation hashing 将 key 分成若干字节,每个字节查一个随机表,然后 XOR:

uint64_t tab_hash(uint64_t key) {
    uint64_t h = 0;
    for (int i = 0; i < 8; i++) {
        h ^= table[i][(key >> (i * 8)) & 0xFF];
    }
    return h;
}

只需要 8 次表查找和 8 次 XOR——极快。而且只需要 8×256×8=16KB8 \times 256 \times 8 = 16\text{KB} 的随机表(L1 cache 放得下)。Tabulation hashing 只有 3-wise 独立,但利用它的特殊结构可以证明更强的性质。这是理论指导实践的经典案例。

六、Cuckoo Filter:从哈希表到概率数据结构

灵感来源

2014 年,Fan 等人发表了 Cuckoo Filter: Practically Better Than Bloom。Cuckoo Filter 不存储完整的 key,只存储 key 的指纹(fingerprint,通常 8-16 bit)。

这使得 Cuckoo Filter 像 Bloom Filter 一样是概率数据结构(有假阳性,无假阴性),但有两个关键优势:

  1. 支持删除:Bloom Filter 不能删除(会引入假阴性),Cuckoo Filter 可以(删除指纹)
  2. 空间效率更高:同样的假阳性率下,Cuckoo Filter 比 Bloom Filter 少用 10-25% 的空间

空间效率分析

假阳性率为 ϵ\epsilon 时:

  • Bloom Filter 需要 nlnϵ(ln2)21.44nlog2(1/ϵ)-\frac{n \ln \epsilon}{(\ln 2)^2} \approx 1.44 n \log_2(1/\epsilon) bit
  • Cuckoo Filter(bucket 大小 b=4b = 4)需要约 (3log2(1/ϵ)+3)n/4(3 \log_2(1/\epsilon) + 3) \cdot n / 4 bit

ϵ<3%\epsilon < 3\% 时,Cuckoo Filter 更省空间。

指纹与踢出

Cuckoo Filter 的一个巧妙之处是:踢出时需要知道被踢元素的"另一个位置",但我们只存了指纹,没存原始 key。怎么办?

解决方案是使用 partial-key cuckoo hashing

h1(x)=hash(x)h_1(x) = \text{hash}(x)
h2(x)=h1(x)hash(fingerprint(x))h_2(x) = h_1(x) \oplus \text{hash}(\text{fingerprint}(x))

XOR 的对称性保证了:给定一个位置和指纹,可以计算出另一个位置:

h1(x)=h2(x)hash(fingerprint(x))h_1(x) = h_2(x) \oplus \text{hash}(\text{fingerprint}(x))

这个设计使得踢出操作不需要原始 key——只需要指纹和当前位置就够了。

Cuckoo Filter 与 Bloom Filter 对比

维度Bloom FilterCuckoo Filter
删除不支持(Counting Bloom 支持,但空间翻倍)原生支持
空间效率(ϵ<3%\epsilon < 3\%较差优 10-25%
空间效率(ϵ>3%\epsilon > 3\%较好略差
查找时间kk 次哈希 + kk 次随机内存访问2 次哈希 + 2 次内存访问
插入时间O(1)O(1) 确定性O(1)O(1) 摊还(偶尔踢出)
合并(union)支持(位 OR)不支持
实现复杂度简单中等

Cuckoo Filter C 实现

// cuckoo_filter.c — 简化的 Cuckoo Filter 实现
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>

#define CF_BUCKET_SIZE 4
#define CF_MAX_KICKS 500
#define CF_FP_BITS 8

typedef uint8_t fingerprint_t;

typedef struct {
    fingerprint_t fps[CF_BUCKET_SIZE];
} CFBucket;

typedef struct {
    CFBucket *buckets;
    size_t num_buckets;
    size_t count;
} CuckooFilter;

static uint32_t cf_hash(const void *data, size_t len) {
    uint32_t h = 0x811c9dc5;
    const uint8_t *p = data;
    for (size_t i = 0; i < len; i++) {
        h ^= p[i];
        h *= 0x01000193;
    }
    return h;
}

static fingerprint_t cf_fingerprint(const void *data, size_t len) {
    uint32_t h = cf_hash(data, len);
    fingerprint_t fp = (h >> 16) & 0xFF;
    return fp ? fp : 1;  // 指纹不能为 0(0 表示空槽)
}

static size_t cf_index(CuckooFilter *cf, const void *data, size_t len) {
    return cf_hash(data, len) % cf->num_buckets;
}

static size_t cf_alt_index(CuckooFilter *cf, size_t index, fingerprint_t fp) {
    uint32_t fp_hash = cf_hash(&fp, sizeof(fp));
    return (index ^ fp_hash) % cf->num_buckets;
}

CuckooFilter *cf_create(size_t capacity) {
    CuckooFilter *cf = calloc(1, sizeof(CuckooFilter));
    cf->num_buckets = capacity / CF_BUCKET_SIZE;
    if (cf->num_buckets < 4) cf->num_buckets = 4;
    cf->buckets = calloc(cf->num_buckets, sizeof(CFBucket));
    return cf;
}

int cf_lookup(CuckooFilter *cf, const void *data, size_t len) {
    fingerprint_t fp = cf_fingerprint(data, len);
    size_t i1 = cf_index(cf, data, len);
    size_t i2 = cf_alt_index(cf, i1, fp);

    for (int j = 0; j < CF_BUCKET_SIZE; j++) {
        if (cf->buckets[i1].fps[j] == fp) return 1;
        if (cf->buckets[i2].fps[j] == fp) return 1;
    }
    return 0;
}

int cf_insert(CuckooFilter *cf, const void *data, size_t len) {
    fingerprint_t fp = cf_fingerprint(data, len);
    size_t i1 = cf_index(cf, data, len);
    size_t i2 = cf_alt_index(cf, i1, fp);

    for (int j = 0; j < CF_BUCKET_SIZE; j++) {
        if (cf->buckets[i1].fps[j] == 0) {
            cf->buckets[i1].fps[j] = fp;
            cf->count++;
            return 1;
        }
    }
    for (int j = 0; j < CF_BUCKET_SIZE; j++) {
        if (cf->buckets[i2].fps[j] == 0) {
            cf->buckets[i2].fps[j] = fp;
            cf->count++;
            return 1;
        }
    }

    // 踢出
    size_t idx = (rand() & 1) ? i1 : i2;
    for (int kick = 0; kick < CF_MAX_KICKS; kick++) {
        int slot = rand() % CF_BUCKET_SIZE;
        fingerprint_t evicted = cf->buckets[idx].fps[slot];
        cf->buckets[idx].fps[slot] = fp;
        fp = evicted;
        idx = cf_alt_index(cf, idx, fp);

        for (int j = 0; j < CF_BUCKET_SIZE; j++) {
            if (cf->buckets[idx].fps[j] == 0) {
                cf->buckets[idx].fps[j] = fp;
                cf->count++;
                return 1;
            }
        }
    }
    return 0;  // 满了
}

int cf_delete(CuckooFilter *cf, const void *data, size_t len) {
    fingerprint_t fp = cf_fingerprint(data, len);
    size_t i1 = cf_index(cf, data, len);
    size_t i2 = cf_alt_index(cf, i1, fp);

    for (int j = 0; j < CF_BUCKET_SIZE; j++) {
        if (cf->buckets[i1].fps[j] == fp) {
            cf->buckets[i1].fps[j] = 0;
            cf->count--;
            return 1;
        }
        if (cf->buckets[i2].fps[j] == fp) {
            cf->buckets[i2].fps[j] = 0;
            cf->count--;
            return 1;
        }
    }
    return 0;
}

七、并发 Cuckoo Hashing

Cuckoo Hashing 的踢出机制给并发带来了独特的挑战:一次插入可能修改多个 bucket,而查找需要读取两个 bucket——如果插入和查找并发执行,读者可能看到不一致的状态。

问题:踢出链破坏读一致性

考虑以下场景:

sequenceDiagram
    participant A as 线程 A(查找 X)
    participant T as 哈希表
    participant B as 线程 B(插入,踢出链经过 X)

    A->>T: 读 T1[h1(X)] → 无 X
    Note over B,T: 踢出链:将 X 从 T2[h2(X)] 搬到 T1[h1(X)]
    B->>T: 写 T1[h1(X)] = X
    B->>T: 清空 T2[h2(X)]
    A->>T: 读 T2[h2(X)] → 无 X
    Note over A: 假未命中!X 实际存在但被漏掉

元素 X 在查找的两步之间被搬移了,导致"假未命中"(false miss)。这在普通开放寻址哈希表中不会发生——元素不会在查找过程中改变位置。

DPDK 的实现策略

DPDK 的 rte_hash 针对网络数据平面的特点,采用了分层并发策略:

读写分离模式:默认模式下,多个线程可以同时读,但只有一个线程可以写。写线程在踢出时使用 store-release 语义,确保读线程看到的是一致的状态。

多写者模式:使用 per-bucket 的自旋锁保护写操作。每个 bucket 用一个 uint32_t 作为锁,锁住 primary 和 secondary 两个 bucket 后才执行踢出。为避免死锁,总是按 bucket 地址从小到大的顺序加锁。

// DPDK 风格的 per-bucket 加锁(简化)
void cuckoo_insert_locked(CuckooHash *ht, uint64_t key, uint64_t value) {
    size_t b1 = h1(ht, key);
    size_t b2 = h2(ht, key);

    // 按地址顺序加锁,避免死锁
    if (b1 < b2) {
        lock(&ht->buckets[b1].lock);
        lock(&ht->buckets[b2].lock);
    } else {
        lock(&ht->buckets[b2].lock);
        lock(&ht->buckets[b1].lock);
    }

    // 在两个 bucket 内执行插入或踢出
    do_insert(ht, key, value, b1, b2);

    unlock(&ht->buckets[b1].lock);
    unlock(&ht->buckets[b2].lock);
}

乐观并发(Optimistic Cuckoo Hashing)

Fan 等人在 2014 年提出了一种更高效的方案——乐观并发控制。核心思想是给每个 bucket 加一个版本计数器

  1. 读者在读取之前记录两个 bucket 的版本号。
  2. 读者读取两个 bucket 的内容。
  3. 读者再次检查版本号——如果版本号没变,说明读取期间没有写操作,结果有效。如果版本号变了,重试。
// 乐观读取(无锁)
int cuckoo_lookup_optimistic(CuckooHash *ht, uint64_t key, uint64_t *value) {
retry:;
    uint32_t v1_before = atomic_load(&ht->buckets[h1(ht, key)].version);
    uint32_t v2_before = atomic_load(&ht->buckets[h2(ht, key)].version);

    // 尝试在两个 bucket 中查找
    int found = search_buckets(ht, key, value);

    uint32_t v1_after = atomic_load(&ht->buckets[h1(ht, key)].version);
    uint32_t v2_after = atomic_load(&ht->buckets[h2(ht, key)].version);

    if (v1_before != v1_after || v2_before != v2_after)
        goto retry;

    return found;
}

这种方案在读多写少的场景下极其高效——读路径完全无锁、无 CAS、无原子操作(只需要两次 atomic_load,在 x86 上就是普通的 MOV 指令)。写路径仍然需要锁,但写的频率远低于读。

路径发现与路径搬移分离

乐观并发的另一个关键优化是把踢出过程分成两个阶段:

  1. 路径发现(path discovery):用 BFS 找到从满 bucket 到空 bucket 的搬移路径,不修改任何数据。这个阶段不需要锁。
  2. 路径搬移(path displacement):沿着路径反向搬移元素。每一步只锁一个 bucket,搬移一个元素。

反向搬移的关键在于:从空 slot 开始,把前一个 bucket 的元素搬过来,这样每一步都保证元素在搬移过程中"同时存在于两个位置"而不是"暂时消失"——读者即使在搬移过程中查找,也一定能找到元素。

八、完整 C 实现

下面是一个包含 BFS 踢出、rehash、查找和删除的完整 2-way cuckoo hash table 实现:

// cuckoo_hash.c — 2-way Cuckoo Hash with BFS eviction and rehash
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>

#define BUCKET_SIZE    4
#define MAX_BFS_DEPTH  512
#define LOAD_THRESHOLD 0.85

typedef struct {
    uint64_t key;
    uint64_t value;
    uint8_t  occupied;
} Slot;

typedef struct {
    Slot slots[BUCKET_SIZE];
} Bucket;

// BFS 搜索节点
typedef struct {
    size_t   bucket;
    int      slot;
    int      parent;
    int      depth;
} BFSNode;

typedef struct CuckooHash {
    Bucket  *buckets;
    size_t   num_buckets;
    size_t   size;
    uint64_t seed1, seed2;
} CuckooHash;

static uint64_t hash64(uint64_t key, uint64_t seed) {
    key ^= seed;
    key = (key ^ (key >> 30)) * 0xbf58476d1ce4e5b9ULL;
    key = (key ^ (key >> 27)) * 0x94d049bb133111ebULL;
    return key ^ (key >> 31);
}

static size_t h1(CuckooHash *ht, uint64_t key) {
    return hash64(key, ht->seed1) % ht->num_buckets;
}

static size_t h2(CuckooHash *ht, uint64_t key) {
    return hash64(key, ht->seed2) % ht->num_buckets;
}

static size_t alt_bucket(CuckooHash *ht, uint64_t key, size_t cur) {
    size_t b1 = h1(ht, key), b2 = h2(ht, key);
    return (cur == b1) ? b2 : b1;
}

CuckooHash *cuckoo_create(size_t capacity) {
    CuckooHash *ht = calloc(1, sizeof(CuckooHash));
    ht->num_buckets = capacity / BUCKET_SIZE;
    if (ht->num_buckets < 16) ht->num_buckets = 16;
    ht->buckets = calloc(ht->num_buckets, sizeof(Bucket));
    ht->seed1 = 0x123456789abcdef0ULL;
    ht->seed2 = 0xfedcba9876543210ULL;
    return ht;
}

int cuckoo_lookup(CuckooHash *ht, uint64_t key, uint64_t *value) {
    size_t b1 = h1(ht, key), b2 = h2(ht, key);
    for (int i = 0; i < BUCKET_SIZE; i++) {
        if (ht->buckets[b1].slots[i].occupied &&
            ht->buckets[b1].slots[i].key == key) {
            if (value) *value = ht->buckets[b1].slots[i].value;
            return 1;
        }
        if (ht->buckets[b2].slots[i].occupied &&
            ht->buckets[b2].slots[i].key == key) {
            if (value) *value = ht->buckets[b2].slots[i].value;
            return 1;
        }
    }
    return 0;
}

int cuckoo_delete(CuckooHash *ht, uint64_t key) {
    size_t b1 = h1(ht, key), b2 = h2(ht, key);
    for (int i = 0; i < BUCKET_SIZE; i++) {
        if (ht->buckets[b1].slots[i].occupied &&
            ht->buckets[b1].slots[i].key == key) {
            ht->buckets[b1].slots[i].occupied = 0;
            ht->size--;
            return 1;
        }
        if (ht->buckets[b2].slots[i].occupied &&
            ht->buckets[b2].slots[i].key == key) {
            ht->buckets[b2].slots[i].occupied = 0;
            ht->size--;
            return 1;
        }
    }
    return 0;
}

// BFS 踢出:寻找最短搬移路径
static int bfs_insert(CuckooHash *ht, uint64_t key, uint64_t value) {
    BFSNode queue[MAX_BFS_DEPTH];
    int head = 0, tail = 0;

    size_t b1 = h1(ht, key), b2 = h2(ht, key);
    queue[tail++] = (BFSNode){b1, -1, -1, 0};
    queue[tail++] = (BFSNode){b2, -1, -1, 0};

    while (head < tail && tail < MAX_BFS_DEPTH) {
        BFSNode cur = queue[head];
        Bucket *b = &ht->buckets[cur.bucket];

        for (int i = 0; i < BUCKET_SIZE; i++) {
            if (!b->slots[i].occupied) {
                // 找到空位,沿 parent 链反向搬移
                int idx = head;
                int empty_slot = i;
                size_t empty_bucket = cur.bucket;

                while (queue[idx].parent >= 0) {
                    BFSNode *parent = &queue[queue[idx].parent];
                    Slot *src = &ht->buckets[parent->bucket].slots[queue[idx].slot];
                    ht->buckets[empty_bucket].slots[empty_slot] = *src;
                    empty_bucket = parent->bucket;
                    empty_slot = queue[idx].slot;
                    idx = queue[idx].parent;
                }
                // 放入新元素
                ht->buckets[empty_bucket].slots[empty_slot] =
                    (Slot){key, value, 1};
                ht->size++;
                return 1;
            }
            // 将该 slot 的元素加入 BFS 队列
            if (cur.depth < 8 && tail < MAX_BFS_DEPTH) {
                size_t alt = alt_bucket(ht, b->slots[i].key, cur.bucket);
                queue[tail++] = (BFSNode){alt, i, head, cur.depth + 1};
            }
        }
        head++;
    }
    return 0;  // BFS 深度超限
}

// rehash:重新选择 seed 并重新插入所有元素
int cuckoo_rehash(CuckooHash *ht) {
    Bucket *old_buckets = ht->buckets;
    size_t old_num = ht->num_buckets;
    size_t new_num = old_num * 2;

    ht->buckets = calloc(new_num, sizeof(Bucket));
    if (!ht->buckets) { ht->buckets = old_buckets; return 0; }

    ht->num_buckets = new_num;
    ht->size = 0;
    ht->seed1 ^= 0xdeadbeefcafebabeULL;
    ht->seed2 ^= 0x0123456789abcdefULL;

    for (size_t i = 0; i < old_num; i++) {
        for (int j = 0; j < BUCKET_SIZE; j++) {
            if (old_buckets[i].slots[j].occupied) {
                Slot *s = &old_buckets[i].slots[j];
                // 先尝试直接插入
                size_t b1 = h1(ht, s->key), b2 = h2(ht, s->key);
                int placed = 0;
                for (int k = 0; k < BUCKET_SIZE && !placed; k++) {
                    if (!ht->buckets[b1].slots[k].occupied) {
                        ht->buckets[b1].slots[k] = *s;
                        ht->size++;
                        placed = 1;
                    }
                }
                for (int k = 0; k < BUCKET_SIZE && !placed; k++) {
                    if (!ht->buckets[b2].slots[k].occupied) {
                        ht->buckets[b2].slots[k] = *s;
                        ht->size++;
                        placed = 1;
                    }
                }
                if (!placed && !bfs_insert(ht, s->key, s->value)) {
                    // rehash 失败(极少发生),恢复旧表
                    free(ht->buckets);
                    ht->buckets = old_buckets;
                    ht->num_buckets = old_num;
                    return 0;
                }
            }
        }
    }
    free(old_buckets);
    return 1;
}

int cuckoo_insert(CuckooHash *ht, uint64_t key, uint64_t value) {
    // 检查是否需要扩容
    double load = (double)ht->size / (ht->num_buckets * BUCKET_SIZE);
    if (load > LOAD_THRESHOLD) {
        cuckoo_rehash(ht);
    }

    // 先尝试直接放入空位
    size_t b1 = h1(ht, key), b2 = h2(ht, key);
    for (int i = 0; i < BUCKET_SIZE; i++) {
        if (!ht->buckets[b1].slots[i].occupied) {
            ht->buckets[b1].slots[i] = (Slot){key, value, 1};
            ht->size++;
            return 1;
        }
    }
    for (int i = 0; i < BUCKET_SIZE; i++) {
        if (!ht->buckets[b2].slots[i].occupied) {
            ht->buckets[b2].slots[i] = (Slot){key, value, 1};
            ht->size++;
            return 1;
        }
    }

    // 没有空位,BFS 踢出
    if (bfs_insert(ht, key, value))
        return 1;

    // BFS 失败,rehash 后重试
    if (cuckoo_rehash(ht))
        return cuckoo_insert(ht, key, value);

    return 0;
}

void cuckoo_destroy(CuckooHash *ht) {
    free(ht->buckets);
    free(ht);
}

int main(void) {
    CuckooHash *ht = cuckoo_create(10000);

    // 插入
    int success = 0, fail = 0;
    for (uint64_t i = 1; i <= 8000; i++) {
        if (cuckoo_insert(ht, i, i * 100))
            success++;
        else
            fail++;
    }
    printf("Inserted: %d, Failed: %d, Load: %.2f%%\n",
           success, fail,
           100.0 * ht->size / (ht->num_buckets * BUCKET_SIZE));

    // 查找
    int found = 0;
    for (uint64_t i = 1; i <= 8000; i++) {
        uint64_t val;
        if (cuckoo_lookup(ht, i, &val) && val == i * 100)
            found++;
    }
    printf("Lookup correct: %d / 8000\n", found);

    // 删除
    int deleted = 0;
    for (uint64_t i = 1; i <= 4000; i++) {
        if (cuckoo_delete(ht, i)) deleted++;
    }
    printf("Deleted: %d, Remaining: %zu\n", deleted, ht->size);

    // 验证删除后查找
    int false_found = 0;
    for (uint64_t i = 1; i <= 4000; i++) {
        uint64_t val;
        if (cuckoo_lookup(ht, i, &val)) false_found++;
    }
    printf("False positives after delete: %d (should be 0)\n", false_found);

    cuckoo_destroy(ht);
    return 0;
}

九、网络设备中的应用

Cuckoo Hashing 在网络领域的应用比在通用编程中更为广泛。原因很简单:网络设备的数据平面需要确定性延迟,而 Cuckoo Hashing 恰好提供了这一保证。

TCAM 替代

TCAM(Ternary Content-Addressable Memory)是网络交换机中用于精确匹配和通配符匹配的专用硬件。它可以在一个时钟周期内查找任意模式,但缺点极为突出:

  • 功耗高:TCAM 每比特的功耗是 SRAM 的 100 倍以上。
  • 容量小:一块高端交换芯片上的 TCAM 通常只有几 MB。
  • 价格贵:同容量下比 SRAM 贵 10-50 倍。

Cuckoo Hashing 用 SRAM 实现精确匹配,查找时间只需 2 次 SRAM 访问(对于桶化版本,一次访问读取整个 bucket)。对于不需要通配符匹配的场景(如 MAC 地址表、精确五元组匹配),Cuckoo Hash 可以完全替代 TCAM,节省大量功耗和芯片面积。

DPDK rte_hash

DPDK(Data Plane Development Kit)的 rte_hash 是 Cuckoo Hashing 最著名的工业级实现之一。它用于网络数据包的精确匹配(如五元组查找)。

// rte_hash 的核心结构(简化)
struct rte_hash {
    uint32_t num_buckets;       // 桶数量(2 的幂)
    uint32_t bucket_entries;    // 每桶 8 个 slot
    struct rte_hash_bucket *buckets;
    // 每个 bucket:
    // - 8 个 signature(哈希值的低 16 位,用于快速比较)
    // - 8 个 key_idx(指向实际 key-value 的索引)
};

// 查找路径
int rte_hash_lookup(struct rte_hash *h, const void *key) {
    uint32_t hash = hash_func(key);
    uint32_t primary = hash & bucket_mask;
    uint32_t secondary = primary ^ (hash >> 16);
    uint16_t sig = hash & 0xFFFF;

    // 检查 primary bucket(8 个 slot,用 SIMD 并行比较签名)
    if (bucket_lookup(h->buckets[primary], sig, key))
        return found;

    // 检查 secondary bucket
    if (bucket_lookup(h->buckets[secondary], sig, key))
        return found;

    return not_found;
}

DPDK rte_hash 在 Intel Xeon 上的性能(100 万个规则):

操作延迟(ns)吞吐量(Mops/s)
查找(命中)30-5035-45
查找(未命中)25-4040-55
插入50-10015-25
删除40-6025-35

查找未命中比命中还快——因为未命中只需要比较签名(16 bit),大部分 slot 的签名不匹配就能排除。命中则需要额外比较完整 key。

OVS(Open vSwitch)流表

OVS 的 Megaflow Cache 使用了基于 Cuckoo Hashing 的 cmap(concurrent map)实现。每个数据包到达时,OVS 需要在流表中查找匹配的 flow rule。传统实现使用链式哈希表,但链式哈希的指针追逐对 cache 不友好。

OVS 的 cmap 采用了 Cuckoo Hashing 的变体:

  • 每个 bucket 包含若干 slot,每个 slot 存储一个 32-bit 的哈希签名和一个指向实际 flow entry 的指针。
  • 查找时先比较签名,签名匹配后再比较完整的 flow key。
  • 使用 RCU(Read-Copy-Update)实现无锁读取,写操作持有全局锁。

这种设计让 OVS 在千万级流表规模下仍能保持每个包 2 次 cache line 访问的查找性能。

十、基准测试

测试环境:Intel i9-12900K,gcc -O2,桶化 Cuckoo(2 表,bucket size = 4),固定表大小(131072 桶 × 4 槽 = 524288 槽),每项取 7 次运行中位数。

不同负载因子下的性能

负载因子插入成功率平均踢出次数/插入查找命中(ns)查找未命中(ns)Mops/s(命中)
50%100%0.00412.629.079
70%100%0.02415.526.964
85%100%0.07116.618.760
90%100%0.10116.616.160
95%100%0.15217.914.156
xychart-beta
    title "Cuckoo(2,4) 查找延迟 vs 负载因子"
    x-axis ["50%", "70%", "85%", "90%", "95%"]
    y-axis "延迟 (ns)" 0 --> 35
    bar  [12.6, 15.5, 16.6, 16.6, 17.9]
    line [29.0, 26.9, 18.7, 16.1, 14.1]

关键观察:

  • 查找命中延迟极稳定(12.6→17.9 ns,变化仅 42%)——总是恰好 2 次 bucket 访问,不受负载影响
  • 查找未命中在高负载反而更快(29→14 ns)——高负载时表更紧凑;检查 8 个槽后快速确认不存在
  • 平均踢出次数极低——即使 95% 负载,每次插入平均搬移不到 0.2 个元素;桶大小=4 使两桶同时全满概率极低
  • 推荐工作区间:70-90% 负载因子

Cuckoo vs Robin Hood vs Chained

以下对比在相同条件下进行(70% 负载因子,367K 个 64-bit 键):

实现查找命中(ns)查找未命中(ns)插入(ns)删除(ns)
Cuckoo(2,4)19.525.418.319.0
Robin Hood17.217.917.519.1
Chained11.013.920.532.4
xychart-beta
    title "吞吐量对比 (Mops/s,70% 负载)"
    x-axis ["查找命中", "查找未命中", "插入", "删除"]
    y-axis "Mops/s" 0 --> 100
    bar [51.3, 39.3, 54.7, 52.6]
    bar [58.1, 55.7, 57.0, 52.2]
    bar [91.0, 71.7, 48.9, 30.9]

几个值得注意的现象:

Chained 查找命中最快(91 Mops/s)。 在负载因子 ≈ 1.0 时(每桶约 1 个元素),链式哈希几乎不需要链表遍历,bucket 定位即命中。代价是删除需要指针遍历(30.9 Mops/s 最慢),且内存分布碎片化对大表不友好。

Cuckoo 和 Robin Hood 整体接近。 在 70% 负载下两者的吞吐量在同一量级。Cuckoo 的优势不在于绝对吞吐量,而在于确定性——每次查找恰好 2 次 bucket 访问,无论表中有多少元素、负载因子多高。

Cuckoo 查找未命中慢于 Robin Hood(39 vs 56 Mops/s)。 Robin Hood 可利用探测距离提前终止(dist < probe_dist 时立即确认不存在),特别高效。Cuckoo 必须检查两个 bucket 的全部 8 个槽才能确认不存在。

尾延迟对比

以下数据采用批量采样(每批 200 次连续查找,50000 个样本),消除 clock_gettime 调用开销的影响:

百分位Cuckoo (2,4)Robin Hood
p505.3 ns4.1 ns
p997.1 ns5.8 ns
p99.956 ns52 ns
p99.99459 ns330 ns
xychart-beta
    title "尾延迟对比 (ns/op)"
    x-axis ["p50", "p99", "p99.9", "p99.99"]
    y-axis "延迟 (ns)" 0 --> 500
    bar [5.3, 7.1, 56.0, 459.0]
    bar [4.1, 5.8, 52.0, 330.1]

在 70% 负载下,Cuckoo 和 Robin Hood 的尾延迟差距不大。Cuckoo 尾延迟的真正优势在高负载(85-95%)下才会显著拉开——此时 Robin Hood 探测序列急剧变长,p99.9 延迟可达数百纳秒;而 Cuckoo 仍然严格 2 次 bucket 访问,延迟几乎不变。

对于网络数据平面(每个包都需要硬实时查找),精确的最坏情况上界比平均吞吐量更重要——这正是 Cuckoo Hashing 在高性能网络设备中广泛应用的根本原因。

十一、工程陷阱清单

序号陷阱症状解法
1哈希函数相关性某些 key 的 h1 和 h2 总是映射到相同的 bucket确保两个哈希函数真正独立(不同 seed,或用 tabulation hashing)
2踢出链死循环插入卡死或无限循环设置 MAX_KICKS 上限(推荐 500-1024),超过就 rehash
3rehash 时机不当负载因子到 95% 才 rehash,导致踢出链平均长度暴增在 85-90% 时就开始渐进式 rehash
4Cuckoo Filter 重复插入同一个 key 插入多次,占满 bucket 的所有 slot插入前先查找;或使用 counting 变体
5并发踢出竞争多线程同时踢出,造成 ABA 问题或数据丢失使用 per-bucket 细粒度锁,或采用 optimistic cuckoo hashing(Fan 2014)
6bucket 大小与 cache line 不对齐一次 bucket 查找跨两个 cache line,延迟翻倍确保 bucket 大小为 64B 的因子(如 8 slots x 8B = 64B)
7指纹碰撞导致误删Cuckoo Filter 删除时,删了同 bucket 中另一个相同指纹的元素使用更长的指纹(12-16 bit),或使用 counting 变体
8DFS 踢出路径过长插入延迟的方差极大,偶尔出现毫秒级延迟尖刺改用 BFS 找最短路径;DPDK 默认用 BFS 且限制深度
9rehash 期间无法服务请求大表 rehash 耗时数百毫秒,造成服务中断使用渐进式 rehash:维护新旧两张表,逐步搬移
10取模运算开销大hash % num_buckets 中除法指令延迟高将 num_buckets 设为 2 的幂,用 hash & (num_buckets - 1) 代替

十二、个人观点

Cuckoo Hashing 是哈希表领域最优雅的设计之一。它用"踢出"这个简单的概念,把查找的最坏情况从 O(n)O(n) 降到了 O(1)O(1)。代价是插入可能需要一条踢出链,但对于读多写少的场景(如网络转发表、缓存),这是非常划算的交易。

我对 Cuckoo Hashing 有几个深层观察:

一、读写不对称的设计哲学。 Cuckoo Hashing 体现了一种"把不确定性从读路径转移到写路径"的设计哲学。传统哈希表在读和写上分摊不确定性——读可能需要长探测,写可能需要扩容。Cuckoo Hashing 则把所有不确定性集中到写路径(踢出链),让读路径完全确定。这种思路在很多系统中都有体现:LSM-tree 把写的不确定性留给 compaction,让读路径更可控;B+tree 把分裂的开销留给写路径,让范围查询更流畅。

二、理论与工程的鸿沟。 原始的 2-ary Cuckoo Hashing 只能到 50% 负载因子,在实践中几乎不可用。是 bucketized 设计(一个工程优化,不需要新理论)把它变成了实用的数据结构。这说明算法论文的理论结果和工程可用性之间,往往隔着一层"工程创造力"。

三、Cuckoo Filter 的启示。 从 Cuckoo Hash Table(精确数据结构)到 Cuckoo Filter(概率数据结构),partial-key hashing 的核心 idea 是:存储更少的信息,但保留足够的信息来执行操作。这个 idea 在数据结构设计中反复出现——Bloom Filter 只存在性信息,HyperLogLog 只存最大前导零。好的数据结构设计,本质上是回答"我最少需要存什么,才能回答这个问题"。

四、确定性的价值。 在通用编程中,期望 O(1)O(1) 和确定性 O(1)O(1) 的差距看起来不大——O(logn)O(\log n) 的尾延迟对大多数应用来说完全可以接受。但在某些领域,这个差距就是"能不能用"的边界。网络设备的数据平面必须在纳秒级完成查找,不能接受任何毛刺;实时交易系统需要严格的延迟 SLA;安全关键系统需要可证明的时间上界。Cuckoo Hashing 告诉我们:确定性是有价值的,值得为它付出插入路径上的额外复杂性。

如果你的工作负载是读多写少、需要确定性延迟、或者需要支持删除的近似成员查询——Cuckoo 家族是完美选择。