吴昊品游戏核心算法 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来说的。这样做就是假设AB都会选择在有限的搜索深度内,得到的最好的走棋方法。

 

  α-β剪枝:

 

 α为当前状态A可获取的最大值,βB可获取的最小值,当搜索到某一层的某个子节点时α<=β,因此对于后面节点α只会更小β只会更大,因此仍满足α<=β,因此这个节点下A无获胜可能,则进行剪纸不继续搜索其子节点。

 

 伪代码:

 

  

 1 function alphabeta(node, depth, α, β, Player)         
 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 15684*4Tic-Tac-Toe AI

  Input:一局胜负未定的棋。

  Output:如果先手X能够在接下来的一步棋中走出必胜手,则输出必胜手的位置,如果不行的话,输出#####

 

  Solve:

 

  1 /*
  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==16return 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==16return 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 

posted on 2013-02-28 13:13  吴昊系列  阅读(993)  评论(0编辑  收藏  举报

导航