java bitmap/bitvector的分析和應用

簡介

    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

發佈了29 篇原創文章 · 獲贊 18 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章