位图Bitmap以及RoaringBitmap

简介

位图(Bitmap)算法,即用位(Bit)来表示数据的状态。如果一个Bit能表示一个数据的两种状态(0/1),那64个bit就能表示64个数据的状态。因此我们能很容易联想到一种场景:如果要表示某一个数字存不存在,那刚好就能用0或1的状态来表示。那具体怎么做呢?举个场景例子,比如某个学校想统计最近3天内有多少同学做了核酸,有一部分同学在这3天有可能被做过好几次核酸(再假设该学校有1万名学生,学号从1-10000)。这时候我们可以用Bitmap来统计,我们给定一个长度为10000的位图,然后用每一位(Bit)代表一名学生,如果该名学生被做过核酸,则该位(Bit)上置为1:

如上图我们标黄的,则是状态置为1的位(Bit),因此学号1,5,9999的学生都做过核酸。我们只需要统计二进制表示有多少个1就可以了,具体计算方法可以参考下我之前写的文章。此时可能有同学就能想到了,这种场景用HashMap或者HashSet同样能计算出来。确实没错,但是用位图的方式,10000个数字用10000个Bit(约1K空间)即可搞定,如果用Integer数组或者HashMap,则需要差不多40K空间。并且如果这里数字的个数不是1万,而是100万甚至1亿,10亿呢,则空间差距会更加明显。

当然位图不是没有缺点,如果想知道某一个学生具体被做了几次核酸,这是办不到的,因为位图天然有去重的特性;另外假设极端情况,全校只有一个同学被做了核酸,那这1万个Bit中,只有一个Bit被赋值,其余空间全浪费了,因为位图它固定占用了1K的空间。

现在我们尝试来用代码实现这个需求。

BitSet

JDK中有位图的默认实现,就是BitSet

public class BitSet implements Cloneable, java.io.Serializable {
    // 地址位,一个word单位的地址位
    private final static int ADDRESS_BITS_PER_WORD = 6;
    // 一个word有多少位。这里即 64
    private final static int BITS_PER_WORD = 1 << ADDRESS_BITS_PER_WORD;
    // bit索引位置掩码
    private final static int BIT_INDEX_MASK = BITS_PER_WORD - 1;
​
    private static final long WORD_MASK = 0xffffffffffffffffL;
​
    // 实际存储状态的位,由long数组实现
    private long[] words;
  
    // 通过右移来定位当前”位“在哪个word中。
    private static int wordIndex(int bitIndex) {
        return bitIndex >> ADDRESS_BITS_PER_WORD;
    }
    .......
  }

我们可以看出来,BitSet中以word为单位来存储位信息,一个word即64位,存储64位状态信息,也就是一个long的大小。

我们用BitSet直接来实现:

public static void main(String[] args) {
        BitSet fl = new BitSet();
        fl.set(1);
        fl.set(5);
        fl.set(9999);
        System.out.println("已做人数:" + fl.cardinality());
}

其中的set方法就是将该索引位设置为1,最后的cardinality方法统计位图中1的个数。下面我们看下这两个方法的实现:

public void set(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
        // 判断当前的bitIndex位于哪个word中
        int wordIndex = wordIndex(bitIndex);
        // 如果当前的位数不够存储,则扩展words数组。
        expandTo(wordIndex);
        // 将对应的”位“状态置为1
        words[wordIndex] |= (1L << bitIndex); // Restores invariants
        checkInvariants();
 }

第一步便是判断当前bitIndex位于哪个word中,其实就是用bitIndex/64来判断。如果实际存储数据的 long[] words长度不够,则扩展数组的长度。因此1和5存储之后,实际words的长度还只是1,但是当9999存储进来之后,就不得不扩展数组长度,因为它的wordIndex是9999/64(即:156),扩展之后整个words的长度为157。最后就是将对应的word中的某一bit置为1(即左移,然后或运算),其中9999超过了long的最大位数,因此实际上它左移的位数为9999%64,即15。最终我们得到words数组存储的具体值如下图:

因此虽然只需要存储三个值,但是由于9999导致了数组的扩展,从而其余的空间全部浪费。我们可以得出如下结论:对于随机的稀疏数据,BitSet会有较大的空间浪费。

最后就是cardinality方法:

public int cardinality() {
    int sum = 0;
    for (int i = 0; i < wordsInUse; i++)
        sum += Long.bitCount(words[i]);
    return sum;
}

相对就比较简单了,统计位图中1的个数,就是直接统计每个word中1的个数,然后做累加操作即可,这里不再赘述。对于BitSet源码这里也是点到即止,整体的BitSet源码有兴趣同学可以自己查阅。

RoaringBitmap

从上面我们看到了BitSet来实现位图的缺点,对于随机稀疏的数据,空间上有比较大的浪费。因此就有很多压缩算法的Bitmap应运而生,RoaringBitmap就是其中之一。附上Github传送

RoaringBitmap的核心思想便是用一个Container数组来存储状态信息。这个Container与上述的word有点类似。对于一个Integer数字来说,那怎么去定位它处于哪一个Container之中呢?RoaringBitmap的算法思想是利用Interger的高16位来定位它在Container的索引位置,最后将这个数字的低16位存储在Container中。因此这样的话,我们最多有2^16次方,即65536个Container,也就是说RoaringBitmap中实际存储信息的Container数组最大长度为65535,每个Container又可以容纳65536个数据。

我们现在也来尝试用RoaringBitmap来实现上面统计人数的场景:

RoaringBitmap roaringBitmap = new RoaringBitmap();
 roaringBitmap.add(1);
 roaringBitmap.add(5);
 roaringBitmap.add(9999);
 System.out.println("已做人数: " + roaringBitmap.getCardinality());

代码也非常简单,跟BitSet使用非常类似,这里也不再赘述。那它会不会也像BitSet那样浪费空间呢?我们看下它的add实现:

// 存储信息的Container数组
RoaringArray highLowContainer = null;
​
public void add(final int x) {
  // 取高16位信息
  final char hb = Util.highbits(x);
  // 根据高16位信息,定位container索引位置
  final int i = highLowContainer.getIndex(hb);
  // i大于等于0,则直接获取对应的container,将低16位信息保存到container中
  if (i >= 0) {
    highLowContainer.setContainerAtIndex(i,
        highLowContainer.getContainerAtIndex(i).add(Util.lowbits(x)));
  } else {
    // i小于0,说明对应的container没有被创建,则先创建container,再将低16位信息保存到container中。
    final ArrayContainer newac = new ArrayContainer();
    highLowContainer.insertNewKeyValueAt(-i - 1, hb, newac.add(Util.lowbits(x)));
  }
}
​
public final class RoaringArray implements Cloneable, Externalizable, AppendableStorage<Container> {
  ......
  static final int INITIAL_CAPACITY = 4;
  // Container对应的高16位Key数组。注意,这里的keys是排序好的数组。
  char[] keys = null;
  // container数组。
  Container[] values = null;  
  ......
}  

注释已经说明了比较详细。其中通过高16位来定位到Container索引位置,数字1、5、9999的高16位都是0,因此也对应了第0个索引位置的Container。如果某几个数字的高16位是1000、2000、10000,那会不会需要扩展数组到10000呢?答案是否定的,Container数组只需要存储实际的key值,然后按照顺序存储,因此如果高16位(Key值)为0、1000、2000、10000这四个数字时,Container数组的Keys存储为: [0,1000,2000,10000]。当有新的key值进入时(比如100),它会通过二分查找来锁定合适的索引位置,即插入之后:[0,100,1000,2000,10000]。

代码中返回的 i 就是对应的Container索引值。区别就是:如果Container已经存在便是正值,如果Container还没创建则是负值。负值情况下则会先创建一个ArrayContainer来存储数据。那问题来了,位图算法为什么要用Array来存储值呢?要回答这个问题,我们需要看下ArrayContainer保存信息的实现方式:

public final class ArrayContainer extends Container implements Cloneable {
  // 数组默认初始化大小
  private static final int DEFAULT_INIT_SIZE = 4;
  private static final int ARRAY_LAZY_LOWERBOUND = 1024;
  static final int DEFAULT_MAX_SIZE = 4096;// containers with DEFAULT_MAX_SZE or less integers
                                           // should be ArrayContainers
  // 数组中实际存储数据个数的一个统计值
  protected int cardinality = 0;
  // 存储低16位数据的数组
  char[] content;
  
  public Container add(final char x) {
    if (cardinality == 0 || (cardinality > 0
            && (x) > (content[cardinality - 1]))) {
      // 数据按顺序存储,如果当前存储数据超过4096,则转换为BitmapContainer存储数据。
      if (cardinality >= DEFAULT_MAX_SIZE) {
        return toBitmapContainer().add(x);
      }
      if (cardinality >= this.content.length) {
        // 扩展数组容量,来支持新的插入
        increaseCapacity();
      }
      content[cardinality++] = x;
    } else {
      // 由于数据介于content数组的中最大值和最小值之间,因此通过二分查找来找到合适的索引位置
      int loc = Util.unsignedBinarySearch(content, 0, cardinality, x);
      // loc大于0,则数组中已经存在该值,不做处理,如果不存在则loc小于0,于是插入该值。
      if (loc < 0) {
        // 
        if (cardinality >= DEFAULT_MAX_SIZE) {
          return toBitmapContainer().add(x);
        }
        if (cardinality >= this.content.length) {
          increaseCapacity();
        }
        // 数组中大于x的值往右挪动,给x腾出位置来。
        System.arraycopy(content, -loc - 1, content, -loc, cardinality + loc + 1);
        content[-loc - 1] = x;
        ++cardinality;
      }
    }
    return this;
  }
}

我们看到,ArrayContainer完全就是用数组来存储信息,然后通过数组拷贝,二分查找等操作来维持这个数组有序。

我们这里能得出一个结论:对于高16位和低16位都是顺序存储,然后通过二分查找来定位索引位置。于是乎,对于一个新加入的数据,能够快速的定位它存不存在,以及需要被存储在什么位置。

有一个点需要注意,当数组存储的数据超过4096之后,则转换为BitmapContainer存储数据。这里虽然终于看到了Bitmap,但为什么是4096呢?我们假设ArrayContainer刚好存满了4096个数据,那此时它大约占用多少空间呢?char占用2个字节,那总共占用空间是4096*2=8K。如果我们用位图,存满所有65536个数据,需要1024个long数组,大约占用1024*8=8K空间。所以,当数据超过4096时,转换成位图存储,这样就能避免空间的浪费。数组可以用来存储稀疏数据,当数据量超过阈值时,用位图来存储稠密数据。

我们把BitmapContaineradd方法也放出来,如下所示:

final long[] bitmap = new long[1024]; 
@Override
public Container add(final char i) {
  final long previous = bitmap[i >>> 6];
  long newval = previous | (1L << i);
  bitmap[i >>> 6] = newval;
  if (USE_BRANCHLESS) {
    cardinality += (int)((previous ^ newval) >>> i);
  } else if (previous != newval) {
    ++cardinality;
  }
  return this;
}

这段代码是比较好理解的,跟BitSet非常相似。只不过用了前值和后值的对比,来判断需不需要对cardinality值加1。previous ^ newval异或之后是0,说明该值之前已经存在,否则 (previous ^ newval) >>> i 就是1。

RoaringBitmap中我们看到了两个关键的Container:ArrayContainer和BitmapContainer。在数据存储时,会做相应的转换。当然,RoaringBitmap还有一个ContainerRunContainer。它采用游程编码方式,来压缩连续出现的数字。如果需要保存的数据是比较连续的数据,则可以考虑使用该Container,它能带来极大的压缩效果,节省存储空间。如果数据随机无规则,最好不要用该Container,因为可能会给存储带来适得其反的效果。

总结

本文对位图算法,以及RoaringBitmap做了简单介绍,当然它们还有很多用法。当然位图最酷的用法,还是位图之间的And,Or,Xor,Andnot操作。如果把两个位图理解成两个不同的集合,则两个不同的位图做And运算,能得出两个位图中都存在的数据,即取交集操作;做Or即相当于做并集;做Andnot相当于取差集;做Xor相当于两个差集的并集