棋盤狀態表示
在設計圍棋 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}"
空間複雜度分析
二維陣列表示的空間複雜度:
| 表示方式 | 每格大小 | 總空間 |
|---|---|---|
int8 | 1 byte | 361 bytes |
int32 | 4 bytes | 1.4 KB |
float64 | 8 bytes | 2.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 中,我們經常需要判斷「兩個局面是否相同」:
- 打劫規則:不能讓局面回到上一手的狀態
- 三劫循環:特殊規則處理
- Transposition Table:記憶已搜索過的局面
- 神經網路快取:避免重複計算
如果每次都逐格比較,需要 O(361) 的時間。有沒有更快的方法?
Zobrist Hashing 原理
Zobrist Hashing(又稱 Zobrist Keys)是一種巧妙的雜湊方法,由 Albert Zobrist 在 1970 年提出。其核心思想是:
- 為每個「位置 × 顏色」的組合預先生成一個隨機數
- 棋盤的雜湊值 = 所有有棋子位置的隨機數的 XOR
- 落子/提子只需要一次 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(異或)運算有幾個重要特性使它非常適合這個應用:
- 自反性:A ⊕ A = 0(同一個數 XOR 自己等於 0)
- 可逆性:A ⊕ B ⊕ B = A(XOR 兩次等於沒有)
- 交換律:A ⊕ B = B ⊕ A
- 結合律:(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)是指一組相連的同色棋子。棋串的「氣」決定了它的死活。
在上圖中,四顆黑子形成一個棋串,這個棋串的氣數決定了它的生死。
為什麼需要高效的棋串管理?
每次落子都可能:
- 創建新棋串
- 合併多個棋串
- 減少敵方棋串的氣
- 提掉沒有氣的棋串
這些操作需要高效執行,因為在 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) 來表示:
| 操作 | 時間複雜度 |
|---|---|
| find | O(α(n)) ≈ O(1) |
| union | O(α(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 |
| 氣數 | 8 | 1-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 種對稱性:
- 4 種旋轉:0°、90°、180°、270°
- 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 | 二維陣列表示 | 矩陣、稀疏資料 |
| 🎬 A2 | Zobrist Hashing | 雜湊函數、XOR 運算 |
| 🎬 A8 | 特徵平面編碼 | 張量、多通道輸入 |
| 🎬 A5 | 對稱性處理 | 群論、二面體群 |
延伸閱讀
- 上一篇:傳統方法的極限 — Minimax 到 MCTS
- 下一篇:Policy Network 詳解 — 神經網路如何預測落子
- 實作練習:30 分鐘跑起第一個圍棋 AI — 親自動手體驗
參考資料
- Zobrist, A. L. (1970). "A new hashing method with application for game playing." ICCA journal.
- Tarjan, R. E. (1975). "Efficiency of a Good But Not Linear Set Union Algorithm." Journal of the ACM, 22(2), 215-225.
- Silver, D., et al. (2016). "Mastering the game of Go with deep neural networks and tree search." Nature, 529, 484-489. — AlphaGo 48 個特徵平面
- 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 的大量模擬至關重要。