面试以及大数据处理中的常客BitMap到底是个什么样子的存在?

前言

相信大家肯定也都遇到过,在很多的面试中,尤其是5年以下java工作经验的面试中,经常会看到如下的面试题:

  • 在2.5亿个整数中找出不重复的整数,内存不足以容纳这2.5亿个整数。

  • 给40亿个不重复的int型整数(32位),乱序,然后再给一个数,如何快速判断这个数是否在那40亿个数当中?

笔者也曾经遇到过,而且很长一段时间内都不知道这种题到底考点是什么,但是那时候年少无知,过了也就过了也没去仔细研究,直到后来开始研究大数据,接触到了今天文章的主角BitMap以及BitMap的衍生技术BloomFilter,才恍然大悟,原来当初的面试题的考点竟然就是位图(BitMap)。

由于现在已经进入了大数据时代,数据量越来越大,所以BitMap以及BitMap的衍生技术BloomFilter的适用场景越来越广阔,所以在当前的技术场景下,BitMap还是值得好好学一下的,所以接下来的两篇文章会分别来介绍下BitMap以及BloomFilter的原理以及应用。

正文

什么是位图

BitMap的基本思想就是用一个bit位来标记某个元素对应的Value。而此Bit位即是对应的元素,value可以使用位的值(0或者1)来进行表示。由于采用了Bit为单位来存储数据,因此在存储空间方面,可以大大节省。

回到前文的面试题,在40亿个随机整数中找出某个数m是否存在其中,4G内存限制

在Java中,int占4字节,1字节=8位(1 byte = 8 bit)

如果每个数字用int存储,那就是40亿个int,因而占用的空间约为  (4000000000*4/1024/1024/1024)≈14.9G

如果按位存储就不一样了,40亿个数就是40亿位,占用空间约为  (4000000000/8/1024/1024/1024)≈0.466G

32字节的int被存为1个Bit,内存消耗变为之前的1/32,简直爽的一比啊。

位图的实现

回顾下上面的两个面试题,这也就代表了位图的两个最主要的适用场景:正整数的快速排序以及判断是否存在;正整数的去重以及计数。

下面就从这两个方面来说明下位图的实现,最后再贴上我自己实现的Demo代码,辅助大家理解位图的实现过程。由于笔者水平有限,仅仅提供Java相关的实现,感兴趣的小伙伴可以使用其他语言实现,逻辑是一样的。

快速排序以及判断元素是否存在

在这种场景下,使用一个bit位来表示数值是否存在,这也是位图效率最高的一种场景,可以达到原始数据1/32的内存消耗。

详细的流程如下:该bit位是0表示不存在,1表示存在;遍历一遍所有的数据,每个数字的值对应bit位相应的顺序,遍历到了一个值,就将该bit位设置为1即可,那么最后所有为1的Bit位的顺序即是所有排序完毕的整数,一次遍历就搞定,时间复杂度O(n)。判断正整数是否存在直接去正整数位置上的Bit位,1就是存在;0就是不存在,时间复杂度O(1),是不是很快很完美?

下面举个栗子,如果我们想快速排序{6,8,2,5,4},根据位图该怎么做呢?根据上面详细见下图:

图片

是不是很简单易懂?完美!看到这里有的小伙伴可能会提出问题,按顺序取数据没问题,如果数据量太大,Bit位岂不是要很长,这时候去固定位置取数据的效率也会受到影响,能不能优化?必须能!方案如下:

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

tmp[0]:可以表示1~32

tmp[1]:可以表示33~64

tmp[2]:可以表示65~96

这样在查询数据时,先用该数据除了32得到数据的下标,然后使用数据除32取余,得到bit位的下标。

存储的问题解决后,剩下就是读写数据的问题了,在这个场景下,主要是增加数据,删除数据以及查询数据,实现流程如下:

根据上文得到的数组下标(array_index)以及bit位下标(bit_index)来进行位运算:例如数字5,则array_index=5/32 = 0;bit_index=(5%32)-1 = 4;下面开始操作:

新增数据

tmp[0] = tmp[0] | 1 << (31 - 5)

计算过程如下:

  • 根据array_index找到数组中对应的数值,将1右移到bit_index的位置后与数组中对应的值按位取或

  • 任何数值和1取或结果都是1,当前位数变成1;其他位数和0取或,结果都不受影响。计算公式:

00001000....0000

00001000....0000

—————————

00001000....0000

删除数据

tmp[0] = tmp[0] & ~(1 << (31 - 5))

计算过程如下:

  • 根据array_index找到数组中对应的数值,将1右移到bit_index的位置后取反,与按数组中对应的值按位取与

  • 任何数值和0取与结果都是0,当前位数变成0,而其他位数和1取与结果为1,结果不受影响。计算公式:

00001000....0000

11110111....1111

—————————

00000000....0000

查找数据

(tmp[0] & (1 << (31 - 5))) != 0 ? true : false

计算过程如下:

  • 根据array_index找到数组中对应的数值,将1右移到bit_index的位置后与数组中对应的值按位取与

  • 判断上面的结果,如果不等于0,说明该数据存在;反之如果为0,则该数据不存在。计算公式:

00000000....0000

00001000....0000

—————————

00000000....0000

最后留下一段笔者实现的Demo帮助大家理解:

public class BitMapTest {

  /**
   * 位图的存储数组
   */
  private static int[] bitArray;
  /**
   * 位图的数组下标
   */
  private int arrayIndex;
  /**
   * 位图的bit位下标
   */
  private int bitIndex;

  /**
   * 根据数值范围实例化类
   *
   * @param size
   */
  public BitMapTest(int size) {
    bitArray = new int[(size >> 5) + 1];
  }

  /**
   * 根据实际数据给位图的数据下标以及bit位下标赋值
   *
   * @param index
   */
  private void setIndex(int index) {
    arrayIndex = index % 32 == 0 ? (index >> 5) - 1 : (index >> 5);
    bitIndex = index % 32 == 0 ? 31 : index % 32 - 1;
  }

  /**
   * 设置指定数值存在(插入)
   *
   * @param index
   */
  public void set1(int index) {
    setIndex(index);
    bitArray[arrayIndex] |= 1 << (31 - bitIndex);
  }

  /**
   * 设置指定数值不存在(删除)
   *
   * @param index
   */
  public void set0(int index) {
    setIndex(index);
    bitArray[arrayIndex] &= ~(1 << (31 - bitIndex));
  }

  /**
   * 指定数值是否存在(查询)
   *
   * @param index
   * @return
   */
  public boolean exists(int index) {
    setIndex(index);
    return (bitArray[arrayIndex] & (1 << (31 - bitIndex))) != 0 ? true : false;
  }

  public static void main(String[] args) {
    BitMapTest test = new BitMapTest(1000);
    test.set1(288);
    test.set1(64);
    test.set1(63);
    test.set0(128);
    System.out.println(test.exists(64));
    System.out.println(test.exists(63));
    System.out.println(test.exists(128));
  }
}

正整数的去重以及计数

在这种场景下,使用两个bit位来表示数值是否存在,可以达到原始数据1/16的内存消耗,效率也是很高的

详细的流程如下:顺序仍对应的是我们的key,value分下面几种情况:00,01,10,11,分别代表0次,1次,2次和多次。数据录入仍旧是一次遍历就搞定,时间复杂度O(n)。如果数字重复,则将对应的两位bit加1即可,如果超过2次即两位bit变成11后不再增加。可以看出遍历完成后去重即完成。判断正整数出现的次数直接去正整数位置上的两个Bit位,根据上面value的集中情况就可以直接得到统计的次数,时间复杂度O(1),是不是很快很完美?

下面举个栗子,如果我们想去重以及计数{3,5,2,3,5,7,3,3},根据位图该怎么做呢?根据上面详细见下图:

图片

数组优化和扩展的方式和上面的一样,这里就不再赘述了。

存储的问题解决后,剩下就是读写数据的问题了,在这个场景下,主要是计数以及取数了,实现流程如下:

根据上文得到的数组下标(array_index)以及bit位下标(bit_index)来进行位运算:例如数字5,则array_index=5/16 = 0;bit_index=(5%16)-1 = 4;下面开始操作:

统计数据:由于流程有分支,此处不写明代码,后续通过Demo中的代码帮助大家理解。

计算过程如下:

  • 根据array_index以及bit_index找到数组中对应的数值,然后在找到对应的2个Bit。

  • 如果两位Bit为'11',则直接返回说明已经超过2次;否则将两位Bit加1得到新的结果。计算公式:

000000000100....

000000000100....

—————————

000000001000....(结果是数字5从1次变成了2次)

取得数据:由于流程有分支,此处不写明代码,后续通过Demo中的代码帮助大家理解。

计算过程如下:

  • 根据array_index以及bit_index找到数组中对应的数值,然后在找到对应的2个Bit。

  • 如果这两位Bit是'11',则表明该正整数出现多次;其余的三种可能性为:'10'出现两次,'01'出现1次,'00'未出现。计算公式:

000000001000....对应的就是5出现两次

最后留下一段笔者实现的Demo帮助大家理解:

public class BitMap4CntTest {

  /**
   * 位图的存储数组
   */
  private static int[] bitArray;
  /**
   * 位图的数组下标
   */
  private int arrayIndex;
  /**
   * 位图的bit位下标
   */
  private int bitIndex;

  /**
   * 根据数值范围实例化类
   *
   * @param size
   */
  public BitMap4CntTest(int size) {
    bitArray = new int[(size >> 4) + 1];
  }

  /**
   * 根据实际数据给位图的数据下标以及bit位下标赋值
   *
   * @param index
   */
  private void setIndex(int index) {
    arrayIndex = index % 16 == 0 ? (index >> 4) - 1 : index >> 4;
    bitIndex = index % 16 == 0 ? 15 : index % 16 - 1;
  }

  /**
   * 位图计数加1
   *
   * @param index
   */
  public void add1(int index) {
    int cnt = getCnt(index);
    switch (cnt) {
      case 0:
        bitArray[arrayIndex] |= 1 << (31 - bitIndex * 2 - 1);
        break;
      case 1:
        bitArray[arrayIndex] &= ~(1 << (31 - bitIndex * 2 - 1));
        bitArray[arrayIndex] |= 1 << (31 - bitIndex * 2);
        break;
      case 2:
        bitArray[arrayIndex] |= 1 << (31 - bitIndex * 2 - 1);
        break;
      default:
        break;
    }
  }

  /**
   * 获取元素数量,数量的取值范围是:0个,1个,2个以及多个
   *
   * @param index
   * @return
   */
  public int getCnt(int index) {
    int result = 0;
    setIndex(index);
    String str = toFullBinaryString(bitArray[arrayIndex]);
    String substring = str.substring(bitIndex * 2, bitIndex * 2 + 2);
    switch (substring) {
      case "00":
        result = 0;
        break;
      case "01":
        result = 1;
        break;
      case "10":
        result = 2;
        break;
      case "11":
        result = 3;
        break;
      default:
        break;
    }
    return result;
  }

  /**
   * 将整数num转化为32位的二进制字符串
   *
   * @param num
   * @return
   */
  public String toFullBinaryString(int num) {
    char[] chs = new char[Integer.SIZE];
    for (int i = 0; i < Integer.SIZE; i++) {
      chs[Integer.SIZE - 1 - i] = (char) (((num >> i) & 1) + '0');
    }
    return new String(chs);
  }

  public static void main(String[] args) {
    BitMap4CntTest test = new BitMap4CntTest(1000);
    test.add1(111);
    test.add1(111);
    test.add1(111);
    test.add1(111);
    test.add1(112);
    test.add1(113);
    test.add1(113);
    test.add1(113);
    test.add1(114);
    test.add1(114);
    System.out.println(test.getCnt(111));
    System.out.println(test.getCnt(112));
    System.out.println(test.getCnt(113));
    System.out.println(test.getCnt(114));
  }
}

总结

以上就是本文对位图的详细讲解。如今的位图除了在高性能的服务端编程中被广泛利用,也在很多大数据组件中被反复实践。

如redis提供了类似的命令,最大可以存放2的32次方,即21亿多的数字,主要有以下几个:SETBIT, GETBIT, BITCOUNT, BITOP, BITPOS,BITFIELD等命令,主要用来做活跃用户在线状态、活跃用户统计、用户签到等场景。

另外位图的变种BloomFilter则在位图的基础上发扬光大,在很多NOSQL数据库中起到了索引判空以及加速查询的作用,下一篇文章会详细介绍。

另外章的学习,能够帮助大家在后续解决类似问题的时候有一个思路,面试的时候遇到这类题时也能做到了然于胸,那这篇文章就发挥出自己的价值了。

最后,笔者长期关注大数据通用技术,通用原理以及NOSQL数据库的技术架构以及使用。如果大家感觉笔者写的还不错,麻烦大家多多点赞和分享转发,也许你的朋友也喜欢。

最后挂个公众号二维码,欢迎大家关注,谢谢大家支持。

图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值