位運算的奇技淫巧(二)
阿新 • • 發佈:2020-09-02
位運算就是基於整數的二進位制表示進行的運算。由於計算機內部就是以二進位制來儲存資料,位運算是相當快的。
> 之前有總結過位運算的技巧,但稍微對[以前寫的文章](https://www.cnblogs.com/RioTian/p/12371164.html)不太滿意,所以重新總結一下
常用的運算子共 6 種,分別為與( `&` )、或( `|` )、異或( `^` )、取反( `~` )、左移( `<<` )和右移( `>>` )。
## 與、或、異或
與( `&` )或( `|` )和異或( `^` )這三者都是兩數間的運算,因此在這裡一起講解。
它們都是將兩個整數作為二進位制數,對二進位制表示中的每一位逐一運算。
| 運算子 | 解釋 |
| :----: | :-------------------------------: |
| `&` | 只有兩個對應位都為 1 時才為 1 |
| `|` | 只要兩個對應位中有一個 1 時就為 1 |
| `^` | 只有兩個對應位不同時才為 1 |
異或運算的逆運算是它本身,也就是說兩次異或同一個數最後結果不變,即 $a \text{^} b \text{^} b = a$ 。
舉例:
$$
\begin{aligned}
5 &=(101)_2\\
6 &=(110)_2\\
5\&6 &=(100)_2 =\ 4\\
5|6 &=(111)_2 =\ 7\\
5\text{^}6 &=(011)_2 =\ 3\\
\end{aligned}
$$
## 取反
取反是對一個數 $num$ 進行的計算,即單目運算。
`~` 把 $num$ 的補碼中的 0 和 1 全部取反(0 變為 1,1 變為 0)。有符號整數的符號位在 `~` 運算中同樣會取反。
補碼:在二進位制表示下,正數和 0 的補碼為其本身,負數的補碼是將其對應正數按位取反後加一。
舉例(有符號整數):
$$
\begin{aligned}
5&=(00000101)_2\\
\text{~}5&=(11111010)_2=-6\\
-5\text{ 的補碼}&=(11111011)_2\\
\text{~}(-5)&=(00000100)_2=4
\end{aligned}
$$
## 左移和右移
`num << i` 表示將 $num$ 的二進位制表示向左移動 $i$ 位所得的值。
`num >> i` 表示將 $num$ 的二進位制表示向右移動 $i$ 位所得的值。
舉例:
$$
\begin{aligned}
11&=(00001011)_2\\
11<<3&=(01011000)_2=88\\
11>>2&=(00000010)_2=2
\end{aligned}
$$
在 C++ 中,右移操作中右側多餘的位將會被捨棄,而左側較為複雜:對於無符號數,會在左側補 0;而對於有符號數,則會用最高位的數(其實就是符號位,非負數為 0,負數為 1)補齊。左移操作總是在右側補 0。
## 複合賦值位運算子
和 `+=` , `-=` 等運算子類似,位運算也有複合賦值運算子: `&=` , `|=` , `^=` , `<<=` , `>>=` 。(取反是單目運算,所以沒有。)
## 關於優先順序
位運算的優先順序低於算術運算子(除了取反),而按位與、按位或及異或低於比較運算子,所以使用時需多加註意,在必要時新增括號。
## 位運算的應用
位運算一般有三種作用:
1. 高效地進行某些運算,代替其它低效的方式。
2. 表示集合。(常用於 [狀壓 DP]() 。)
3. 題目本來就要求進行位運算。
需要注意的是,用位運算代替其它運算方式(即第一種應用)在很多時候並不能帶來太大的優化,反而會使程式碼變得複雜,使用時需要斟酌。(但像“乘 2 的非負整數次冪”和“除以 2 的非負整數次冪”就最好使用位運算,因為此時使用位運算可以優化複雜度。)
### 乘 2 的非負整數次冪
```cpp
int mulPowerOfTwo(int n, int m) { // 計算 n*(2^m)
return n << m;
}
```
### 除以 2 的非負整數次冪
```cpp
int divPowerOfTwo(int n, int m) { // 計算 n/(2^m)
return n > > m;
}
```
!!! warning
我們平常寫的除法是向 0 取整,而這裡的右移是向下取整(注意這裡的區別),即當數大於等於 0 時兩種方法等價,當數小於 0 時會有區別,如: `-1 / 2` 的值為 $0$ ,而 `-1 >> 1` 的值為 $-1$ 。
### 判斷一個數是不是 2 的正整數次冪
```cpp
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }
```
### 對 2 的非負整數次冪取模
```cpp
int modPowerOfTwo(int x, int mod) { return x & (mod - 1); }
```
### 取絕對值
在某些機器上,效率比 `n > 0 ? n : -n` 高。
```cpp
int Abs(int n) {
return (n ^ (n >> 31)) - (n >> 31);
/* n>>31 取得 n 的符號,若 n 為正數,n>>31 等於 0,若 n 為負數,n>>31 等於 -1
若 n 為正數 n^0=n, 數不變,若 n 為負數有 n^(-1)
需要計算 n 和 -1 的補碼,然後進行異或運算,
結果 n 變號並且為 n 的絕對值減 1,再減去 -1 就是絕對值 */
}
```
### 取兩個數的最大/最小值
在某些機器上,效率比 `a > b ? a : b` 高。
```cpp
// 如果 a> =b,(a-b)>>31 為 0,否則為 -1
int max(int a, int b) { return b & ((a - b) >> 31) | a & (~(a - b) >> 31); }
int min(int a, int b) { return a & ((a - b) >> 31) | b & (~(a - b) >> 31); }
```
### 判斷符號是否相同
```cpp
bool isSameSign(int x, int y) { // 有 0 的情況例外
return (x ^ y) >= 0;
}
```
### 交換兩個數
```cpp
void swap(int &a, int &b) { a ^= b ^= a ^= b; }
```
### 獲取一個數二進位制的某一位
```cpp
// 獲取 a 的第 b 位,最低位編號為 0
int getBit(int a, int b) { return (a >> b) & 1; }
```
### 表示集合
一個數的二進位制表示可以看作是一個集合(0 表示不在集合中,1 表示在集合中)。比如集合 `{1, 3, 4, 8}` ,可以表示成 $(100011010)_2$ 。而對應的位運算也就可以看作是對集合進行的操作。
| 操作 | 集合表示 | 位運算語句 |
| ------ | :-------------: | :-------------------------: |
| 交集 | $a \cap b$ | `a & b` |
| 並集 | $a \cup b$ | `a|b` |
| 補集 | $\bar{a}$ | `~a` (全集為二進位制都是 1) |
| 差集 | $a \setminus b$ | `a & (~b)` |
| 對稱差 | $a\triangle b$ | `a ^ b` |
### 二進位制的狀態壓縮
二進位制狀態壓縮,是指將一個長度為 $m$ 的 $bool$ 陣列用一個 $m$ 位的二進位制整數表示並存儲的方法。利用下列位運算操作可以實現原 $bool$ 陣列中對應下標元素的存取。(xor 等價於 ^)
| 操作 | 運算 |
| :-------------------------------------------------: | :----------------: |
| 取出整數 n 在二進位制表示下的第 k 位 | (n >> k) & 1 |
| 取出整數n 在二進位制表示下的第 0 ~ k - 1 位 (後 k 位) | n & ((1 << k) - 1) |
| 對整數 n 在二進位制表示下的第 k 位取反 | n xor (1 << k) |
| 對整數 n 在二進位制表示下的第 k 位賦值 1 | n \| (1 << k) |
| 對整數 n 在二進位制表示下的第 k 位賦值 0 | n & (~(1 << k)) |
這種方法運算簡便,並且節省了程式執行的時間和空間。當m不太大時,可以直接使用一個整數型別儲存。當m較大時,可以使用若干個整數型別(int陣列),也可以直接利用 $C++STL$ 為我們提供的 $bitset$ 實現
### 遍歷某個集合的子集
```cpp
// 遍歷 u 的非空子集
for (int s = u; s; s = (s - 1) & u) {
// s 是 u 的一個非空子集
}
```
用這種方法可以在 $O(2^{popcount(u)})$ ( $popcount(u)$ 表示 $u$ 二進位制中 1 的個數)的時間複雜度內遍歷 $u$ 的子集,進而可以在 $O(3^n)$ 的時間複雜度內遍歷大小為 $n$ 的集合的每個子集的子集。(複雜度為 $O(3^n)$ 是因為每個元素都有 不在大子集中/只在大子集中/同時在大小子集中 三種狀態。)
## 內建函式
GCC 中還有一些用於位運算的內建函式:[詳細文章介紹](https://www.cnblogs.com/RioTian/p/13527410.html)
1. `int __builtin_ffs(int x)` :返回 $x$ 的二進位制末尾最後一個 $1$ 的位置,位置的編號從 $1$ 開始(最低位編號為 $1$ )。當 $x$ 為 $0$ 時返回 $0$ 。
2. `int __builtin_clz(unsigned int x)` :返回 $x$ 的二進位制的前導 $0$ 的個數。當 $x$ 為 $0$ 時,結果未定義。
3. `int __builtin_ctz(unsigned int x)` :返回 $x$ 的二進位制末尾連續 $0$ 的個數。當 $x$ 為 $0$ 時,結果未定義。
4. `int __builtin_clrsb(int x)` :當 $x$ 的符號位為 $0$ 時返回 $x$ 的二進位制的前導 $0$ 的個數減一,否則返回 $x$ 的二進位制的前導 $1$ 的個數減一。
5. `int __builtin_popcount(unsigned int x)` :返回 $x$ 的二進位制中 $1$ 的個數。
6. `int __builtin_parity(unsigned int x)` :判斷 $x$ 的二進位制中 $1$ 的個數的奇偶性。
這些函式都可以在函式名末尾新增 `l` 或 `ll` (如 `__builtin_popcountll` )來使引數型別變為 ( `unsigned` ) `long` 或 ( `unsigned` ) `long long` (返回值仍然是 `int` 型別)。
例如,我們有時候希望求出一個數以二為底的對數,如果不考慮 `0` 的特殊情況,就相當於這個數二進位制的位數 `-1` ,而一個數 `n` 的二進位制表示的位數可以使用 `32-__builtin_clz(n)` 表示,因此 `31-__builtin_clz(n)` 就可以求出 `n` 以二為底的對數。
由於這些函式是內建函式,經過了編譯器的高度優化,執行速度十分快(有些甚至只需要一條指令)。
## 更多位數
如果需要操作的集合非常大,可以使用 `bitset容器` 。
## 題目推薦
[Luogu P1225 黑白棋遊戲](https://www.luogu.com.cn/problem/P1225)
## 參考
位運算技巧:
Other Builtins of GCC:
英文文件參考: