前言
相信大家肯定也都遇到过,在很多的面试中,尤其是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数据库的技术架构以及使用。如果大家感觉笔者写的还不错,麻烦大家多多点赞和分享转发,也许你的朋友也喜欢。
最后挂个公众号二维码,欢迎大家关注,谢谢大家支持。