目录
1. 前言
2. 算法流程
3. 代码实现
3.1 终局及胜负判定方法
3.2 搜索邻节点
3.3 打印棋盘状态
3.4 代码
4. 小结
1. 前言
Tic-Tac-Toe中文常译作井字棋,即在3 x 3的棋盘上,双方轮流落子,先将3枚棋子连成一线的一方获得胜利。Tic-Tac-Toe变化简单,可能的局面和棋局数(注意区分局面与棋局!)都很有限(相比中国象棋、日本象棋、围棋等来说连九牛一毛都不到!具体有多少可能的局面以及可能的棋局数,本系列完成以后就可以给出答案了),因此常成为博弈论和游戏树搜寻的教学例子,同时也是人工智能的一道好题目。
本系列考虑实现一个Tic-Tac-Toe AI,以由浅入深循序渐进的方式来逐步完成这个实现。途中将会介绍minimax、alpha-beta pruning、MCTS等等算法,最终实现一个不可战胜的Tic-Tac-Toe AI,并以此为基础考虑更复杂的博弈游戏AI的实现。
Tic-Tac-Toe棋盘为3x3的正方形,共9个格子(tile or grid)。棋盘状态可以用一个含9个元素的一维数组表示,或者一个3x3的二维数组表示。这里假定用9个元素的一维数组board[9]表示,其中[0],[1],[2]表示棋盘的最上一行从左到右三个格子;[3],[4],[5]表示棋盘的中间一行从左到右三个格子;[6],[7],[8]表示棋盘的中间一行从左到右三个格子。
每个格子有三种状态,分别用0,1,2表示如下:
0: empty tile
1: X tile,假定总是X先走。
2: O tile
考虑以树的形式来表达所有可能的游戏过程(或者说棋局)。其中每个盘面状态表示一个节点,树的根节点就是棋盘的初始状态。一个游戏过程(或者说棋局)表示一局可能的Tic-Tac-Toe游戏的按顺序构成的盘面状态序列。比如说,第1个状态肯定是初始状态;第2个状态是由player1下了一手棋后所到达的状态;第3个状态是由player2下了一手棋后所到达的状态;。。。以下以此类推。
本文首先考虑搜索一种可能的游戏过程,也就是从树的根节点到达某个可能的终局状态的路径。这个可以用常见的深度优先搜索或者广度优先搜索的方式来实现。
初始状态显然为s0 = [0,0,0, 0,0,0, 0,0,0].
终局可以分为以下几种可能:
- 决出胜负的局面(双方总共手数可能等于9手也可能小于9手)
- 平局(双方手数必然等于9手)
2. 算法流程
用深度(或广度)优先算法解决这一搜索问题的伪代码描述如下:
将S0入栈或队列(深度优先搜索是使用栈,广度优先搜索使用队列)
将S0加入visited(通常用字典实现)
While (1):
从栈顶取出一个节点s
If s is end-of-game state:
记录结果并退出
Else:
For each neighbor of s:
If neighbor is not yet visited:
将neighbor入栈
将neighbor加入visited
Visit用python set实现,这样可以利用python set的高效的查询效率。考虑到python set的元素必须是hashable类型,所以棋盘状态用tuple表示。
3. 代码实现
3.1 终局及胜负判定方法
参见is_endofgame()
判定当前局面是否终局。当前用了个比较呆笨的办法。用win_comb预存了所有可能的胜利局面,然后针对当前局面,遍历是否某一个玩家的棋子满足win_comb中的某个组合。注意,由于在搜索过程中,是按照每人一手交替的方式前进的,不可能存在两个棋手的棋子都满足胜局条件的情况。
本函数给出的结果包括:是否终局;如果是的话,则进一步给出胜负结果(是否平局,不是的话赢家是谁)。
3.2 搜索邻节点
参见find_neighbor()
针对当前棋盘状态,给出可能的下一个棋盘状态的列表。
首先根据当前棋盘状态确定接下来该谁下(whose turn),然后遍历所有空的棋盘格(tile or grid)给出所有可能的下一个棋盘状态的列表。
3.3 打印棋盘状态
参见print_board()
将用一维数组表示的棋盘状态用二维的方式描绘出来以方便查看。
注意,如前所述,总是假定“X”先走。一个可能的终局状态如下所示:
3.4 代码
# -*- coding: utf-8 -*-
"""
Created on Sat Dec 31 12:53:10 2022
@author: chenxy
"""
import random
from collections import deque
win_comb=((0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6))
def is_endofgame(s):
end_flag = False
winner = 0 # draw or tie.
for comb in win_comb:
if s[comb[0]]==1 and s[comb[1]]==1 and s[comb[2]]==1:
winner = 1
end_flag = True
break
elif s[comb[0]]==2 and s[comb[1]]==2 and s[comb[2]]==2:
winner = 2
end_flag = True
break
else:
continue
if (not end_flag) and s.count(0)==0:
end_flag = True
return end_flag, winner
def find_neighbor(s):
neighbor_list = []
# decides whose turn
if s.count(1) == s.count(2):
turn = 1
elif s.count(1) == s.count(2) + 1:
turn = 2
else:
print('Invalid input state: ', s)
return None
for k in range(len(s)):
if s[k] == 0:
s_next = list(s)
s_next[k] = turn
neighbor_list.append(tuple(s_next))
return neighbor_list[::-1]
def print_board(s):
print('----------')
for k in range(len(s)):
if k%3 == 2:
end = ' \n----------\n'
else:
end = ' | '
if s[k] == 1:
char = "X"
elif s[k] == 2:
char = "O"
else:
char=' '
print(char,end=end)
# Initialization
s0 = tuple([0] * 9)
path = []
q = deque()
visited = set()
# Put initial state s0 into stack/queue
q.append(s0)
visited.add(s0)
while 1:
s = q.pop() # DFS: Depth First Search
# s = q.popleft() # BFS: Breadth First Search
path.append(s)
print(s)
end_flag, winner = is_endofgame(s)
if end_flag:
s_end = s
break
neighbors = find_neighbor(s)
# random.shuffle(neighbors)
for neighbor in neighbors:
if neighbor not in visited:
q.append(neighbor)
visited.add(neighbor)
for s in path:
print_board(s)
运行结果如下:
(0, 0, 0, 0, 0, 0, 0, 0, 0)
(1, 0, 0, 0, 0, 0, 0, 0, 0)
(1, 2, 0, 0, 0, 0, 0, 0, 0)
(1, 2, 1, 0, 0, 0, 0, 0, 0)
(1, 2, 1, 2, 0, 0, 0, 0, 0)
(1, 2, 1, 2, 1, 0, 0, 0, 0)
(1, 2, 1, 2, 1, 2, 0, 0, 0)
(1, 2, 1, 2, 1, 2, 1, 0, 0)
----------
| |
----------
| |
----------
| |
----------
----------
X | |
----------
| |
----------
| |
----------
----------
X | O |
----------
| |
----------
| |
----------
----------
X | O | X
----------
| |
----------
| |
----------
----------
X | O | X
----------
O | |
----------
| |
----------
----------
X | O | X
----------
O | X |
----------
| |
----------
----------
X | O | X
----------
O | X | O
----------
| |
----------
----------
X | O | X
----------
O | X | O
----------
X | |
----------
4. 小结
以上实现只是沿着节点树的某一条确定性的分支走到底。由于只是寻找某(任意)一种游戏进行过程的路径,沿着任何一条分支都可以到达终局状态(可能分胜负,也可能是平局)。所以以上程序搜索得到的结果非常平凡。
如何让它变得更有趣一些呢?追加一点随机性。如下所示追加将邻节点列表随机打乱顺序的处理:
这样就可以在每次运行时得到一个不同的棋局。
下一篇将考虑Tic-Tac-Toe(3x3)所有可能的游戏过程,也即所有可能的(从初始状态到达终局的)路径。并由此可以统计出所有可能的盘面状态总数。