BitMap详解

2,838 阅读15分钟

BitMap是最常见的大数据算法,既然是说大数据算法,我们先尝试给大数据算法一个定义,或者说是限定一下这个系列的范围。

大数据算法:在给定的资源约束下,以大数据为输入,在给定时间约束内可以计算出给定问题加过的算法。

大数据算法会有传统的算法有不一样的地方:

  1. 资源有约束
  2. 时间有约束
  3. 大数据作为输入
  4. 不一定是精确算法

前三点可以看作是对算法的要求,第四点可以看作是在大数据场景下算法可以做出的让步。比如说在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位)长的位图:

image.png 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命令的执行过程如下:

    1. 计算byte=⌊offset÷8⌋ (即>>3),byte 值表示指定的 offset 位于位数组的哪个字节(计算在第几行);
  1. 指定 buf[i]中的i了,接下来就要计算在8个字节中的第几位呢?使用 bit=(offset % 8)+1bit 计算可得;
  2. 根据 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);
}

image.png

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);
}

image.png

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]。

并以此类推,最终求出总和。

image.png

步骤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