[Qt-Toy]基於博弈樹的五子棋AI算法

 

我的新博客:http://ryuzhihao.cc/

本文在我的新博客中的鏈接:http://ryuzhihao.cc/?p=601

 

前些天研究了一下棋譜2333,然後就順便寫了這個程序。整個程序是基於Qt開發,就UI而言毫無亮點,所以接下來的文章將主要介紹五子棋電腦AI的設計。可能這會是一篇非常長的博文。

 

在正文開始之前,首先貼一下程序的下載鏈接以及程序截圖~

一、程序預覽

1 下載地址:

https://pan.baidu.com/s/1nuJiPDJ

2 程序截圖:


圖1 五子棋程序截圖


圖2 一次着棋回合 (紅色爲鼠標位置,紫色框爲電腦上次着棋位置)

二、五子棋基本棋型

在設計AI之前,我們應該先告訴電腦何種棋型容易獲勝,什麼情形下應該進攻,什麼情形下應該採取防守策略。所以爲了設計更好的AI,需要先對五子棋棋型有些瞭解,在緊接着的章節裏,我在介紹棋型的同時,也會順便介紹一些算法的實現策略。

       【約定】 _爲空,○爲敵方棋子,●爲己方棋子

① 連五:五顆棋子連在一起,獲得勝利。

●●●●●

② 活四:四顆棋子相連,同時兩端均爲空(即有兩個位置可以形成連五)。

當活四出現的時候,對方如果單純採取防守策略時,已經無法阻擋自己的勝利(除非對方採取進攻策略,一招制勝,我們的程序也要注意這一點)

_●●●●_

③ 死四:四顆棋子,但只有一個位置可以形成連五。

相比活四而言,死四的威脅要小的多,因爲這個時候對方只要跟着防守即可。但是死四出現時,其優先級應當比下面提到的活三要高(因爲活四雖能輕易破解,但是對於雙方都意味着一步結束比賽,故必須注意)。

_●●●●○

●_●●●

 ●●_●●

④ 活三:可以形成活四的三,有如下常見的幾種棋型:

活三棋型是進攻時最常見的棋型。因爲活三之後,如果對方不予理會,則可直接一手變成活四。因此當敵方活三出現時,需要進行防守。

 _●●●_

_●_●●_

⑤ 死三:能夠形成死四的三。死三與活三相比,危險係數降低了不少,因爲死三即便不去防守,下一手也只能形成死四,我們仍然可以防守的住。

_●●●○ _●_●●○
_●●_●○ ●_ _●●
●_●_● ○_●●●_○

 

⑥ 活二:能夠形成活三的二。活二看似人畜無害,因爲它只下一手便能形成活三,等形成活三我們仍能防守。但其實活二其實很重要,因爲在開局階段,如果能夠形成較多的活二棋型,那麼當我們將活二變成活三時,就能將自己的活三綿延不絕,讓對手防不勝防。

_ _●●_ _

_●_● _

_●_ _●_

⑦ 死二:能夠形成眠三的二。

_ _ _●●○ _ _●_●○
_●_ _●○ ●_ _ _●

 

三、着棋估值

着棋估值,是整個程序中最關鍵的一步。因爲估值方法,是教會電腦判斷如何根據當前棋盤形式,找到最適合的着棋位置的關鍵。而一個好的估值方法,也能大大提高電腦AI的獲勝概率。

事實上,如果不需要讓電腦AI具有預見未來若干步的本領,那麼只要實現這一步即可。並且,如果僅僅有着棋估值作爲AI判斷的考量標準時,電腦AI也能有不錯的表現(如果不小心,你會很容易輸給它)。

着棋估值,我們用這樣的函數原型描述它:

int envaluate ( Point p , Type who);

     其中,參數p表示當前估值的棋盤座標點,who表示站在哪一方的角度進行估值(是玩家?還是電腦?)。

那麼,當我們不需要電腦有遠見的能力時,我們可以用如下代碼從整張當前棋盤中,找到最合適的落腳點:

/// 輪到AI的回合
void isTimeToAI()
{
  Point bestPos1;  // 最佳的進攻位置
  Point bestPos2;  // 最佳的防守位置

  // 首先,分析採取進攻策略時的情況
  // 當前棋盤中採取進攻策略的最高權重max1
  int max1 = 0;


  for(int r=0; r<size; r++) // 雙層循環遍歷棋盤
  {
    for(int c=0; c<size ;c++)
    {
      Point cur(r,c);  // 當前查詢的位置

      if(isNull(cur))  // 如果已經有棋子了
        continue;
      
      int value = envaluate(cur,COMPUTER);

      if(max1 > value)
      {
        max1 = value;
        bestPos1 = cur;
      }
    }
  }

  // 然後,分析採取防守策略時的情況
  // 當前棋盤中採取防守策略的最高權重max2
  int max2 = 0;


  for(int r=0; r<size; r++) // 雙層循環遍歷棋盤
  {
    for(int c=0; c<size ;c++)
    {
      Point cur(r,c);  // 當前查詢的位置

      if(isNull(cur))  // 如果已經有棋子了
        continue;

      int value = envaluate(cur,PLAYER);

      if(max2 > value)
      {
        max2 = value;
        bestPos2 = cur;
      }
    }
  }

  // 從防守和進攻中找到最好的情況。
  if(max1 >= max2)  // 進攻
  {
    dropChessAt(bestPos1);
  }
  else             // 防守
  {
    dropChessAt(bestPos2);
  }
}

上述代碼,先從進攻的角度去尋找,又從防守的角度去尋找。如果進攻的優先級更高,那麼採取進攻策略;反之,採取防守策略。

那麼,判斷每個着棋位置的棋型envaluate(p, cur) 函數應該如何去定義?

根據第二章節的分析,我們可以設定如下棋型的權值順序:

連五 > 活四 > 死四 > 活三 > 活二 (略大於)死三 > 死二 > 其他棋型 

       那麼,我們在給定上述權值順序時,便可以得出如下的估值函數代碼:

int BoardAI::envaluateAt(Point p,int who)   // 是哪一個玩家下在p位置
{
    int value = 0;
    int opposite = (who == PLAYER)?COMPUT:PLAYER; // 敵對方是誰

    for(int i=0; i<8; i++)  // 8個方向
    {
        //判斷是否存在 *11110 ("活四" 必勝 沒辦法去賭了)
        if(getPointAt(p,i,1) == who && getPointAt(p,i,2) == who
                && getPointAt(p,i,3) == who && getPointAt(p,i,4) == who
                && getPointAt(p,i,5) == EMPTY)
        {
            value+=400000;  // 40萬
        }

        // 判斷是否存在 21111* (死四A)  如果是己方則下子獲得勝利,對手的話要竭力去賭
        if(getPointAt(p,i,1) == who &&getPointAt(p,i,2) == who
                && getPointAt(p,i,3) == who &&getPointAt(p,i,4) == who
                && getPointAt(p,i,5) == opposite)
        {
            value += 300000; // 30萬
        }

        // 判斷是否存在 111*1 (死四B)
        if(getPointAt(p,i,-1) == who &&getPointAt(p,i,1) == who
                && getPointAt(p,i,2) == who &&getPointAt(p,i,3) == who)
        {
            value += 300000; // 30萬

        }

        // 自行添加其他棋型的判斷,這裏省略掉了,同上。

        // 判斷是否存在 1*001(死二)
        if(getPointAt(p,i,-1) == who && getPointAt(p,i,1) == EMPTY
                && getPointAt(p,i,2) == EMPTY && getPointAt(p,i,3) == EMPTY)
        {
            value +=100;
        }
         
        // 周圍如果已有棋子數目比較多的話,適當增加一下權值

        value += (getPointAt(p,i,-1)== who
                  +getPointAt(p,i,-1)==opposite)*25;
    }
    return value;
}

在上面的代碼中,出現了一個函數:

int getPointAt (Point pint dirint offset);

      其中,p爲當前探測的中心點,dir爲探測方向,Offset是距離p的偏移量,返回值爲該點的棋子類型(空、白棋、黑棋)。通過這個函數,我們可以藉助查詢距離p任意方向,且任意長度的點的類型。其具體的實現如下:

QPoint m_offset[8] = {
    QPoint(0,-1),QPoint(1,-1),QPoint(1,0),QPoint(1,1),
    QPoint(0,1),QPoint(-1,1),QPoint(-1,0),QPoint(-1,-1)
};

int BoardAI::getPointAt(Point p, int dir, int offset)
{
    int r = p.row;
    int c = p.col;

    r = r + offset*m_offset[dir].y();
    c = c + offset*m_offset[dir].x();

    if(r<0 || c<0 || r>=BOARDSIZE || c>=BOARDSIZE)
        return OUTRANGE;

    return board[r][c];
}

四、有遠見的電腦AI?

通過前面的分析,我們已經能夠得到一個還不錯的AI了,或許我們也可以稱之爲一個不錯的棋手。但是現實中遇到的高手,大多能夠預見未來的若干步,並分析出當前最佳的策略。一則,可以避免未來可能發生的槽糕的情形;二則,可以爲未來可以構建的奇招打下基礎。

那麼,應該如何實現這樣有遠見的AI呢?

這裏,我們採用構建博弈樹的方式,選擇能夠導致未來最佳情形的策略。所謂博弈樹的構建,其實是以當前棋局爲根節點,然後下一步,我們可能在當前的任意一個空位着棋,那麼生成相應數目的葉節點(即每個葉節點,是我們在其父結點的基礎上,着下一棋的結果)。

那麼這樣,我們重複多次之後,就有可能生成如下的博弈樹:

 

這裏,我們只需要簡單的遞歸即可實現這個步驟。我們只需分析每個葉節點的權值(也就是未來幾步的情形),從中選取最好的情形,並按照這個策略着棋即可。

當然,我們可能會遇到一些可能的情況。比如中間某步時,敵方/己方獲得勝利,那麼我們可以賦這種情形一個較大的權重,但是仍要繼續遍歷,因爲有可能剩餘的步驟裏,敵方/己方可能在較短的路徑長度(PL)結束遊戲。

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章