跳至主要内容

棋盤狀態表示

喺設計圍棋 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 個特徵平面