BitMap是最常见的大数据算法,既然是说大数据算法,我们先尝试给大数据算法一个定义,或者说是限定一下这个系列的范围。
大数据算法:在给定的资源约束下,以大数据为输入,在给定时间约束内可以计算出给定问题加过的算法。
大数据算法会有传统的算法有不一样的地方:
- 资源有约束
- 时间有约束
- 大数据作为输入
- 不一定是精确算法
前三点可以看作是对算法的要求,第四点可以看作是在大数据场景下算法可以做出的让步。比如说在10亿的数据中求 count distinct
操作,完全精确的算法会十分占用空间资源,而且也很难在快速计算出结果。如果这时候允许一定的误差,就可以在极短的时间使用少量的内容算出结果,比如基数估计算法中的Hyperloglog。
本系列会包括 BitMap、Roaring BitMap、Bloom Filter、Counting Bloom Filter、Linear Counting、Loglog Counting、HyperLogLog Counting 等算法。我会把这些算法一个个过一遍,看论文、写代码、整理学习笔记。
基本原理
BitMap 的基本原理就是用一个 bit 来标记某个元素对应的 Value,而 Key 即是该元素。由于采用一 个bit 来存储一个数据,因此可以大大的节省空间。
我们通过一个具体的例子来说明 BitMap 的原理,假设我们要对 0-31 内的 3 个元素 (10, 17,28) 排序,那么我们就可以采用 BitMap 方法(假设这些元素没有重复)。
如下图,要表示 32 个数,我们就只需要 32 个 bit(4Bytes),首先我们开辟 4Byte 的空间,将这些空间的所有 bit 位都置为 0。 然后,我们要添加(10, 17,28) 这三个数到 BitMap 中,需要的操作就是在相应的位置上将0置为1即可。比如现在要插入 10 这个元素,只需要将蓝色的那一位变为1即可。
将这些数据插入后,假设我们想对数据进行排序或者检索数据是否存在,就可以依次遍历这个数据结构,碰到位为 1 的情况,就当这个数据存在。
实现
我们利用java模拟实现如下:
package com;
/**
* @author pisceszhang
* java的BitMap实现,其中包括增删改查基本功能
* @date 2022/6/11 10:30 下午
*/
public class IntegerBitMap {
private byte[] bits;
IntegerBitMap(int cap) {
bits = new byte[cap/8 + 1];
}
/*
1.向数组中添加元素
2.删除元素
3.检查元素是否在集合中
4.检查元素的索引
*/
/**
* num/8得到该元素位于数组的第几项
* @param num
* @return
*/
public int getIndex(int num) {
return num >> 3;
}
/**
* 位于该位置的第几个
* @param num
* @return
*/
public int getPosition(int num) {
return num % 8;
// 对8取余,相当于提取小于8的位,那么即对7取且。
return (bitoffset & 0x7);
}
/**
* 将对应位置的数字与1进行或,即可增加
* @param num
*/
public void add(int num) {
bits[getIndex(num)] |= 1 << getPosition(num);
}
/**
* 将1移动对应位置后,进行取反,并进行与操作,即可其他位置不动,该位值变为0.
* @param num
*/
public void remove(int num) {
bits[getIndex(num)] &= ~(1<<getPosition(num));
}
/**
* 看一下该位值是否为1即可
*/
public boolean contains(int num){
return (bits[getIndex(num)] & 1<<getPosition(num)) != 0;
}
}
使用
BitMap 的使用场景很广泛,比如说 Oracle、Redis 中都有用到 BitMap。当然更多的系统会有比 BitMap 稍微复杂一些的算法,比如 Bloom Filter、Counting Bloom Filter,这些会在后面逐一展开。
下面举一个在算法中用到 BitMap 来解决问题的例子。
已知某个文件内包含一些电话号码,每个号码为8位数字,统计不同号码的个数。
在这里就不再做和其它算法的对比,直接说一下 BitMap 的思路。
8 位的整数,相当于是范围在(0,99999999),也就是说 99999999 个 bit,也就是 12M 左右的内存,比起用类似 HashMap 的方式的话能节省很大的空间。 可以理解为从0 到 99999999 的数字,每个数字对应一个 Bit位,所以只需要 12M 左右的内存表示了所有的 8 位数的电话。
查询的时候就很简单了,直接统计有多少位是 1 就可以了。
redis中的BitMap详解
-
SETBIT
:为位数组指定偏移量上的二进制位设置值,偏移量从0开始计数,二进制位的值只能为0或1。返回原位置值。 -
GETBIT
:获取指定偏移量上二进制位的值。 -
BITCOUNT
:统计位数组中值为1的二进制位数量。 -
BITOP
:对多个位数组进行按位与、或、异或运算。
Redis 提供了 Bitmaps 这个 “数据类型” 可以实现对位的操作:
但是Bitmaps 本身不是一种数据类型, 实际上它就是字符串(key-value) , 但是它可以对字符串的位进行操作
我们使用sds来作为BitMap的实例,如下展示了一个用 SDS 表示的一字节(8位)长的位图:
Redis 中的每个对象都是有一个 redisObject 结构表示的。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 执行底层实现的数据结构的指针
void *ptr;
} robj;
type
的值为REDIS_STRING
表示这是一个字符串对象sdshdr.len
的值为1表示这个SDS保存了一个1字节大小的位数组- buf数组中的
buf[0]
实际保存了位数组 - buf数组中的
buf[1]
为自动追加的\0
字符 SDS不需要“\0”来判断字符串是否到结尾,那么为什么还要在buf数组最后用“\0”作为结尾呢?其实主要是Redis还要用到C语言中对字符串的处理函数,以“\0”作为结尾,Redis就可以使用这些函数而不用自己去实现。
具体方法的源码如下:
GETBIT
GETBIT
用于返回位数组在偏移量上的二进制位的值。值得我们注意的是,GETBIT
的时间复杂度是O(1)
。
GETBIT
命令的执行过程如下:
-
- 计算byte=⌊offset÷8⌋ (即
>>3
),byte 值表示指定的 offset 位于位数组的哪个字节(计算在第几行);
- 计算byte=⌊offset÷8⌋ (即
- 指定 buf[i]中的i了,接下来就要计算在8个字节中的第几位呢?使用 bit=(offset % 8)+1bit 计算可得;
- 根据 byte 和 bit 在位数组中定位到目标值返回即可。
GETBIT
命令源码如下所示:
void getbitCommand(client *c) {
robj *o;
char llbuf[32];
uint64_t bitoffset;
size_t byte, bit;
size_t bitval = 0;
// 获取offset
if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset,0,0) != C_OK)
return;
// 查找对应的位图对象
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
checkType(c,o,OBJ_STRING)) return;
// 计算offset位于位数组的哪一行
byte = bitoffset >> 3;
// 计算offset在一行中的第几位,等同于取模
bit = 7 - (bitoffset & 0x7);
// #define sdsEncodedObject(objptr) (objptr->encoding == OBJ_ENCODING_RAW || objptr->encoding == OBJ_ENCODING_EMBSTR)
if (sdsEncodedObject(o)) {
// SDS 是RAW 或者 EMBSTR类型
if (byte < sdslen(o->ptr))
// 获取指定位置的值
// 注意它不是真正的一个二维数组不能用((uint8_t*)o->ptr)[byte][bit]去获取呀~
bitval = ((uint8_t*)o->ptr)[byte] & (1 << bit);
} else {
// SDS 是 REDIS_ENCODING_INT 类型的整数,先转为String
if (byte < (size_t)ll2string(llbuf,sizeof(llbuf),(long)o->ptr))
bitval = llbuf[byte] & (1 << bit);
}
addReply(c, bitval ? shared.cone : shared.czero);
}
SETBIT
SETBIT
用于将位数组在偏移量的二进制位的值设为value,并向客户端返回旧值。
SITBIT
命令的执行过程如下:
-
计算len=⌊offset÷8⌋+1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节
-
检查位数组的长度是否小于len,如果是的话,将SDS的长度扩展为len字节,并将所有新扩展空间的二进制位设置为0
-
计算byte=⌊offset÷8⌋byte ,byte值表示指定的offset位于位数组的那个字节(就是计算在那个buf中的i)
-
使用bit=(offset mod 8)+1bit 计算可得目标buf[i]的具体第几位
-
根据byte和bit的值,首先保存oldValue,然后将新值value设置到目标位上
-
返回旧值
因为SETBIT命令执行的所有操作都可以在常数时间内完成,所以该命令的算法复杂度为O(1)。
void setbitCommand(client *c) {
robj *o;
char *err = "bit is not an integer or out of range";
uint64_t bitoffset;
ssize_t byte, bit;
int byteval, bitval;
long on;
// 获取offset
if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset,0,0) != C_OK)
return;
// 获取我们需要设置的值
if (getLongFromObjectOrReply(c,c->argv[3],&on,err) != C_OK)
return;
/* 判断指定值是否为0或1 */
if (on & ~1) {
// 设置了0和1之外的值,直接报错
addReplyError(c,err);
return;
}
// 根据key查询SDS对象(会自动扩容)
if ((o = lookupStringForBitCommand(c,bitoffset)) == NULL) return;
/* 获得当前值 */
byte = bitoffset >> 3;
byteval = ((uint8_t*)o->ptr)[byte];
bit = 7 - (bitoffset & 0x7);
bitval = byteval & (1 << bit);
/* 更新值并返回旧值 */
byteval &= ~(1 << bit);
byteval |= ((on & 0x1) << bit);
((uint8_t*)o->ptr)[byte] = byteval;
// 发送数据修改通知
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_STRING,"setbit",c->argv[1],c->db->id);
server.dirty++;
addReply(c, bitval ? shared.cone : shared.czero);
}
BITCOUNT
BITCOUNT
命令用于统计给定位数组中值为1的二进制位的数量。功能似乎不复杂,但实际上要高效地实现这个命令并不容易,需要用到一些精巧的算法。
统计一个位数组中非0二进制位的数量在数学上被称为"计算汉明重量"。 对于一个有限集合来说,集合元素的排列方式是有限的,并且对于一个有限长度的位数组来说,它能表示的二进制位排列也是有限的。根据这个原理,我们可以创建一个表,表的键为某种排列的位数组,而表的值则是相应位数组中值为1的二进制位的数量。
1.查表法
对于8位长的位数组来说,我们可以创建下表,通过这个表格我们可以一次从位数组中读入8位,然后根据这8位的值进行查表,直接知道这个值包含了多少个1。
可惜,查表法耗内存呀!
2.variable-precision SWAR
目前已知效率最好的通用算法为variable-precision SWAR
算法,该算法通过一系列位移和位运算操作,可以在常数时间内计算多个字节的汉明重量,并且不需要使用任何额外的内存
代码如下:
public static int bitCount(int num) {
num = (num & 0x55555555) + ((num >> 1) & 0x55555555);
num = (num & 0x33333333) + ((num >> 2) & 0x33333333);
num = (num & 0x0F0F0F0F) + ((num >> 4) & 0x0F0F0F0F);
num = (num * 0x01010101) >>24;
//num = (num >> 24 & 0xFF) + (num >> 16 & 0xFF) + (num >> 8 & 0xFF)+(num & 0xFF);
return num;
}
因为int有四个字节,包含了8个十六进制的位。我们可以把i的二进制位理解成:长度为32的数组,每个元素取值区间[0,1],每个元素正好能代表这个位是不是1.
所以,问题就可以转化为,求这个数组的和。
根据分治法的思想,我们可以把相邻的两个数字相加,得到长度为16的数组,每个元素取值区间[0,2]。
并以此类推,最终求出总和。
步骤1
这一步用到0x55555555
作为掩码,其二进制表示为01010101010101010101010101010101
此时i可理解为长度为32的数组,每个元素取值区间[0,1],元素宽度1bit。
通过i & 0x55555555
运算,取得了i的奇数位置元素,存储为16个2bit整数;
通过(i>>1) & 0x55555555
运算,取得了i的偶数位置元素,存储为16个2bit整数;
两者相加,相当于16组2bit整数按位相加,问题就转化成了2bit的二进制加法。
由于原数组每个元素取值区间[0,1],所以每组相加的结果会在[0,2]区间内,2bit刚好存储。
最终得到长度为16的数组,每个元素取值区间[0,2]。
步骤2
这一步用到0x33333333
作为掩码,其二进制表示为00110011001100110011001100110011
此时i可理解表示为长度为16的数组,每个元素取值区间[0,2],元素宽度2bit。
通过i & 0x33333333
运算,取得了i的奇数位置元素,存储为8个4bit整数;
通过(i>>2) & 0x33333333
运算,取得了i的偶数位置元素,存储为8个4bit整数;
两者相加,相当于8组4bit整数按位相加,问题就转化成了4bit的二进制加法。
由于原数组每个元素取值区间[0,2],所以每组相加的结果会在[0,4]区间内,4bit刚好存储。 最终得到长度为8的数组,每个元素取值区间[0,4]。
步骤3
这一步用到0x0F0F0F0F
作为掩码,其二进制表示为00001111000011110000111100001111
此时i可理解表示为长度为8的数组,每个元素取值区间[0,4],元素宽度4bit。
通过i & 0x0F0F0F0F
运算,取得了i的奇数位置元素,存储为4个8bit整数;
通过(i>>4) & 0x33333333
运算,取得了i的偶数位置元素,存储为4个8bit整数;
两者相加,相当于4组8bit整数按位相加, 问题就转化成了8bit的二进制加法。
由于原数组每个元素取值区间[0,4],所以每组相加的结果会在[0,8]
区间内,8bit足够存储。
最终得到长度为4的数组,每个元素取值区间[0,8]。
步骤4
按照上面的思路,本来应该继续将长度为4的数组转换为长度为2的数组。
但是这里由于4个8bit整数相加存在简便运算,就不继续往下合并了。
到这一步是时i=0x02030404
,为了求出最终结果,我们可以想到位移的办法将每8bit取出(参考ip掩码计算),然后再依次相加。
最终结果也就是 (i & 0xFF) + ((i>>8) & 0xFF) + ((i>>16) & 0xFF) + ((i>>24) & 0xFF)
为了理解算法里的做法,这里需要简单的数学推导
(i * 0x01010101)>>24 == ((i<<24)>>24) + ((i<<16)>>24) + ((i<<8)>>24) + ((i<<0)>>24)
// 将左移和右移合并,并考虑溢出,最终结果一致
(i * 0x01010101)>>24 == (i & 0xFF) + ((i>>8) & 0xFF) + ((i>>16) & 0xFF) + ((i>>24
源码实现
Redis 中通过调用redisPopcount
方法统计汉明重量,源码如下所示:
long long redisPopcount(void *s, long count) {
long long bits = 0;
unsigned char *p = s;
uint32_t *p4;
// 为查表法准备的表
static const unsigned char bitsinbyte[256] = {0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,4,5,5,6,5,6,6,7,5,6,6,7,6,7,7,8};
// CPU一次性读取8个字节,如果4字节跨了两个8字节,需要读取两次才行
// 所以考虑4字节对齐,只需读取一次就可以读取完毕
while((unsigned long)p & 3 && count) {
bits += bitsinbyte[*p++];
count--;
}
// 一次性处理28字节,单独看一个aux就容易理解了,其实就是SWAR算法
// uint32_t:4字节
p4 = (uint32_t*)p;
while(count>=28) {
uint32_t aux1, aux2, aux3, aux4, aux5, aux6, aux7;
aux1 = *p4++;// 一次性读取4字节
aux2 = *p4++;
aux3 = *p4++;
aux4 = *p4++;
aux5 = *p4++;
aux6 = *p4++;
aux7 = *p4++;
count -= 28;// 共处理了4*7=28个字节,所以count需要减去28
aux1 = aux1 - ((aux1 >> 1) & 0x55555555);
aux1 = (aux1 & 0x33333333) + ((aux1 >> 2) & 0x33333333);
aux2 = aux2 - ((aux2 >> 1) & 0x55555555);
aux2 = (aux2 & 0x33333333) + ((aux2 >> 2) & 0x33333333);
aux3 = aux3 - ((aux3 >> 1) & 0x55555555);
aux3 = (aux3 & 0x33333333) + ((aux3 >> 2) & 0x33333333);
aux4 = aux4 - ((aux4 >> 1) & 0x55555555);
aux4 = (aux4 & 0x33333333) + ((aux4 >> 2) & 0x33333333);
aux5 = aux5 - ((aux5 >> 1) & 0x55555555);
aux5 = (aux5 & 0x33333333) + ((aux5 >> 2) & 0x33333333);
aux6 = aux6 - ((aux6 >> 1) & 0x55555555);
aux6 = (aux6 & 0x33333333) + ((aux6 >> 2) & 0x33333333);
aux7 = aux7 - ((aux7 >> 1) & 0x55555555);
aux7 = (aux7 & 0x33333333) + ((aux7 >> 2) & 0x33333333);
bits += ((((aux1 + (aux1 >> 4)) & 0x0F0F0F0F) +
((aux2 + (aux2 >> 4)) & 0x0F0F0F0F) +
((aux3 + (aux3 >> 4)) & 0x0F0F0F0F) +
((aux4 + (aux4 >> 4)) & 0x0F0F0F0F) +
((aux5 + (aux5 >> 4)) & 0x0F0F0F0F) +
((aux6 + (aux6 >> 4)) & 0x0F0F0F0F) +
((aux7 + (aux7 >> 4)) & 0x0F0F0F0F))* 0x01010101) >> 24;
}
/* 剩余的不足28字节,使用查表法统计 */
p = (unsigned char*)p4;
while(count--) bits += bitsinbyte[*p++];
return bits;
}
不难发现 Redis 中同时运用了查表法和SWAR算法完成BITCOUNT
功能。
总结
BitMap 的思想在面试的时候还是可以用来解决不少问题的,然后在很多系统中也都会用到,算是一种不错的解决问题的思路。
但是 BitMap 也有一些局限,因此会有其它一些基于 BitMap 的算法出现来解决这些问题。
- 数据碰撞。比如将字符串映射到 BitMap 的时候会有碰撞的问题,那就可以考虑用 Bloom Filter 来解决,Bloom Filter 使用多个 Hash 函数来减少冲突的概率。
- 数据稀疏。又比如要存入(10,8887983,93452134)这三个数据,我们需要建立一个 99999999 长度的 BitMap ,但是实际上只存了3个数据,这时候就有很大的空间浪费,碰到这种问题的话,可以通过引入 Roaring BitMap 来解决
参考文献:
juejin.cn/post/707474…
mp.weixin.qq.com/s/CmUlEvucC…
zhuanlan.zhihu.com/p/165968167