吴昊品游戏核心算法 Round 11 —— Tic-Tac-Toe AI (极大极小博弈树)(POJ 1568)
吴昊继续,其实在吴昊系列Round 9中,也就是正统黑白棋(Othello)的AI中,我就有介绍过一种叫极大极小博弈树的算法,双方互相刷自己的博弈值,直到最后将整盘棋OVER。这里,我们使用相同的方法,来解决Tic-Tac-Toe的AI。
如图所示,我们没有必要非得拘泥于3*3的棋盘,4*4,甚至是6*6都是可以的,但是,哪一种棋盘最具有可玩性,这需要建立一个模型来分析一下。
Tic-Tac-Toe与人工智能
这种游戏的变化简单,常成为博弈论和游戏树搜寻的教学例子。这个游戏只有765个可能局面,26830个棋局。如果将对称的棋局视作不同,则有255168个棋局。
由于这种游戏的结构简单,早期这游戏就成为了人工智能的一个好题目。学生都要从既有的玩法中,归纳出游戏的致胜之道,并将策略演绎成为程式,让电脑与用户对弈。
世界上第一个电脑游戏,1952年为EDSAC电脑制作的OXO游戏,就是以该游戏为题材,可以正确无误地与人类对手下棋。
(如图,这是一盘和局的具体情况)
由此看来,井字棋的变化并不是很多,由此,我们可以对井字棋的规则加以一些变化,来增添游戏的复杂度。
Tic-Tac-Toe变种A:
因 为原本的游戏如果下法无误,将得和局,所以出现变化,玩法是在下完第七子时(先方第四子),最初的第一子要消失,第八子下完第二子消失,以此类 推,保持盘上只有六子,下子后必须先处理消失之子,方可判断是否连成一条线,这种玩法普通在纸上玩时,通常不用圈叉,多以不同颜色数字来表示(不然难以分 辨何子先下,但是高手可以不用数字),不过后来各类翻译机都内建此游戏,就都以圈叉表示了。
此种玩法难度增高,但却有必胜法,先下者如下在边则必胜,如下角或中央,双方正确进行会和局,但是由于变化复杂(若只用圈叉不用数字),多数人难以计算此变化,容易下错,增加游戏娱乐性。
Tic-Tac-Toe变种B(很有趣,不过会造成先手必赢的局面):
由原来的平面过三关,改变成为立体的 3x3x3 过三关。不过趣味不高,因为只要先手下在立方体中央就保证必胜。
为增加趣味性,双方各执两种棋子并依序使用,在同种棋子连成一线时,就赢得胜利。 例:玩家甲执○◎子;玩家乙执X※子,下子顺序依序为○(玩家甲)X(玩家乙)◎(玩家甲)※(玩家乙)。 ○◎○连成一线不算甲方赢,因虽然皆甲方之子,但种类不同;甲方需○○○连成一线或◎◎◎连成一线才算赢。
另一种玩法是以三位玩家来进行游戏,连成一线者赢,连成一线者的上家为输,有一方将不赢不输。
以上可以看出,先手优势几乎在所有的游戏中(少数例外)都是类似的,所以,作为先手,应该予以适当的惩罚。
极大极小搜索:
A和B对弈,轮到A走棋了,那么我们会遍历A的每一个可能走棋方法,然后对于前面A的每一个走棋方法,遍历B的每一个走棋方法,然后接着遍历A的每一个走棋方法,如此下去,直到得到确定的结果或者达到了搜索深度的限制。当达到了搜索深度限制,此时无法判断结局如何,一般都是根据当前局面的形式,给出一个得分,计算得分的方法被称为评价函数,不同游戏的评价函数差别很大,需要很好的设计。
在搜索树中,表示A走棋的节点即为极大节点,表示B走棋的节点为极小节点。称A为极大节点,是因为A会选择局面评分最大的一个走棋方法,称B为极小节点,是因为B会选择局面评分最小的一个走棋方法,这里的局面评分都是相对于A来说的。这样做就是假设A和B都会选择在有限的搜索深度内,得到的最好的走棋方法。
α-β剪枝:
设α为当前状态A可获取的最大值,β为B可获取的最小值,当搜索到某一层的某个子节点时α<=β,因此对于后面节点α只会更小β只会更大,因此仍满足α<=β,因此这个节点下A无获胜可能,则进行剪纸不继续搜索其子节点。
伪代码:
2 if depth = 0 or node is a terminal node
3 return the heuristic value of node
4 if Player = MaxPlayer // 极大节点
5 for each child of node // 极小节点
6 α := max(α, alphabeta(child, depth-1, α, β, not(Player) ))
7 if β ≤ α // 该极大节点的值>=α>=β,该极大节点后面的搜索到的值肯定会大于β,因此不会被其上层的极小节点所选用了。对于根节点,β为正无穷
8 break (* Beta cut-off *)
9 return α
10 else // 极小节点
11 for each child of node // 极大节点
12 β := min(β, alphabeta(child, depth-1, α, β, not(Player) )) // 极小节点
13 if β ≤ α // 该极大节点的值<=β<=α,该极小节点后面的搜索到的值肯定会小于α,因此不会被其上层的极大节点所选用了。对于根节点,α为负无穷
14 break (* Alpha cut-off *)
15 return β
16 (* Initial call *)
17 alphabeta(origin, depth, -infinity, +infinity, MaxPlayer)
注意:
alpha-beta剪枝使得每一个子状态在它的父亲兄弟们的约束下,得出一个相应得值,所以与其父兄节点有关系,而记忆化搜索则默认子节点只与自己的状态有关系,忽略了父兄的约束条件,实际上一颗博弈树上可能有多颗节点同时指向一个节点,若把alpha-beta与记忆化结合起来,那么该节点将只被一组父兄节点限制一次,也就只考虑了这组父兄所带来的alpha-beta界剪枝下的结果,很有可能把本属于另外组别父兄节点的最优解给误剪掉了。
Source:POJ 1568(4*4的Tic-Tac-Toe AI)
Input:一局胜负未定的棋。
Output:如果先手X能够在接下来的一步棋中走出必胜手,则输出必胜手的位置,如果不行的话,输出#####
Solve:
2 Highlights:
3 (1)和以前的黑白棋AI一样,极大极小函数的对偶演绎地非常漂亮
4 (2)定义chess=-4,这里是有其用意的,后面有利用四个回车将其补齐成chess=0
5 (3)判断一盘棋是否结束,还是沿用模拟算法的那四个条件,也就是行,列,对角线和反对角线
6 (4)我们将搜索的深度的最大值定义为12层,如果12层仍然未搜索出结果(也就是说只有1/4的地方有落子),我们就判定为搜索不力而无法找到必胜手
7 (5)将inf设置到100000000实在是有些小题大做,极大值为1,极小值为-1,平局为0,就已经足矣了
8 */
9 #include <iostream>
10 using namespace std;
11
12 #define inf 100000000
13
14 //棋盘,棋子的数目,以及标识棋盘上的位置的(xi,xj)
15 int state[5][5],chess,xi,xj;
16 char ch;
17
18 //声明极大--极小函数
19 int minfind(int,int,int);
20 int maxfind(int,int,int);
21
22 //判断一盘棋是否结束了
23 bool over(int x,int y)
24 {
25 bool flag = false;
26 int row[5],col[5];
27 //将行数组和列数组都初始化为0,这是一个安全而又稳妥的办法
28 memset(row,0,sizeof(row));
29 memset(col,0,sizeof(col));
30 //判断横竖的情况
31 for(int i=0;i<4;i++)
32 for (int j=0;j<4;j++)
33 {
34 if (state[i][j]=='x')
35 {
36 row[i]++;
37 col[j]++;
38 }
39 if (state[i][j]=='o')
40 {
41 row[i]--;
42 col[j]--;
43 }
44 }
45 //如果存在行和列位+4或者-4的情况(这里巧妙地运用了相反数来标称OX双方)
46 if (row[x]==-4 || row[x]==4 || col[y]==-4 || col[y]==4)
47 flag = true;
48 //对对角线和反对角线分别计数
49 int tot1 = 0, tot2 = 0;
50 //考虑对角线和反对角线这两种情况
51 for (int i=0;i<4;i++)
52 {
53 if (state[i][i]=='x') tot1++;
54 if (state[i][3-i]=='x') tot2++;
55 if (state[i][i]=='o') tot1--;
56 if (state[i][3-i]=='o') tot2--;
57 }
58 //要判定这一点是否真的刚好走在对角线和反对角线上
59 if ((tot1==4 || tot1==-4) && x==y) flag = true;
60 if ((tot2==4 || tot2==-4) && x==3-y) flag = true;
61 return flag;
62 }
63
64 //极大函数与极小函数形成一个对偶的关系,甚是精彩!
65 int maxfind(int x,int y,int mini)
66 {
67 int tmp, maxi = -inf;
68 if (over(x,y)) return maxi;
69 if (chess==16) return 0;
70 for (int i=0;i<4;i++)
71 for (int j=0;j<4;j++)
72 if (state[i][j]=='.')
73 {
74 state[i][j]='x';
75 chess++;
76 tmp = minfind(i,j,maxi);
77 chess--;
78 state[i][j]='.';
79 maxi = max(maxi, tmp);
80 if (maxi>=mini) return maxi;
81 }
82 return maxi;
83 }
84
85 int minfind(int x,int y,int maxi)
86 {
87 int tmp, mini = inf;
88 //判断胜负,如果结束了,则输出mini,也就是极小值
89 if (over(x,y)) return mini;
90 //平局
91 if (chess==16) return 0;
92 for (int i=0;i<4;i++)
93 for (int j=0;j<4;j++)
94 {
95 if (state[i][j]=='.')
96 {
97 state[i][j]='o';
98 chess++;
99 //以此为依托,来寻找极大值
100 tmp = maxfind(i,j,mini);
101 chess--;
102 state[i][j]='.';
103 mini = min(mini, tmp);
104 if (mini<=maxi) return mini;
105 }
106 return mini;
107 }
108
109 bool tryit()
110 {
111 int tmp, maxi = -inf;
112 for (int i=0;i<4;i++)
113 for (int j=0;j<4;j++)
114 if (state[i][j]=='.')
115 {
116 //将X的一步棋下出来
117 state[i][j] = 'x';
118 chess++;
119 //根据当前的极大值,博弈出极小值
120 tmp = minfind(i,j,maxi);
121 //不行的话,返回原来的状态
122 chess--;
123 state[i][j] = '.';
124 if (tmp>=maxi)
125 {
126 maxi = tmp;
127 xi = i;
128 xj = j;
129 }
130 //此为存在必胜的情况
131 if (maxi==inf) return true;
132 }
133 return false;
134 }
135
136 int main()
137 {
138 while(scanf("%c",&ch))
139 {
140 int flag=0;
141 //退出标识
142 if (ch=='$') break;
143 //为什么要定义为-4呢?我疑惑了半天
144 chess = -4;
145 for (int i=0;i<4;i++)
146 //这里是因为每次都要读入一个回车符,所以预先取chess=-4,在之后的四行由于回车和.的ASC码不同,就会加四遍,直到chess=0
147 for (int j=0;j<5;j++)
148 {
149 scanf("%c",&state[i][j]);
150 //如果有棋子的话,则chess++
151 chess += state[i][j]!='.';
152 }
153 if (chess<=4)
154 {
155 //强力剪枝,因为如果只有1/4的子的话,可以100%判断形势未定,这样可以节约不少时间,据说是从25ms减到了0ms
156 printf("#####\n");
157 flag=1;
158 continue;
159 }
160 if (tryit()) printf("(%d,%d)\n",xi,xj);
161 else if(!flag) printf("#####\n");
162 }
163 return 0;
164 }
165
166