python高级算法和数据结构:Bloom过滤器

在海量或高并发应用场景,有一种需求是确认某条记录是否已经存在。例如对搜索引擎而言,它需要快速判断某个网页是否已经收录,通常引擎要存储几十上百亿个网页,要在如此巨量的数据中查找一条记录,用大海捞针来形容确实不为过。

海量或是高并发场景应对的需求总是有一种“要马跑的尽可能快,又要马吃尽可能少的草”的味道。如果面试中有这类问题,面试官总是期待你拿出的方案既要使用尽可能少的内存,然后速度又要尽可能的快。好在还真有方法能满足这些需求,针对“一条记录在海量数据中是否存在”这样的要求,布隆过滤器就能很好的满足内存需求少,查询速度快。

要实现布隆过滤器,我们需要两个条件,一个是需要含有m个元素的数组,一个是含有k个哈希函数的集合。由于我们只需要确定“是否存在”因此数组使用bit就行,而每个哈希函数,它们输出的结果是0到m-1之间的整数。需要注意的是,一条记录并不会只对应m个bit中的某一个,而是对于其中的k个bit,k是我们预先定义好的常数,也就是说无论记录的内容有多长,对网页而言,记录的内容即使url,无论这个url是10个字节还是1024个字节,我们都只用k个bit来对应它。

当需要插入一条记录时,我们使用k个哈希函数来计算出k个下标,然后在数组中将对应下标的bit设置为1。有一个难点是,如果哈希函数给出相同结果怎么办,在理论上哈希函数产生的结果发送碰撞无法避免,但是我们可以尽肯能降低碰撞发送的概率,因此我们可以采用以下做法:
1,使用哈希函数生成器,例如函数H(i)被调用后,它返回一个哈希函数,输入的参数i不同,它就能产生不同的哈希函数。在布隆过滤器初始化时,我们就需要使用这样的生成器生成k个不同的哈希函数。

2,使用双重或三重哈希。这里我们将采用双重哈希,这两个哈希函数分别名为Murmur和Fowler-Noll-Vo ,这两个函数有某些特定性质使得他们生成的结果会尽可能少的产生碰撞。我们使用这两个哈希函数来实现步骤1,也就是用这两个哈希函数来构建哈希函数生成器,假设我们要从生成器中创建第i个哈希函数,那么可以执行的步骤就是:
h i ( k e y ) = m u r m u r h a s h ( k e y ) + i + ∗ f n v 1 ( k e y ) + i ∗ i {h}_{i}(key)=murmurhash(key)+i+*fnv1(key)+i*i hi(key)=murmurhash(key)+i+fnv1(key)+ii
这里murmurshash对应的就是Murmur哈希,fnv1对应的就是Foler-Noll-Vo哈希,h(i)就是从生成器中创建出来的第i个哈希函数。对python而言,它们都有相应的包可以直接调用,例如要使用murmur哈希函数,使用如下命令进行安装:

pip install mmh3

对fnv1哈希函数而言,使用如下命令进行安装:

pip install fnvhash

接着我们先实现布隆过滤器的初始化代码:


def init_bloom_filter(records, min_size):
    size = max(2 * len(records), min_size)
    bloom_filter = BloomFilter(size)
    for record in records:
        bloom_filter.insert(record)
    return bloom_filter

初始化布隆过滤器时,需要传入当记录,同时给定过滤器所支持的最小记录数。接下来我们看看给定一条记录,如何查询该记录是否已经存在:

def check_record(bloom_filter, record):
    if bloom_filter.contains(record):
        return True
    return False
def add_record(bloom_filter, record):
    bloom_filter.insert(record)

接下来我们需要做的就是实现布隆过滤器。由于我们需要在m个bit中取出k个以便用来对应某一条记录,因此我们需要完成如下工作:
1,根据下标i来读取或写入给定比特位
2,给定一条记录,确定对应的k个比特位
3,使用k个哈希函数生成k个比特位对应的下标。

我们使用byte数组来作为布隆过滤器的底层数组,但是一个Byte对应8个bits因此要找到下标为k的bit,我们需要做一些转换运算:

BITS_IN_BYTE = 8

def find_bit_coordinates(index):
    bit_index = math.floor(index / BITS_IN_BYTE)
    bit_offset = index % BITS_IN_BYTE
    return bit_index, bit_offset

def read_bits(bits_array, index):
    # 获取给定比特位的位置然后读取
    element, bit = find_bit_coordinates(index)
    return (bits_array[element] & (1 << bit)) >> bit


def write_bits(bits_array, index):
    element, bit = find_bit_coordinates(index)
    bits_array[element] = bits_array[element] | (1 << bit)

上面实现的函数分别是根据下标查找指定比特位,读取和写入指定比特位,由于我们使用byte array来作为布隆过滤器的底层数组,而一个byte对应8个bit,所以在查找时我们需要除以8和对8求余,我们实验一下上面实现的函数:

if __name__ == '__main__':
    bits_array = bytearray([157, 25, 44, 204])
    '''
    在数组中查找下标为19的比特位,element=2, bit=3,返回结果为1
    '''
    bit_read = read_bits(bits_array, 19)
    print("bit read for offset {} is {}".format(19, bit_read))

    '''
    将下标为15的比特位设置为1,于是数组内容会变成:
    [157, 153, 44, 204]
    '''
    write_bits(bits_array, 15)
    for byte in bits_array:
        print("byte is : {}".format(byte))

上面代码运行后可以发现结果跟预料的一致。接下来的工作是,给定一条记录后,我们需要找到该记录对应的k个比特位,并将它们设置为1.前面我们使用两个哈希函数进行组合来生成k个哈希函数,于是我们使用 h i {h}_{i} hi来分别生成k个比特位对应的下标,相应实现如下:

def key_to_positions(k, num_bits, seed, record):
    bit_positions = []
    for i in range(k):
        hm = mmh3.hash(record, seed)
        hf = fnv1a_32(str.encode(record))
        bit_positions.append((hm + i * hf + i*i) % num_bits)

    return bit_positions

bit_positions对应k个比特位的下标,在理论上bit_positions数组中存在相同下标的概率会非常小,有了这些基础函数后,我们就可以定义布隆过滤器的实现:

import math
import mmh3
from fnvhash import fnv1a_32
from random import randrange
from numpy import log as ln

class BloomFilter:
    def  __init__(self, max_size, max_tolerance=0.01, seed=randrange(1024)):
        self.size = 0
        self.max_size = max_size
        self.seed = seed
        #为了确保给定精确度我们需要设置底层数组的长度
        self.num_bits = -math.ceil(max_size * ln(max_tolerance) / ln(2) / ln(2))
        self.k = -math.ceil(ln(max_tolerance) /ln(2))
        self.bits_array = bytearray({})
        num_elements = math.ceil(self.num_bits / BITS_IN_BYTE)
        for i in range(num_elements):
            self.bits_array.append(0)

在上面代码中有几点需要解释一下,布隆过滤器不是一个完全精准的过滤器,它存在一定的概率会出错,也就是某个记录已经存储了,但它可能会返回说记录不存在,这种出错的概率对应代码中的max_tolerance,要想出错的概率越小,那么要求数组的元素就得越大,同时记录对应的比特位数,也就是数值k也需要相应增大,出错的概率和数组大小的相关性可以从数学上精确计算出来。

数学理论先放在一边,我们先看看如何判定一个记录是否已经存储,假设给定两个字符串:str1 = “hello”, str2 = “world”,k = 3,于是我们分别用构造的3个哈希函数计算他们对应的bit,如果第一个字符串计算的结果为: h 0 ( s t r 1 ) h_{0}(str1) h0(str1)=9, h 1 ( s t r 1 ) h_{1}(str1) h1(str1) = 2, h 3 ( s t r 1 ) h_{3}(str1) h3(str1)=6,于是我们就得到比特数组里面查看第2,6,9个比特是否全部为1,以此判断字符串"hello"是否已经被存储,同理如果 h 0 ( s t r 2 ) h_{0}(str2) h0(str2)=10, h 1 ( s t r 2 ) h_{1}(str2) h1(str2) = 4, h 3 ( s t r 2 ) h_{3}(str2) h3(str2)=14,那么我们就得查看第10,4,14三个位置的比特位是否全部为1,以此判断字符串"world"是否已经存储,如果当前比特数组的内容如下:
请添加图片描述
这样我们就判断"hello"没有被存储,但是"world"已经被存储,相应的实现如下:

    def  contains(self, record, positions = None):
        if positions is None:
            positions = key_to_positions(self.k, self.num_bits, self.seed, record)
        for pos in positions:
            if read_bits(self.bits_array, pos) == 0:
                return False
        return True

当我们要存储一条记录时,首先计算记录对应的比特位下标,然后将对应下标的比特位设置为1,我们看看如何使用布隆过滤器:

if __name__ == '__main__':
    filter = BloomFilter(1024)
    filter.insert("hello")
    filter.insert("world")
    print("check {} is in filter: {}".format("hello", filter.contains("hello")))
    print("check {} is in filter: {}".format("python", filter.contains("python")))

运行上面代码后,第一个输出返回True,第二个返回false。接下来我们看看维护布隆过滤器在海量数据中检索记录是否存在的应用上能够显示出强大威力。布隆过滤器的特点是用空间换准确性,它可以使用几十M的内存来处理几十亿条记录,代价就是有一定的出错概率。

从上面代码实践看到,布隆过滤器的特点是,在一个含有m比特的数组中选取k个比特来表示一个记录,由于一旦确定k个比特的下标后,比特之间的顺序对判断记录是否已经存储没有关系,我们只关系给定的k个比特是否全部为1,于是根据组合数学,从m个元素中选取k个的可能性为:
( m k ) \left(\begin{array}{c}m\\ k\end{array}\right) (mk)= m ! k ! ( m − k ) ! \frac{m!}{k!(m-k)!} k!(mk)!m!
同时我们还需要注意的是,对布隆过滤器而言,它只有加入,没有删除,因此当给定一条记录,确定它对应的k个比特位后,只有有其中一个比特位0,那么就能确认这条记录没有存储过,但反之则不能成立,如果k个比特都设置成了1,那么也有可能记录并没有存储过。

我们分析一下布隆过滤器的效率,在初始化时我们需要确认数组的大小,k的数值,这些参数其实可以在给定容忍错误率后推算出来,由于哈希函数的构造,以及k个下标的技术都依赖于数组大小和k的数值,布隆过滤器在初始化时需要把数组m个比特设置为0,以及构建k个哈希函数,因此时间复杂度为O(k+m)。

当存储一条记录时,我们需要对它进行哈希,然后根据给定的k个下标设置比特位,假设对记录x进行一次哈希的时间用T(x)表示,那么这个过程所需时间为 kT(x),T(x)往往取决于记录的长度,如果我们假设能提前知道记录的最大长度为z,于是插入记录的时间就是kT(z),由于z是提前知道的常量,因此T(z)也是一个常量,于是插入记录的时间就是O(k)。同理,我们能推论出查找给定记录的时间也是O(k)。

接下来重点就是在给定可允许的错误率时,如何计算m和k。对任何记录我们计算它对应的k个比特位的下标,然后根据下标将对应的比特设置为1,假设哈希函数计算结果具有充分的随机性,于是m个比特位中,某一个被设置为1的概率是1/m,于是经过了k次后,某个比特位的值依然为0的概率就是:
( 1 − 1 m ) k (1-\frac{1}{m}{)}^{k} (1m1)k
当我们插入n条记录后,某个比特位的值还是0的概率就是:

( 1 − 1 m ) k ∗ n � ≈ e − k ∗ n m (1-\frac{1}{m}{)}^{k*n�}\approx {e}^{-\frac{k*n}{m}} (1m1)knemkn
如果某条记录不存在,但是布隆过滤器出错认为记录存在,于是给定的k个比特位都必须设置成1,于是对应的概率就是:
( 1 − ( 1 − 1 m ) k n ) k ≈ ( 1 − e − k . n m ) k (1-(1-\frac{1}{m}{)}^{kn}{)}^{k}\approx (1-{e}^{-\frac{k.n}{m}}{)}^{k} (1(1m1)kn)k(1emk.n)k (1)
我们要选取k,使得上面式子表示的概率尽可能小,根据微积分原理,我们要对上面式子针对k进行求导,为了方便计算,我们先对上面公式进行ln运算:
k ⋅ l n ( 1 − e − k . n m ) k\cdot ln(1-{e}^{-\frac{k.n}{m}}) kln(1emk.n)
然后再针对k求导为:
0 = l n ( 1 − e − k . n m ) + k . n m . − e − k . n m 1 − e − k . n m ln(1-{e}^{-\frac{k.n}{m}})+\frac{k.n}{m}.\frac{-{e}^{-\frac{k.n}{m}}}{1-{e}^{-\frac{k.n}{m}}} ln(1emk.n)+mk.n.1emk.nemk.n (3)
当k取值为ln(2)*m/n时,上面导函数结果为0,于是k取这个值能上出错的概率尽可能小,我们把k带入公式(1)得到出错的最小概率为:
p= ( 1 2 ) m n . l n ( 2 ) (\frac{1}{2}{)}^{\frac{m}{n}.ln(2)} (21)nm.ln(2) (2)

(2)中左边的p对应我们设置的能容忍的最大错误概率,也就是前面代码里的0.01,于是我们设置好这个值后就能计算出数组的大小,也就是m,根据(2)两边取对数:
l n ( p ) ln(p) ln(p)= − m n . l n ( 2 ) -\frac{m}{n}.ln(2) nm.ln(2)
其中n是记录的条数,也是一个预先设置好的数值,根据上面我们解出m的值为:
− n ∗ ln ⁡ ( p ) ln ⁡ ( 2 ) 2 -n*\frac{\ln(p)}{\ln(2{)}^{2}} nln(2)2ln(p) (4)
我们也正是根据上面运算结果来初始化num_bits这个值。当我们对公式(3)中的m,n看做常量把k解出来,所得结果为:
k = l n ( 2 ) ∗ m n k=ln(2)*\frac{m}{n} k=ln(2)nm (5)
然后把求出的m值,也就是(4)带入到(5)就能得到k值为:
− ln ⁡ ( p ) ln ⁡ ( 2 ) -\frac{\ln(p)}{\ln(2)} ln(2)ln(p)
这跟我们在对吗中对k的初始化一致。

更多精彩内容请点击这里

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值