跳至主要内容

棋盤狀態表示

在設計圍棋 AI 之前,首先要解決一個基本問題:如何讓電腦「看懂」棋盤?

這個問題看似簡單,卻涉及到資料結構、演算法、以及神經網路輸入設計等多個層面。本文將從最基礎的二維陣列開始,逐步介紹圍棋 AI 使用的各種棋盤表示方法,最終說明 AlphaGo 如何將棋盤編碼成神經網路可以處理的格式。


二維陣列表示:最直觀的方式

基本概念

圍棋棋盤是一個 19×19 的方格網路,最直觀的表示方式就是使用二維陣列

import numpy as np

# 棋盤常數
BOARD_SIZE = 19
EMPTY = 0
BLACK = 1
WHITE = 2

# 初始化空棋盤
board = np.zeros((BOARD_SIZE, BOARD_SIZE), dtype=np.int8)

# 落子:黑子在 D4 (3, 3)
board[3, 3] = BLACK

# 落子:白子在 Q16 (15, 3)
board[15, 3] = WHITE

座標系統

圍棋使用兩種常見的座標系統:

1. 數字座標(程式用)

    0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18
0 . . . . . . . . . . . . . . . . . . .
1 . . . . . . . . . . . . . . . . . . .
2 . . . . . . . . . . . . . . . . . . .
3 . . . ● . . . . . + . . . . . ○ . . .
...

2. 字母-數字座標(棋譜用)

    A  B  C  D  E  F  G  H  J  K  L  M  N  O  P  Q  R  S  T
19 . . . . . . . . . . . . . . . . . . .
18 . . . . . . . . . . . . . . . . . . .
17 . . . . . . . . . . . . . . . . . . .
16 . . . ● . . . . . + . . . . . ○ . . .
...

注意:字母座標跳過 "I"(避免與數字 1 混淆)。

座標轉換函數

def coord_to_index(coord):
"""
將棋譜座標轉換為陣列索引
例如:'D4' -> (3, 15)
"""
letters = 'ABCDEFGHJKLMNOPQRST' # 跳過 I
col = letters.index(coord[0].upper())
row = BOARD_SIZE - int(coord[1:])
return row, col


def index_to_coord(row, col):
"""
將陣列索引轉換為棋譜座標
例如:(3, 15) -> 'Q16'
"""
letters = 'ABCDEFGHJKLMNOPQRST'
return f"{letters[col]}{BOARD_SIZE - row}"

空間複雜度分析

二維陣列表示的空間複雜度:

表示方式每格大小總空間
int81 byte361 bytes
int324 bytes1.4 KB
float648 bytes2.9 KB

對於現代電腦來說,這點記憶體完全不是問題。但在需要儲存大量棋盤狀態(如 MCTS 搜索樹)時,使用 int8 可以顯著減少記憶體使用。

基本操作

class Board:
def __init__(self, size=19):
self.size = size
self.board = np.zeros((size, size), dtype=np.int8)
self.current_player = BLACK
self.ko_point = None # 打劫禁著點
self.move_history = []

def is_valid_move(self, row, col):
"""檢查落子是否合法"""
# 1. 位置在棋盤內
if not (0 <= row < self.size and 0 <= col < self.size):
return False

# 2. 位置是空的
if self.board[row, col] != EMPTY:
return False

# 3. 不是打劫禁著點
if self.ko_point == (row, col):
return False

# 4. 不是自殺(除非能提子)
# ... 需要更複雜的檢查

return True

def place_stone(self, row, col):
"""落子"""
if not self.is_valid_move(row, col):
return False

self.board[row, col] = self.current_player
self.move_history.append((row, col))

# 處理提子
captures = self._remove_captured_stones(row, col)

# 更新打劫禁著點
self._update_ko(row, col, captures)

# 切換玩家
self.current_player = WHITE if self.current_player == BLACK else BLACK

return True

Zobrist Hashing:快速識別重複局面

問題背景

在圍棋 AI 中,我們經常需要判斷「兩個局面是否相同」:

  1. 打劫規則:不能讓局面回到上一手的狀態
  2. 三劫循環:特殊規則處理
  3. Transposition Table:記憶已搜索過的局面
  4. 神經網路快取:避免重複計算

如果每次都逐格比較,需要 O(361) 的時間。有沒有更快的方法?

Zobrist Hashing 原理

Zobrist Hashing(又稱 Zobrist Keys)是一種巧妙的雜湊方法,由 Albert Zobrist 在 1970 年提出。其核心思想是:

  1. 為每個「位置 × 顏色」的組合預先生成一個隨機數
  2. 棋盤的雜湊值 = 所有有棋子位置的隨機數的 XOR
  3. 落子/提子只需要一次 XOR 運算即可更新雜湊值

隨機數表

import numpy as np

# 生成隨機數表
# 使用固定種子確保可重複性
np.random.seed(42)

# 每個位置、每種顏色各一個隨機數
# zobrist[color][row][col]
zobrist_table = np.random.randint(
0, 2**64,
size=(3, BOARD_SIZE, BOARD_SIZE), # 3: EMPTY, BLACK, WHITE
dtype=np.uint64
)

# 輪到誰下棋的隨機數
zobrist_player = np.random.randint(0, 2**64, dtype=np.uint64)

XOR 運算的特性

XOR(異或)運算有幾個重要特性使它非常適合這個應用:

  1. 自反性:A ⊕ A = 0(同一個數 XOR 自己等於 0)
  2. 可逆性:A ⊕ B ⊕ B = A(XOR 兩次等於沒有)
  3. 交換律:A ⊕ B = B ⊕ A
  4. 結合律:(A ⊕ B) ⊕ C = A ⊕ (B ⊕ C)

這意味著:

  • 落子:hash ^= zobrist[color][row][col]
  • 提子:hash ^= zobrist[color][row][col](同樣的操作!)

實現

class ZobristBoard:
def __init__(self, size=19):
self.size = size
self.board = np.zeros((size, size), dtype=np.int8)
self.current_player = BLACK
self.hash = np.uint64(0)

# 初始化隨機數表
self._init_zobrist_table()

def _init_zobrist_table(self):
np.random.seed(12345)
self.zobrist = np.random.randint(
0, 2**64,
size=(3, self.size, self.size),
dtype=np.uint64
)
self.zobrist_player = np.random.randint(0, 2**64, dtype=np.uint64)

def place_stone(self, row, col, color):
"""落子並更新雜湊值"""
# 移除舊狀態(如果有棋子)
old_color = self.board[row, col]
if old_color != EMPTY:
self.hash ^= self.zobrist[old_color, row, col]

# 添加新棋子
self.board[row, col] = color
self.hash ^= self.zobrist[color, row, col]

def remove_stone(self, row, col):
"""提子並更新雜湊值"""
color = self.board[row, col]
if color != EMPTY:
self.hash ^= self.zobrist[color, row, col]
self.board[row, col] = EMPTY

def switch_player(self):
"""切換玩家並更新雜湊值"""
self.hash ^= self.zobrist_player
self.current_player = WHITE if self.current_player == BLACK else BLACK

def get_hash(self):
"""獲取當前局面的雜湊值"""
return self.hash

增量更新的優勢

使用 Zobrist Hashing,更新雜湊值只需要 O(1) 時間:

操作傳統比較Zobrist
比較兩個局面O(361)O(1)
落子後更新O(361)O(1)
提子後更新O(361)O(k),k 是提子數

碰撞機率

使用 64 位元雜湊,碰撞機率極低:

P(碰撞) ≈ n² / 2^65

其中 n 是不同局面的數量。即使有 10 億個局面,碰撞機率也只有約 10^-3。

實際上,許多圍棋程式使用 64 位元 Zobrist Hashing 而不做碰撞檢查,因為碰撞機率低到可以忽略。


棋串表示(Union-Find):連通性問題

棋串的概念

在圍棋中,棋串(String 或 Chain)是指一組相連的同色棋子。棋串的「氣」決定了它的死活。

在上圖中,四顆黑子形成一個棋串,這個棋串的氣數決定了它的生死。

為什麼需要高效的棋串管理?

每次落子都可能:

  1. 創建新棋串
  2. 合併多個棋串
  3. 減少敵方棋串的氣
  4. 提掉沒有氣的棋串

這些操作需要高效執行,因為在 MCTS 中,一秒鐘可能需要進行數萬次落子模擬。

Union-Find 資料結構

Union-Find(又稱 Disjoint Set Union, DSU)是處理「連通性」問題的經典資料結構:

class UnionFind:
def __init__(self, size):
# parent[i] 指向 i 的父節點
# 如果 parent[i] = i,則 i 是根節點
self.parent = list(range(size))
self.rank = [0] * size

def find(self, x):
"""
找到 x 所屬集合的根節點(帶路徑壓縮)
"""
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # 路徑壓縮
return self.parent[x]

def union(self, x, y):
"""
合併 x 和 y 所屬的集合(帶按秩合併)
"""
root_x = self.find(x)
root_y = self.find(y)

if root_x == root_y:
return # 已經在同一集合

# 按秩合併:將較小的樹連到較大的樹
if self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
elif self.rank[root_x] > self.rank[root_y]:
self.parent[root_y] = root_x
else:
self.parent[root_y] = root_x
self.rank[root_x] += 1

時間複雜度

Union-Find 的時間複雜度使用反阿克曼函數 α(n) 來表示:

操作時間複雜度
findO(α(n)) ≈ O(1)
unionO(α(n)) ≈ O(1)

α(n) 增長極慢,對於任何實際的 n,α(n) ≤ 4。因此可以認為是常數時間。

圍棋中的 Union-Find 應用

class GoStringManager:
def __init__(self, size=19):
self.size = size
num_points = size * size

# Union-Find 結構
self.parent = list(range(num_points))
self.rank = [0] * num_points

# 每個棋串的氣(用根節點索引)
self.liberties = [set() for _ in range(num_points)]

# 每個棋串的棋子(用根節點索引)
self.stones = [set() for _ in range(num_points)]

def _index(self, row, col):
return row * self.size + col

def _neighbors(self, row, col):
"""獲取四鄰"""
neighbors = []
if row > 0: neighbors.append((row - 1, col))
if row < self.size - 1: neighbors.append((row + 1, col))
if col > 0: neighbors.append((row, col - 1))
if col < self.size - 1: neighbors.append((row, col + 1))
return neighbors

def find(self, idx):
if self.parent[idx] != idx:
self.parent[idx] = self.find(self.parent[idx])
return self.parent[idx]

def add_stone(self, row, col, color, board):
"""
添加一顆棋子,處理連接和提子
"""
idx = self._index(row, col)

# 創建新棋串
self.stones[idx] = {idx}
self.liberties[idx] = set()

# 計算這顆棋子的氣
for nr, nc in self._neighbors(row, col):
nidx = self._index(nr, nc)
if board[nr, nc] == 0: # 空點是氣
self.liberties[idx].add(nidx)

# 與同色鄰居合併
for nr, nc in self._neighbors(row, col):
nidx = self._index(nr, nc)
if board[nr, nc] == color:
self._merge_strings(idx, nidx)

# 減少敵方棋串的氣
opponent = 3 - color # BLACK=1, WHITE=2
captured = []
for nr, nc in self._neighbors(row, col):
nidx = self._index(nr, nc)
if board[nr, nc] == opponent:
root = self.find(nidx)
self.liberties[root].discard(idx)
if len(self.liberties[root]) == 0:
captured.append(root)

return captured

def _merge_strings(self, idx1, idx2):
"""合併兩個棋串"""
root1 = self.find(idx1)
root2 = self.find(idx2)

if root1 == root2:
return

# 按秩合併
if self.rank[root1] < self.rank[root2]:
root1, root2 = root2, root1

self.parent[root2] = root1
if self.rank[root1] == self.rank[root2]:
self.rank[root1] += 1

# 合併氣和棋子集合
self.liberties[root1] |= self.liberties[root2]
self.stones[root1] |= self.stones[root2]

# 移除自己位置的氣(自己不是自己的氣)
for stone_idx in self.stones[root2]:
self.liberties[root1].discard(stone_idx)

氣的計算

計算棋串的氣數是圍棋中最常見的操作之一:

def count_liberties(self, row, col, board):
"""計算某顆棋子所屬棋串的氣數"""
idx = self._index(row, col)
root = self.find(idx)
return len(self.liberties[root])

使用 Union-Find,這個操作是 O(1) 的(假設 find 是 O(1))。


特徵編碼的演進

從傳統的手工特徵到 AlphaGo Zero 的簡潔特徵,圍棋 AI 的特徵編碼經歷了重大演進。

GNU Go 的手工特徵

早期的圍棋程式(如 GNU Go)使用大量手工設計的特徵:

def extract_features_gnugo(board, point):
"""
GNU Go 風格的手工特徵提取
"""
features = {}

# 1. 棋形模式(預定義的局部形狀)
features['pattern'] = match_pattern(board, point)

# 2. 戰術特徵
features['can_capture'] = check_capture(board, point)
features['can_connect'] = check_connect(board, point)
features['creates_eye'] = check_eye_creation(board, point)

# 3. 局部特徵
features['liberties_after'] = count_liberties_after_move(board, point)
features['enemy_liberties'] = count_enemy_liberties(board, point)

# 4. 全局特徵
features['distance_to_edge'] = min(
point[0], point[1],
18 - point[0], 18 - point[1]
)

# ... 更多特徵

return features

這種方法的問題:

  • 需要大量人類專家知識
  • 特徵可能不完整
  • 難以發現新的模式

AlphaGo 的 48 個特徵平面

AlphaGo(2016 Nature 版本)使用了 48 個特徵平面作為神經網路的輸入:

def encode_alphago_features(board, player, history):
"""
AlphaGo 2016 的 48 個特徵平面

Returns:
(19, 19, 48) 的張量
"""
features = np.zeros((19, 19, 48), dtype=np.float32)

# 平面 1: 黑子位置
features[:, :, 0] = (board == BLACK)

# 平面 2: 白子位置
features[:, :, 1] = (board == WHITE)

# 平面 3: 空點
features[:, :, 2] = (board == EMPTY)

# 平面 4: 全 1(偏置項)
features[:, :, 3] = 1

# 平面 5-12: 氣數編碼(1-8氣各一個平面)
for i, num_libs in enumerate(range(1, 9)):
for r in range(19):
for c in range(19):
if board[r, c] != EMPTY:
libs = count_liberties(board, r, c)
if libs == num_libs:
features[r, c, 4 + i] = 1

# 平面 13-20: 提子後對方氣數
# ...

# 平面 21-28: 自己落子後的氣數
# ...

# 平面 29-36: 過去 8 步的歷史(每步一個平面)
for i, (r, c) in enumerate(history[-8:]):
features[r, c, 28 + i] = 1

# 平面 37-44: 征子判斷
# ...

# 平面 45-48: 對稱性相關
# ...

# 平面 49: 輪到誰下(全 1 表示黑,全 0 表示白)
if player == BLACK:
features[:, :, 47] = 1

return features

48 個平面的分類

類別平面數說明
棋子位置3黑子、白子、空點
常數1全 1
氣數81-8 氣
提子後氣數8假設落子後的氣數
落子後氣數8假設落子後的氣數
歷史8過去 8 步
征子8複雜的征子分析
其他4輪次、對稱性等

AlphaGo Zero 的 17 個特徵平面

AlphaGo Zero(2017)大幅簡化了特徵編碼,只使用 17 個平面

def encode_alphago_zero_features(board_history, player):
"""
AlphaGo Zero 的 17 個特徵平面

Args:
board_history: 過去 8 個局面(包括當前)
player: 當前玩家

Returns:
(19, 19, 17) 的張量
"""
features = np.zeros((19, 19, 17), dtype=np.float32)

# 平面 1-8: 黑子歷史(最近 8 步)
for i, board in enumerate(board_history[-8:]):
features[:, :, i] = (board == BLACK)

# 平面 9-16: 白子歷史(最近 8 步)
for i, board in enumerate(board_history[-8:]):
features[:, :, 8 + i] = (board == WHITE)

# 平面 17: 輪到誰下(全 1 表示黑,全 0 表示白)
if player == BLACK:
features[:, :, 16] = 1

return features

17 個平面的簡潔性

類別平面數說明
黑子歷史8過去 8 個時間步的黑子位置
白子歷史8過去 8 個時間步的白子位置
當前玩家1全 1 或全 0

為什麼可以這麼簡潔?

關鍵洞見:讓神經網路自己學習特徵。

AlphaGo Zero 證明了:

  • 不需要手工計算氣數(網路可以從棋子位置推斷)
  • 不需要征子分析(網路可以學會識別征子)
  • 不需要複雜的提子預測(網路可以理解落子後果)

這體現了深度學習的核心優勢:端到端學習

特徵平面的視覺化

讓我們看一下這些特徵平面實際長什麼樣:

原始棋盤:              黑子平面:              白子平面:
. . . . . . 0 0 0 0 0 0 0 0 0 0 0 0
. ● ○ . . . 0 1 0 0 0 0 0 0 1 0 0 0
. . ● ○ . . -> 0 0 1 0 0 0 + 0 0 0 1 0 0
. . . ● . . 0 0 0 1 0 0 0 0 0 0 0 0
. . . . . . 0 0 0 0 0 0 0 0 0 0 0 0

每個平面都是一個二元矩陣(0 或 1),神經網路可以通過卷積操作來提取這些平面中的模式。


對稱性處理:8 種變換

圍棋的對稱性

圍棋棋盤具有 8 種對稱性

  1. 4 種旋轉:0°、90°、180°、270°
  2. 4 種翻轉後旋轉:水平翻轉 + 4 種旋轉

這形成了一個 二面體群 D4

數學表示

設 (x, y) 為棋盤上的座標(中心在 (9, 9)),8 種變換可以表示為:

變換公式
恆等(x, y)
旋轉 90°(-y, x)
旋轉 180°(-x, -y)
旋轉 270°(y, -x)
水平翻轉(-x, y)
垂直翻轉(x, -y)
對角翻轉(y, x)
反對角翻轉(-y, -x)

程式實現

def get_symmetries(board):
"""
獲取棋盤的 8 種對稱變換

Returns:
8 個棋盤的列表
"""
symmetries = []

# 原始
symmetries.append(board.copy())

# 旋轉 90°
symmetries.append(np.rot90(board, k=1))

# 旋轉 180°
symmetries.append(np.rot90(board, k=2))

# 旋轉 270°
symmetries.append(np.rot90(board, k=3))

# 水平翻轉
symmetries.append(np.fliplr(board))

# 垂直翻轉
symmetries.append(np.flipud(board))

# 對角翻轉
symmetries.append(board.T)

# 反對角翻轉
symmetries.append(np.rot90(board.T, k=2))

return symmetries


def apply_symmetry(board, sym_index):
"""
應用第 sym_index 種對稱變換
"""
return get_symmetries(board)[sym_index]


def inverse_symmetry(move, sym_index, board_size=19):
"""
將變換後的落子座標轉回原始座標
"""
row, col = move

if sym_index == 0: # 恆等
return row, col
elif sym_index == 1: # 旋轉 90°
return col, board_size - 1 - row
elif sym_index == 2: # 旋轉 180°
return board_size - 1 - row, board_size - 1 - col
elif sym_index == 3: # 旋轉 270°
return board_size - 1 - col, row
elif sym_index == 4: # 水平翻轉
return row, board_size - 1 - col
elif sym_index == 5: # 垂直翻轉
return board_size - 1 - row, col
elif sym_index == 6: # 對角翻轉
return col, row
elif sym_index == 7: # 反對角翻轉
return board_size - 1 - col, board_size - 1 - row

資料增強

在訓練神經網路時,可以利用對稱性進行資料增強

def augment_training_data(board, policy, value):
"""
將一個訓練樣本擴展為 8 個
"""
augmented = []

for sym_index in range(8):
# 變換棋盤
aug_board = apply_symmetry(board, sym_index)

# 變換策略(361 維向量)
policy_2d = policy.reshape(19, 19)
aug_policy_2d = apply_symmetry(policy_2d, sym_index)
aug_policy = aug_policy_2d.flatten()

# 價值不變
aug_value = value

augmented.append((aug_board, aug_policy, aug_value))

return augmented

這讓訓練資料量增加 8 倍,且完全免費(不需要收集新資料)。

推理時的對稱性利用

AlphaGo 在實際對弈時也利用對稱性:

def predict_with_symmetry(model, board):
"""
利用對稱性增強預測
"""
policies = []
values = []

for sym_index in range(8):
# 變換輸入
aug_board = apply_symmetry(board, sym_index)

# 神經網路預測
policy, value = model.predict(aug_board)

# 將策略變換回原始座標系
policy_2d = policy.reshape(19, 19)
original_policy = inverse_symmetry_2d(policy_2d, sym_index)
policies.append(original_policy.flatten())

values.append(value)

# 平均所有預測
avg_policy = np.mean(policies, axis=0)
avg_value = np.mean(values)

return avg_policy, avg_value

這種做法可以稍微提高預測的準確性和穩定性。


棋盤表示方法總結

方法用途複雜度特點
二維陣列基本儲存O(361) 空間簡單直觀
Zobrist Hashing局面識別O(1) 查詢高效雜湊
Union-Find棋串管理O(α(n)) 操作處理連通性
特徵平面神經網路輸入O(361×平面數)編碼豐富資訊
對稱性變換資料增強8× 資料量免費擴增

這些技術相互配合,構成了現代圍棋 AI 的基礎設施。


動畫對應

本文涉及的核心概念與動畫編號:

編號概念物理/數學對應
🎬 A1二維陣列表示矩陣、稀疏資料
🎬 A2Zobrist Hashing雜湊函數、XOR 運算
🎬 A8特徵平面編碼張量、多通道輸入
🎬 A5對稱性處理群論、二面體群

延伸閱讀


參考資料

  1. Zobrist, A. L. (1970). "A new hashing method with application for game playing." ICCA journal.
  2. Tarjan, R. E. (1975). "Efficiency of a Good But Not Linear Set Union Algorithm." Journal of the ACM, 22(2), 215-225.
  3. Silver, D., et al. (2016). "Mastering the game of Go with deep neural networks and tree search." Nature, 529, 484-489. — AlphaGo 48 個特徵平面
  4. Silver, D., et al. (2017). "Mastering the game of Go without human knowledge." Nature, 550, 354-359. — AlphaGo Zero 17 個特徵平面

📌 重點摘要

本文重點:

  • Zobrist Hashing 使用 XOR 運算,可以在 O(1) 時間內判斷兩個局面是否相同,是處理打劫和 transposition table 的關鍵技術
  • AlphaGo 使用 48 個特徵平面編碼棋盤,包含棋子位置、氣數、歷史著法等資訊;AlphaGo Zero 簡化為 17 個平面,讓網路自己學習特徵
  • 圍棋棋盤具有 8 種對稱性(二面體群 D4),可用於資料增強,讓訓練資料免費增加 8 倍

常見問題

為什麼 Zobrist Hashing 可以用 O(1) 時間更新雜湊值?

Zobrist Hashing 利用 XOR 的自反性(A XOR A = 0)和可逆性。落子只需 XOR 一個預先生成的隨機數,提子也是同樣操作。這比逐格比較(O(361))快得多,在 MCTS 大量模擬中非常重要。

AlphaGo Zero 為什麼可以只用 17 個特徵平面?

AlphaGo 原版用 48 個平面包含氣數、征子分析等手工計算的特徵。但 AlphaGo Zero 證明:讓神經網路自己從棋子位置歷史中學習這些特徵更有效。這體現了深度學習「端到端」的優勢。

Union-Find 在圍棋 AI 中有什麼用途?

Union-Find 用於高效管理棋串(連通的同色棋子)。每次落子可能創建、合併棋串或減少敵方氣數。使用 Union-Find,這些操作都是接近 O(1) 的時間複雜度,對 MCTS 的大量模擬至關重要。