Java位向量的實現原理與巧妙應用
1、博文介紹
本篇博文將會介紹幾本的位運算含義、位向量介紹、BitSet實現原理、Java位向量的應用、拓展介紹Bloom Filter等。
2、位運算介紹
1) 位運算符
java中位運算操作符主要包括: &: 與 |: 或 ^: 異或
~: 非
前三種可以和 = 結合使用,比如 &=、|=、^=;但是~是單目運算符,不能和=結合使用。
<<: 左移運算,相當於乘法,低位補0;
>>: 右移運算,相當於除法,有符號移位若高位為正,則高位補0,若為負,則高位補1;
java中增加了一種"無符號"右移,>>>,它使用零擴展,無論正負都在高位插入0;
移位操作與等號也可以組合使用: >>=、<<=
2)位運算簡單應用

// 1. 獲得int型最大值;2147483647的十六進制為0x7FFFFFFF,其中最高位為符號位 System.out.println((1 << 31) - 1);// 2147483647, 由於優先級關系,括號不可省略 System.out.println(~(1 << 31));// 2147483647 // 2. 獲得int型最小值 System.out.println(1 << 31); System.out.println(1 << -1); // 3. 判斷一個數n是不是2的冪 System.out.println((n & (n - 1)) == 0); /*如果是2的冪,n一定是100... n-1就是1111.... 所以做與運算結果為0*/ // 4. 計算2的n次方 n > 0 System.out.println(2<<(n-1)); // 5. 從低位到高位,將n的第m位置為0 System.out.println(n & ~(0<<(m-1))); /* 將1左移m-1位找到第m位,取反后變成111...0...1111 n再和這個數做與運算*/ // 6. 從低位到高位,取n的第m位 int m = 2; System.out.println((n >> (m-1)) & 1); // 7. 從低位到高位.將n的第m位置為1 System.out.println(n | (1<<(m-1))); /*將1左移m-1位找到第m位,得到000...1...000 n在和這個數做或運算*/ // 8. 獲得long類型的最大值 System.out.println(((long)1 << 127) - 1); // 9. 乘以2運算 System.out.println(10<<1); // 10. 求兩個整數的平均值 System.out.println((a+b) >> 1); // 11. 除以2運算(負奇數的運算不可用) System.out.println(10>>1); // 12. 判斷一個數的奇偶性,利用的是最后一位 System.out.println((10 & 1) == 1); System.out.println((9 & 1) == 1); // 13. 不用臨時變量交換兩個數(面試常考) a ^= b; b ^= a; a ^= b; // 14. 取絕對值(某些機器上,效率比n>0 ? n:-n 高) int n = -1; System.out.println((n ^ (n >> 31)) - (n >> 31)); /* n>>31 取得n的符號,若n為正數,n>>31等於0,若n為負數,n>>31等於-1 若n為正數 n^0-0數不變,若n為負數n^-1 需要計算n和-1的補碼,異或后再取補碼, 結果n變號並且絕對值減1,再減去-1就是絕對值 */ // 15. 取兩個數的最大值(某些機器上,效率比a>b ? a:b高) System.out.println(b&((a-b)>>31) | a&(~(a-b)>>31)); // 16. 取兩個數的最小值(某些機器上,效率比a>b ? b:a高) System.out.println(a&((a-b)>>31) | b&(~(a-b)>>31)); // 17. 判斷符號是否相同(true 表示 x和y有相同的符號, false表示x,y有相反的符號。) System.out.println((a ^ b) > 0);
3)應用 - 小游戲中狀態的判斷,如斗地主判斷四人是否處於准備狀態
充分利用一個位有兩種狀態,可以代表開閉、是否准備好等二狀態場景中,即便是多狀態也可以用多位來實現,比如在迷宮問題中,可以用00 01 10 11 來代表四個方向。如果正常的判斷四人是否處於准備狀態,可定義四個變量,但是如果用位運算,則一個byte類型變量的低4位就足夠了。
在提高運行速度的同時,也對程序的可讀性造成了影響,上面只是舉例位運算可以應用在類似的場景中,具體適不適合根據項目背景而定。可以使用設計模式來解決,底層用位實現,封裝到上層之后只公開方法。
實現代碼:

/** * Java 位運算的常用方法封裝<br> */ public class BitUtils { /** * 獲取運算數指定位置的值<br> * 例如: 0000 1011 獲取其第 0 位的值為 1, 第 2 位 的值為 0<br> * * @param source * 需要運算的數 * @param pos * 指定位置 (0<=pos<=7) * @return 指定位置的值(0 or 1) */ public static byte getBitValue(byte source, int pos) { return (byte) ((source >> pos) & 1); } /** * 將運算數指定位置的值置為指定值<br> * 例: 0000 1011 需要更新為 0000 1111, 即第 2 位的值需要置為 1<br> * * @param source * 需要運算的數 * @param pos * 指定位置 (0<=pos<=7) * @param value * 只能取值為 0, 或 1, 所有大於0的值作為1處理, 所有小於0的值作為0處理 * * @return 運算后的結果數 */ public static byte setBitValue(byte source, int pos, byte value) { byte mask = (byte) (1 << pos); if (value > 0) { source |= mask; } else { source &= (~mask); } return source; } /** * 將運算數指定位置取反值<br> * 例: 0000 1011 指定第 3 位取反, 結果為 0000 0011; 指定第2位取反, 結果為 0000 1111<br> * * @param source * * @param pos * 指定位置 (0<=pos<=7) * * @return 運算后的結果數 */ public static byte reverseBitValue(byte source, int pos) { byte mask = (byte) (1 << pos); return (byte) (source ^ mask); } /** * 檢查運算數的指定位置是否為1<br> * * @param source * 需要運算的數 * @param pos * 指定位置 (0<=pos<=7) * @return true 表示指定位置值為1, false 表示指定位置值為 0 */ public static boolean checkBitValue(byte source, int pos) { source = (byte) (source >>> pos); return (source & 1) == 1; } /** * 入口函數做測試<br> * * @param args */ public static void main(String[] args) { // 取十進制 11 (二級制 0000 1011) 為例子 byte source = 11; // 取第2位值並輸出, 結果應為 0000 1011 for (byte i = 7; i >= 0; i--) { System.out.printf("%d ", getBitValue(source, i)); } // 將第6位置為1並輸出 , 結果為 75 (0100 1011) System.out.println("\n" + setBitValue(source, 6, (byte) 1)); // 將第6位取反並輸出, 結果應為75(0100 1011) System.out.println(reverseBitValue(source, 6)); // 檢查第6位是否為1,結果應為false System.out.println(checkBitValue(source, 6)); // 輸出為1的位, 結果應為 0 1 3 for (byte i = 0; i < 8; i++) { if (checkBitValue(source, i)) { System.out.printf("%d ", i); } } } }
3、Java位向量介紹-BitSet
位向量,也叫位圖,是一個我們經常可以用到的數據結構,在使用小空間來處理大量數據方面有着得天獨厚的優勢;位向量的定義就是一串由0.1組成的序列。
Java中對位向量的實現類時Java.util.BitSet;C++標准庫中也有相應的實現,原理都是一樣的;BitSet源碼也很簡單,很容易看懂,如果讀者在對位向量有一定的了解后,可以通過讀源碼來了解BitSet的具體實現。
一個bit上有兩個值,正好可以用來判斷某些是非狀態的場景,在針對大數據場景下判斷存在性,BitSet是相比其他數據結構比如HashMap更好的選擇,在Java中,位向量是用一個叫words的long型數組實現的,一個long型變量有64位,可以保存64個數字;比如我們有[2,8,6,10,15]這5個數要保存,一般存儲需要 5*4 = 20字節的存儲空間。但是如果我們使用Java.util.BitSet進行存儲則可以節省很多的空間只需要一個long型數字就夠了。BitSet只面向數字只面向數字使用,對於string類型的數據,可以通過hashcode值來使用BitSet。
由於,1 << 64, 1<<128, 1<<192 這些數字的結果都為1,BitSet內部,long[]數組的大小由BitSet接收的最大數字決定,這個數組將數字分段表示[0,63],[64,127],[128,191]...。即long[0]用來存儲[0,63]這個范圍的數字的“存在性”,long[1]用來存儲[64,127],依次輪推,這樣就避免了位運算導致的沖突。原理如下:
|------------|----------|----------|----------|----------| | | 數字范圍 [0,63] [64,127] [128,191] ... | |------------|----------|----------|----------|----------| | | long數組索引 0 1 2 ... | |------------|----------|----------|----------|----------|
Java的BitSet每次申請空間,申請64位,即一個long型變量所占的位數;
BitSet源碼實現-縮小版:

package java.util; import java.io.*; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.LongBuffer; public class BitSet implements Cloneable, java.io.Serializable { /** 在Java里面BitSets被打包成一個叫“words”的long型數組,不過words是private的對外不公開, 只公開了操作他們的方法; */ private final static int ADDRESS_BITS_PER_WORD = 6; //2^6=64,程序中出現的 >>6 private final static int BITS_PER_WORD = 64; private final static int BIT_INDEX_MASK = 63; private static final long WORD_MASK = 0xffffffffffffffffL; private long[] words; private transient int wordsInUse = 0; //開了幾個long型數組 public BitSet() { initWords(64); } public BitSet(int nbits) { if (nbits < 0) throw new NegativeArraySizeException("nbits < 0: " + nbits); initWords(nbits); } private void initWords(int nbits) { //初始化多少個long型數組才能存下?除以64(>>6) 然后+1; words = new long[((nbits-1) >> 6) + 1]; } public void set(int bitIndex) { int wordIndex = (bitIndex >> 6); //除以64定位到某個long型變量; words[wordIndex] |= (1L << bitIndex); // Restores invariants } public boolean get(int bitIndex) { int wordIndex = (bitIndex >> 6); return (words[wordIndex] & (1L << bitIndex)) != 0; } public void clear(int bitIndex) { words[wordIndex] &= ~(1L << bitIndex); } public void clear() { while (wordsInUse > 0) words[--wordsInUse] = 0; } public boolean isEmpty() { return wordsInUse == 0; } public int cardinality() { int sum = 0; for (int i = 0; i < wordsInUse; i++) sum += Long.bitCount(words[i]); return sum; } public void and(BitSet set) { if (this == set) return; while (wordsInUse > set.wordsInUse) words[--wordsInUse] = 0; // Perform logical AND on words in common for (int i = 0; i < wordsInUse; i++) words[i] &= set.words[i]; recalculateWordsInUse(); checkInvariants(); } }
4、BitSet的應用
1)《編程珠璣》中的排序問題
問題重述:一個最多包含n個正整數的文件,每個數都小於n,其中n=107,並且沒有重復。最多有1MB內存可用。要求用最快方式將它們排序並按升序輸出。
解決方案就是:把文件一次讀入,出現的數字在位向量對應索引處中標注為1,讀取完文件之后,將位向量從低位向高位依次將為1的索引輸出即可。
相關代碼:

package cn.liuning.test; import java.util.BitSet; public class MainTest { /** 使用BitSet進行排序 */ public static void main(String[] args) { int[] data={1,2,5,9,11,21,12,15}; int max = 0; for(int i=0;i<data.length;i++){ if(max < data[i]){ max = data[i]; } } BitSet bm=new BitSet(max+1); System.out.println("The size of bm:"+bm.size()); for(int i=0;i<data.length;i++){ bm.set(data[i], true); } StringBuffer buf=new StringBuffer(); buf.append("["); for(int i=0;i<bm.size();i++){ if(bm.get(i) == true){ buf.append(String.valueOf(i)+" "); } } buf.append("]"); System.out.println(buf.toString()); } } /* 輸出: The size of bm:64 [1 2 5 9 11 12 15 21 ] */
2)使用BitSet做String類型數據的存在性校驗
一種方案:
BitSet bitSet = new BitSet(Integer.MAX_VALUE);//hashcode的值域 //0x7FFFFFFF (int類型的最大值,第一位是符號位,可用Integer.MAX_VALUE代替) String url = "http://baidu.com/a"; int hashcode = url.hashCode() & 0x7FFFFFFF; bitSet.set(hashcode); System.out.println(bitSet.cardinality()); //狀態為true的個數 System.out.println(bitSet.get(hashcode)); //檢測存在性 bitSet.clear(hashcode); //清除狀態
使用上述算法需要解決Java中hashcode存在沖突的問題。即不同的String可能得到的hashcode是一樣的(即使不重寫hashcode方法)。如何解決?調整hashcode生成算法:我們可以對一個String使用多個hashcode算法,生成多個hashcode,然后在同一個BitSet進行多次“着色”,在判斷存在性時,只有所有的着色位為true時,才判定成功。
String url = "http://baidu.com/a"; int hashcode1 = url.hashCode() & 0x7FFFFFFF; bitSet.set(hashcode1); int hashcode2 = (url + "-seed-").hashCode() & 0x7FFFFFFF; bitSet.set(hashcode2); System.out.println(bitSet.get(hashcode1) && bitSet.get(hashcode2)); //也可以在兩個不同的bitSet上進行2次“着色”,這樣沖突性更小。但會消耗雙倍的內存
其實我們能夠看出,這種方式降低了誤判的概率。但是如果BitSet中存儲了較多的數字,那么互相覆蓋着色,最終數據沖突的可能性會逐漸增加,最終仍然有一定概率的判斷失誤。所以在hashcode算法的個數與實際String的個數之間有一個權衡,我們建議:
“hashcode算法個數 * String字符串的個數” < Integer.MAX_VALUE * 0.8;
另一種解決方案:多個BitSet並行保存
改良1)中的實現方式,我們仍然使用多個hashcode生成算法,但是每個算法生成的值在不同的BitSet中着色,這樣可以保持每個BitSet的稀疏度(降低沖突的幾率)。在實際結果上,比1)的誤判率更低,但是它需要額外的占用更多的內存,畢竟每個BitSet都需要占用內存。這種方式,通常是縮小hashcode的值域,避免內存過度消耗。
BitSet bitSet1 = new BitSet(Integer.MAX_VALUE);//127M
BitSet bitSet2 = new BitSet(Integer.MAX_VALUE); String url = "http://baidu.com/a"; int hashcode1 = url.hashCode() & 0x7FFFFFFF; bitSet1.set(hashcode1); int hashcode2 = (url + "-seed-").hashCode() & 0x7FFFFFFF; bitSet2.set(hashcode2);
System.out.println(bitSet1.get(hashcode1) && bitSet2.get(hashcode2));
最后:我們要考慮是否有必要完全避免誤判,可能有時候這種誤判也是我們需要的結果。如果做到100%的正確判斷率,在原理上說BitSet是無法做的,BitSet能夠保證“如果判定結果為false,那么數據一定是不存在;但是如果結果為true,可能數據存在,也可能不存在(沖突覆蓋)”,即“false == YES,true == Maybe”。有人提出將沖突的數據保存在類似於BTree的額外數據結構中,事實上這種方式增加了設計的復雜度,而且最終仍然沒有良好的解決內存占用較大的問題。
3)BloomFilter(布隆姆過濾器)
BloomFilter 的設計思想和BitSet有較大的相似性,目的也一致,它的核心思想也是使用多個Hash算法在一個“位圖”結構上着色,最終提高“存在性”判斷的效率。請參見Guava BloomFilter。如下為代碼樣例:
Charset charset = Charset.forName("utf-8"); BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(charset),2<<21);//指定bloomFilter的容量 String url = "www.baidu.com/a"; bloomFilter.put(url); System.out.println(bloomFilter.mightContain(url));
5、延伸閱讀和參考資料
BloomFilter(布隆姆過濾器)
http://www.programgo.com/article/17112318628/ Hash和Bloom Filter
http://wfwei.github.io/posts/hash-rel/ 相似哈希、完美哈希、Bloom Filter介紹 ***推薦閱讀
http://shift-alt-ctrl.iteye.com/blog/2194519 BitSet使用
http://longshaojian.iteye.com/blog/1946865 java位運算實際應用
http://www.cnblogs.com/wuyuegb2312/p/3136831.html 位向量定義與應用 C++
http://blog.luoyuanhang.com/2016/05/15/I-位向量的實現與應用8/