# 位元運算 Bitwise Operation
我們一般看到的數字都是十進位的數字,十進位的數字意旨每十個數字進一位,因此每位數字只能是 $0$~$9$。那麼二進位就是每二個數字進一位,數字只能是 $0$ 或者 $1$。這特性剛好與電路通電與不通電的特性合得來,因此電腦的底層儲存的都是二進位的數字
這篇主要會介紹三個東西 : 不同的進位制、資料型態儲存、布林代數。透過這三者,我們才能完整地介紹位元運算,最後會介紹位元運算在競賽上的常見用法
## 不同進位數字的表示法
### 範例說明
基本上就是數字加小括號,右下角加小標代表進位制
- 十進位 : $(1782)_{10}$
- 二進位 : $(1011)_{2}$
- 八進位 : $(3254)_{8}$
### 換種方式來看十進位、二進位與八進位
我們可以把十進位數字 $(1782)_{10}$ 粗略的這樣做拆分
$$
(1782)_{10}=1\cdot 10^{3}+7\cdot 10^{2}+8\cdot 10^{1}+2\cdot 10^0
$$
二進位數字 $(1011)_{2}$ 大概就會長這樣
$$
(1011)_{2}=1\times 2^3+0\times 2^2+1\times 2^1+1\times 2^0=(11)_{10}
$$
八進位數字 $(3254)_{8}$ 大概就會是長這樣
$$
(3254)_{8}=3\cdot8^3+2\cdot8^2+5\cdot8^1+4\cdot8^0=(1708)_{10}
$$
### $k$ 進位數字
藉由上面的規律不難發現,今天若有一個 $k$ 進位的數字,我們知道他共會由 $0$~$k-1$ 這些符號組成。設一個序列長度為 $n$ 的 $k$ 進位數字 $a$,轉成十進位可以寫成這種形式 :
$$
a_{n-1} \cdots a_2a_1a_0=a_{n-1} \cdot k^{n-1}+\cdots+ a_2\cdot k^2+a_1\cdot k^1+a_0\cdot k^0
$$
### 十六進位
十六進位在資訊領域也是蠻常見的,基本上會把 $10$\~$15$ 寫成 $A$\~$F$。轉換方法跟前面提及的差不多,很 trivial 所以在此不多贅述
### 實作十進位轉八進位
由於十進位與二進位以外的進位法難以用現有的資料型態表達,所以直接使用字串來做為輸出。若要手算的話,短除法就可以達成了,在程式上也是差不多的作法,只是用 `while` 迴圈來實作
```cpp
string convert(int x){
string ret;
while(x > 1){ // 除到變成 1 就完成了
ret.push_back('0' + x % 8); // 這邊就取餘數就好,記得要轉成字元
x /= 8; // 短除法,每次除 8
}
reverse(ret.begin(), ret.end()); // 因每次都 push_back(),最後要反過來
return ret;
}
```
要轉成其他進位制也很 trivial,請自己當作練習試試看
## 資料型態的儲存
這邊只是粗淺地講 C/C++ 中各資料型態是怎麼儲存。如果想知道完整的知識,可以去學計算機組織與架構、計算機概論與數位邏輯設計,這些課應該都會提到
### 原理說明
電腦是通過電壓的大小 (high / low) 來看要存哪一種資料,因此只會有兩種狀態 : $0$、$1$。在每一個位元 (bit) 中,我們只會用 $0$、$1$ 表示,bit 是儲存資料的基本單位,想像成格子就好
### 整數 int
電腦中儲存整數有固定的大小,每個 int 用 $4$ bytes 也就是 $32$ bits 儲存,可以用 $32$ 個格子來表示
#### Sign Bit
第一個格子叫做 sign bit,如果是 $0$ 就是正的 $1$ 就是負的
<center>
<img src="https://hackmd.io/_uploads/S1HDY1_teg.png" style="margin: 0 auto; display: block; width: 600px">
</center>
#### 舉例說明
以一個 $3$ bit 的系統來說,所有能代表的數字大概長這樣
|bit|value|
|---|---|
|011|3|
|010|2|
|001|1|
|000|0|
|111|-1|
|110|-2|
|101|-3|
|100|-4|
這種表示法叫做二補數,如果要把正的變成負的就要把 $0$ 變成 $1$、把 $1$ 變成 $0$,然後 $+ 1$,然後你就會發現最後的 $-4$ 不適用
### 浮點數 float
用 $4$ bytes 也就是 $32$ bits 儲存,大致可以分為三個部分 :
- sign : 正負號($S$)
- exponent : 位移量($E$)
- mantissa : 尾數($M$)
<center>
<img src="https://hackmd.io/_uploads/SyjgpkuKee.png" style="margin: 0 auto; display: block; width: 600px">
</center>
一個浮點數寫起來就會是 $S\times M\times 2^E$
#### 舉例說明
**$7.25$ 化成浮點數儲存**
1. 判斷正負 : 正
2. 將其化為二進制 : $111.01$
3. 將小數點移到最前面 : $1.1101$
發現位移量為$2$ 並捨去最前面的$1$
4. 計算 $E$ 加上 $2^8-1$ 其中 $8$ 為格子數量 : $E=(2^8-1)+2=(129)_{10}=(1000 0001)_{2}$
5. 寫答案 : $01000000111010000000000000000000$
**可以驗算一下答案**
$S=+1$
$E=129-127=2$
$M=(0.1101)_{2}+1=1.8125$
$S\times M\times 2^E=7.25$
### 備註
- 因為格子都是有限的,所以當紀錄的數字超出格子時就會溢位,導致數字出現錯誤
- 以上浮點數看不懂沒關係,不是資工系其實沒必要知道
## 布林代數
電腦處理位元運算是基於電路的邏輯閘,邏輯閘的基礎是基於布林代數,所以先來講講布林代數在幹嘛
### 簡介
布林代數是由[喬治·布爾](https://zh.wikipedia.org/zh-tw/%E4%B9%94%E6%B2%BB%C2%B7%E5%B8%83%E5%B0%94)提出。主要是處理邏輯問題,將邏輯從哲學轉化成數學。整個代數只有兩種元素 : $\text{Ture}$ ($1$), $\text{False}$ ($0$),有時會寫成 T/F。這東西被信息學之父[克勞德·香農](https://zh.wikipedia.org/zh-tw/%E5%85%8B%E5%8A%B3%E5%BE%B7%C2%B7%E9%A6%99%E5%86%9C)用於處理電路問題,現今主要用於數學論證與電路設計上 ||討厭數位邏輯設計的,應該知道要怪誰了吧||
### 運算子
主要有三種運算子
- OR $\lor$ : 或
- AND $\land$ : 且
- NOT $\neg$ : 否
要注意的是,OR $\lor$ 跟 AND $\land$ 是二元運算子 (左右各放一個數字),NOT $\neg$ 是一元運算子 (只能放右邊的數字)
### 真值表 Truth Table
我們可以用「真值表」來看所有的運算,有課本寫說真值表就是運算子的定義。左邊是 $P$、$Q$ 分別代入的數字 (T/F);右邊是運算結果
**這是 OR $\lor$ 跟 AND $\land$ 的真值表**
|$P$|$Q$|$P\lor Q$|$P\land Q$|
|----|----|----|----|
|0|0|0|0|
|0|1|1|0|
|1|0|1|0|
|1|1|1|1|
**這是 NOT$\neg$ 的真值表**
|$P$|$\neg P$|
|----|----|
|0|1|
|1|0|
### 互斥或 XOR
有了上面三個運算子,我們就可以進行運算,也可以創造出新的運算子。接下來的 XOR $\oplus$ 運算子也算是很常用的運算子,真值表如下
|$P$|$Q$|$P\oplus Q$|
|----|----|----|
|0|0|0|
|0|1|1|
|1|0|1|
|1|1|0|
以下真值表也可以當作我們合成新運算子的工具,算是我們從基本的 AND、OR 與 NOT 推導出來的結果
|$P$|$Q$|$P\lor Q$|$P\land Q$|$\neg(P\land Q)$|$(P\lor Q)\land \neg(P\land Q)$|$P\oplus Q$|
|----|----|----|----|----|----|----|
|0|0|0|0|1|0|0|
|0|1|1|0|1|1|1|
|1|0|1|0|1|1|1|
|1|1|1|1|0|0|0|
會發現 $(P\lor Q)\land \neg(P\land Q)$ 與 $P\oplus Q$ 的真值表是相同的,這時我們就可以寫作 :
$$
P\oplus Q\equiv (P\lor Q)\land \neg(P\land Q)
$$
符號 $\equiv$ 我們稱為邏輯等價
### 用於敘述
設 $P$、$Q$ 敘述分別為「會下雨」、「地面會溼」,那這樣的敘述「若下雨,則地板會溼」就可以寫成 $\neg P\lor Q$,可以用真值表來寫寫看
|情況|$P$|$Q$|$\neg P\lor Q$|結果|
|----|----|----|----|----|
|沒下雨、地面沒溼|0|0|1|成立 (沒有下雨,無論地面有沒有溼都是對的)|
|沒下雨、面板溼|0|1|1|成立 (同上)|
|下雨、地面沒溼|1|0|0|不成立|
|下雨、地面溼|1|1|1|成立|
冷知識 : 「若 $p$ 則 $q$」的邏輯我們會用運算子 $p\rightarrow q$ 來表示
### 備註
這部分非常深,簡化這些邏輯有非常多技巧,在不同領域應用也很多,建議自己找書來看。關鍵字如下 :
- [mathatical logic](https://www.reddit.com/r/math/comments/1418n9n/whats_a_good_book_to_learn_rigorous_mathematical/)
- [digital logic design](https://www.tenlong.com.tw/products/9789867696274)
## 位元運算
當我們使用布林代數來處理位元問題時,就稱為位元運算。為了與邏輯運算做出區隔,跟別人講的時候記得加上 (bitwise),比如說 bitwise OR、bitwise AND 等等
### 位元運算子
邏輯運算與位元運算的整體使用起來非常相似,只是邏輯運算只作用於一個 T/F,位元運算則是作用於一整排 int (bit string) 上的 0/1
|運算|邏輯運算子|位元運算子|
|---|---|----|
|OR|$\lor$|\||
|AND|$\land$|&|
|XOR|$\oplus$|^|
|NOT|$\neg$|\~|
比較特別的是 $>>$、$<<$,在十進位的意思就是 $\div 2$、$\times 2$,但是在二進位的意思就是左移與右移
|運算|數學運算子|位元運算子|
|---|---|---|
|左移|$\div 2$ (向下取整)|<<|
|右移|$\times 2$|>>|
舉例來說
$$
5 \times 2 = 5 << 1 = (101)_2 << 1=(1010)_2=10
$$
$$
5 \div 2 = 5 >> 1 = (101)_2 >> 1=(10)_2=2
$$
左移後最右邊的位置要補 $0$
**注意 : C 語言中的位元運算子只適用於整數 int**
### 舉例說明
**在 C 語言寫 `5|6` 就會得到 $7$ :**
- $5=(101)_{2}$、$6=(110)_{2}$
<center>
<img src="https://hackmd.io/_uploads/Hy4nrl_Yxe.png" style="margin: 0 auto; display: block; width: 600px">
</center>
**在 C 語言寫 `5^6` 就會得到 $3$ :**
- $5=(101)_{2}$、$6=(110)_{2}$
<center>
<img src="https://hackmd.io/_uploads/SJ9NLlOFle.png" style="margin: 0 auto; display: block; width: 600px">
</center>
### 備註
- 這些位元運算子也可以加個`=`變成賦值運算子
- 由於位元運算子在 C 語言優先度非常低,要使用時記得括號括好
- 有一個好用的函式 `__builtin_popcount()`,可以帶入數字計算二進位中 $1$ 的數量
## 程式範例
### 二進位轉十進位
可以先用字串儲存二進位
```cpp
// bi[]: 輸入的字串, ans: 儲存答案
void binary_to_decimal(char bi[], int &ans){
int len = strlen(bi), ret = 0, ep = 1;
// len: 字串長度, ret: 紀錄運算過程, ep: 2的次方數
for(int i = len - 1; i >= 0; i--){
ret += (bi[i] - '0') * ep;
ep <<= 1;
}
ans = ret;
}
```
### 十進位轉二進位
答案以字串儲存
```cpp
void decimal_to_binary(int &n, char ans[]){
int idx = 0; // 這就是陣列編號
while(n){ // 這過程相當於短除法
ans[idx++] = (n & 1) + '0'; // (n & 1) 就是 (n % 2)
n >>= 1; // 除以 2
}
ans[idx] = '\0'; // 字串末尾一定要加上 '\0'
int len = strlen(ans); // 字串長度
for(int i = 0; i < len / 2; i++){ // 字串前後reverse過來
char tmp = ans[i]; // 做交換
ans[i] = ans[len - 1 - i];
ans[len - 1 - i] = tmp;
}
}
```
## 位元運算常見應用
以下這些都是常見手法,你可以自己寫寫真值表看檢驗是不是正確的。如果你是個注意力驚人的同學,應該可以注意到更多「trivial」的做法 (?
### 計算 $2^i$
```cpp
int ans = (1 << i); // 1 向左移 i 格就是算完了,輕輕鬆鬆
```
### 找出整數 $s$ 的第 $i$ 位 bit 是 $0$ 還是 $1$
```cpp
int ans = s & (1 << i);
if (ans) cout << "1"; // 若不為 0 則 ans 會是 2^i
else cout << "0"; // 若為 0 則 ans 會是 0
```
### 整數 $x$ 除以 $2$ / 乘以 $2$
```cpp
int d = x >> 1; // x 除以 2
int m = x << 1; // x 乘以 2
```
注意到 $x\cdot 2^i$ 就可以這樣寫
```cpp
int d = x >> i; // x 除以 2^i
int m = x << i; // x 乘以 2^i
```
小心這裡的除法都是向下取整,因為資料型態 `int` 無法儲存到小數位
### $0$/$1$ 取反
```cpp
x = ~x; // 其實就是 bitwise NOT
```
### 取整數 $a$ 的補數
前面有提到我們用的系統是二補數的整數系統,因此以下兩種寫法是一樣的 (不溢位的話)
```cpp
int ans = -a;
```
```cpp
int ans = (~a) + 1;
```
### 將 $s$ 的第 $i$ 位設為 $1$
如果第 $i$ 位已經是 $1$ 了,就不會改到任何的值
```cpp
s |= (1 << i);
```
### 將 $s$ 的第 $i$ 位 $0$/$1$ 翻轉
望周知,這種寫法不會改變第 $i$ 位以外的值,因為 $0\oplus0=0$ 且 $1\oplus 0=1$
```cpp
s ^= (1 << i);
```
### 將 $s$ 的第 $i$ 位設為 $0$
如果第 $i$ 位已經是 $0$ 了,就不會改到任何的值
```cpp
s &= ~(1 << i);
```
### 求出 $x$ 最低的 $1$ 在第幾位
假設答案是第 $i$ 位,這裡會回傳 $2^i$
```cpp
int ans = x & -x;
```
### 判斷整數 $x$ 是不是 $2$ 的冪次
```cpp
return (x & -x) == x;
```
### 整數 $x, y$ 交換
相當於 `swap(x, y)`
```cpp
x = x ^ y;
y = y ^ x;
x = x ^ y;
```
**這寫法有點玄,但是容我用幾句解釋 :**
- 設 $z=x\oplus y$,則 $z$ 所儲存的是「每一位是否不同」的 bit string
- $x\oplus z$ 就是 $x$ 把每一位與 $y$ 不相同的 bit 翻轉
- $y\oplus z$ 就是 $y$ 把每一位與 $x$ 不相同的 bit 翻轉
**舉個例子 :**
$x=10$ 與 $y=12$ 交換得到 $x'=12$ 與 $y'=10$
|第 $i$ 位|$x$|$y$|$z$ (是否不同)|$x'=x\oplus z$|$y'=y\oplus z$|
|---|---|---|---|---|---|
|4|1|1|0|1|1|
|3|0|1|1|1|0|
|2|1|0|1|0|1|
|1|0|0|0|0|0|
**所以程式碼就是這個意思**
1. 我們把 $z$ 的角色交給 $x$
```cpp
x = x ^ y;
```
2. 接下來把 $y$ 變成原本的 $x$
```cpp
y = y ^ x;
```
3. 最後把原本 $x$ 中「不相同的位元」翻過來
```cpp
x = x ^ y;
```
**最後,這裡還有個噁心的寫法**
```cpp
x ^= y ^= x ^= y;
```
### 判斷整數 $x$ 是不是奇數
這很簡單
```cpp
if(x & 1) cout << "YES\n";
else cout << "NO\n";
```
## 題目練習
[Zerojudge c461. apcs 邏輯運算子 (Logic Operators)](https://zerojudge.tw/ShowProblem?problemid=c461)
[Zerojudge e797. p4. 數位邏輯運算](https://zerojudge.tw/ShowProblem?problemid=e797)
[Zerojudge a132. 10931 - Parity](https://zerojudge.tw/ShowProblem?problemid=a132)
[Zerojudge b005. 布林矩陣的等價短陣](https://zerojudge.tw/ShowProblem?problemid=b005)
[Zerojudge a414. 位元運算之進位篇](https://zerojudge.tw/ShowProblem?problemid=a414)
[Zerojudge d632. C and S ??](https://zerojudge.tw/ShowProblem?problemid=d632)
[Zerojudge e253. a + b problem](https://zerojudge.tw/ShowProblem?problemid=e253)
[Zerojudge e545. 10019 - Funny Encryption Method](https://zerojudge.tw/ShowProblem?problemid=e545)
[Zerojudge e799. p6. 資工系的浪漫](https://zerojudge.tw/ShowProblem?problemid=e799)
[CSES Range Xor Queries](https://cses.fi/problemset/task/1650/) (bitwise XOR 可以差分喔)
[CSES Prime Multiples](https://cses.fi/problemset/task/2185)
----
## 參考資料
- [海大競程 - Time Complexity and Bitwise](https://hackmd.io/@LeeShoWhaodian/ComplexityandBitwise#/)
- [維基百科 - Bitwise operation](https://en.wikipedia.org/wiki/Bitwise_operation)
- [台師大演算法筆記 - bit](https://web.ntnu.edu.tw/~algo/Bit.html)
- [哈哈屁眼 - 位元運算](https://hackmd.io/@HAHAHAHAHA/bitwise)
----
> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2025/8/25