位運算的奇技淫巧(二)

RioTian發表於2020-09-02

位運算就是基於整數的二進位制表示進行的運算。由於計算機內部就是以二進位制來儲存資料,位運算是相當快的。

之前有總結過位運算的技巧,但稍微對以前寫的文章不太滿意,所以重新總結一下

常用的運算子共 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 的非負整數次冪

int mulPowerOfTwo(int n, int m) {  // 計算 n*(2^m)
  return n << m;
}

除以 2 的非負整數次冪

int divPowerOfTwo(int n, int m) {  // 計算 n/(2^m)
  return n >> m;
}

!!! warning
我們平常寫的除法是向 0 取整,而這裡的右移是向下取整(注意這裡的區別),即當數大於等於 0 時兩種方法等價,當數小於 0 時會有區別,如: -1 / 2 的值為 \(0\) ,而 -1 >> 1 的值為 \(-1\)

判斷一個數是不是 2 的正整數次冪

bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }

對 2 的非負整數次冪取模

int modPowerOfTwo(int x, int mod) { return x & (mod - 1); }

取絕對值

在某些機器上,效率比 n > 0 ? n : -n 高。

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 高。

// 如果 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); }

判斷符號是否相同

bool isSameSign(int x, int y) {  // 有 0 的情況例外
  return (x ^ y) >= 0;
}

交換兩個數

void swap(int &a, int &b) { a ^= b ^= a ^= b; }

獲取一個數二進位制的某一位

// 獲取 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\) 實現

遍歷某個集合的子集

// 遍歷 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 中還有一些用於位運算的內建函式:詳細文章介紹

  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\) 的個數的奇偶性。

這些函式都可以在函式名末尾新增 lll (如 __builtin_popcountll )來使引數型別變為 ( unsigned ) long 或 ( unsigned ) long long (返回值仍然是 int 型別)。
例如,我們有時候希望求出一個數以二為底的對數,如果不考慮 0 的特殊情況,就相當於這個數二進位制的位數 -1 ,而一個數 n 的二進位制表示的位數可以使用 32-__builtin_clz(n) 表示,因此 31-__builtin_clz(n) 就可以求出 n 以二為底的對數。

由於這些函式是內建函式,經過了編譯器的高度優化,執行速度十分快(有些甚至只需要一條指令)。

更多位數

如果需要操作的集合非常大,可以使用 bitset容器

題目推薦

Luogu P1225 黑白棋遊戲

參考

位運算技巧: https://graphics.stanford.edu/~seander/bithacks.html

Other Builtins of GCC: https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html

英文文件參考:https://www.jjj.de/fxt/fxtbook.pdf

相關文章