布隆过滤器与 Swift 4.2

做者:Soroush Khanlou,原文连接,原文日期:2018-09-19 译者:WAMaker;校对:numbbbbb小铁匠Linus;定稿:Forelaxgit

Swift 4.2 为哈希的实现带来了一些新的变化。在此以前,哈希交由对象自己全权代理。当你向对象索取 哈希值(hashValue)时,它会把处理好的整型值做为哈希值返回。而如今,实现了 Hashable 协议的对象则描述了它的参数是如何组合,并传递给做为入参的 Hasher 对象。这样作有如下几点好处:github

  • 写出好的哈希算法很难。Swift 的使用者不须要知道如何组合参数来得到更好的哈希值。
  • 出于不提倡用户以任何形式存储哈希值,以及 一些安全方面因素 的考虑,哈希值在程序每次运行的时候都应该有所不一样。描述性的哈希容许哈希函数的种子在每次程序运行的时候发生改变。
  • 能实现更多有意思的数据结构,这也是咱们这篇文章接下来会聚焦的。

我以前写过一篇关于 如何使用 Swift 的 Hashable 协议从零实现 Dictionary 的文章(先阅读它会帮助你阅读本文,但这不是必须的)。今天,我想谈论一种不一样类型的,基于几率性而非明确性的数据结构:布隆过滤器(Bloom Filters)。咱们会使用 Swift 4.2 的新特性,包括新的哈希模型来构建它。算法

布隆过滤器很怪异。想象这样一种数据结构:数据库

  • 你可以往里插入数据
  • 你可以查询一个值是否存在
  • 只须要少许存储资源就能存储大量对象

可是:swift

  • 你不能枚举其中的对象
  • 它有时会出现误报(但不会出现漏报)
  • 你不能从中移除数据

何时会想要这种数据结构呢?Medium 使用它们来 跟踪博文的阅读状态。必应使用它们作 搜索索引。你可使用它们来构建一个缓存,在无需访问数据库的状况下就能判断用户名是否有效(例如在 @-mention 中)。像服务器这样可能拥有巨大的规模,却不必定有巨大资源的场景中,它们会很是有用。数组

(若是你以前作过图形方面的工做,可能好奇它是如何与 高光过滤器 产生联系的。答案是没有联系。高光过滤器(bloom filters)是小写的 b,而布隆过滤器(Bloom Filters)是由一个叫布隆的人命名的。彻底是个巧合。)缓存

那它们是如何运做的呢?安全

将对象放入布隆过滤器如同将它放入集合或字典:计算对象的哈希值,并根据存储数组的大小对哈希值求余。就这点而言,使用布隆过滤器只须要修改该索引处的值:将 false 改成 true,而不用像使用集合或字典那样,把对象存放到索引位置。bash

咱们先经过一个简单的例子来理解过滤器是若是运做的,以后再对它进行扩展。想象一个拥有 8 个 false 值的布尔数组(或称之为 比特数组):服务器

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   |   |   |   |   |
复制代码

它表明了咱们的布隆过滤器。我想要插入字符串“soroush”。它的哈希值是 9192644045266971309,当这个值余 8 时获得 5。咱们修改那一位的值。

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   |   | * |   |   |
复制代码

接下来我想要插入字符串“swift”,它的哈希值是 7052914221234348788,余 8 得 4,修改索引 4 的值。

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   | * | * |   |   |
复制代码

要测试布隆过滤器是否包含“soroush”,我再次计算它的哈希值并求余,仍旧获得余数 5,对应值是 true。“soroush”确实在布隆过滤器中。

然而仅仅测试可以经过的用例是不够的,咱们须要写一些会致使失败的用例。测试字符串“khanlou”取余获得的索引值是 2,所以咱们知道它不在布隆过滤器中。到此为止一切都好。接下去一个测试:对“hashable”字符串取余获得的索引值是 5,这就发生了一次冲突!即便这个值历来没有被加入过,过滤器仍返回了 true。这即是布隆过滤器会发生误报的例子。

有两个主要的策略能够尽量减小误报。第一个策略,也是两个策略中相对有趣的:咱们可使用不一样的哈希函数计算两次或三次哈希值而非一次。只要它们都是表现良好的哈希函数(均匀分布,一致性,最小的碰撞概率),咱们就能为每一个值生成多个索引改变布尔值。此次,咱们计算两次“soroush”的哈希值,生成 2 个索引并改变布尔值。这时,当咱们检查“khanlou”是否在布隆过滤器中,其中一个哈希值可能会和“soroush”的一个哈希值冲突,但两个值同时发生冲突的可能性就会变得很小。你能够把这个数字扩大。在下面的代码我会作 3 次哈希计算,但你能够作更屡次。

固然,若是你计算更屡次哈希值,每一个元素在布尔数组中会占据更多的空间。事实上,如今的数据几乎不占用空间。8 个布尔值的数组对应 1 字节。因此第二个减少误报的策略是扩大数组的规模。咱们能将数组变得足够大而不用担忧它的空间消耗。下面的代码中咱们会默认使用 512 比特大小的数组。

如今,即便同时使用这些策略,你依然会获得冲突,即误报,但冲突的概率会减少。这是布隆过滤器的一个缺陷,但在合适的场景用它来节省速度与空间是一笔不错的交易。

在开始具体的代码以前我有另外三点想要谈谈。首先,你不能改变布隆过滤器的大小。当你对哈希值取余时,这是在破坏信息,在不保留原始哈希值的状况下你不能回溯以前的信息 —— 保留原始值至关于否决了这个数据结构节约空间的优点。

其次,你能看到想要枚举布隆过滤器全部的值是多么异想天开。你再也不拥有这些值,只是它们以哈希形式存在的替代品。

最后,你一样能看到想要从布隆过滤器中移除元素是不可能的。若是想将布尔值变回 false,你并不知道是哪些值将它变为 true。是准备移除的值仍是其它值?这样作会形成漏报和误报。(这对你来讲多是值得权衡的)你能够在每一个索引上保留计数而非布尔值来解决这个问题,虽然保留计数仍是会带来存储问题,但根据使用场景的不一样,这样作或许是值得的。

废话很少说,让咱们开始着手编码。我在这里作的一些决策和你可能会作的有所不一样,第一个不一样就是要不要让对象支持范型。我认为让对象包含更多关于它须要存储内容的元数据是有意义的,但若是你发现这样作限制太多,你能够改变它。

struct BloomFilter<Element: Hashable> {
	// ...
}
复制代码

咱们须要存储两种主要的数据。第一个是 data,用于表示比特数组。它存储了全部和哈希值有关的标记:

private var data: [Bool]
复制代码

接下来,咱们须要不一样的哈希函数。一些布隆过滤器确实会使用不一样的方法计算哈希值,但我以为使用相同的算法,同时混入一个随机生成的值会更简单。

private let seeds: [Int]
复制代码

当初始化布隆过滤器时,咱们须要初始化这两个实例变量。比特数组会简单的重复 false 值来初始化,而种子值则使用 Swift 4.2 的新 API Int.random 来生成咱们须要的种子值。

init(size: Int, hashCount: Int) {
	data = Array(repeating: false, count: size)
	seeds = (0..<hashCount).map({ _ in Int.random(in: 0..<Int.max) })
}
复制代码

同时,建立一个带有默认值的便利构造器。

init() {
	self.init(size: 512, hashCount: 3)
}
复制代码

咱们要实现两个主要的方法:insertcontains。它们都须要接收元素做为参数并为每个种子值计算出对应的哈希值。私有的帮助方法会颇有用。因为种子值表明了“不一样的”哈希函数,咱们就须要为每个种子生成对应的哈希值。

private func hashes(for element: Element) -> [Int] {
	return seeds.map({ seed -> Int in
		// ...
	})
}
复制代码

要实现函数主体,咱们须要建立一个 Hasher 对象(Swift 4.2 新特性),将想要进行哈希计算的对象传给它。带上种子确保了生成的哈希值不会冲突。

private func hashes(for element: Element) -> [Int] {
	return seeds.map({ seed -> Int in
		var hasher = Hasher()
		hasher.combine(element)
		hasher.combine(seed)
		let hashValue = abs(hasher.finalize())
		return hashValue
	})
}
复制代码

同时,注意哈希值的绝对值。哈希计算有可能产生负数,这会致使咱们的数组访问崩溃。取绝对值操做减小了 1 比特的信息熵,对咱们来讲是有益的。

理想的状况是你可以使用种子来初始化 Hasher 而不是把它混合进去。Swift 的 Hasher 会在每次程序启动的时候被分配一个不一样的种子(除非你 设置固定的环境变量 让种子在不一样启动间保持一致,而这样作一般是一些测试目的),意味着你不能把这些值写到磁盘上。若是咱们控制了 Hasher 的种子,咱们就能将这些值写到磁盘上了。而就像这个布隆过滤器展现的那样,它应该只被用于内存缓存。

hashes(for:) 方法完成了不少繁重的工做,让 insert 方法很是简洁:

mutating func insert(_ element: Element) {
	hashes(for: element)
		.forEach({ hash in
			data[hash % data.count] = true
		})
}
复制代码

生成全部的哈希值,分别余上 data 数组的长度,并设置对应索引位的值为 true

contains 方法也一样简单,同时也给了咱们使用 Swift 4.2 另外一个新特性 allSatisfy 的机会。这个新方法能够判断序列中的全部对象是否都经过了某项用 block 表示的测试:

func contains(_ element: Element) -> Bool {
	return hashes(for: element)
		.allSatisfy({ hash in
			data[hash % data.count]
		})
}
复制代码

由于 data[hash % data.count] 的结果已是布尔值了,它与 allSatisfy 十分契合。

你也能够添加 isEmpty 方法用来检测 data 中的全部值是否都是 false。

布隆过滤器是一种奇怪的数据结构。咱们接触的大多数数据结构都是明确性的。当把一个对象放入字典中时,你知道那个值以后一直在那儿。而布隆过滤器是几率性的,牺牲肯定性来换取空间和速度。布隆过滤器不是你会天天用的数据结构,但当你确实须要它时,就会感觉到有它真好。

本文由 SwiftGG 翻译组翻译,已经得到做者翻译受权,最新文章请访问 swift.gg。 Open Annotation Sidebar

相关文章
相关标签/搜索