Day27 Bitwise Operation
===
今天要來詳細分享一些使用bitboard的好處,可以如何快速操作盤面、優化程式碼,順便分享自己當年有多蠢,給大家一個錯誤示範。
## 蜜月橋牌Bitboard
研究所時期我因為程度太差,所以沒能發揮出bitboard的強大,當時我還是使用Array來表示手牌,因為實際上我們還是需要知道那張牌是什麼,二元序列只表達了牌的相對大小關係,所以只有在做Minimax跟查詢殘局庫時才會轉成二元序列做操作。
但實際上我們可以用一個**64位元的無號整數**來表達蜜月橋牌的手牌,按照[蜜月橋牌程式叫牌與換牌階段的策略改進](http://doi.org/10.6345/NTNU202201230)中的設計方式如下圖:
![](https://hackmd.io/_uploads/HyU28vIJke.png)
每13個bit表示一個花色,按照點數大小A~2做排列,依序為梅花、方塊、紅心、黑桃,剩下12個bit當作保留區,**標記為1代表有這張牌、0代表沒這張牌**,最後使用3個bitboard分別來記錄我方手牌、對方手牌、棄牌堆。
這邊為了方便我們需要先建立幾個遮罩,首先是花色的遮罩,方便我們快速的切割不同花色,再來是前n位的遮罩。
```cpp
uint64_t flowerMasks[4]={0x0000000000001fff,
0x0000000003ffe000,
0x0000007ffc000000,
0x0000fff8000000000}
uint64_t masks[53] = {0x00...0, 0x00...1, 0x00...3, 0x00...7, ...}
```
一些基本操作請看前幾天的bitboard基本操作,這邊簡單示範一下遮罩的用法。
```cpp
bitboard &= flowerMasks[n] // 這樣可以只留下其中一個花色的牌
bitboard ^= masks[n] // 將前n個位元0跟1交換
```
這樣要將bitboard轉換成二元序列只需要簡單兩行:
`first`為先手方的牌,`second`為後手方的牌
做OR運算後可以得到所有的牌,再使用`_pext_u64`將先手方的中的這些bit給取出來,就可以得到1代表先手方0代表後手方的二元序列了。
`_pext_u64`是BMI2指令集的內建函式,用來提取特定的位元。
```cpp
cards = first | second;
board = _pext_u64(first, cards);
```
想要得到各花色張數也非常快,利用`flowerMasks`來快速切出四個花色,再利用`_mm_popcnt_u64`來數有幾個1,`nums[]`用來儲存各花色張數。
`_mm_popcnt_u64`是POPCNT指令集的內建函式,用來計算位元中1的個數。
```cpp
for(int i = 0; i < 4; i++){
uint64_t flower = cards&flowerMasks[i];
nums[i] = _mm_popcnt_u64(flower);
}
```
因為這邊使用了大量的**Bitwise Operation(位元運算)**,所以速度上會比使用Array的資料結構快得非常多。
接下來我們來看一下當年懵懂無知的我,是怎麼寫出讓學長崩潰的爛code。
## 蜜月橋牌的Minimax
再看一次前幾天提到的蜜月橋牌表示法:
![](https://i.imgur.com/3pW5Tdn.png)
因為我們使用了二元序列的資料結構,做Minimax要展開合法走步時,就是選擇1的部分展開,若是到下一層發生先後手轉換的情形,則把盤面與`11111...111`(幾張牌就幾個1,此時前面的遮罩就發揮作用了)做XOR運算,這樣就可以把0跟1交換,一樣選擇1的部分展開就可以了。
![](https://i.imgur.com/55KxEbC.png)
### 剪枝
然後這邊可以做一個最簡單的剪枝,以梅花為例,我們會發現盤面`000111`中不論我們選擇哪張牌,之後盤面都將變為`00011`,所以相鄰的1我們只需要做一次即可。
![](https://i.imgur.com/aixdhoL.png)
## 實作過程
這邊我們要做Minimax就是要展開所有的合法走步,也就是要先取出二元序列中所有的1的部分,有連續1的部分就只取一個。
我的想法很簡單,就是一個for迴圈掃過所有的牌,每次左移一個bit,找到1的部分,然後判斷是否有連續的1,非常直觀,寫起來如下。
### 直覺寫法
`cards`為雙方剩餘手牌總張數
`board`為雙方手牌的二元序列
`newboard`為更新後雙方手牌的二元序列
`reorder`為更新手牌二元序列的函式
```cpp=
for (int i = 0; i < cards; i++) {
if ((board & (1 << i)) == 1 && (board & (1 << (i + 1))) != 1){
unsigned int newboard = reorder(board, i, cards);
//do thing...
}
}
```
判斷連續的1我也是用最簡單的想法,直接再往左移一個bit,如果不是1我才取,有1的話代表有連續,則再繼續左移,這樣掃完我就可以知道我要取的1在哪些位置,然後取出來再更新盤面。
更新盤面我這邊是直接重新排手牌,要把出掉的牌給移掉。
`choose`為選出的那張牌
```cpp=
unsigned int reorder(unsigned int board, int choose, int cards){
unsigned int newboard = 0;
for (int i = cards - 1; i >= 0; i--){
if (i != choose){
newboard <<= 1;
if ((board & (1 << i)) == 1){
newboard = newboard + 1;
}
}
}
return newboard;
}
```
這邊忠實還原當時的我有多蠢,給大家當作錯誤示範,實在是有很大的進步空間,當時彥吉學長看了看我寫的code,搖了搖頭,**露出了關愛智障的表情**,然後直接現場改寫。
### 優化第一版:
`p`為要取出的牌
```cpp=
while (board) {
auto p = board & -board; // uint32_t p = _blsi_u32(board);
board ^= p; // board = _blsr_u32(board);
// do thing...
}
```
這邊直接用個範例,假設雙方都還各剩5張牌的情況,`board = 0110110100`,與`-board`做AND運算就可以取得最右邊的1,最後把取出來的牌與原本的手牌二元序列做XOR運算就可以更新手牌資訊了。
`board & -board`可以直接寫成`_blsi_u32(board)`、`board ^ p`也可以寫成`_blsr_u32(board)`出來結果是一樣的。
```cpp
board = 0110101100
-board = 1001010100
p = board & -board
p = 0000000100
board ^= p
board = 0110101000
```
**從一開始的for迴圈需要執行10次,現在這個while迴圈只需要跑5次**,盤面更新也非常簡單快速,就一行搞定,其實到這我已經是跪著看了,然而還不只是這樣...
我們可以發現雖然這邊迴圈執行次數變少了,但是並沒有判斷連續1的剪枝,所以就繼續看下去!
### 優化第二版:
```cpp=
while (board) {
auto p = board & -board; // uint32_t p = _blsi_u32(board);
board &= board + p;
// do thing...
}
```
這邊一樣直接給範例,一開始跟前面一樣,不一樣的是`board &= board + p`,這樣會非常神奇的把後面連續的1都給消掉。
如此這個while迴圈**只需要執行3次!!!!!!**
真的是跪到天花板了,到底是多神才能想到這種寫法,感恩吉神讚嘆吉神!
```cpp
board = 0110101100
p = 0000000100
board + p = 0110110000
board &= board + p
= 0110100000
```
## 結論
利用bitboard加上位元運算可以讓程式效率大幅提升,除了好的演算法,還需要搭配好的資料結構,甚至實作中都是滿滿的細節,只能說電腦對局水真的太深了。
雖然說寫程式不難,但要寫好程式真的好難。
`_blsi_u32`跟`_blsr_u32`是x86的內建函式(BMI1指令集),包含上面的`_pext_u64`跟`_mm_popcnt_u64`,我在學長介紹之前是從來沒看過也沒用過,難怪學長們寫程式還會考慮到什麼cpu指令集,身為管院跨考資工所的半路出家仔,我連寫最基本的for迴圈都會被學長嫌效率太差,真的是被震撼教育,後來是花了大量的時間學習才勉強能跟上大家。
## Reference
* [Design and implementation aspects of a Surakarta program](https://www.researchgate.net/publication/329160216_Design_and_implementation_aspects_of_a_Surakarta_program)
* [蜜月橋牌程式開發及殘局庫的建立](http://doi.org/10.6345/NTNU202001544)
* [蜜月橋牌程式叫牌與換牌階段的策略改進](http://doi.org/10.6345/NTNU202201230)