【演算法】位運算技巧

Nemo&發表於2021-03-26

對於仍然不太清楚位操作符的同學們,可以看看這篇文章:位操作符

特別注意

特別注意:使用按位操作符時要注意,相等(==)與不相等(!=)的優先順序在按位運算子之上!!!!
這意味著,位運算子的優先順序極小,所以使用位運算子時,最好加上括號()

重要技巧

基本的操作我就直接略過了。下面是我認為必須掌握的技巧:(注意,我把一些生僻的技巧都已經砍掉了,留下來的,就是我認為應該會的)

  1. 使用 x & 1 == 1 判斷奇偶數。(注意,一些編輯器底層會把用%判斷奇偶數的程式碼,自動優化成位運算)

  2. 不使用第三個數,交換兩個數。x = x ^ y , y = x ^ y , x = x ^ y。(早些年喜歡問到,現在如果誰再問,大家會覺得很low)

  3. 兩個相同的數異或的結果是 0,一個數和 0 異或的結果是它本身。(對於找數這塊,異或往往有一些別樣的用處。)

  4. x & (x - 1) ,可以將最右邊的 1 設定為 0。(這個技巧可以用來檢測 2的冪,或者檢測一個整數二進位制中 1 的個數,又或者別人問你一個數變成另一個數其中改變了多少個bit位,統統都是它)

  5. i+(~i)=-1,i 取反再與 i 相加,相當於把所有二進位制位設為1,其十進位制結果為-1。

  6. 對於int32而言,使用 n >> 31取得 n 的正負號。並且可以通過 (n ^ (n >> 31)) - (n >> 31) 來得到絕對值。(n為正,n >> 31 的所有位等於0。若n為負數,n >> 31 的所有位等於1,其值等於-1)

  7. 使用 (x ^ y) >= 0 來判斷符號是否相同。(如果兩個數都是正數,則二進位制的第一位均為0,x^y=0;如果兩個數都是負數,則二進位制的第一位均為1;x^y=0 如果兩個數符號相反,則二進位制的第一位相反,x^y=1。有0的情況例外,^相同得0,不同得1)

  8. “異或”是一個無進位加法,說白了就是把進位砍掉。比如01^01=00。

  9. “與”可以用來獲取進位,比如01&01=01,然後再把結果左移一位,就可以獲取進位結果。

使用掩碼遍歷二進位制位

這個方法比較直接。我們遍歷數字的 32 位。如果某一位是 1 ,將計數器加一。

我們使用 位掩碼 來檢查數字的第 \(i^{th}\) 位。一開始,掩碼 m=1 因為 1 的二進位制表示是

\[0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0001\]
0000 0000 0000 0000 0000 0000 0000 0001

顯然,任何數字跟掩碼 11 進行邏輯與運算,都可以讓我們獲得這個數字的最低位。檢查下一位時,我們將掩碼左移一位。

\[0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0010\]
0000 0000 0000 0000 0000 0000 0000 0010

【演算法】位運算技巧

並重復此過程,我們便可依次遍歷所有位。

Java

public int hammingWeight(int n) {
    int bits = 0; // 用來儲存 1 的個數

    int mask = 1;  // 掩碼,從最低位開始

    for (int i = 0; i < 32; i++) {
        // 注意這裡是不等於0,而不是等於1,因為我們的位數是不斷在變化的,可能等於2、4、8...
        // 使用按位操作符時要注意,相等(==)與不相等(!=)的優先順序在按位運算子之上!!!!
        // 使用按位運算子時,最好加上括號()
        if ((n & mask) != 0) {
            bits++;
        }
        mask <<= 1;
    }
    return bits;
}

注意:這裡判斷 n & mask 的時候,千萬不要錯寫成 (n & mask) == 1,因為這裡你對比的是十進位制數。(我之前就這麼寫過,記錄一下...)

無符號右移遍歷二進位制位

逐位判斷
根據 與運算 定義,設二進位制數字 n ,則有:

  • \(n \& 1 = 0\),則 n 二進位制 最右一位 為 00 ;
  • \(n \& 1 = 1\),則 n 二進位制 最右一位 為 11 。

根據以上特點,考慮以下 迴圈判斷 :

  1. 判斷 n 最右一位是否為 1 ,根據結果計數。
  2. 將 n 右移一位(本題要求把數字 n 看作無符號數,因此使用 無符號右移 操作)。

演算法流程:

  1. 初始化數量統計變數 res = 0。
  2. 迴圈逐位判斷: 當 n = 0 時跳出。
  3. res += n & 1 : 若 \(n \& 1 = 1\) ,則統計數 res 加一。
  4. n >>= 1 : 將二進位制數字 n 無符號右移一位( Java 中無符號右移為 ">>>" ) 。
  5. 返回統計數量 res。
public class Solution {
    public int hammingWeight(int n) {
        int res = 0;
        while(n != 0) {
            res += n & 1;  // 遍歷
            n >>>= 1;  // 無符號右移
        }
        return res;
    }
}

反轉最後一個 1

對於特定的情況,如 只有 1 對我們有用時,我們不需要遍歷每一位,我們可以把前面的演算法進行優化。

我們不再檢查數字的每一個位,而是不斷把數字最後一個 1 反轉,並把答案加一。當數字變成 0 的時候偶,我們就知道它沒有 1 的位了,此時返回答案。

注意:這裡我們說的是最後一個 1而不是最後一位 1,這個 1 可能在任何位上。

這裡關鍵的想法是對於任意數字 n ,將 n 和 n - 1 做與運算,會把最後一個 1 的位變成 0 。為什麼?考慮 n 和 n - 1 的二進位制表示。

巧用 \(n \& (n - 1)\)
\((n - 1)\) 解析: 二進位制數字 n 最右邊的 1 變成 0 ,此 1 右邊的 0 都變成 1 。
\(n \& (n - 1)\) 解析: 二進位制數字 n 最右邊的 1 變成 0 ,其餘不變。
【演算法】位運算技巧

【演算法】位運算技巧
圖片 1. 將 n 和 n-1 做與運算會將最低位的 1 變成 0

在二進位制表示中,數字 n 中最低位的 1 總是對應 n - 1 中的 0 。因此,將 n 和 n - 1 與運算總是能把 n 中最低位的 1 變成 0 ,並保持其他位不變。

比如下面這兩對數:
【演算法】位運算技巧

肯定有人又是看的一臉懵逼,我們拿 11 舉個例子:(注意最後一位 1 變成 0 的過程)
【演算法】位運算技巧

使用這個小技巧,程式碼變得非常簡單。

Java

public int hammingWeight(int n) {

    int sum = 0;  // 用來存放 1 的個數

    while (n != 0) {
        sum++;
        n &= (n - 1);
    }
    return sum;
}

Java內建函式:1 的個數

public int hammingWeight(int n) {
    return Integer.bitCount(n);
}

異或相消(異或運算的特性)

我們先來看下異或的數學性質(數學裡異或的符號是 \(\oplus\)):

  • 交換律:\(p \oplus q = q \oplus p\)
  • 結合律:\(p \oplus (q \oplus r) = (p \oplus q) \oplus r\)
  • 恆等率:\(p \oplus 0 = p\)
  • 歸零率:\(p \oplus p = 0\)

異或運算有以下三個性質。

  1. 任何數和 0 做異或運算,結果仍然是原來的數,即 \(a \oplus 0=a\)
  2. 任何數和其自身做異或運算,結果是 0,即 \(a \oplus a=0\)
  3. 異或運算滿足交換律和結合律,即 \(a \oplus b \oplus a=b \oplus a \oplus a=b \oplus (a \oplus a)=b \oplus0=b\)

假設陣列中有 2m+1 個數,其中有 m 個數各出現兩次,一個數出現一次。令 \(a_{1}\)\(a_{2}\)\(\ldots\)…、\(a_{m}\) 為出現兩次的 m 個數,\(a_{m+1}\) 為出現一次的數。根據性質 3,陣列中的全部元素的異或運算結果總是可以寫成如下形式:

\[(a_{1} \oplus a_{1}) \oplus (a_{2} \oplus a_{2}) \oplus \cdots \oplus (a_{m} \oplus a_{m}) \oplus a_{m+1}\]

根據性質 2 和性質 1,上式可化簡和計算得到如下結果:

\[0 \oplus 0 \oplus \cdots \oplus 0 \oplus a_{m+1}=a_{m+1}\]

因此,陣列中的全部元素的異或運算結果即為陣列中只出現一次的數字。

下面我們來舉個例子吧:
假如我們有 [21,21,26] 三個數,是下面這樣:
【演算法】位運算技巧

回想一下,之所以能用“異或”,其實我們是完成了一個 同一位上有 2 個 1 清零 的過程。上面的圖看起來可能容易,如果是這樣 (下圖應為 26^21):
【演算法】位運算技巧

Java

class Solution {
    public int singleNumber(int[] nums) {
        int single = 0;
        for (int num : nums) {
            single ^= num;
        }
        return single;
    }
}

例項

位1的個數

編寫一個函式,輸入是一個無符號整數(以二進位制串的形式),返回其二進位制表示式中數字位數為 '1' 的個數(也被稱為漢明重量)。

提示:

  • 請注意,在某些語言(如 Java)中,沒有無符號整數型別。在這種情況下,輸入和輸出都將被指定為有符號整數型別,並且不應影響您的實現,因為無論整數是有符號的還是無符號的,其內部的二進位制表示形式都是相同的。
  • 在 Java 中,編譯器使用二進位制補碼記法來表示有符號整數。因此,在上面的 示例 3 中,輸入表示有符號整數 -3。

進階:

  • 如果多次呼叫這個函式,你將如何優化你的演算法?

示例 1:

輸入:00000000000000000000000000001011
輸出:3
解釋:輸入的二進位制串 00000000000000000000000000001011 中,共有三位為 '1'。

示例 2:

輸入:00000000000000000000000010000000
輸出:1
解釋:輸入的二進位制串 00000000000000000000000010000000 中,共有一位為 '1'。

示例 3:

輸入:11111111111111111111111111111101
輸出:31
解釋:輸入的二進位制串 11111111111111111111111111111101 中,共有 31 位為 '1'。

提示:
輸入必須是長度為 32 的 二進位制串。


答案

方法 1:迴圈和位移動

演算法

這個方法比較直接。我們遍歷數字的 32 位。如果某一位是 1 ,將計數器加一。

我們使用 位掩碼 來檢查數字的第 \(i^{th}\) 位。一開始,掩碼 m=1 因為 1 的二進位制表示是

\[0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0001\]
0000 0000 0000 0000 0000 0000 0000 0001

顯然,任何數字跟掩碼 11 進行邏輯與運算,都可以讓我們獲得這個數字的最低位。檢查下一位時,我們將掩碼左移一位。

\[0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 0010\]
0000 0000 0000 0000 0000 0000 0000 0010

並重復此過程。

Java

public int hammingWeight(int n) {
    int bits = 0;
    int mask = 1;
    for (int i = 0; i < 32; i++) {
        if ((n & mask) != 0) {
            bits++;
        }
        mask <<= 1;
    }
    return bits;
}

複雜度分析

  • 時間複雜度:\(O(1)\)。執行時間依賴於數字 n 的位數。由於這題中 n 是一個 32 位數,所以執行時間是 \(O(1)\) 的。

  • 空間複雜度:\(O(1)\)。沒有使用額外空間。


逐位判斷
根據 與運算 定義,設二進位制數字 n ,則有:

  • \(n \& 1 = 0\),則 n 二進位制 最右一位 為 00 ;
  • \(n \& 1 = 1\),則 n 二進位制 最右一位 為 11 。

根據以上特點,考慮以下 迴圈判斷 :

  1. 判斷 n 最右一位是否為 1 ,根據結果計數。
  2. 將 n 右移一位(本題要求把數字 n 看作無符號數,因此使用 無符號右移 操作)。

演算法流程:

  1. 初始化數量統計變數 res = 0。
  2. 迴圈逐位判斷: 當 n = 0 時跳出。
  3. res += n & 1 : 若 \(n \& 1 = 1\) ,則統計數 res 加一。
  4. n >>= 1 : 將二進位制數字 n 無符號右移一位( Java 中無符號右移為 ">>>" ) 。
  5. 返回統計數量 res。
public class Solution {
    public int hammingWeight(int n) {
        int res = 0;
        while(n != 0) {
            res += n & 1;  // 遍歷
            n >>>= 1;  // 無符號右移
        }
        return res;
    }
}

方法 2:位操作的小技巧

演算法

我們可以把前面的演算法進行優化。我們不再檢查數字的每一個位,而是不斷把數字最後一個 1 反轉,並把答案加一。當數字變成 0 的時候偶,我們就知道它沒有 1 的位了,此時返回答案。

這裡關鍵的想法是對於任意數字 n ,將 n 和 n - 1 做與運算,會把最後一個 1 的位變成 0 。為什麼?考慮 n 和 n - 1 的二進位制表示。

巧用 \(n \& (n - 1)\)
\((n - 1)\) 解析: 二進位制數字 n 最右邊的 1 變成 0 ,此 1 右邊的 0 都變成 1 。
\(n \& (n - 1)\) 解析: 二進位制數字 n 最右邊的 1 變成 0 ,其餘不變。
【演算法】位運算技巧

【演算法】位運算技巧
圖片 1. 將 n 和 n-1 做與運算會將最低位的 1 變成 0

在二進位制表示中,數字 n 中最低位的 1 總是對應 n - 1 中的 0 。因此,將 n 和 n - 1 與運算總是能把 n 中最低位的 1 變成 0 ,並保持其他位不變。

使用這個小技巧,程式碼變得非常簡單。

Java

public int hammingWeight(int n) {
    int sum = 0;
    while (n != 0) {
        sum++;
        n &= (n - 1);
    }
    return sum;
}

複雜度分析

  • 時間複雜度:\(O(1)\)。執行時間與 n 中位為 1 的有關。在最壞情況下,n 中所有位都是 1 。對於 32 位整數,執行時間是 \(O(1)\) 的。

  • 空間複雜度:\(O(1)\)。沒有使用額外空間。

只出現一次的數字

給定一個非空整數陣列,除了某個元素只出現一次以外,其餘每個元素均出現兩次。找出那個只出現了一次的元素。

說明:

你的演算法應該具有線性時間複雜度。 你可以不使用額外空間來實現嗎?

示例 1:

輸入: [2,2,1]
輸出: 1

示例 2:

輸入: [4,1,2,1,2]
輸出: 4

答案

方法一:位運算
如果不考慮時間複雜度和空間複雜度的限制,這道題有很多種解法,可能的解法有如下幾種。

  • 使用集合儲存數字。遍歷陣列中的每個數字,如果集合中沒有該數字,則將該數字加入集合,如果集合中已經有該數字,則將該數字從集合中刪除,最後剩下的數字就是隻出現一次的數字。

  • 使用雜湊表儲存每個數字和該數字出現的次數。遍歷陣列即可得到每個數字出現的次數,並更新雜湊表,最後遍歷雜湊表,得到只出現一次的數字。

  • 使用集合儲存陣列中出現的所有數字,並計算陣列中的元素之和。由於集合保證元素無重複,因此計算集合中的所有元素之和的兩倍,即為每個元素出現兩次的情況下的元素之和。由於陣列中只有一個元素出現一次,其餘元素都出現兩次,因此用集合中的元素之和的兩倍減去陣列中的元素之和,剩下的數就是陣列中只出現一次的數字。

上述三種解法都需要額外使用 \(O(n)\) 的空間,其中 n 是陣列長度。

如何才能做到線性時間複雜度和常數空間複雜度呢?

答案是使用位運算。對於這道題,可使用異或運算 \(\oplus\)。異或運算有以下三個性質。

任何數和 0 做異或運算,結果仍然是原來的數,即 \(a \oplus 0=a\)
任何數和其自身做異或運算,結果是 0,即 \(a \oplus a=0\)
異或運算滿足交換律和結合律,即 \(a \oplus b \oplus a=b \oplus a \oplus a=b \oplus (a \oplus a)=b \oplus0=b\)

假設陣列中有 2m+1 個數,其中有 m 個數各出現兩次,一個數出現一次。令 \(a_{1}\)\(a_{2}\)\(\ldots\)…、\(a_{m}\) 為出現兩次的 m 個數,\(a_{m+1}\) 為出現一次的數。根據性質 3,陣列中的全部元素的異或運算結果總是可以寫成如下形式:

\[(a_{1} \oplus a_{1}) \oplus (a_{2} \oplus a_{2}) \oplus \cdots \oplus (a_{m} \oplus a_{m}) \oplus a_{m+1}\]

根據性質 2 和性質 1,上式可化簡和計算得到如下結果:

\[0 \oplus 0 \oplus \cdots \oplus 0 \oplus a_{m+1}=a_{m+1}\]

因此,陣列中的全部元素的異或運算結果即為陣列中只出現一次的數字。

Java

class Solution {
    public int singleNumber(int[] nums) {
        int single = 0;
        for (int num : nums) {
            single ^= num;
        }
        return single;
    }
}

複雜度分析

  • 時間複雜度:\(O(n)\),其中 n 是陣列長度。只需要對陣列遍歷一次。

  • 空間複雜度:\(O(1)\)

劍指 Offer 56 - II. 陣列中數字出現的次數 II

在一個陣列 nums 中除一個數字只出現一次之外,其他數字都出現了三次。請找出那個只出現一次的數字。

示例 1:

輸入:nums = [3,4,3,3]
輸出:4

示例 2:

輸入:nums = [9,1,7,9,7,9,7]
輸出:1

位運算答案

// 雖然位運算很麻煩,但是我也得試試啊
class Solution {
    public int singleNumber(int[] nums) {

        // 位運算有點麻煩

        // 把所有數的二進位制位相加,對每一位取餘3,那麼剩下來的就是我們需要找的數字了
        int[] counts = new int[32]; // 用來儲存答案數字的32位二進位制
        
        // 遍歷陣列中的所有數,將他們的二進位制位相加,存起來
        for(int num : nums) {
            // 從0到31,從低位到高位
            for(int j = 0; j < 32; j++) {
                counts[j] += num & 1;
                num >>>= 1;
            }
        }

        // 從高位開始,把每一位二進位制 % 3
        int res = 0;    // 結果答案
        for(int i = 31; i >= 0; i--) {
            // 先移位再加個位,就如 sum += sum * 10 + 個位
            res <<= 1;
            res |= counts[i] % 3;
        }
        return res;

    }
}

雜湊表答案

class Solution {
    public int singleNumber(int[] nums) {

        // 位運算有點麻煩

        // 雜湊表
        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {
            
            int count = map.getOrDefault(nums[i], 0) + 1;

            map.put(nums[i], count);
        }

        for (int i = 0; i < nums.length; i++) {
            
            int count = map.getOrDefault(nums[i], 0);
            if (count == 1) {
                return nums[i];
            }
        }

        return 0;
    }
}

兩數之和

第268題:不使用運算子 + 和 - ,計算兩整數 a 、b 之和。
【演算法】位運算技巧

答案

位運算的題,大部分都有一些特別的技巧,只要能掌握這些技巧,對其拼裝組合,就可以破解一道道的題目。很多人說那些技巧想不到,我覺得是因為沒有認真的去學習和記憶。你要相信,基本上所有人最開始都是不會的,只是後來他們通過努力學會了記住了,而你因為沒努力一直停留在不會而已。不要覺得那些一眼看到題就能想到解法的人有多麼了不起。“無他,唯手熟爾!”

下面這兩個技巧大家需要記住,這也是講解本題的目的:

  • “異或”是一個無進位加法,說白了就是把進位砍掉。比如01^01=00。

  • “與”可以用來獲取進位,比如01&01=01,然後再把結果左移一位,就可以獲取進位結果。

根據上面兩個技巧,假設有 12+7:
【演算法】位運算技巧

根據分析,完成題解:

//JAVA
class Solution {
    public int getSum(int a, int b){
        while(b != 0){
            int temp = a ^ b;
            b = (a & b) << 1;
            a = temp;
        }
        return a;
    }
}

劍指 Offer 65. 不用加減乘除做加法

寫一個函式,求兩個整數之和,要求在函式體內不得使用 “+”、“-”、“*”、“/” 四則運算子號。

示例:

輸入: a = 1, b = 1
輸出: 2

答案

//JAVA
class Solution {
    public int add(int a, int b) {
        // 該位都為1,&,則進位
        // 異或運算,^,非進位加

        // 我們使用temp來記錄進位的位數二進位制
        // 每次我們都將 非進位和 與 進位二進位制 做非進位加法運算,直到沒有進位為止(進位為0)
        while(b != 0) { // 當進位為 0 時跳出
            int temp = (a & b) << 1;  // temp = 進位
            a ^= b; // a = 非進位和
            b = temp; // b = 進位
        }
        return a;
    }
}

相關文章