吴昊品游戏核心算法 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 

你可能感兴趣的:(round)