简介
bitmap在很多海量数据处理的情况下会用到。一些典型的情况包括数据过滤,数据位设置和统计等。 它的引入和应用通常是考虑到海量数据的情况下,用普通的数组会超出数据保存的范围。使用这种位图的方式虽然不能在根本上解决海量数据处理的问题,但是在一定的数据范围内,它是一种有效的方法。bitmap在java的类库里有一个对应的实现:BitSet。我们会对bitmap的引入做一个介绍,然后详细分析一个bitvector的精妙实现,并在后面和java中的BitSet实现做一个对比。在本文中对bitmap, bitvector不做区分,他们表达的是同一个意思。
bitmap的引出
假设我们有一个很大的数据集合,比如说是一组数字,它是保存在一个很大的文件中。它总体的个数为400个亿。里面有大量重复的数据,如果去除重复的元素之后,大概的数据有40个亿。那么,假定我们有一台内存为2GB的机器。我们该如何来消除其中重复的元素呢?再进一步考虑,如果我们消除了重复的元素之后,怎么统计里面元素的个数并将消重后的元素保存到另外的一个结果文件里呢?
我们先来做一个大致的估计。假定数字的范围都是从0到Integer.MAX_VALUE。如果我们开一个数组来保存的话,是否可行呢?一个int数字4个字节,要保存0到Integer.MAX_VALUE个数字,那么就需要2的31次方个,也就是说2G个元素。这么一相乘,除非有8GB的内存,否则根本就保存不下来这么多数据。
bitmap分析和应用
现在,如果我们换一种方式,用bitmap试试呢?bitmap它本质上也是一个数组,只是用数组中间对应的位来表示一个对应的数字。假设我们用byte数组。比如说数字1则对应数组第1个元素的第一位。数字9则超出了第一个元素的8位范围,它对应第二个元素的第一位。这样依次类推,我们可以将这40亿个元素映射到这个byte数组里。一个数字对应到数组中位的关系如下图所示:
在上图中,假设i是数组中的一个字节,那么它将对应有下面的8个位。假设i是第一个字节,那么数字1就对应到第1位,后面的元素依次类推。
通过这一番讨论,我们也可以很容易得到数字和保存在数组中元素具体位之间的关系。假设有一个数字i,它对应保存的元素位置为: i / 8。假设数组为a,那么则为a[i/8]。那么它对应到a[i/8]中间的哪个位呢?它对应这个元素中的第i % 8这一位。
有了这些讨论,我们再来看bitmap的一个具体实现。
bitmap的一个实现
针对前面讨论的部分,bitmap主要的功能包括有一下几个方面。1. 置位(set):将某一位置为1. 2. 清楚位(clear),清楚某一位,将其置为0. 3. 读取位(get),读取某一位的数据,看结果是1还是0. 4. 容器所能容纳的位个数(size),相当于返回容器的长度。5. 被置位的元素个数(count),返回所有被置为1的位的个数。我们就一个个来分析:
首先,我们要定义一个byte数组,来保存这些数据。另外,我们也需要元素来保存里面所有位的个数和被置位的元素个数。因此,我们有如下的定义:
- private byte[] bits;
- private int size;
- private int count = -1;
现在,假设我们要构造一个BitVector,我们就需要指定它的长度。它的一个构造函数可以构造成如下:
- public BitVector(int n) {
- size = n;
- bits = new byte[(size >> 3) + 1];
- }
这里,指定的参数n表示有多少个数字,相当于要置多少个位。由于我们要用byte来保存,所以能保存这么多数字的byte个数为n / 8 + 1。这种长度用移位的方式来表示则为(size >> 3) + 1。右移3位相当于除以8.
set
前面已经提到过,set某个位的元素,需要找到元素所在的byte,然后再设置byte对应的位。而n / 8得到的就是对应byte的索引,而n % 8得到的是对应byte中的位。这部分的代码实现如下:
- public final void set(int bit) {
- bits[bit >> 3] |= 1 << (bit & 7);
- count = -1;
- }
和我前面讨论的类似,这里不过是利用移位的方式实现同样的效果。前面bit >> 3相当于bit / 8。而bit & 7则相当于bit % 8。为什么bit & 7会相当于这个效果呢?在前面有一篇分析HashMap实现的文章里也讨论过这种手法。因为这里一个byte是8位,而8对应的二进制表示形式为1000,那么比它小1的7的二进制形式为0111。在将bit和7进行与运算的时候,所有大于第3位的高位都被置为0,之保留最低的3位。这样,最低的3位数字最小是0,最大是7.就相当于对数字8求模的运算效果。
clear
和前面的set方法相反,这里是需要将特定的位置为0。
- public final void clear(int bit) {
- bits[bit >> 3] &= ~(1 << (bit & 7));
- count = -1;
- }
get
get这部分的代码主要是判断这一位是否被置为1。我们将这个byte和对应位为1的数字求与运算,如果结果不是0,则表示它被置为1.
- public final boolean get(int bit) {
- return (bits[bit >> 3] & (1 << (bit & 7))) != 0;
- }
count
count方法的实现是一个比较精妙的手法。按照我们原来的理解,如果要计算里面所有被置为1的位的个数,我们需要遍历每个byte,然后求每个byte里面1的个数。一种想当然的办法就是每次和数字1移位的数字进行与运算,如果结果为0表示该位没有被置为1,否则表示该位有被置位。这种办法没问题,不过对于每个字节,都要这么走一轮的话,相当于前面运算量的8倍。如果我们可以优化一下的话,对于大数据来说还是有一定价值的。下面是另一种高效方法的实现,采用空间换时间的办法:
- public final int count() {
- // if the vector has been modified
- if (count == -1) {
- int c = 0;
- int end = bits.length;
- for (int i = 0; i < end; i++)
- c += BYTE_COUNTS[bits[i] & 0xFF]; // sum bits per byte
- count = c;
- }
- return count;
- }
- private static final byte[] BYTE_COUNTS = { // table of bits/byte
- 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
- };
这里建立了一个BYTE_COUNTS的数组。里面记录了对应一个数字1的个数。我们在bit[i] && 0xff运算之后得到的是一个8位的数字,范围从0到255.那么,问题就归结到找到对应数字的二进制表示里1的个数。比如说数字0有0个1, 1有1个1, 2有1个1,3有2个1...。在一个byte里面,最多有256种,如果我们将这256个数字对应的1个数都事先编码保存好的话,后面求这个数字对应的1个数只要直接取就可以了。
和BitSet的比较
前面我们讨论的bitmap的实现实际上是摘自开源软件lucene的代码片段。它采用byte数组来做为内部数据保存的方式。各种置位的操作和运算都采用二进制移位等运算方式来实现尽可能的高效率。在java内部的类库里,实际上也有一个类似的实现。那就是BitSet。
BitSet的内部实现和BitVector的实现稍微有点不一样,它内部是采用long[]数组来保存元素。这样,每次的置位和清位操作方式就有差别。比如说置位,原来是对要置的数字除以8,现在则是除以64,相当于>> 6这中移位6次的操作。
另外,在BigSet里并没有实现求所有被置为1的元素的个数,如果要求他们的话,因为要在64位的数字范围内来找,不可能再用前面数字列表的方法来加快其统计速度,只能一位一位的运算和比较统计了。这是这种实现一个不足的地方。
BitSet的内部代码实现还有一个比较有意思的地方,我们先看这一段代码:
- public void set(int bitIndex) {
- if (bitIndex < 0)
- throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
- int wordIndex = wordIndex(bitIndex);
- expandTo(wordIndex);
- words[wordIndex] |= (1L << bitIndex); // Restores invariants
- checkInvariants();
- }
- private static int wordIndex(int bitIndex) {
- return bitIndex >> ADDRESS_BITS_PER_WORD;
- }
这是java里对应的置位实现方法。按照我们的理解,它应该是找到对应的long元素,然后再将对64取模后对应的位设置为1.可是这代码里的设置部分却如下: words[wordIndex] |= (1L << bitIndex); // Restores invariants. 这里用到了移位,但是没有对64求模。为什么呢?这样不会出错吗?在我们的理解里,如果对数字向左移位,如果超出了数字的表示范围,潜意识里就会认为那些部分被忽略掉了。这样想的话,那么这么一通移位下来不就得到个0了吗?我们后面针对这一点继续分析。
一个有意思的地方
这个问题的答案并不复杂。如果我们去察看书上的定义,仔细看才发现。<< >>等这样的移位运算,实际上是循环移位效果的。也就是说,如果我一个数字向左移位到溢出了,它不是被忽略掉,而是后续会在低位继续补进。比如说我们看下面一个最简单的代码:
- class test
- {
- public static void main(String[] args)
- {
- for(int i = 0; i < 100; i++)
- System.out.println(1 << i);
- }
- }
如果我们执行上面这一段代码,会发现实际的结果是当溢出之后又开始重新从头来显示,部分的输出结果如下所示:
- 1
- 2
- 4
- 8
- 16
- 32
- 64
- 128
- 256
- 512
- 1024
- 2048
- 4096
- 8192
- 16384
- 32768
- 65536
- 131072
- 262144
- 524288
- 1048576
- 2097152
- 4194304
- 8388608
- 16777216
- 33554432
- 67108864
- 134217728
- 268435456
- 536870912
- 1073741824
- -2147483648
- 1
- 2
- 4
- 8
现在,我们也就理解了为什么前面直接用一个左移位的运算来表示。因为这是循环的移位,相当于已经实现了求模的运算效果了。老实说,这种方式可行,不过个人觉得不太直观,还是用一个类似于求模运算的方式来表示好一些。
总结
bitmap通过充分利用数组里面每一位的置位来表示数据的存在与否。比如说某一位设置为1,表示数据存在,否则表示不存在。通过充分利用数据的空间,它比直接利用一个数组,然后数组里面的每一个元素来表示一个数组的空间利用率高。比如说有一个同等长度的int数组,原来一个int元素用来表示一个数据,现在利用int元素的每一位,它可以表示32个元素。所以说,在一定程度上,某些数据映射、过滤等问题通过bitmap它可以处理的范围更大。当然,bitmap也受到计算机本身数据表示范围的限制,在超出一定的范围之后,我们还是需要考虑结合数据划分等手段。另外,在考虑这些数据结构的详细实现时,有很多细节的东西也会加深我们的认识,也许很多就是我们平时忽略的地方。
BitSet简介
类实现了一个按需增长的位向量。位 set 的每个组件都有一个boolean值。用非负的整数将BitSet的位编入索引。可以对每个编入索引的位进行测试、设置或者清除。通过逻辑与、逻辑或和逻辑异或操作,可以使用一个BitSet修改另一个BitSet的内容。
默认情况下,set 中所有位的初始值都是false。
每个位 set 都有一个当前大小,也就是该位 set 当前所用空间的位数。注意,这个大小与位 set 的实现有关,所以它可能随实现的不同而更改。位 set 的长度与位 set 的逻辑长度有关,并且是与实现无关而定义的。
除非另行说明,否则将 null 参数传递给BitSet中的任何方法都将导致NullPointerException。
在没有外部同步的情况下,多个线程操作一个BitSet是不安全的
基本原理
BitSet是位操作的对象,值只有0或1即false和true,内部维护了一个long数组,初始只有一个long,所以BitSet最小的size是64,当随着存储的元素越来越多,BitSet内部会动态扩充,最终内部是由N个long来存储,这些针对操作都是透明的。
用1位来表示一个数据是否出现过,0为没有出现过,1表示出现过。使用用的时候既可根据某一个是否为0表示,此数是否出现过。
一个1G的空间,有 8*1024*1024*1024=8.58*10^9bit,也就是可以表示85亿个不同的数
使用场景
常见的应用是那些需要对海量数据进行一些统计工作的时候,比如日志分析、用户数统计等等
如统计40亿个数据中没有出现的数据,将40亿个不同数据进行排序等。
现在有1千万个随机数,随机数的范围在1到1亿之间。现在要求写出一种算法,将1到1亿之间没有在随机数中的数求出来
package util;
import java.util.Arrays;
import java.util.BitSet;
public class BitSetDemo {
/**
* 求一个字符串包含的char
*
*/
public static void containChars(String str) {
BitSet used = new BitSet();
for (int i = 0; i < str.length(); i++)
used.set(str.charAt(i)); // set bit for char
StringBuilder sb = new StringBuilder();
sb.append("[");
int size = used.size();
System.out.println(size);
for (int i = 0; i < size; i++) {
if (used.get(i)) {
sb.append((char) i);
}
}
sb.append("]");
System.out.println(sb.toString());
}
/**
* 求素数 有无限个。一个大于1的自然数,如果除了1和它本身外,不能被其他自然数整除(除0以外)的数称之为素数(质数) 否则称为合数
*/
public static void computePrime() {
BitSet sieve = new BitSet(1024);
int size = sieve.size();
for (int i = 2; i < size; i++)
sieve.set(i);
int finalBit = (int) Math.sqrt(sieve.size());
for (int i = 2; i < finalBit; i++)
if (sieve.get(i))
for (int j = 2 * i; j < size; j += i)
sieve.clear(j);
int counter = 0;
for (int i = 1; i < size; i++) {
if (sieve.get(i)) {
System.out.printf("%5d", i);
if (++counter % 15 == 0)
System.out.println();
}
}
System.out.println();
}
/**
* 进行数字排序
*/
public static void sortArray() {
int[] array = new int[] { 423, 700, 9999, 2323, 356, 6400, 1,2,3,2,2,2,2 };
BitSet bitSet = new BitSet(2 << 13);
// 虽然可以自动扩容,但尽量在构造时指定估算大小,默认为64
System.out.println("BitSet size: " + bitSet.size());
for (int i = 0; i < array.length; i++) {
bitSet.set(array[i]);
}
//剔除重复数字后的元素个数
int bitLen=bitSet.cardinality();
//进行排序,即把bit为true的元素复制到另一个数组
int[] orderedArray = new int[bitLen];
int k = 0;
for (int i = bitSet.nextSetBit(0); i >= 0; i = bitSet.nextSetBit(i + 1)) {
orderedArray[k++] = i;
}
System.out.println("After ordering: ");
for (int i = 0; i < bitLen; i++) {
System.out.print(orderedArray[i] + "\t");
}
System.out.println("iterate over the true bits in a BitSet");
//或直接迭代BitSet中bit为true的元素iterate over the true bits in a BitSet
for (int i = bitSet.nextSetBit(0); i >= 0; i = bitSet.nextSetBit(i + 1)) {
System.out.print(i+"\t");
}
System.out.println("---------------------------");
}
/**
* 将BitSet对象转化为ByteArray
* @param bitSet
* @return
*/
public static byte[] bitSet2ByteArray(BitSet bitSet) {
byte[] bytes = new byte[bitSet.size() / 8];
for (int i = 0; i < bitSet.size(); i++) {
int index = i / 8;
int offset = 7 - i % 8;
bytes[index] |= (bitSet.get(i) ? 1 : 0) << offset;
}
return bytes;
}
/**
* 将ByteArray对象转化为BitSet
* @param bytes
* @return
*/
public static BitSet byteArray2BitSet(byte[] bytes) {
BitSet bitSet = new BitSet(bytes.length * 8);
int index = 0;
for (int i = 0; i < bytes.length; i++) {
for (int j = 7; j >= 0; j--) {
bitSet.set(index++, (bytes[i] & (1 << j)) >> j == 1 ? true
: false);
}
}
return bitSet;
}
/**
* 简单使用示例
*/
public static void simpleExample() {
String names[] = { "Java", "Source", "and", "Support" };
BitSet bits = new BitSet();
for (int i = 0, n = names.length; i < n; i++) {
if ((names[i].length() % 2) == 0) {
bits.set(i);
}
}
System.out.println(bits);
System.out.println("Size : " + bits.size());
System.out.println("Length: " + bits.length());
for (int i = 0, n = names.length; i < n; i++) {
if (!bits.get(i)) {
System.out.println(names[i] + " is odd");
}
}
BitSet bites = new BitSet();
bites.set(0);
bites.set(1);
bites.set(2);
bites.set(3);
bites.andNot(bits);
System.out.println(bites);
}
public static void main(String args[]) {
//BitSet使用示例
BitSetDemo.containChars("How do you do? 你好呀");
BitSetDemo.computePrime();
BitSetDemo.sortArray();
BitSetDemo.simpleExample();
//BitSet与Byte数组互转示例
BitSet bitSet = new BitSet();
bitSet.set(3, true);
bitSet.set(98, true);
System.out.println(bitSet.size()+","+bitSet.cardinality());
//将BitSet对象转成byte数组
byte[] bytes = BitSetDemo.bitSet2ByteArray(bitSet);
System.out.println(Arrays.toString(bytes));
//在将byte数组转回来
bitSet = BitSetDemo.byteArray2BitSet(bytes);
System.out.println(bitSet.size()+","+bitSet.cardinality());
System.out.println(bitSet.get(3));
System.out.println(bitSet.get(98));
for (int i = bitSet.nextSetBit(0); i >= 0; i = bitSet.nextSetBit(i + 1)) {
System.out.print(i+"\t");
}