Bitmap浅析

今日又见到关于bitmap的文章, 这里参考并修正一些文字内容.

0x00 Bimap

Bit-map的基本思想就是用一个二进制的bit位来标记某个元素对应的value, 而key就是该元素。由于采用了bit为单位来存储数据,因此在存储空间方面特别节省。so,它的特点就是: 节省存储空间

假设有这样一个需求:在20亿个随机整数中找出某个数m是否存在其中,并假设32位操作系统,4G内存。

在32位PHP中, int占4Byte(s), 而1Byte=8bit(s), 一个有符号 int(singed int)可以表示的值范围是(最高位是符号位) - 2^31 ~ 2^31-1, 即 -2,147,483,648 ~ 2,147,483,647, 可以通过常量PHP_INT_MAX,PHP_INT_MAX 来查看。

如果每个数字用int存储,那就是20亿个int, 占用的空间为: 2000000000*4/1024/1024/1024 ≈ 7.45 Gb。

如果每个数字用一个二进制的位来存储,就是20亿个bit, 占用空间为: 2000000000/8/1024/1024/1024 ≈ 0.233 Gb。

从计算公式来看, bit存储是int存储所需空间的1/32。

那么, 如果用bit来表示一个数呢?

在二进制中,一个bit只能表示2个值: 0和1。我们就可以使用1来表示存在, 0表示不存在。(这是通常的做法, 虽然在实际电路中0是高电压,1是低电压)

我们用一个byte的8bits来表示0~7这些数字。

我们用一个图来表示 1,2,4,6 这几个数字:

计算机内存分配的最小单位是byte, 那么怎么表示 12, 13, 15 呢? 这当然得在另外一个byte上表示了:

这样的话, 就像一个二维数组了.

1个int占32bits, 那么我们只需要申请一个int数组长度为 int tmp[1+N/32] 即可存储(N表示要存储的数值的最大值),于是:

tmp[0] 可以表示 0 ~ 31,tmp[1] 可以表示 32 ~ 63,tmp[3]可以表示 64 ~ 95,以此类推......

如此一来, 给定任意整数M, 那么M/32就得到它在数组中的下标, M%32 就知道它在此下标中的位置。

添加

那么, 我们怎么把一个数放进去呢? 例如, 想把5这个数字放进去, 怎么做呢?

首先, 5/32=0, 5%32=5, 也就是说它应在在tmp[0]的第5个位置, 那我们把1向左移动5位(得到5在一个byte中的表示), 然后按位或:

换成二进制就是:

这就相当于: 86 | 32 = 118

86 | ( 1 << 5 ) = 118

byte[0] = byte[0] | ( 1 << 5 )

也就是说, 要想插入一个数, 将1左移代表该数自的那一位, 然后与原数进行位或操作.

化简一下就是: 86 + (5 / 8) | ( 1 << (5 % 8) )

因此, 公式可以概括为: p + (i / 8) | ( 1 << (5 % 8) ) 其中, p表示现在的值, i表示待插入的数.

PHP演示:

var_dump(bindec(b'01010110') | (1 << 5)); // int(118)

清除

以上是添加, 那如果要清除该怎么做呢?

从1,2,4,6 中移除6, 该怎么做呢?

从图上看, 只需将该数所在的位置为0即可.

1左移6位, 就到达6这个数字所代表的位, 庵后按位取反, 最后与原数按位与, 这样就把该位置为0了.

byte[0] = byte[0] & ( ~ ( 1 << 6 ) )

化简一下就是:

byte[0] = b[0] & ( ~ ( 1 << ( i % / 8) ) )

php演示:

$a = b'01010110'; //string
var_dump($a); //string(8) "01010110"
$b = decbin(1 << 6); // '1000000'
var_dump($b); // string(7) "1000000"
//补充位数到8, 不足在前面补0
$b = str_pad($b, 8, '0', STR_PAD_LEFT);// '01000000'
var_dump($b); //string(8) "01000000"
//取反
$arr = [];
for($i=0; $i < 8; $i++){
    $arr[] = substr($b, $i, 1) == '1' ? 0 : '1';
}
$b = implode('', $arr);
echo '取反后的b: '; var_dump($b); //string(8) "10111111"
$c = $a & ($b); //位与
var_dump($c); //string(8) "00010110"

查找

前面说了, 每一位代表一个数字, 1 表示有(存在), 0 表示无(不存在)。 通过把该置为1或0来达到添加和清除的效果, 那么判断一个数存不存在就是判断该数所在的位是1还是0.

比如, 我们想知道3在不在, 那么只需要判断 byte[0] & ( 1 << 3 ) 如果这个值是0则不存在, 如果是1则存在.

0x01 Bitmap有什么用?

大量(不重复)数据的快速排序、查找、去重。

快速排序

假如我们要对0-7捏的5个元素(4, 7, 2, 5, 3)排序(这里我们假定这些元素没有重复),我们就可以采用Bit-map的方法来达到排序的目的。

要表示8个数, 我们就只需要8个Bits(1 Byte)。

首先,我们开辟1 Byte的空间,将这些空间的所有bit位都设置为0;然后将对应的位设置为1;最后,遍历一遍bit区域, 将该位是1的位的编号输出(2,3,4,5,7)。这样就达到了排序的目的, 时间复杂度为O(n)。

优点:

  • 运算效率高, 不需要进行比较和移位
  • 占用内存少, 比如一千万个数, N = 10000000, 只需占用内存为 N/8=1250000 (Bytes) = 1.192 Mb

缺点:

  • 所有的数据都不能重复,即不可使用Bitmap对重复的数据进行排序和查找
  • 只有当数据比较密集时才有优势。

    因为占用的空间和循环的次数与最大数有关, 如果有几千个数分布在1 ~ 20亿之间, 那占用的空间就会很大, 遍历的次数也多

快速去重

前面计算过了, 20亿个int(4)的整数, 约需要7.45Gb的内存, 而普通4Gb内存的主机根本无法容纳下这20亿个整数。

那么我们如何用Bitmap来实现这个业务需求呢?

其实这个问题很简单,一个数字的状态只有3种:不存在、只有一个、有重复(多个)。因此, 我们只需要2bits就可以对一个数字的状态进行存储了。假设我们设定一个数字不存在未00,存在一次为01,存在两次及以上为11。那我们大概需要存储空间 2000000000 * 2 / 8 /1024/1024/1024 ≈ 0.466Gb

接下来的任务就是把这20亿个数字存进去,如果对应的状态位是00,则将其变为01,表示存在一次;如果对应的状态位是01,则将其变为11,表示已经有一个了即出现了多次;如果为11, 则对应的状态位保持不变,仍表示出现多次。

最后,统计状态位是01的个数,就得到了不重复的数字个数,事件复杂度为O(n)。

PHP演示:

//我还没出生

快速查找

int数组中的一个元素是4bytes占32bits, 那么下标就是 N/32, 在对应的元素中的位置是 N % 32, 如果该位的值为1就表示存在。

小结&回顾

Bitmap主要用于快速检索关键字状态,通常要求关键字是一个连续的序列(或者是一个连续序列中的大部分),最基本的情况,使用1bit表示一个关键字的状态(可表示2种状态),但根据需要也可以使用2bits(表示4种状态),3bits(表示8种状态)。

Bitmap的主要应用场合:表示连续(或接近连续, 即大部分会出现)的关键字蓄力的状态(状态数/关键字个数 越小越小)。

32位环境下, 对于一个整形数,比如int $a=1 在内存中占32bits, 这是为了方便计算机的运算。但是对于某些应用场景而言,这属于一种巨大的浪费,因为我们可以用对应的32bits存储十进制的0~31个数, 而这就是Bitmap的基本思想。Bitmap算法利用这种思想处理大数量数据的排序、查询、去重。

补充1:

在数字没有溢出的前提下, 对于整数和负数,左移一位都相当于乘以2,左移n位就相当于乘以2的n次方,右移一位相当于除以2,右移n位相当于除以2的n次方。

  • << 左移, 相当于乘以2的n次方, 比如: 1 << 6 相当于 1*2^6=64, 3 << 4 = 3 * 2^4 = 48

  • >> 右移, 相当于除以2的n次方,比如: 64 >> 3 = 64 ÷ (2^3) = 8

  • ^ 异或, 相当于求余, 比如: 48^32 相当于 48 % 32 = 16

PHP演示:

//异或 (这里不考虑负数)
$a = decbin(48);
$b = decbin(32);
var_dump($a); //string(6) "110000"
var_dump($b); //string(6) "100000"
//补充满8位
$a_bytes_quantity = ceil(strlen($a)/8); //a的bytes数量
$b_bytes_quantity = ceil(strlen($b)/8); //b的bytes数量
$bytes_quantity = max($a_bytes_quantity, $b_bytes_quantity);
$a = str_pad($a, 8 * $bytes_quantity, '0', STR_PAD_LEFT);
$b = str_pad($b, 8 * $bytes_quantity, '0', STR_PAD_LEFT);
var_dump($a); //string(8) "00110000"
var_dump($b); //string(8) "00100000"
//异或
$c = '';
for($i=0, $n=8*$bytes_quantity; $i < $n; $i++){
    $c .= substr($a, $i, 1) != substr($b, $i, 1) ? '1' : '0';
}
var_dump($c); //string(8) "00010000"
var_dump(bindec($c)); //int(16)

补充2

不使用第三方变量, 交换两个变量的值:

  • 方式1:
a = a + b;
b = a -b;
a = a - b;
  • 方式2:
a = a ^ b;
b = a ^ b;
a = a ^ b;

PHP演示:

$a = 48;
$b = 32;
//方式1:
$a = $a + $b;
$b = $a - $b;
$a = $a - $b;
echo $a . ', ' . $b; //32, 48
echo PHP_EOL;
//方式2:
$a = $a ^ $b;
$b = $a ^ $b;
$a = $a ^ $b;
echo $a . ', ' . $b; //48, 32

0x02 BitSet

Java/C++中的BitSet实现了一个位向量,它可以根据需要增长,每一位都有一个布尔值,一个BitSet的位可以被非负整数索引(PS:意思就是每一位都可以表示一个非负整数)。可以查找、设置、清除某一位。通过逻辑运算符可以修改另一个BitSet的内容。默认情况下,所有的位都有一个默认值false。

php也有bitset模块可以使用. 但是我们可以使用php提供的数组来模拟实现, https://www.cnblogs.com/kennyhr/p/3964024.html, https://github.com/maxwilms/bloom-filter

0x03 布隆过滤器 Bloom Filter

定义

Bloom filter是一个数据结构,它可以用来判断某个元素是否在集合内,具有运行快速、内存占用小的特点。

而高效插入和查询的代价就是,Bloom Filter 是一个基于概率的数据结构:它只能告诉我们一个元素绝对不在集合内或可能在集合内。

Bloom filter的基础数据结构是一个 比特向量(可理解为数组)。

主要应用于大规模数据下不需要精确过滤的场景,如检查垃圾邮件地址,爬虫URL地址去重,解决缓存穿透问题等。

如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(哈希表)等等数据结构都是这种思路,但是随着集合中元素的增加,需要的存储空间越来越大;同时检索速度也越来越慢,检索时间复杂度分别是O(n)、O(log n)、O(1)。

布隆过滤器的原理是:当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组(Bit array)中的 K 个点,把它们置为 1 。检索时,只要看看这些点是不是都是1就知道元素是否在集合中;如果这些点有任何一个 0,则被检元素一定不在;如果都是1,则被检元素很可能在(之所以说“可能”是误差的存在)。

BloomFilter 流程

  1. 首先需要 k 个 hash 函数,每个函数可以把 key 散列(Hash)成为 1 个整数;
  2. 初始化时,需要一个长度为 n 比特的数组,每个比特位初始化为 0;
  3. 某个 key 加入集合时,用 k 个 hash 函数计算出 k 个散列值,并把数组中对应的比特位置为 1;
  4. 判断某个 key 是否在集合时,用 k 个 hash 函数计算出 k 个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中。

PHP版本的BloomFilter

PHP版BloomFilter引入了第三方位操作库bitset,所以使用前需安装bitset环境,下载地址是: http://pecl.php.net/package/Bitset

BloomFilter库主要用到Bitset的4个方法:

  • bitset_empty(int bits) - 分配位数组空间,并把每一位初始化为0,位数组长度为bitcount,其实就3行代码:
    len = (bits+CHAR_BIT-1)/CHAR_BIT;
    bitset_data = emalloc( len+1 );
    memset( bitset_data, 0, len+1 );
  • bitset_to_string(unsigned char *bitset_data) - 将位数组转换为字符串表示,代码就5行
len *= CHAR_BIT;
output_str = emalloc( len+1 ); 
output_str[ len ] = '\0';
for(count = 0; count < len; count++ ){
   output_str[count ] = ( (bitset_data[ count/CHAR_BIT ] >> (count % CHAR_BIT)) & 1 ) ? '1' : '0';
}
  • bitset_incl(unsigned char *bitset_data, int bit) - 位数组位置bit(之前BF的hash后命中的位置)上的值置为1,实际操作就1行,不过位数组长度做成了动态的,所以还涉及erealloc之类的操作
bitset_data[ bit/CHAR_BIT ] |= 1 << (bit % CHAR_BIT);
  • bitset_in(unsigned char *bitset_data, int bit) - 判断某个位置bit上的值是否为1,位操作语句比较精炼
if( bitset_data[ bit/CHAR_BIT ] & (1 << (bit % CHAR_BIT) ) )
RETURN_TRUE;

BloomFilter源码分析

class BloomFilter {
    public $field; // 位数组
    public $len; // 位数组长度

    // BF自己的hash函数由3个独立hash函数联立计算
    function hash($key) {
        // 生成3个正整数的数组,hexdec - 十六进制转十进制
        // 每个值都mod过$len
        return array(
            abs(hexdec(hash('crc32', 'm' . $key . 'a')) % $this->len),
            abs(hexdec(hash('crc32', 'p' . $key . 'b')) % $this->len),
            abs(hexdec(hash('crc32', 't' . $key . 'c')) % $this->len)
        );
    }

    // 定义初始化BF长度和位数组
    function __construct($len) {
        $this->len = $len;
        $this->field = bitset_empty($this->len);
    }

    // 通过载入BF文件进行初始化
    // BF文件可以通过file_put_contents('bloom',$bf->field)生成
    static function init($field) {
        $bf = new self(strlen(bitset_to_string($field)));
        $bf->field = $field;
        return $bf;
    }

    // 哈希一个条目
    function add($key) {
        foreach ($this->hash($key) as $h) ? bitset_incl($this->field, $h);
    }

    // 查找条目是否在BF内
    function has($key) {
        foreach ($this->hash($key) as $h) if (!bitset_in($this->field, $h)) return false;
        return true;
    }
    // 计算BF假阳性率
    // $k - hash函数个数
    // $numItems - 哈希的条目数
    function falsePositiveRate($numItems) {
        // 因为hash函数都是独立的,故对1做hash,形成的1值应该在3个不同位置
        // 所以$k=BF自己的hash函数(1)的值=独立的hash函数个数
        $k = count($this->hash('1'));
        return pow((1 - pow((1 - 1 / $this->len), $k * $numItems)), $k);
    }
}

参考:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值