【演算法】位運算技巧
阿新 • • 發佈:2021-03-27
對於仍然不太清楚位操作符的同學們,可以看看這篇文章:[位操作符](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;