BitMap是一種很常用的資料結構,它的思想的和原理是很多演算法的基礎,當然,並且在索引,資料壓縮,海量資料處理等方面有廣泛應用。
一、簡介
BitMap 是一種很常用的資料結構,它的思想和原理是很多演算法的基礎,比如Bloom Filter 。
BitMap 的基本原理就是用一個 bit 位來存放某種狀態(如果理解不了,看完下文再回頭來看即可),適用於擁有大規模資料,但資料狀態又不是很多的情況。通常是用來判斷某個資料存不存在的。
它最大的一個特點就是對記憶體的佔用極小,所以經常在大資料中被優化使用。
為什麼說佔用記憶體小呢?其實從名字就可以看出端倪,直譯過來叫點陣圖,但不是圖形學裡面的點陣圖哦,關鍵單詞是Bit。比如通過某種方法用一個 bit 來表示一個 int,這樣的話記憶體足足壓縮至 1/32(1 int = 4 byte = 32 bit,PS:理論計算而已,實操時並不會有 1/32 這麼誇張,下文會解釋),所以原先需要8G記憶體的資料,現在只需要256M,豈不樂哉?當然了,其中演算法的一些概念在下文會詳解。
二、初窺 BitMap
1、概念理解
所謂的 BitMap 就是用一個 Bit 位來標記某個元素對應的 Value, 而 Key 即是該元素。由於採用了 Bit 為單位來儲存資料,因此在儲存空間方面,可以大大節省。
比如有個 int 陣列 [2,6,1,7,3],內含5個元素,儲存的空間大小為 5 * 32 = 160 bit,取的時候,使用元素的下標來獲取對應位上的元素。
但是如果換種思路,把元素的值作為下標,每個下標位使用 bit 來標記,有值則為1,否則為0,此時我們只需要在記憶體上開闢一個連續的二進位制位空間,長度為8(因為上面資料最大的元素是7,但是需要考慮下標起點為0),則可以表示成:
說明:初始化一個長度是8的 BitMap,初始值均為0,然後將[2,6,1,7,3]填入對應的下標處,上圖中藍色域,即將這幾個下標處的值設定為1,所以表示為:1 1 0 0 1 1 1 0。此時佔用的記憶體空間為 8 bit,而原來是 160 bit(順便解釋下上文提到的 1/32,因為我們開闢的是連續的內容空間,所以會有冗餘)。
2、案例說明
① 案例一:還是上文的陣列,需求是查詢元素6是否在陣列中。 原先我們需要遍歷整個陣列,時間複雜度為 O(n); 而現在我們只需要查驗下標為6的位元組是0還是1即可,如果是1,則代表存在,時間複雜度直接降為 O(1)。 所以,**最直接的應用場景便是:**資料的查重。
② 案例二:有兩個陣列,判斷這兩個陣列中的重複元素。 原先的最淺顯的做法是雙層for迴圈進行判斷比較。 而現在,只需要將轉換完成的兩個BirMap進行與運算即可,如:11001110B & 10100000B = 10000000B,所有得出結果,只有元素 7 重複。 當然,最直接的應用場景是: 每個客戶都有不同的標籤,當需要查詢同時符合標籤a和標籤b的客戶的時候,只需要將標籤a和標籤b的客戶查出來進行如上的與運算即可。
3、補充說明
① 實際使用的時候,並不會向上面一樣很隨意地將長度設定為8,一般會設定為32(int型)或64(long型),理由見下文 BitSet 原始碼即可。
② 除了上文提到的與運算,當然了,邏輯或和邏輯異或操作都是OK的。
③ 每個Bit位只能是0或1,所以只能代表true or false,當我們要進行少量統計的時候,可以使用2-BitMap,即每個位上可以使用 00、01、10、11來分別表示數量為 0、1、2,此時的 11 一般無意義。
三、BitSet 原始碼
1、簡述
對於 BitMap 這種經典的資料結構,在 Java 語言裡面,其實已經有對應實現的資料結構類 java.util.BitSet 了(***@since ***JDK1.0),而 BitSet 的底層原理,其實就是用 long 型別的陣列來儲存元素,所以回過頭來看上文提到的為什麼實際使用的時候,長度一般會是有規則的,因為此處使用的是long型別的陣列,而 1 long = 64 bit,所以資料大小會是64的整數倍。
/**
* The internal field corresponding to the serialField "bits".
*/
private long[] words;
複製程式碼
至於 Java 中的 BitSet 為什麼使用 long 陣列而不使用 int 陣列,我覺得應該是出於 Java 語言的效能考慮的,因為在進行邏輯與等一系列位運算的時候,是需要將兩個陣列中的元素一一進行位運算的,而使用 long 的一個好處是陣列的長度減少了,從而遍歷的次數也就減少了。
總之就是和場景有關係,抽象概念上就有點類似 Java 中字串的匹配演算法(indexOf)使用的是 BF(暴力檢索)演算法一樣,為什麼不用更優解呢?還不是因為更優解在少量資料的情況下反而是拖後腿的那一位。
2、成員變數
3、構造方法
有參構造的引數代表的是元素的長度,不是陣列的大小,比如傳參1和64,陣列的長度均為1,整個size均為64,但是傳參65的時候,陣列長度為2,size為128,因為陣列是long型別,而一個long可以儲存64個bit元素。
4、 initWords 函式
該函式只在兩個構造方法中呼叫,作用是初始化陣列,而陣列的長度則會通過 workIndex(nbits-1) + 1 來獲取。
5、 wordIndex 函式
這個方法很重要, 它是用來獲取某個數在 words 陣列中的索引的,採用的演算法是將這個數右移6位,why?因為 bitIndex >> 6 == bitIndex / (2^6) == bitIndex /64,而long就是64個位元組。
6、ensureCapacity 函式
又是一個很重要的方法,作用是動態擴容,因為在初始化的時候,我們並不知道將來會需要儲存多大的資料。
7、size 和 length 函式
size 方法很好理解,返回的其實就是陣列的空間大小,即陣列長度*64。 而 length 方法,看原始碼其實有點晦(qu)澀(qiao),簡言之,返回的是 BitSet 的“邏輯大小”,即BitSet 中最高設定位的索引加 1 。
舉個例子,一個 BitSet 中儲存了兩個元素,10和50,那麼,此時這個 BitMap 的:size = 64;length = 51。
8、題外話
其餘的 set、get等方法暫不贅述,總之一句話,想要深刻理解 BitSet 的原始碼,對於二進位制的計算需要有一定的掌握水準。不得不承認,BitSet 的原始碼,很多細節的設計太精妙了。
四、擴充
如要論述擴充,要麼就是論述場景的高層次應用,要麼就是論述此演算法的不足之處,此處各提一個點:
① 不足:資料稀疏問題,比如三個元素(1,100,10000000),則需要初始化的長度為 10000000,很不合理,此時可以使用 Roaring BitMap 演算法來解決,而 Java 程式可以使用goolge的 **EWAHCompressedBitmap **來解決。
② 擴充:資料碰撞問題,比如上文提到的爬蟲應用場景是將URL進行雜湊運算,然後將hash值存入BitMap之中,但是不得不面臨一個尷尬的情況,那就是雜湊碰撞,而布隆演算法(Bloom Filter)就可以解決這個問題,為什麼是擴充呢?因為它是以 BitMap 為基礎的排重演算法。