底层技术SwissTable的实现对比

1 简介

术语 "开放地址探测"(Open Addressing)。我们先搞清楚这个核心概念,然后对比:

  • 什么是"开放地址探测(Open Addressing)"?

在哈希表中,开放地址探测 是一种解决哈希冲突的方法。当两个 key 计算出相同的哈希槽(bucket)时,它不会用链表储存多个元素(如 Go 旧版或 Java HashMap 可能做的),而是寻找下一个"可用"的槽位进行存储。

核心思想:

markdown 复制代码
        哈希表是一个数组。

        冲突时,在数组中寻找其他空槽位。

        查找或插入都沿着某种"探测序列(Probing sequence)"前进。

2 几种典型的开放地址探测方式

    1. 线性探测(Linear Probing):

简单地向后查找下一个槽位,步长固定为 1。

缺点是容易形成"聚集"(clustering)。

scss 复制代码
	hash(k) = h
    

检查位置:h, h+1, h+2, ...

    1. 扰动探测(PERTURB + Probing)(Python dict 用):

Python 使用的算法来源于早期 CPython 的 open addressing 实现:

ini 复制代码
  perturb = hash
  i = hash & mask  # 初始位置

  while True:
      yield i
      i = (i * 5 + 1 + perturb) & mask
      perturb >>= 5
      

扰动探测(PERTURB shift) 的优势:

生成不规则、非线性的探测序列,避免线性探测中的聚集问题。

perturb 值会逐步衰减,使得后续探测路径逐渐趋于稳定。

    1. Grouped Probing(组内探测)(Go 1.24 Swiss Table、Rust 使用):

Grouped Probing 主要特点是:

每个探测"步骤"检查一个小的、固定大小的槽组(如 8 个槽)。

每个槽有一个 控制字节,可以提前并行比较,快速淘汰不匹配的槽。

探测顺序通过类似线性偏移 + 再哈希调整的方式继续。

示意(Go 1.24):

ini 复制代码
  bucket = hash >> shift
  group := buckets[bucket : bucket+8]   // 查找一个组
  control_bytes := controls[bucket : bucket+8]
  

先在 group 中用 SIMD(或等价操作)筛选控制字节匹配的槽位。

若都不匹配,就根据 secondary hash(或乘法探测)计算下一个 group。

  • 优点:

更强的缓存局部性。

SIMD 优化潜力。

在现代 CPU 上更快。

  • 算法比较示例

    python 复制代码
    特性				Python dict(PERTURB)		Go 1.24(Grouped Probing)
    探测路径			依赖扰动函数 + hash		控制字节 + bucket 分组线性探测
    单次探测比较位置	1 个	一次比较 			8 个控制位
    是否使用tombstone		是					是
    内存访问局部性			中						高
    查找性能(大多数情况)		中等偏高		极高(特别是在缓存命中时)

3、对比

开放地址探测:哈希冲突后在数组中继续找空位的技术统称。

PERTURB 探测:Python 的做法,通过扰动值制造非线性探测序列。

Grouped Probing:Go/Rust 的现代做法,用组内并行比较提升速度,兼容 SIMD。

  • 对比 旧版1.23.4 与 1.24

我在我的笔记本电脑上运行了本文的基准测试

脚本。

测试内容:

创建并填充1_000_000项目图 10_000_000使用 mod 索引执行查找。 将新条目插入1_000_000到 map 中。 1_000_000从 map 中移除第一个。

结果

erlang 复制代码
    操作		执行时间 1.23(毫秒)		go 1.24(毫秒)			改进 (%)
    初始		287.340375				184.787167	速度提升 		35.67%
    插入		118.564333				66.4095	速度提升 			43.99%
    删除		39.893875				61.364875				-53.85% 速度慢

在Go 1.24 的 Swiss Tables:银弹还是仅仅是一个闪亮的新玩意?文章中,我们还可以看到 Swiss Tables 在内存使用方面的效率。

Go 1.24 的瑞士表:万能还是闪亮的新玩意?内存对比图

尽管 Go Swiss Table 使用存储桶和稀疏哈希函数(用于雪崩效应)将键均匀分布在预分配的内存块上,但随着表变满,冲突将生成聚集部分,从而增加搜索时间。

通过 10 个位置表很容易看出这种情况:

vbnet 复制代码
  Table: [nil, C, JS, PHP, PERL, nil, nil, nil, nil, nil]

  Adding a new "B" key would cause:
  - len(B) = 1
  - position 1 is occupied by "C", look next
  - position 2 is occupied by "JS", look next
  - position 3 is occupied by "PHP", look next
  - position 4 is occupied by "PRL", look next
  - position 4 is free, add "B"

  Table: [nil, C, JS, PHP, PERL, B, nil, nil, nil, nil]
  Index:   0,  1,  2,  3,   4,   5,   6,   7,   8,   9  
  

现在,任何返回 1、2、3、4 或 5 的哈希函数调用都需要搜索最多 4 个位置,对于SSE3来说这很容易,我承认,但如果有更好的方法呢?

  • 弹性哈希

2025 年 1 月,发表了一篇关于开放寻址的新论文,提出了一种理论上比线性探测更好的新方法,称为弹性散列。

请随意阅读新闻文章或报纸以了解完整内容。

它声称击败了一个有 40 年历史的猜想(姚猜想),该猜想认为线性探测是一种简单、具有接近最佳效率的方法,并且不会随着负载的增加而发生灾难性的性能下降。

克拉皮文在不知道姚猜想的情况下发现了新的策略,这表明我们应该更频繁地挑战已知的约束。

它是什么?

基本上,它不是逐个检查位置,或者 16×16 检查位置(就像我们喜爱的瑞士表那样),而是创建了一种新的二维策略,使用虚拟溢出桶来计算插入地址。

这个想法是,有一个名为φ(i, j)的新函数,它返回一个节点的位置,其中:

ini 复制代码
i = 主桶(哈希结果)
j = 溢出计数

因此,使用我们之前的哈希函数,键"JS"和"Go"都会返回 2 作为它们的"主存储桶"。

插入顺序将决定键是否放置在位置 φ(2, 1) = 2 或 φ(2, 2) = 7。

神奇之处在于φ函数,它能够虚拟地创建用于碰撞的溢出桶。它的性能优于用于最坏和平均情况的线性探测的复杂度算法。通过这些跳跃或虫洞,它探测的地址更少,从而带来更好的理论 插入性能。

  • 跳跃

弹性哈希会应用于swissTable表吗? 也许吧。我希望如此。时间会证明一切。

4 小结

很高兴能够实时体验哈希表效率的这些最新改进。

一个月前的一篇论文改进了核心数据结构算法某一方面的代码复杂性,这是一个好消息!

但是还有一些未解决的问题:

复制代码
  弹性散列是否会比线性探测 + SSE3 更快?
  Elastic Hash 能否从 SIMD 的并行读取中受益?
  哪种语言将首先实现这一新算法?
  如果您碰巧知晓了其中一些答案,请留下评论!

这表明,性能改进是没有界限的,即使是十年前的规则也不安全,无论你身在何处,总有改进的空间。

相关推荐
田里的水稻5 分钟前
C++_队列编码实例,从末端添加对象,同时把头部的对象剔除掉,中的队列长度为设置长度NUM_OBJ
java·c++·算法
纪元A梦13 分钟前
贪心算法应用:保险理赔调度问题详解
算法·贪心算法
Ripple123121 小时前
数据结构:顺序表与链表
数据结构·链表
Jayden_Ruan1 小时前
C++逆向输出一个字符串(三)
开发语言·c++·算法
cyforkk1 小时前
Spring Boot @RestController 注解详解
java·spring boot·后端
一个响当当的名号2 小时前
B树,B+树,B*树(无代码)
数据结构·b树
canonical_entropy2 小时前
可逆计算:一场软件构造的世界观革命
后端·aigc·ai编程
点云SLAM2 小时前
C++ 常见面试题汇总
java·开发语言·c++·算法·面试·内存管理