Day13 圍棋征子邏輯 === 今天要來實作一個很有趣的題目,就是圍棋的征子,這邊會用上很多前幾天所分享到的演算法,剛好當作複習。 這邊一樣不浪費篇幅寫規則,這邊附上維基百科的傳送門:[圍棋基礎規則](https://zh.wikipedia.org/zh-tw/%E5%9B%B4%E6%A3%8B#%E8%A7%84%E5%88%99)。 這邊還有[圍棋進階規則](https://zh.wikipedia.org/zh-tw/%E5%9C%8D%E6%A3%8B%E8%A6%8F%E5%89%87%E6%AF%94%E8%BC%83),說真的很多進階規則我自己也搞不太懂,像是假生,我也是在當圍棋替代役的時候聽棋協的秦秘書長說的。 想學圍棋的話可以報名[黑嘉嘉圍棋教室基礎課](https://www.heijiajia.com.tw/origin/#/shop/products/30k-10k-v2),裡面很多AI功能都是我與正在UCLA讀博士的Steven大大做的,所以自行業配一波XDD。 ## 征子 [征子](https://zh.wikipedia.org/wiki/%E5%BE%81%E5%AD%90)是圍棋的一種吃子技巧,如下圖此時黑棋只要下在A位,無論白棋如何掙扎都是無法逃脫的。 ![](https://i.imgur.com/GbQi8Gh.png) ![](https://i.imgur.com/MfdLBAh.gif) 征子會讓對方棋子始終保持在1~2氣的狀態,從1氣被叫吃,跑一手後仍只有2氣,只要進攻方注意到緊氣的位置,就能以這種方式把對方給吃掉。 征子對下圍棋的人來說是個非常基礎的吃子技巧,但將征子給實作出來就沒那麼容易了,有很多小細節藏在裡面。 大家可以先停在這裡思考一下,回想一下前幾天所分享到的各種演算法,試著動手寫寫看再回來繼續往下看。 ## 實作 ### 資料結構 最簡單的就是使用二維陣列來表示盤面狀態,這邊以9路棋盤為例:黑棋為`X`、白棋為`O`、空為`.` 例: ```python= board = [ [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", "X", ".", "."], [".", ".", ".", ".", ".", "X", "O", "X", "."], [".", ".", ".", ".", ".", ".", "O", "X", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."] ] ``` 開始實作征子前要先根據圍棋的規則完成一些小功能。 ### 判斷同塊棋與氣 首先要先判斷哪些棋子是連接在一起的,只要是相連的棋就視為同一塊,還要紀錄[氣](https://zh.wikipedia.org/wiki/%E6%B0%A3_(%E5%9C%8D%E6%A3%8B))的位置,簡單說就是同一塊棋旁邊的空位就是這塊棋的氣。 想法上就是用遞迴去解,從目標棋子開始,往上下左右四個方向找,如果也是自己的棋,就把這顆子加入棋塊,然後再對這顆子的上下左右去找,為了避免回頭重複找,所以要記錄找過的地方。 ```python= # 定義棋盤大小 BOARD_SIZE = 9 # 定義方向 (上下左右) DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] def is_in_bounds(x, y): # 檢查座標是否在棋盤內 return 0 <= x < BOARD_SIZE and 0 <= y < BOARD_SIZE ``` `board` 表示棋盤的二維陣列。 `visited` 用來存已經訪問過的點。 `stones` 儲存棋串。 `liberties` 氣的英文好像都是翻成liberty,我也不知道為什麼,這邊用set來存是因為有可能同一個氣被重複計算到,直接用set比較方便。 例如下圖,A點會被上跟左兩顆黑棋重複計算到。 ![image](https://hackmd.io/_uploads/rywlDfN0C.png) ```python= def get_stones_and_liberties(board, x, y): if board[x][y] == '.': return [], [] visited = set() # 已訪問過的點 stones = [] # 儲存棋串中所有的棋子位置 liberties = set() # 記錄該棋串的氣 color = board[x][y] # 棋子的顏色 # 深度優先搜尋 (DFS) def dfs(x, y): # 避免重複訪問 if (x, y) in visited: return visited.add((x, y)) # 標記已訪問過 stones.append((x, y)) # 將該點加入棋串 # 檢查四個方向 for dx, dy in DIRECTIONS: nx, ny = x + dx, y + dy # 檢查是否在邊界內 if is_in_bounds(nx, ny): if board[nx][ny] == '.': # 如果是空點,記錄為氣 liberties.add((nx, ny)) elif board[nx][ny] == color: # 如果是同顏色的棋子,繼續搜尋 dfs(nx, ny) # 從輸入的位置開始搜尋 dfs(x, y) return stones, liberties ``` 這邊應該還蠻簡單的,很像修資料結構演算法時,老師會出的小作業。 ### 判斷征子 接下來要進入到征子最重要的部分了,我們要判斷黑棋能不能吃掉白棋,如果我們單純的使用Minimax + Alpha-Beta Pruning的話,應該是非常難在有限的時間內跑出解來,畢竟光是9x9的棋盤也已經過於複雜。 但是這邊因為是要判斷征子,我們就要利用征子的特性,讓對方的棋子始終保持在1~2氣之間的狀態,所以我們就可以通過類似Threat Space Search的概念,將搜索範圍限縮在「緊氣」的點上,而白棋被「叫吃」就只能逃跑,會大幅減少搜索難度。 進攻方嘗試緊氣去吃掉對方,防守方要逃跑增加氣,所以要先標註出氣的位置,這就會利用到前面寫的`get_stones_and_liberties`了,進攻方把所有能緊氣的地方都試下看看,由於征子的特性,進攻方在進攻時讓目標棋子的氣大於2氣則為失敗,目標棋子只剩1氣時,因為輪到進攻方下,此時就可以直接將對方吃掉,就是征子成功,目標棋子為0氣那就更不用說了當然就是已經吃掉了。 `target` 為目標棋子,如果是一塊棋那給其中一顆棋子的座標即可。 `color` 為先手方顏色,O或X `target_color` 為目標棋子顏色 `opponent_color` 對方棋子顏色 `score` 一開始min層設為10,max層設為-10,因為回傳只有1跟0(成功/失敗)所以不用真的設成無限大,設成2跟-2也可以。 如果先手方顏色與目標顏色不同,那先手方則為進攻方,顏色相同就當然是防守方了~ ```python= def is_ladder(board, target, color): if (board[target[0]][target[1]] == '.'): return 0 target_color = board[target[0]][target[1]] # 目標棋子顏色 opponent_color = 'X' if color == 'O' else 'O' # 對方顏色 stones, liberties = get_stones_and_liberties(board, target[0], target[1]) # 進攻方 (max層) if color != target_color: if len(liberties) > 2: return 0 # 目標大於2氣 失敗 if len(liberties) <= 1: return 1 # 目標氣少於等於1 成功 score = -10 for x, y in liberties: board[x][y] = color score = max(is_ladder(board, target, opponent_color), score) board[x][y] = '.' if score == 1: # 找到一條獲勝路徑提前剪枝 break # 防守方 (min層) if color == target_color: if len(liberties) >= 2: return 0 # 防守方有2個或以上的氣,逃脫成功 score = 10 for x, y in liberties: board[x][y] = color score = min(is_ladder(board, target, opponent_color), score) board[x][y] = '.' if score == 0: # 找到一條逃跑路徑提前剪枝 break return score ``` 如果能吃掉會回傳1,吃不掉會回傳0,最後附上一個印出棋盤的功能方便大家印出棋盤測試。 ```python= def print_board(board): index_A = 'abcdefghijklmnopqrs' size = len(board) index_A = index_A[0:size] for c in ' '+index_A: print(c, end=' ') print() for i, line in enumerate(board): print(index_A[i], end=' ') for value in line: print(value, end=' ') print() print() ``` 以下是測試範例,board1應該要回傳1,board2應該要回傳0。 ``` board1 = [ [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", "X", ".", "."], [".", ".", ".", ".", ".", "X", "O", "X", "."], [".", ".", ".", ".", ".", ".", "O", "X", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."] ] board2 = [ [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", "X", ".", "."], [".", ".", ".", ".", ".", "X", "O", "X", "."], [".", ".", ".", ".", ".", ".", "O", "X", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", "O", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."] ] ``` ## 總結 在實作圍棋征子邏輯的過程,我們用到了Minimax Algorithm、Alpha-Beta Pruning,利用氣來縮小搜索空間,類似於Threat Space Search的概念,希望大家在複習前面所學時,也能同時感受到圍棋的趣味。 ## 特殊情況思考 這個範例其實是征子失敗的,但如果是按照上面的程式會回傳1。 這邊就留給大家思考是為什麼了,這幾天太肝了,只好拆成兩篇,明天再來解答。 ``` board = [ [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", "X", ".", "."], [".", ".", ".", ".", ".", "X", "O", "X", "."], [".", ".", ".", ".", ".", ".", "O", "X", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", "O", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."], [".", ".", ".", ".", ".", ".", ".", ".", "."] ] ```