java bitmap/bitvector的分析和应用

本文深入探讨位图(Bitmap)原理及其实现,特别是BitVector的精妙实现,并与Java中的BitSet进行对比分析。

简介

    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也受到计算机本身数据表示范围的限制,在超出一定的范围之后,我们还是需要考虑结合数据划分等手段。另外,在考虑这些数据结构的详细实现时,有很多细节的东西也会加深我们的认识,也许很多就是我们平时忽略的地方。

 

参考资料

http://alvinalexander.com/java/jwarehouse/lucene-1.3-final/src/java/org/apache/lucene/util/BitVector.java.shtml

http://docs.oracle.com/javase/7/docs/api/java/util/BitSet.html

引用地址:http://shmilyaw-hotmail-com.iteye.com/blog/1741608

<think>我们正在比较Java中的BitmapIntMap数据结构。根据提供的引用,我们可以总结如下: Bitmap(位图)是一种用于高效存储操作位(布尔值)的数据结构。它通常用于处理大量整数的集合,特别是当整数范围相对集中时。Bitmap通过每个位表示一个整数是否存在,从而节省空间。 IntMap从引用中看似乎是一种使用整型数组实现的Bitmap,但具体实现可能有所不同。引用[3]提到BitMap是一个接口,而IntMapLongMap是其实现。引用[3]中BitMap接口定义如下: ```java public interface BitMap { int MACHINE32 = 32; int MACHINE64 = 64; void add(long var1); boolean contains(long var1); void remove(long var1); } ``` 而IntMapLongMap是实现了该接口的具体类。 根据引用[4],Bitmap(这里可能指的是IntMap实现)使用一个int数组(ints)来存储位。每个int可以表示32位(因为MACHINE32=32),所以对于给定的整数i,它被分解为: - r = i / 32 (数组索引) - c = i % 32 (在该int中的位偏移) 添加元素i:将ints[r]的第c位设置为1。 判断元素i是否存在:检查ints[r]的第c位是否为1。 移除元素i:将ints[r]的第c位设置为0。 因此,我们可以认为IntMap是Bitmap的一种具体实现,它使用int数组来存储位。 但是,请注意引用[1]中提到了BitMap应用,包括快速排序、快速去重快速查找。这是因为位图可以非常高效地表示一个整数集合,并支持快速的集合操作(如并集、交集)。 区别与比较: 1. 概念层面:Bitmap是一种抽象的概念,即使用位来表示集合的数据结构。而IntMap是Bitmap的一种具体实现(使用int数组)。 2. 存储:Bitmap通常使用基本类型的数组(如int数组或long数组)来存储位。IntMap使用int数组,而LongMap可能使用long数组(每个long可以存储64位)。 3. 存储密度:Bitmap非常节省空间。例如,要存储0到9999之间的整数,使用位图只需要大约10000/8=1250字节,而使用HashSet<Integer>则需要更多的空间(因为每个Integer对象指针的开销)。 4. 操作效率:Bitmap的添加、删除查找操作都是O(1)的(通过位运算)。而IntMap作为Bitmap的一种实现,同样具有这些特性。 5. 应用场景:Bitmap适用于整数集合的紧凑表示快速集合运算。它常用于去重、排序(如位图排序)、布隆过滤器等。而IntMapLongMap是Bitmap的两种实现,分别针对32位64位机器(或处理32位64位数据)进行了优化。 总结: - Bitmap是一种设计思想(位级存储),而IntMap是Bitmap的一种具体实现(使用int数组)。 - 在Java中,我们可以通过实现BitMap接口来定义不同的位图实现,如IntMap(32位)LongMap(64位)。 - Bitmap(包括IntMap)在空间效率某些操作(如集合成员检查)上具有优势,但仅限于整数(或可以映射为整数的对象)且范围相对集中的情况。 相关问题: 1. BitMap在去重排序中的具体实现原理是什么? 2. 除了IntMapLongMap,还有哪些常见的Bitmap实现? 3. BitMap在处理大规模数据时有什么局限性?</think>### JavaBitmap与IntMap的比较 #### 1. **基本概念** - **Bitmap(位图)** 通过位运算存储布尔值集合的数据结构,每个位表示一个元素是否存在(0/1)。 示例:存储数字`[3,5,7]`时,位图表示为`00101010`(从右向左,第3/5/7位为1)[^1][^4]。 - **IntMap** 是Bitmap的一种具体实现(基于`int[]`数组),每个`int`存储32位(`MACHINE32=32`)。 核心操作: ```java // 添加元素 void add(long i) { int r = (int)(i / 32); // 数组下标 int c = (int)(i % 32); // 位偏移 ints[r] |= (1 << c); // 置位 } // 检查元素 boolean contains(long i) { return (ints[r] & (1 << c)) != 0; } ```[^3][^4] #### 2. **核心区别** | **特性** | **Bitmap(接口)** | **IntMap(实现类)** | |----------------|----------------------------|---------------------------| | **层级** | 抽象接口(定义操作规范) | 具体实现类(基于`int[]`) | | **存储单元** | 未限定(可32/64位) | 固定使用32位`int`存储 | | **空间效率** | 高(1位/元素) | 同Bitmap | | **扩展性** | 支持多实现(如`LongMap`) | 仅32位场景 | #### 3. **性能与应用场景** - **共同优势** - **低内存消耗**:存储100万整数仅需125KB(对比`HashSet`约40MB)[^1]。 - **O(1)操作**:添加/删除/查询均为位运算,效率极高[^4]。 - **适用场景**: - 海量整数去重(如用户ID过滤) - 快速排序(位图排序)[^1] - 布隆过滤器(快速存在性检查)[^3] - **IntMap的局限性** 仅适合32位整数范围($0$ 到 $2^{32}-1$),更大范围需`LongMap`(基于`long[]`,64位/单元)[^3]。 #### 4. **典型实现对比** ```java // BitMap接口(通用规范) public interface BitMap { void add(long i); boolean contains(long i); void remove(long i); } // IntMap实现(32位专用) public class IntMap implements BitMap { private int[] ints; public void add(long i) { int r = (int)(i / 32); ints[r] |= (1 << (i % 32)); } } ```[^3][^4] #### 5. **选择建议** - 优先用**Bitmap接口**:保证代码可扩展性(后续可切换`LongMap`)。 - **IntMap适用场景**: - 数据范围在 $0$ 到 $4.2 \times 10^9$ 内 - 内存敏感型应用(如嵌入式系统) - **避免场景**: - 稀疏数据(空间利用率低) - 非整数映射(需额外哈希函数) --- ### 相关问题 1. Bitmap如何实现**千万级数据去重**?空间复杂度是多少? 2. 为什么位图排序(Bitmap Sort)的时间复杂度是 $O(n)$?有哪些限制条件? 3. 如何处理超出$2^{32}$范围的整数集合?`LongMap`与`IntMap`的设计差异是什么? 4. Bitmap在**布隆过滤器**中的作用机制是怎样的?如何降低误判率? [^1]: Java位集合之BitMap,实现快速排序、去重、查找。 [^3]: BitMap接口定义与IntMap/LongMap实现。 [^4]: BitMap操作原理与IntMap源码分析
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值