1. 程式人生 > >【演算法】位運算技巧

【演算法】位運算技巧

對於仍然不太清楚位操作符的同學們,可以看看這篇文章:[位操作符](https://www.cnblogs.com/blknemo/p/14141417.html) # 特別注意 > **特別注意:**使用按位操作符時要注意,相等(==)與不相等(!=)的優先順序在按位運算子之上!!!! 這意味著,位運算子的優先順序極小,所以使用位運算子時,最好加上括號() # 重要技巧 基本的操作我就直接略過了。下面是我認為**必須掌握**的技巧:(注意,我把一些生僻的技巧都已經砍掉了,留下來的,就是我認為應該會的) 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 ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303143158686-2088470505.png) 並重復此過程,我們便可依次遍歷所有位。 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 時跳出。 1. `res += n & 1` : 若 $n \& 1 = 1$ ,則統計數 res 加一。 2. `n >>= 1` : 將二進位制數字 n 無符號右移一位( Java 中無符號右移為 ">>>" ) 。 3. 返回統計數量 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 ,其餘不變。 ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210317130023958-1743448993.png) ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210302184550215-312235815.png) 圖片 1. 將 n 和 n-1 做與運算會將最低位的 1 變成 0 在二進位制表示中,數字 n 中最低位的 1 總是對應 n - 1 中的 0 。因此,將 n 和 n - 1 與運算總是能把 n 中最低位的 1 變成 0 ,並保持其他位不變。 比如下面這兩對數: ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303143931854-2024166787.png) 肯定有人又是看的一臉懵逼,我們拿 11 舉個例子:(注意最後一位 1 變成 0 的過程) ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303143946115-1760011685.png) 使用這個小技巧,程式碼變得非常簡單。 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] 三個數,是下面這樣: ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303144239381-476743695.png) 回想一下,之所以能用“異或”,其實我們是完成了一個 同一位上有 2 個 1 清零 的過程。上面的圖看起來可能容易,如果是這樣 (下圖應為 26^21): ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303144257885-180163097.png) Java ``` class Solution { public int singleNumber(int[] nums) { int single = 0; for (int num : nums) { single ^= num; } return single; } } ``` # 例項 ## [位1的個數](https://leetcode-cn.com/problems/number-of-1-bits/) 編寫一個函式,輸入是一個無符號整數(以二進位制串的形式),返回其二進位制表示式中數字位數為 '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 時跳出。 1. `res += n & 1` : 若 $n \& 1 = 1$ ,則統計數 res 加一。 2. `n >>= 1` : 將二進位制數字 n 無符號右移一位( Java 中無符號右移為 ">>>" ) 。 3. 返回統計數量 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 ,其餘不變。 ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210317130023958-1743448993.png) ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210302184550215-312235815.png) 圖片 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)$。沒有使用額外空間。 ## [只出現一次的數字](https://leetcode-cn.com/problems/single-number) 給定一個非空整數陣列,除了某個元素只出現一次以外,其餘每個元素均出現兩次。找出那個只出現了一次的元素。 說明: 你的演算法應該具有線性時間複雜度。 你可以不使用額外空間來實現嗎? 示例 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](https://leetcode-cn.com/problems/shu-zu-zhong-shu-zi-chu-xian-de-ci-shu-ii-lcof/) 在一個數組 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 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 之和。 ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303142300527-1228788507.png) ## 答案 >
位運算的題,大部分都有一些特別的技巧,只要能掌握這些技巧,對其拼裝組合,就可以破解一道道的題目。很多人說那些技巧想不到,我覺得是因為沒有認真的去學習和記憶。你要相信,基本上所有人最開始都是不會的,只是後來他們通過努力學會了記住了,而你因為沒努力一直停留在不會而已。不要覺得那些一眼看到題就能想到解法的人有多麼了不起。“無他,唯手熟爾!” 下面這兩個技巧大家需要記住,這也是講解本題的目的: - “異或”是一個無進位加法,說白了就是把進位砍掉。比如01^01=00。 - “與”可以用來獲取進位,比如01&01=01,然後再把結果左移一位,就可以獲取進位結果。 根據上面兩個技巧,假設有 12+7: ![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210303142343308-1781878060.png) 根據分析,完成題解: ``` //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. 不用加減乘除做加法](https://leetcode-cn.com/problems/bu-yong-jia-jian-cheng-chu-zuo-jia-fa-lcof/) 寫一個函式,求兩個整數之和,要求在函式體內不得使用 “+”、“-”、“*”、“/” 四則運算子號。 示例: ``` 輸入: 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;