目录
1. 前言
2. 算法流程
3. 代码实现
3.1 终局及胜负判定方法
3.2 搜索邻节点
3.3 打印棋盘状态
3.4 代码
4. 小结
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].
终局可以分为以下几种可能:
用深度(或广度)优先算法解决这一搜索问题的伪代码描述如下:
将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表示。
参见is_endofgame()
判定当前局面是否终局。当前用了个比较呆笨的办法。用win_comb预存了所有可能的胜利局面,然后针对当前局面,遍历是否某一个玩家的棋子满足win_comb中的某个组合。注意,由于在搜索过程中,是按照每人一手交替的方式前进的,不可能存在两个棋手的棋子都满足胜局条件的情况。
本函数给出的结果包括:是否终局;如果是的话,则进一步给出胜负结果(是否平局,不是的话赢家是谁)。
参见find_neighbor()
针对当前棋盘状态,给出可能的下一个棋盘状态的列表。
首先根据当前棋盘状态确定接下来该谁下(whose turn),然后遍历所有空的棋盘格(tile or grid)给出所有可能的下一个棋盘状态的列表。
参见print_board()
将用一维数组表示的棋盘状态用二维的方式描绘出来以方便查看。
注意,如前所述,总是假定“X”先走。一个可能的终局状态如下所示:

# -*- coding: utf-8 -*-
"""
Created on Sat Dec 31 12:53:10 2022@author: chenxy
"""import random
from collections import dequewin_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 = Falsewinner = 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 = 1end_flag = Truebreakelif s[comb[0]]==2 and s[comb[1]]==2 and s[comb[2]]==2:winner = 2end_flag = Truebreakelse:continueif (not end_flag) and s.count(0)==0:end_flag = Truereturn end_flag, winner def find_neighbor(s):neighbor_list = []# decides whose turnif s.count(1) == s.count(2):turn = 1elif s.count(1) == s.count(2) + 1:turn = 2else:print('Invalid input state: ', s)return Nonefor k in range(len(s)):if s[k] == 0:s_next = list(s)s_next[k] = turnneighbor_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 Searchpath.append(s)print(s)end_flag, winner = is_endofgame(s)if end_flag:s_end = s breakneighbors = 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 | |
----------
以上实现只是沿着节点树的某一条确定性的分支走到底。由于只是寻找某(任意)一种游戏进行过程的路径,沿着任何一条分支都可以到达终局状态(可能分胜负,也可能是平局)。所以以上程序搜索得到的结果非常平凡。
如何让它变得更有趣一些呢?追加一点随机性。如下所示追加将邻节点列表随机打乱顺序的处理:

这样就可以在每次运行时得到一个不同的棋局。
下一篇将考虑Tic-Tac-Toe(3x3)所有可能的游戏过程,也即所有可能的(从初始状态到达终局的)路径。并由此可以统计出所有可能的盘面状态总数。
上一篇:企业级nginx使用