有趣的位運算

有趣的位運算

  計算機的終極程序其實只有0和1,轉化成集成電路的低電壓和高電壓來進行存儲和運算。若是你是計算機相關專業出身或者是一名軟件開發人員即便不對計算機體系結構如數家珍,至少也要達到可以熟練使用位運算的水平,要否則仍是稱爲代碼搬運工比較好:),位運算很是簡單,很是容易理解並且頗有趣,在平時的開發中應用也很是普遍,特別是須要優化的大數據量場景。你所使用的編程語言的+-*/實際上底層也都是用位運算實現的。在面試中若是你能用位運算優化程序、進行集合操做是否是也能加分呀。花費不多的時間就能帶來很大的收穫何樂而不爲。本文總結了位運算的基本操做、經常使用技巧和場景實踐,但願能給你帶來收穫。java

原碼、反碼和補碼

  在討論位運算以前有必要補充一下計算機底層使用的編碼表示,計算機內部存儲、計算的任何信息都是由二進制(0和1)表示,而二進制有三種不一樣的表示形式:原碼反碼補碼。計算機內部使用補碼來表示。git

  原碼,就是其二進制表示(注意,有一位符號位)
  反碼,正數的反碼就是原碼,負數的反碼是符號位不變,其他位取反
  補碼,正數的補碼就是原碼,負數的補碼是反碼+1程序員

  符號位,最高位爲符號位,0表示正數,1表示負數。在位運算中符號位也參與運算。github

位運算的基本操做

  這裏只涉及編程語言中擁有運算符號的位運算,其餘運算不在討論範圍內。經常使用的位運算主要有6種:按位與、按位或、左移、右移、按位取反、按位異或。最後補充一種邏輯右移。面試

按位與操做 &編程

  按位與&操做是指對兩操做數進行按位與運算,其中兩位都爲1結果爲1,其餘狀況爲0。按位與是二目運算符。數組

1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0

  例如:3 & 17 = 1編程語言

  3=00000011大數據

  17=00010001優化

  &=00000001

  注意,這裏表示二進制不足的位用0補足。

按位或操做 |

  按位或 | 操做是指對兩個操做數進行按位或運算,其中有至少有1位爲1結果就爲1,兩位都爲0結果爲0。按位或運算是二目運算符。

1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0  

  例如:3 | 17 = 19

  3=00000011

  17=00010001

  | =00010011

 按位非操做 ~

  按位非操做 ~ 就是對操做數進行按位取反,原來爲1結果爲0,原來爲0結果爲1。按爲非操做是單目運算符。

  例如:~33=-34

  33= 00000000000000000000000000100001  (整數爲32位)

  ~33=11111111111111111111111111011110=-34    (補碼錶示,符號位也參與運算)

左移操做 <<

  左移操做 << 是把操做數總體向左移動,左移操做是二目運算符。

  例如:33 << 2 = 100001 << 2 = 10000100 = 132

  -2147483647 << 1 = 10000000000000000000000000000001 << 1 = 10 = 2 (符號位也參與運算)

  技巧:a << n = a * 2^n (a爲正數)

右移操做 >>

  右移操做 >> 是把操做數總體向右移動,右移操做是二目運算符。

  例如:33 >> 2 = 100001 >> 2 = 001000 = 8

  -2147483647 >> 1 = 10000000000000000000000000000001 << 1 = 11000000000000000000000000000000 = -1073741824 (符號位也參與運算,補足符號位)

  技巧:a >> n = a / 2^n (a爲正數)

  補充:邏輯右移 >>> 

  邏輯右移和右移的區別是,右移將A的二進制表示的每一位向右移B位,右邊超出的位截掉,左邊不足的位補符號位的數(好比負數符號位是1則補充1,正數符號位是0則補充0),因此對於算術右移,原來是負數的,結果仍是負數,原來是正數的結果仍是正數。邏輯右移將A的二進制表示的每一位向右移B位,右邊超出的位截掉,左邊不足的位補0。因此對於邏輯右移,結果將會是一個正數。

  例如上面的-2147483647 >>> 1 = 01000000000000000000000000000000 = 1073741824 (補足0)。

按位異或操做 ^

  按位異或  ^ 操做是把兩個操做數作按位異或操做,其中兩位相同則爲0,不一樣則爲1,按位異或是二目運算符,又稱爲不進位加法。

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

  例如:33 ^ 12 = 45

  33=00100001

  12=00001100

  ^ = 00101101

  技巧:異或是不進位加法,兩個數作加法,把進位捨去。

 

位運算的技巧應用

 不進位加法-異或 ^

  異或是按位相同爲0不一樣爲1,其實就是作加法的過程把進位捨去了。這樣一來咱們就能夠利用這個性質解決問題。想想加法是怎麼實現的呢?

  若是兩數相加沒有進位是否是直接可使用異或了?那若是有進位呢?那就把進位加上。

a + b = (a ^ b) + 進位

 

  考慮一下進位如何實現,有進位的地方就是兩個位都爲1的地方,這就能夠利用按位與運算了,與運算兩個都爲1結果爲1其餘狀況爲0,把兩數相與的結果左移一位就是進位的結果。

a + b = (a ^ b) + ((a & b) << 1)

  這樣就完了嗎?沒有啊,這個仍是使用了+號啊。不使用+號那就遞歸直到進位爲0,或者使用循環一直對進位作不進位加法直到進位爲0。

  加法有了,-*/還會遠嗎

public int plus(int a, int b) {
        if (b == 0)
            return a;
        int _a = (a ^ b);
        int _b = ((a & b) << 1);
        return plus(_a, _b);
}

public int plus(int a, int b) {
        while (b != 0) {
            int _a = (a ^ b);
            int _b = ((a & b) << 1);
            a = _a;
            b = _b;
        }
        return a;
} 

 異或兩次等於沒有異或 a ^ b ^ b = a

  基於兩個相同的數異或結果爲0,一個數和0異或結果不變那麼就是異或兩次等於沒有異或。a ^ b ^ b = a。

  技巧應用:給一個數組,數組中的數字只有一個出現了一次,其餘的都出現了兩次,找出這個只出現一次的數字。

  這個問題就能夠巧妙的使用異或運算,把數組的數字所有異或一遍,獲得的結果就是隻出現一次的數字。

public int singleNumber(int[] nums) {
        int result = 0, n = nums.length;
        for (int i = 0; i < n; i++)
        {
            result ^= nums[i];
        }
        return result;
}

  相似的問題還有不少,統一來講就是數組中只有一個數字出現了m次,其餘的都出現了k次,找出出現m次的數字。這一類問題基本上均可以考慮使用異或來解決。有興趣能夠參考:http://www.lintcode.com/en/problem/single-number,連接後能夠再加-ii,-iii,-iv。

取a最後一位1的位置a & (-a)

  在機器中都是採用補碼形式存在,負數的補碼是反碼+1。所以a & (-a)是取最後一位1。

  例如:33 & (-33) = 1

  33 = 00000000000000000000000000100001

  -33=11111111111111111111111111011111

  & = 00000000000000000000000000000001

  技巧應用:給一個數組,只有兩個數出現了一次,剩下的都出現了兩次,找出出現一次的兩個數字。

  這個問題能夠拆解成兩個問題,把數組分紅兩部分,沒一部分都知足只有一個數出現了一次剩下的都出現了兩次,找出只出現一次的數字。這個問題就能夠利用異或來解決了。關鍵就是怎麼把數組分紅這樣的兩部分。那就先把全部數字異或起來最後結果就是至關於只出現一次的兩個數字異或的結果,對這個結果取最後一個1,那麼在這一位這兩個數確定是不一樣的,接下來就能夠根據這一位是否是1來把全部數字分到兩個數組中。

  本身體會。

public int[] singleNumber(int[] nums) {
        //用於記錄,區分「兩個」數組
        int diff = 0;
        for(int i = 0; i < nums.length; i ++) {
            diff ^= nums[i];
        }
        //取最後一位1
        diff &= -diff;
        
        int[] rets = {0, 0}; 
        for(int i = 0; i < nums.length; i ++) {
            //分屬兩個「不一樣」的數組
            if ((nums[i] & diff) == 0) {
                rets[0] ^= nums[i];
            }
            else {
                rets[1] ^= nums[i];
            }
        }
        return rets;
    }

去掉a的最後一位1 a & (a - 1)

  兩個相同的數相與結果不變,那麼a & (a - 1)就獲得了a去掉最後一位1的數,這很是好理解。

  例如:33 & (33 - 1) = 33 & 32 = 100001 & 100000 = 100000 = 32

  技巧應用I:判斷一個數是不是2的次冪。從二進制的角度思考,一個數若是是2的次冪,那麼須要知足這個數大於0,這個數的二進制表示有且只有一個1.

  直接把這個惟一的1消去看是否爲0就能夠了。

public boolean isPowerOf2(int n) {
       return n > 0 && (n & (n - 1)) == 0;  
}

  技巧應用II:求一個整數的二進制表示的1的個數。有了這個技巧這個問題就很是簡單了,把1所有消去,看消了幾回就能夠了。

public int countOnes(int num) {
        int count = 0;
        while (num != 0) {
            num = num & (num - 1);
            count++;
        }
        return count;
}

  技巧應用III:求一個整數轉化爲另外一個整數須要改變多少位。這個問題也就是求兩個整數有多少位不一樣就好了,改變不一樣的位置就能變成另外一個數。使用異或很是簡單的求出有多少位不一樣,而後問題就變成了上一個問題,求異或結果的1的個數。

public int countOnes(int num) {
        int count = 0;
        while (num != 0) {
            num = num & (num - 1);
            count++;
        }
        return count;
}
public int bitSwapRequired(int a, int b) {
        return countOnes(a ^ b);
}

使用bit表示狀態

  在解決一個問題的時候,一般須要記錄一些數據的狀態和枚舉,可使用整數、布爾類型或者數組來表示,可是當狀態多了以後就會佔用大量的存儲空間。這時候就能夠把狀態壓縮成bit來表示。

  例如:求一個集合的全部子集。這是一個NP問題,一般狀況下使用回溯遞歸來解決。

public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> ret = new ArrayList<>();
        if(nums == null || nums.length == 0) return ret;
        List<Integer> list = new ArrayList<>();
        dfs(ret, list, nums, 0);
        return ret;
    }
    
private void dfs(List<List<Integer>> ret, List<Integer> list, int[] nums, int start) {
        if (start > nums.length)
            return;
        ret.add(new ArrayList<Integer>(list));
        for (int i=start; i<nums.length; i++) {
            list.add(nums[i]);
            dfs(ret, list, nums, i+1);
            list.remove(list.size()-1);
        }
}

  換一個角度,使用一個正整數二進制表示的第i位是1仍是0來表明集合的第i個數取或者不取。因此從0到2^n-1總共2^n個整數,正好對應集合的2^n個子集。若是集合爲{1,2,3}則

0 000 {}
1 001 {1}
2 010 {2}
3 011 {1,2}
4 100 {3}
5 101 {1,3}
6 110 {2,3}
7 111 {1,2,3}
public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> ret = new ArrayList<>();
        int n = nums.length;
        for (int i=0; i<(1 << n); i++) {
            List<Integer> subset = new ArrayList<Integer>();
            for (int j=0; j<n; j++) {
                if ((i & (1<<j)) != 0) //檢查是不是1
                    subset.add(nums[j]);
            }
            ret.add(subset);
        }
        return ret;
}

  雖然在時間複雜度上沒有優化可是這個位運算的解法仍是比遞歸快了整整1ms(在leetcode上)。

位運算的工程實踐

  接下來看一個工程實踐的例子,給兩個8G的文件,文件每行存儲了一個正整數,求這兩個文件的交集,存儲在另外一個文件中,要求最多隻能使用4G內存。你可能會想到把大文件分割成小文件,分批對比,這樣比較麻煩,若是想到使用bit來壓縮狀態表示的話這個問題就變得簡單了。

  使用一個bit來表示這個整數存在或不存在,存在置1,不存在置0。先遍歷一個文件,把全部整數的狀態置位,而後遍歷另外一個文件,讀取整數的bit若是爲1則存儲在結果文件中,若是爲0則繼續。這樣就求出了交集。若是用一個bit表示一個整數的狀態的話,4G內存能夠表示34359738368個整數。若是一個整數存儲在文件中平均佔用6個字符的話4G內存所表示的整數可以存滿192GB的空間。這樣看起來,這種解法在時間和空間上都是知足要求的,而且思路清晰簡單。

  那麼問題來了,怎麼把二進制的某一位置1或者置0呢?好比,將a的第n位置1,首先經過1 << n獲得只有第n位爲1的數,而後進行按位或運算a | (1 << n)。同理若是把第n位置0,獲得只有第n位爲1的數後取反,而後作按位與運算a & ~(1 << n)。

  那麼若是相判斷某一位是否爲1呢?一樣的先經過1 << n獲得只有第n位爲1的數而後作按爲與運算,若是結果爲0則原來位上爲0不然爲1, a & (1 << n)。

  例如:

  33 | (1 << 3) = 100001 | 001000 = 101001
  33 & ~(1 << 5) = 100001 & 000001 = 000001

//僞代碼
bit=0
while (num1 = readLine(file1)) {
    bit |= (1 << num1)
}
while (num2 = readLine(file2)) {
    if ((bit & (1 << num2)) == 0)
        continue
    else
        writeLine(file3, num2)
}

  其實這個就是BitSet的簡單實現,能夠看一下Java中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();
}
public void clear(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        int wordIndex = wordIndex(bitIndex);
        if (wordIndex >= wordsInUse)
            return;

        words[wordIndex] &= ~(1L << bitIndex);

        recalculateWordsInUse();
        checkInvariants();
}
public boolean get(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        checkInvariants();

        int wordIndex = wordIndex(bitIndex);
        return (wordIndex < wordsInUse)
            && ((words[wordIndex] & (1L << bitIndex)) != 0);
}

  這個問題還有許多拓展問題,例如求差集、並集、對海量的URL去重、網頁去重、垃圾郵件過濾等等,這些問題均可以用相似的思路去解決,只不過在真實的工程實踐中不少代碼能夠不用寫的這麼底層。可使用已經實現好的BitSet、Redis Bit和Bloomfilter。關於Bloomfilter能夠參考下面的開源項目,集成了Java的BitSet和Redis,有很好的擴展性。能夠直接使用Maven引入依賴,使用也很簡單。

  項目地址:https://github.com/wxisme/bloomfilter

總結

  熟悉和藹於使用位運算會將一些問題簡單化而且可以提高效率和空間利用率,在某些特定場景下也是必須的,還能夠幫助你去閱讀包含位運算的源代碼。一樣也不能濫用位運算,有時候會增長問題複雜度並且會讓你的代碼變得閱讀性不好,例如上面的工程問題,若是整數的數量不多,那你大可沒必要用bitset解決,使用最簡單的方法還能夠避免一些未知的問題(坑)。

補充:浮點數的二進制表示

  浮點數不在本文討論範圍,浮點數的表示要比整數複雜一些,計算機中的浮點數自己就是有偏差的,而且須要比較多的CPU運算,所以儘可能使用整數類型,關於浮點數的表示能夠參考:程序員必知之浮點數運算原理詳解

  推薦閱讀書籍:《深刻理解計算機系統(原書第3版)》

    

  若是文章對你有幫助,請點擊推薦鼓勵做者 :)

相關文章
相關標籤/搜索