有趣的位運算


有趣的位運算

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

原碼、反碼和補碼

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

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

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

位運算的基本操作

  這里只涉及編程語言中擁有運算符號的位運算,其他運算不在討論范圍內。常用的位運算主要有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版)》

    

  如果文章對你有幫助,請點擊推薦鼓勵作者 :)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM