使用QT creator實現一個五子棋AI包括GUI實現(8K字超詳細)

鮮衣發表於2021-05-07

五子棋AI實現

五子棋遊戲介紹

五子棋的定義

五子棋是全國智力運動會競技專案之一,是具有完整資訊的、確定性的、輪流行動的、兩個遊戲者的零和遊戲。因此,五子棋是一個博弈問題。

五子棋的玩法

五子棋有兩種玩法:

玩法一:雙方分別使用黑白兩色的棋子,下在棋盤直線與橫線的交叉點上,先形成五子連線者獲勝。

玩法二:自己形成五子連線就替換對方任意一枚棋子。被替換的棋子可以和對方交換棋子。最後以先出完所有棋子的一方為勝。

本次實驗的玩法是第一種。

五子棋的具體規則

  1. 對局雙方各執一色棋子,棋盤一共15行15列,225個下棋點。

  2. 空棋盤開局。

  3. 黑先、白後,交替下子,每次只能下一子。

  4. 棋子下在棋盤的空白點上,棋子下定後,不得向其它點移動,不得從棋盤上拿掉或拿起另落別處。

  5. 黑方的第一枚棋子必須下在天元點上,即中心交叉點

  6. 輪流下子是雙方的權利,但允許任何一方放棄下子權(即:PASS權)。

五子棋博弈演算法的具體實現

演算法定義:

由於是零和遊戲的博弈問題,因此針對此問題的經典演算法為min-max演算法以及針對其的alpha-beta剪枝優化演算法。

min-max演算法:

設遊戲的兩個遊戲者為MAX和MIN。MAX先下棋,然後兩人輪流出招,對於每一步當前的棋盤局面,使用一個評估函式 \(score(x)\) 來評價MAX距離遊戲勝利的遠近。MAX越容易獲得勝利,\(score(x)\)的值就越大。

演算法思想:

對於MAX來說,他的每一步棋都要使得當前棋盤局面的評估函式最大,即\(max(score(board[r][l]))\)\(r\),\(l\) 表示MAX下的棋子的位置。

而相反,對於MIN來說,他的每一步棋都要使得當前棋盤局面的評估函式最小,即\(min(score(board[r][l]))\)\(r\),\(l\) 表示MIN下的棋子的位置。

因此,使用深度有限搜尋的方法,即限制問題搜尋的深度為\(depth\)(\(depth\)表示MAX和MIN從目前棋盤輪流下子的次數),一旦搜尋深度到達\(depth\)就計算預測棋盤的得分,然後往上回溯,搜尋樹以及搜尋過程如圖所示:

image-20210426190230358 image-20210426190230358 image-20210426190230358 使用QT creator實現一個五子棋AI包括GUI實現(8K字超詳細)

圖引自:《人工智慧》(一):min-max演算法 - 簡書 (jianshu.com)

該樹的深度為5層,但是\(depth\)為4。

\(depth = 3\)為MIN層,MIN選擇所有預測局面中的得分最小的情況作為自己這一步下棋的位置,而\(depth = 2\)的MAX層在\(depth=3\)的MIN層的基礎上形成的局面中選擇得分最大的位置來作為自己這一步下棋的位置(就是MAX考慮下棋時,遍歷225個下棋的點位,選擇局面得分最高的點下棋)。以此類推,最後找到能夠使\(depth=0\)的MAX下一步得分最高的點。

Min-Max的核心就是假設雙方每一步都是相對於評估函式的最優解的情況下來選擇下一步該走的位置。

演算法虛擬碼:

cpp程式碼:
void chess_board::min_max_search(QTextBrowser *detail_info)
{
    score_next = max_sear(0,detail_info);
}
int chess_board::max_sear(int deep,QTextBrowser *detail_info)
{
    if(deep==depth)
        return cal_score(first_me);
    int v = -0x3f3f3f3f;
    int old_a = 0;
    for(int l=0;l<board_col;l++)
        for(int r=0;r<board_row;r++)
        {
            if(board[r][l]=='0')//如果當前位置是空子的話
            {
                set_temp_chess(r,l,true);
                v = max(v,min_sear(deep+1, detail_info));
                if(deep==0&&v!=old_a)//如果是在最表層且有更新,則選擇這一步
                {
                    char tip_out[1005] = {0};
                    sprintf(tip_out,"update max:\n l = %d, r = %d \nv = %d\n",l,r,v);
                    detail_info->textCursor().insertText(tip_out);
                    next.set_rl(r,l);
                    old_a = v;
                }
                clear_temp_chess(r,l);//注意要回溯
            }
        }
    return v;
}
int chess_board::min_sear(int deep,QTextBrowser *detail_info)
{
    if(deep==depth)
        return cal_score(first_me);
    int v = 0x3f3f3f3f;
    for(int l=0;l<board_col;l++)
        for(int r=0;r<board_row;r++)
        {
            if(board[r][l]=='0')//如果當前位置是空子的話
            {
                set_temp_chess(r,l,false);
                v = min(v,max_sear(deep+1, detail_info));
                clear_temp_chess(r,l);//注意要回溯
            }
        }
    return v;
}
實現細節:
  1. 由於只有一個棋盤,因此要注意dfs回溯的時候要清除臨時下的棋子。
  2. MAX根節點,也就是最高層,每次更新\(v\)的時候記錄下落子的位置,搜尋完後對應的位置就是人工智慧要落子的位置。
演算法缺點:

太慢了。

如果遍歷的深度為n,每一層要對225個下棋的點進行遍歷,每次遍歷又是遞迴呼叫。因此每一次下棋就需要對\(225^n\)中棋盤進行分數評估,加上分數評估也需要時間,因此演算法複雜度很高,一般\(depth=2\)的時候每一步的等待時間要超過10s。

\(\alpha\)-\(\beta\) 剪枝演算法:

為了針對上述MAX-MIN演算法複雜度高的問題,\(\alpha\)-\(\beta\) 剪枝演算法應運而生。

剪枝思想


考慮圖中畫圈的部分:

已知MAX要選擇所有MIN已經造成的得分中最高的那一個位置落子;

對於圖中的①號節點,其遍歷過②號節點以及對應的子樹之後目前的得分是4;

其開始遍歷自己的第二棵子樹,其根節點為③,節點③遍歷自己的第一個子樹後獲得的值為40;

由於節點①取最大值,因此節點③目前的值有保留的必要。

繼續搜尋第二棵子樹,發現第二棵子樹的根節點對應的值為-36;

由於節點③對應的是MIN玩家,要選擇最小的位置,因此MIN玩家更新自己落子的位置,節點③的值更新為-36;

由於節點③對應的是MIN玩家,其選擇的落子位置得分不會比-36大;

而節點①是MAX玩家,其不可能選擇落子得分為-36的局面;

因此節點③就沒有往後搜尋的必要了,因此後面的搜尋都可以去掉。完成一個剪枝。

注意,這個搜尋過程是在建立樹的過程中剪枝的,是先剪枝,再有整個問題的完整搜尋樹

演算法具體實現:
  • 每一個節點維護一個\(\alpha\)\(\beta\)值以及這個節點的估值\(v\),表示該節點的估值應該在\([\alpha,\beta]\)區間內,一旦\(\alpha \ge \beta\),這個節點就不再進行擴充,成為死節點。

  • \(\alpha\)\(\beta\)的更新有兩個來源:

    • 如果目前考慮的節點為MAX玩家對應的節點,則它的\(\alpha\)值為目前所有孩子節點的值中的最大值(此時\(v == \alpha\)),如果目前考慮的節點為MIN玩家對應的節點,則它的\(\beta\)值為目前所有孩子節點中值的最小值(此時\(v == \beta\))。
    • \(\alpha\)\(\beta\)的初始值繼承自其雙親節點。最初的根節點其\(\alpha = -\infin, \beta=\infin\)

具體過程如圖所示:

這裡略過12張圖片,具體看下面連結

圖片引自:詳解Minimax演算法與α-β剪枝_文劍木然的專欄-CSDN部落格_α-β剪枝

自己用筆畫一遍就會了啦

演算法虛擬碼:

分為三個函式,alpha_beta_search函式代表呼叫的入口,Max_Value函式代表Max的操作,Min_Value函式代表Min的操作。

cpp程式碼:
int chess_board::max_value(int deep,int alpha,int beta,QTextBrowser *detail_info)
{
    if(deep==depth)
        return cal_score(first_me);
    int v = -0x3f3f3f3f;
    int old_a = 0;
    for(int l=0;l<board_col;l++)//啟發式搜尋
        for(int r=0;r<board_row;r++)
        {            
            if(board[r][l]=='0')//如果當前位置是空子的話
            {
                set_temp_chess(r,l,true);
                v = max(v,min_value(deep+1,alpha,beta,detail_info));
                if(v>=beta)
                {
                    clear_temp_chess(r,l);//注意要回溯
                    return v;
                }
                alpha = max(alpha,v);
                if(deep==0&&alpha==v&&alpha!=old_a)//如果是在最表層且有更新,則選擇這一步
                {
                    char tip_out[1005] = {0};
                    sprintf(tip_out,"update max:\n l = %d, r = %d \nv = %d\n",l,r,v);
                    detail_info->textCursor().insertText(tip_out);
                    next.set_rl(r,l);
                    old_a = alpha;
                }
                clear_temp_chess(r,l);//注意要回溯
            }
        }
    return v;
}

int chess_board::min_value(int deep,int alpha,int beta, QTextBrowser *detail_info)
{
    if(deep==depth)
        return cal_score(first_me);
    int v = 0x3f3f3f3f;
    int old_a = 0;
    vector<node>h_score;
    for(int l=0;l<board_col;l++)//啟發式搜尋
        for(int r=0;r<board_row;r++)
        {
            if(board[r][l]=='0')//如果當前位置是空子的話
            {
                set_temp_chess(r,l,false);
                v = min(v,max_value(deep+1,alpha,beta,detail_info));
                if(v<=alpha)
                {
                    clear_temp_chess(r,l);//注意要回溯
                    return v;
                }
                beta = min(beta,v);
                clear_temp_chess(r,l);//注意要回溯
            }
        }
    return v;
}

void chess_board::alpha_beta_search(QTextBrowser *detail_info)
{
    int alpha = -0x3f3f3f3f;
    int beta = 0x3f3f3f3f;
    score_next = max_value(0,alpha,beta,detail_info);
}

實現細節

注意要回溯,對的,基本上沒什麼了,注意要回溯。

有無\(\alpha\)-\(\beta\) 剪枝對比:

估值函式:

估值函式的定義:

用於估計當前棋盤局面的函式 \(score(x)\), 來評價MAX距離遊戲勝利的遠近。MAX越容易獲得勝利,\(score(x)\)的值就越大。

估值函式實現思路:

多玩幾盤五子棋就會發現 (也許也不會發現) ,評估一個局面某一玩家是否容易取得勝利主要是看棋盤橫豎邊以及斜邊中含有的棋型,假設\(A\)棋子表示MAX下的棋子,\(B\)表示MIN下的棋子,\(0\)表示沒有棋子。如果某一行列中有"0AAAA0"的話,說明MAX就要獲得勝利了,所以棋盤評估的得分就高,如果某一行列中有"0BBBB0"的話,說明MIN要獲得勝利了,棋盤評估的得分就低。而且比對的話會發現棋盤中有"0AAAA0"比棋盤中有“00AAA0”MAX更容易獲得勝利,因此棋型"0AAAA0"的分值要比“00AAA0”的分值要更高。

所以,經過分析,實現估值函式的思路就是先找出五子棋中出現的所有棋型併為其打分,然後遍歷當前棋盤中每一行每一列每一斜邊,尋找出所有符合的棋型,如果棋型對MAX有利,則在總分數上加上該棋型所對應的分值,如果棋型對MIN有利,則減去該棋型對應的分值。

經過多次的調整,確定棋型以及得分如下:(這裡顯示的為MAX的棋型,MIN的話要把所有的A改成B)

//                                      MAX    MIN
// 死二 A: BAA000 或 000AAB             150    140
// 死二 B: BA0A00 或 00A0AB             250    240
// 死二 C: BA00A0 或 0A00AB             200    190
// 活二 A:  0AA000 或 000AA0             650    640
// 活二 B:  00A0A0 或 0A0A00             400    390
// 死三 A: BAAA00 或 00AAAB             500    490
// 死三 B: BA0AA0 或 0AA0AB             800    790
// 死三 C: A00AA                        600    590
// 死三 D: A0A0A                        600    590
// 活三 A: 0A0AA0                       2000   1990
// 活三 B: 0AAA00 或 00AAA0             3000   2990
// 死四 A: BAAAA0 或 0AAAAB             2500   2490
// 死四 B: AAA0A                        3000   2990
// 死四 C: AA0AA                        2600   2590
// 活四     0AAAA0                       300000    299990
// 連棋     AAAAA                        30000000  299999990

估值函式程式碼實現:

實現細節:

使用一個15*15的char型陣列來儲存棋盤,四種陣列儲存行,列,左下到右上的斜行,左上到右下的斜行一共 \(15+15+21+21=72\)個陣列,將整個棋盤劃分成72份,每次遍歷預測的時候動態更新棋盤和這72個陣列,然後每次只要尋找這72個陣列中有無匹配的字串就行了。這個部分的難點就是斜邊的42個陣列該如何取出來以及如何更新。

各個具體分值定義:
    partern chess_type_all_my[25] = {{"BAA000", 150, 140}, {"000AAB", 150, 140}, {"BA0A00", 250, 240}, {"00A0AB", 250, 240}, {"BA00A0", 200, 190}, {"0A00AB", 200, 190}, {"0AA000", 650, 640}, {"000AA0", 650, 640},{"0A0A00", 300, 290}, {"00A0A0", 300, 290}, {"BAAA00", 500, 490}, {"00AAAB", 500, 490}, {"BA0AA0", 800, 790}, {"0AA0AB", 800, 790}, {"A00AA", 600, 590}, {"A0A0A", 600, 590}, {"0A0AA0", 2000, 1990}, {"0AAA00", 3000, 2990}, {"00AAA0", 3000, 2990}, {"BAAAA0", 2500, 2490}, {"0AAAAB", 2500, 2490}, {"AAA0A", 3000, 2990}, {"AA0AA", 2600, 2590}, {"0AAAA0", 300000, 299990}, {"AAAAA", 3000000, 29999990}};

	partern chess_type_all_opt[25] = {{"ABB000", 150, 140}, {"000BBA", 150, 140}, {"AB0B00", 250, 240}, {"00B0BA", 250, 240}, {"AB00B0", 200, 190}, {"0B00BA", 200, 190}, {"0BB000", 650, 640}, {"000BB0", 650, 640},{"0B0B00", 300, 290}, {"00B0B0", 300, 290}, {"ABBB00", 500, 490}, {"00BBBA", 500, 490}, {"AB0BB0", 800, 790}, {"0BB0BA", 800, 790}, {"B00BB", 600, 590}, {"B0B0B", 600, 590}, {"0B0BB0", 2000, 1990}, {"0BBB00", 3000, 2990}, {"00BBB0", 3000, 2990}, {"ABBBB0", 2500, 2490}, {"0BBBBA", 2500, 2490}, {"BBB0B", 3000, 2990}, {"BB0BB", 2600, 2590}, {"0BBBB0", 300000, 299990}, {"BBBBB", 3000000, 29999990}};

函式解析:
int find_count(const char* temp,const char*partern)

這個函式用來匹配每一個陣列中句型的個數。使用strstr方法,尋找到匹配字串的位置後count++,然後將尋找的起始字串向後移,繼續尋找,直到strstr返回為NULL

程式碼如下:

int find_count(const char* temp,const char*partern)
{
    int count1 = 0;
    int cur = 0;
    char *now = NULL;
    while((now = strstr(temp+cur,partern))!=NULL)
    {
        cur = now - temp + strlen(partern);
        count1++;
    }
    return count1;
}
chess_board::chess_board(void)

這個是類的建構函式,用於構造這72個陣列,行列的陣列還比較好構造,但是斜邊的陣列就不那麼好構造了,因為有的斜邊少於5個點可以落子,這種斜邊不能考慮,如下圖紅圈部分,四個角都是:這也就是為什麼斜邊的陣列只有21個而不是27個

因此,需要確定座標系,叫左下角作為座標原點,使用\(l+r = b\)中的\(b\)來區分不同的陣列(這裡很難講清楚,自己體會)

程式碼如下:

chess_board::chess_board(void)
{
    for(int r=0;r<15;r++)
    {
        for(int l=0;l<15;l++)
        {
            board[r][l] = '0';
            rows[r][l] = '0';
            cols[r][l] = '0';
        }
        board[r][15] = '\0';
        rows[r][15] = '\0';
        cols[r][15] = '\0';
    }
    for(int b=0;b<21;b++)//左下向右上
        for(int l=0;l<15;l++)
        {
            int r = l+b-10;//b = r+10-l
            if(l>=0&&l<15&&r>=0&&r<15)
                edge_lr[b][l] = '0';
            else
                edge_lr[b][l] = '\0';
        }
    for(int b=0;b<21;b++)//右下向左上
        for(int l=0;l<15;l++)
        {
            ·····
        }
    algorithm_opt = 1;
    depth = 4;//先遍歷兩步看看吧
}
int chess_board::cal_score(bool is_att)

終於來到計算分數的函式了,在這個函式中,由於斜邊對應的陣列每個的起始不同,需要計算(這裡有點複雜),就是要根據\(b\)來推出陣列的起始位置,這個每個人的實現方法不同對應的演算法也不同,自己想。

該函式遍歷每一個陣列,找出每一個陣列中存在的句型,如果這個句型在我方中存在,那就在總分上加上\(句型的分值*句型的數量\),如果這個句型在對方句型庫中存在,那就在總分上減去\(句型的分值*句型的數量\),符合零和遊戲的特點。

int chess_board::cal_score(bool is_att)
{
    int score = 0;
    for(int r=0;r<15;r++)//每一行
    {
        string temp = rows[r];
        if(pd_null(temp))//判斷是否為空,沒有棋子就不要查了
            continue;
        for(int i = 0;i<type_count;i++)//查詢每一種棋型
        {
            int count1 = find_count(temp.c_str(),chess_type_all_my[i].ptn.c_str());
            if(i==8)
                count1-= find_count(temp.c_str(),chess_type_all_my[7].ptn.c_str());
            if(i==19)
                count1-= find_count(temp.c_str(),chess_type_all_my[18].ptn.c_str());
            score+=count1*chess_type_all_my[i].my_score;
            int count2 = find_count(temp.c_str(),chess_type_all_opt[i].ptn.c_str());
            if(i==8)
                count2-= find_count(temp.c_str(),chess_type_all_opt[7].ptn.c_str());
            if(i==19)
                count2-= find_count(temp.c_str(),chess_type_all_opt[18].ptn.c_str());
            score-=count2*chess_type_all_my[i].opt_score;
        }
    }
    for(int l =0;l<15;l++)//每一列
    {
		····
    }
    for(int b = 0;b<=20;b++)//左下到右上的斜行
    {
        ····
    }
    for(int b = 0;b<=20;b++)//左上到右下的斜行
    {
        ····
    }
    return score;
}

遇到的一些坑:

  • 寫計算句型數量的函式的時候,每次找到一個句型之後沒有跳過匹配的句型,只是簡單的將尋找的起始位置加1,這樣會遇到重複計算的情況,如:"00AAA0"和"0AAA00"就會重複計算,這樣會導致明明只有一個三活卻讀出了兩個。
  • 寫計算分值的時候,因為定義的句型中間"00AAA0"和"0AAA00"有時會重複計算,為了避免這個事情發生。以這個為例,在計算"00AAA0"的時候,會先數一下"0AAA00"是否已經被計算過了,如果計算過了,就會將"00AAA0"的數量減一。
  • 一開始將72個陣列的更新放在了一個臨時的陣列當中,每次預測的時候都要複製一遍72個陣列,這樣降低了遍歷的速度,利用深度優先搜尋可回溯的特點,動態更新,可以加快搜尋的速度。
  • 在對每一個棋型的分值進行評估的時候,將自己的五連珠和對手的五連珠的分值設定的太過接近,導致遍歷的時候AI不去管對面即將五連珠的棋型,反而專注於自己的五連珠(但是自己始終比對手慢一步),導致防守失敗。因此對手五連珠的分值應該設為自己五連珠分值的10倍

alpha-beta剪枝優化

儘管alpha-beta剪枝對速度有所優化,但是在實際問題中,我們還是很少使用樸素的alpha-beta剪枝演算法,因為還是太慢了。

樸素的alpha-beta剪枝缺陷:

還是慢。。。

在可接受的範圍內只能搜到\(depth=2\)的情況,相當於只預測了一輪(MAX下了一次,然後MIN下了一次,再輪到MAX下的時候就要算分了)。這樣的層數下演算法肯定還是不夠聰明,因此得想辦法加快。

alpha-beta剪枝演算法加速:

前剪枝:

在五子棋中,落子的點一般都在別的已經落子的棋子旁邊,很容易看出,在當前棋盤的局面下,如果落子在紅圈所在的地方肯定是沒有意義的,因為其對評估函式的結果不會變得更好。

所以,在遍歷的時候,我們可以只考慮目前在棋盤上所有棋子的附近的棋子。換句話說,對於某一個位置,如果其四周都沒有棋子的話,就不考慮它,將其剪枝。因此,在上圖中只用考慮黑圈中的棋子。這樣就達到了前剪枝,節省了大量的時間。

啟發式搜尋:
啟發式搜尋的定義:

用於生成待搜尋位置的函式,在樸素的alpha-beta剪枝中,節點搜尋的順序是按照從下到上,從左往右的順序進行的。但是深入理解alpha-beta剪枝會發現:節點的值的排列順序非常的影響alpha-beta剪枝的效率:對於某個節點來說,如果第一個孩子節點就是所有孩子節點的分數的最小值,而該節點又是MIN節點,就可以在第一個節點滿足alpha-beta剪枝的條件:\(\alpha>=\beta\),這樣就可以大大增加剪枝的效率。

例如:對於下圖中黑圈內的區域,如果葉子節點值為4的節點能和葉子節點值為7的節點順序互換,那麼就可以將葉子節點值為7的節點剪掉,增加了剪枝的效率。

因此,我們需要一個啟發式搜尋的函式,該函式可以有效的評估哪些落子的點的評估函式值大,哪些落子的點評估函式的值小,這樣就可以大大加快剪枝的效率了。

本實驗設計的啟發式搜尋函式:

將該函式命名為\(h(x)\),針對每一個落子的點,遍歷經過這個點的行,列,斜線的四個陣列,找出相應的對我方有利的棋型然後加分,如果遇到兩個以上的三活以及四活,則將\(h(x)\)加一個很大的值,表示優先遍歷。

這樣的好處是AI可以有效的識別兩個三活以及死四以及活四這種必殺棋的情況,並且針對其進行進攻和防守。

實現程式碼如下:

int partern_count[type_count]={0};//用來存每一個棋子的數量
int sum_score = 0;
int b_lr = r+10-l;
int b_rl = r+l-4;
for(int i = 0;i<type_count;i++)//查詢每一種棋型
{
    string temp_rl;
    string temp_lr;
    if(b_rl>=0&&b_rl<=20)//如果不存在,則不考慮
        temp_rl = &edge_rl[b_rl][(b_rl-10<0)?0:b_rl-10];
    if(b_lr>=0&&b_lr<=20)
        temp_lr = &edge_lr[b_lr][(10-b_lr<0)?0:10-b_lr];
    int count1 = find_count(rows[r],chess_type_all_my[i].ptn.c_str());//行
    int count2 = find_count(cols[l],chess_type_all_my[i].ptn.c_str());//列
    int count3 = find_count(temp_rl.c_str(),chess_type_all_my[i].ptn.c_str());//左下到右上
    int count4 = find_count(temp_lr.c_str(),chess_type_all_my[i].ptn.c_str());//左上到右下
    if(i==8)
    {
        count1 -= find_count(rows[r],chess_type_all_my[7].ptn.c_str());
        count2 -= find_count(cols[l],chess_type_all_my[7].ptn.c_str());//列
        count3 -= find_count(temp_rl.c_str(),chess_type_all_my[7].ptn.c_str());//左下到右上
        count4 -= find_count(temp_lr.c_str(),chess_type_all_my[7].ptn.c_str());//左上到右下
    }
    if(i==19)
    {
        count1 -= find_count(rows[r],chess_type_all_my[18].ptn.c_str());
        count2 -= find_count(cols[l],chess_type_all_my[18].ptn.c_str());//列
        count3 -= find_count(temp_rl.c_str(),chess_type_all_my[18].ptn.c_str());//左下到右上
        count4 -= find_count(temp_lr.c_str(),chess_type_all_my[18].ptn.c_str());//左上到右下
    }
    sum_score+=(count1+count2+count3+count4)*chess_type_all_my[i].my_score;
    int count5 = find_count(rows[r],chess_type_all_opt[i].ptn.c_str());//行
    int count6 = find_count(cols[l],chess_type_all_opt[i].ptn.c_str());//列
    int count7 = find_count(temp_rl.c_str(),chess_type_all_opt[i].ptn.c_str());//左下到右上
    if(deep==0&&r==6&&l==5)
    {
        cout<<count1<<" "<<count2<<" "<<count3<<" "<<count4<<" "<<endl;
    }
    int count8 = find_count(temp_lr.c_str(),chess_type_all_opt[i].ptn.c_str());//左上到右下
    if(i==8)
    {
        count5 -= find_count(rows[r],chess_type_all_opt[7].ptn.c_str());//行
        count6 -= find_count(cols[l],chess_type_all_opt[7].ptn.c_str());//列
        count7 -= find_count(temp_rl.c_str(),chess_type_all_opt[7].ptn.c_str());//左下到右上
        count8 -= find_count(temp_lr.c_str(),chess_type_all_opt[7].ptn.c_str());//左上到右下
    }
    if(i==19)
    {
        count5 -= find_count(rows[r],chess_type_all_opt[18].ptn.c_str());//行
        count6 -= find_count(cols[l],chess_type_all_opt[18].ptn.c_str());//列
        count7 -= find_count(temp_rl.c_str(),chess_type_all_opt[18].ptn.c_str());//左下到右上
        count8 -= find_count(temp_lr.c_str(),chess_type_all_opt[18].ptn.c_str());//左上到右下
    }
    sum_score-=(count5+count6+count7+count8)*(chess_type_all_my[i].opt_score);
    partern_count[i] = count1+count2+count3+count4;
}
int winner = 0;
for(int i=17;i<=23;i++)//如果存在兩個以上的兩個活三和活四
    winner += partern_count[i];
if(winner>1)
    sum_score+=3000000;
h_score.push_back(node{r,l,sum_score});
clear_temp_chess(r,l);//注意要回溯
}

待實現的優化方法:

啟發式搜尋方法改進

能不能在現在得分的基礎上加上當前局面的評估函式得分,以此為依據來對要遍歷的落子點進行排序。然後只選擇最好的前10個節點進行擴充。

當然這種只選擇最好的節點擴充的方法非常依賴於啟發式函式的正確性,如果這種啟發式搜尋函式不好的話,只搜尋前10個節點可能會喪失最優解,體現在AI上就是下棋變“蠢”了。

使用迭代加深深度優先搜尋加速

迭代加深深度優先搜尋就是先搜尋depth=1的情況,然後搜尋depth=2的情況,以此類推。這種方法看似很慢,其實會加速alpha-beta剪枝。因為淺層搜尋得到的最優解很有可能是深層搜尋下評估函式得分高的點,因此按照淺層節點的得分進行排序來擴充節點,基本上都是有序的,會大大加快alpha-beta剪枝的速度。

GUI介面的編寫

開發平臺

Qt Creator 4.14.2 (Community)

使用原因:

QT Creator 是一個將c++編輯器以及GUI介面拖拽元件實現整合在一起的一個軟體。可以用於快速開發GUI介面。

關於QT的語法以及編輯器的使用實在太多了,無法列舉出來,這裡給個網址自己看(找了好久的中文文件):

Qt教程,Qt5程式設計入門教程(非常詳細) (biancheng.net)

介面介紹以及具體實現:

①:QPushButton

點選元件①會讓遊戲正式開始,如果沒有點選②,③設定演算法和先後手會預設使用alpha-beta剪枝和先手來進行對局。

使用槽函式和訊號,connet方法來連線訊號和槽函式。

connect(w.ui->start_bt_2, SIGNAL(clicked()),this , SLOT(on_start_bt_2_clicked()));
②:QPushButton

點選元件②會彈出視窗,其中有兩個單選框,兩個都是QRadioButton,按確定後會設定先後手

如何展示彈窗?

先定義一個QDialog,點選按鈕後,將QDialog resize後,然後定義兩個QRadioButton加入到Group當中,最後通過點選button來進行先後手的選擇。

void MainWindow::on_start_bt_clicked()
{
    who_first.resize(200,150);
    tip_who->resize(200,50);
    tip_who->setText("   請問您是執黑還是執白");
    tip_who->setStyleSheet("font-size:16px");
    firstGroup = new QButtonGroup(&who_first);
    firstGroup->addButton(white,0);
    firstGroup->addButton(black,1);
    black->setText("黑");
    white->setText("白");
    black->setChecked(1);
    white->setGeometry(QRect(50, 60, 30, 20));
    black->setGeometry(QRect(120, 60, 30, 20));
    who_first.show();
    tip_who->show();
    white->show();
    black->show();
    is_ok->setGeometry(QRect(50, 100, 100, 30));
    is_ok->setText("確定");
    is_ok->show();
    connect(is_ok, SIGNAL(clicked()), this, SLOT(on_is_ok_bt_clicked()));
}
③:QPushButton

點選元件③會彈出視窗,其中有兩個單選框,兩個都是QRadioButton,按確定後選擇演算法

彈窗同上。

④:QLabel

對話方塊,用於顯示遊戲進行的提示。

使用setText函式來對QLabel的內容

⑤:QTextBrowser

資訊框,用於顯示搜尋的資訊。

//為out_tip賦值
w.ui->detail_info->textCursor().insertText(out_tip);

⑥:paintEvent:

棋盤,實時更新陣列中的棋子。為了使遊戲能順利進行。

使用paintEvent事件+mousePressEvent事件:

使用paintEvent事件將圖片花在背景上,然後畫橫線和豎線,之後再在特定的五個點畫棋盤的標註。

使用mousePressEvent事件檢測滑鼠是否點選螢幕,觸發事件後會返回點選的座標,根據座標計算出點選的行列,然後傳入棋盤,在指定位置置"A","B"。

使用update()方法可以重新呼叫paintEvent,實現棋盤的更新。

為了防止搜尋過程顯示到螢幕上,需要使用一個標誌位,no_update,然後每次下棋之前將棋盤情況複製到一個暫存的棋盤當中,no_update=1時一直paint這個暫時的棋盤,然後當no_update=0後paint目前的棋盤。

如何實現多程式

為何需要多執行緒

因為GUI圖形程式一般分為前端顯示和後端運算兩個部分,如果只使用單個程式的話會導致在後端運算的時候前端介面卡死,因此需要使用多程式來解決這個問題。

實現方法

要開啟一個新的程式需要QThread類來實現,但是QThread的run函式是固定好的,因此需要自己定義一個類,這個類繼承QThread類,然後重寫QThread的run()函式,之後使用start()函式來開啟執行緒,呼叫run()函式即可。

程式碼如下

類的定義:

class back_game: public QThread
{
    Q_OBJECT
public:
    MainWindow w;
private slots:
    void on_play_begin_bt_clicked();
    void on_start_bt_2_clicked();
    void on_start_ag_clicked();
    void on_is_ok_bt1_clicked();
protected:
    void run(); // 新執行緒入口
// 省略掉一些內容
};
#endif // MAINWINDOW_H

main函式中的呼叫:

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    back_game game;
    game.w.is_press=0;
    game.w.show();
    game.start();
//    return a.exec();
    return a.exec();
}

將所有的遊戲流程都放在run()函式當中了。

訊號與槽函式如何使用:

使用connect函式來連結訊號和槽函式,

connect(sender, SIGNAL(event()), receiver, SLOT(function()));

sender:訊號的傳送者,如果為按鈕,點選後就會傳送clicked()訊號

receiver: 訊號的接受者,如果單純只是想使用某一個函式而不改變元件的話,可以使用this,this指的是function所在的類。

SIGNAL(event()):傳送的訊號,不同元件不同

function():槽函式,注意,槽函式一定要定義在private:slot這樣宣告為私有槽函式的私有成員當中。不然沒有用。

遇到點選事件但是沒有響應怎麼辦:

考慮以下五種情況:

1、看看你的類宣告中有沒有Q_OBJECT,沒有加上(並檢查是否已經包含#include<QtCore/QObject>標頭檔案)

2、你宣告的函式要加宣告:

private slots:

3、檢查槽函式名拼寫是否有誤

4、確認對應的signal和slot的引數是否一致

5、如果還不行的話,清理專案,刪掉原有的moc_xxx.cpp,重新執行qmake.

如果上述情況還是不行的話,可能是因為多程式,而其中的一個程式佔用了cpu內的所有時間,因此需要在當前的死迴圈內加上一個msleep(1),該執行緒休息1ms,為訊號的接受提供CPU時間。

while(1)
{
    ·····
    msleep(1);
}

總結:

通過本次實驗,我受益匪淺,先列舉如下:

  • 熟悉如何使用min-max以及alpha-beta剪枝來解決一個博弈問題
  • 瞭解了許多能加速alpha-beta剪枝的方法,並且自己實現了兩個
  • 學會了如何構造評估函式並且實現,如何對評估函式進行調優
  • 學會使用了QT create開發平臺,瞭解瞭如何使用拖拽元件的方法快速製作GUI
  • 初步瞭解了QT多執行緒的使用方法,解決了程式之間競爭CPU時間的問題
  • 發現並解決了為何槽函式無法使用的問題

相關文章