基於落點打分的井字棋智慧下棋演算法(C語言實現)

Gofan發表於2023-10-17

本文設計了一種基於落地打分的井字棋下棋演算法,能夠實現電腦不敗,所以如果玩家會玩的話,一般是平局。

演算法核心

電腦根據對落子位置的打分,選擇分數最高的位置,若不同落點分數相同則隨機選擇位置(隨機選擇就不會顯得那麼呆板)

所以怎麼打分是關鍵!

基本思想是,判斷落點附近的位置的棋子型別,進行打分,進一步解釋,根據井字棋的規則,橫、豎、對角連成三子則判獲勝,所以每一個落點和與他同一橫、豎、對角的棋子型別有關。所以我們可以指定一個打分表如下:

C代表己方棋子(電腦),P代表對方(玩家)棋子,空代表沒有棋子

型別 得分
C+C 100
P+P 50
C+空 6
P+空 4
空+空 2
P+C 1

簡單解釋一下,C+C表示己方已經有2個棋子了,下一步馬上可以贏,給最高分,且其他分數相加不會超過100分。同理P+P是50分,如果不存在C+C情況,那麼50分將是最高分,其他分數相加不會超過。

C+空P+空的分數高低取決於電腦是進攻型還是防守型,但是他們分數一定不能相差太多。

這裡舉個例子說明得分怎麼計算

image-20231017114843378

我們計算黃色方格的得分為 橫(C+空)+豎(C+空)+對角(P+空)=6+6+4=16。橙色方格得分為橫(C+空)+豎(P+空)+對角(P+空)=6+4+4=14。所以電腦會選擇走黃色方格。

也就是說,最終每個可以下的格子的打分等於橫、豎、對角的打分之和,若沒有對角線,則對角線為0分。

核心演算法介紹如上,接下來是實現。

程式碼實現

程式碼大致可分為三個模組,制定井字棋基本操作和規則、電腦下棋、介面列印。

井字棋下棋規則

我們建立一個SZQ_basic.c檔案,在標頭檔案SZQ_basic.h進行相關的定義和宣告。

//`SZQ_basic.h`
#include<stdio.h>
#include <string.h>
#include<stdlib.h>

#define N 3
#define P_pawn 'o' //玩家棋子 o
#define C_pawn 'X' //電腦棋子 X

char** creatQP(); //建立棋盤
void printQP(char** QP); //列印棋盤
int inputQZ(char** QP, int row, int column); //輸入棋子
int isPlayerWin(char** QP); //判斷勝負
int isQPFull(char** QP); //判斷平局

基本思想是,透過二維數char[3][3]組儲存棋子,空位置則用空格符填充,輸入棋子時判斷是不是空格符,如果是才可以輸入(下棋)。

判斷勝負的返回值包括 1 ,0 , -1,分別表示玩家勝,未結束,電腦勝。平局則是透過判斷棋盤是否滿,遍歷二維陣列看是否還有空格符就可以了。

  1. 建立棋盤

    /*
    * @brief    creat  chequer
    */
    char** creatQP() {
    	char** QP;
    	QP = (char**)malloc(sizeof(char*) * N);
    	if (QP == NULL)
    		return NULL;
    	for (int i = 0; i < N; i++) {
    		QP[i] = (char*)malloc(sizeof(char) *N);
    		if (QP[i] == NULL)
    			return NULL;
    		memset(QP[i], ' ', sizeof(char)*N);
    	}
    	return QP;
    }
    
  2. 列印棋盤

    /*
    * @brief    printf Chequer
    * @para     棋盤地址
    */
    void printQP(char** QP) {
    
    	if (QP == NULL) {
    		printf("print Chequer failed!");
    		return;
    	}
    
    	puts("|_____________________________    棋盤    ___________________________|");
    	printf("\t\t 行號→  1   2   3\t\t玩家棋子:“o”\n\t\t 列號↓\t\t\t\t電腦棋子:“X”\n");
    	printf("\t\t       |---|---|---|\n");
    	for (int i = 0; i < N; i++) {
    		printf("\t\t      %d|", i + 1);
    		for (int j = 0; j < N; j++) {
    			printf(" %c |", QP[i][j]);
    		}
    		printf("\n");
    		printf("\t\t       |");
    		for (int j = 0; j < N; j++) {
    			printf("---|");
    		}
    		printf("\n");
    	}
    	puts("|--------------------------------------------------------------------|");
    }
    
  3. 輸入棋子

    int inputQZ(char** QP, int row, int column) {
    	if (row < 0 || row>2 || column < 0 || column>2) {
    		printf("輸入棋子位置不合法!\n請重新輸入:>>");
    		return 0;
    	}
    	if (QP[row][column] != ' ') {
    		printf("該位置已有棋子!\n請重新輸入:>>");
    		return 0;
    	}
    	QP[row][column] = P_pawn;
    	return 1;
    }
    
  4. 判斷棋盤滿

    /*
    * brief  判斷棋盤是否滿了,滿了即平局
    */
    int isQPFull(char** QP) {
    	for (int i = 0; i < N; i++)
    	{
    		for (int j = 0; j < N; j++)
    		{
    			if (QP[i][j] ==' ')
    				return 0;
    		}
    	}
    	return 1;
    }
    
  5. 判斷勝負

    /*
    *@brief  判斷遊戲是否結束(玩家獲勝?)
    *@ret    1:玩家勝   0:遊戲未結束   -1:玩家敗
    */
    int isPlayerWin(char** QP) {
    	int flag = 0;
    
    	//判斷行成線
    	for (int i = 0; i < N; i++)
    	{
    		if (QP[i][0] == QP[i][1] && QP[i][0] == QP[i][2] && QP[i][0] == C_pawn)
    			flag = -1;
    		else if (QP[i][0] == QP[i][1] && QP[i][0] == QP[i][2] && QP[i][0] == P_pawn)
    			flag = 1;
    	}
    	//判斷列成線
    	for (int j = 0; j < N; j++)
    	{
    		if (QP[0][j] == QP[1][j] && QP[0][j] == QP[2][j] && QP[0][j] == C_pawn)
    			flag = -1;
    		else if (QP[0][j] == QP[1][j] && QP[0][j] == QP[2][j] && QP[0][j] == P_pawn)
    			flag = 1;
    	}
    	//判斷正對角成線
    	if (QP[0][0] == QP[1][1] && QP[0][0] == QP[2][2] && QP[0][0] == C_pawn)
    		flag = -1;
    	else if (QP[0][0] == QP[1][1] && QP[0][0] == QP[2][2] && QP[0][0] == P_pawn)
    		flag = 1;
    
    	//判斷反對角成線
    	if (QP[0][2] == QP[1][1] && QP[0][2] == QP[2][0] && QP[0][2] == C_pawn)
    		flag = -1;
    	else if (QP[0][2] == QP[1][1] && QP[0][2] == QP[2][0] && QP[0][2] == P_pawn)
    		flag = 1;
    
    	return flag;
    }
    

電腦下棋演算法

接下來建立SZQ_engine.c原始檔實現電腦下棋,標頭檔案相關的函式宣告如下

#include <time.h>
#include "SZQ_basic.h"

int computerPlay(char** QP);

int row_score(char** QP, int row);
int column_score(char** QP, int column);
int Pdiag_score(char** QP, int postion);
int Ndiag_score(char** QP, int postion);

檔案一共包含5個函式,含score的函式是計算在行、列、正對角、反對角情況下的得分,因為每一個落點位置最多隻有這四種情況疊加(中心點位置特殊,這四種情況都有),所以只要把每種情況的得分相加,computerPlay函式負責彙總和確定落點,以及下棋。

image-20231017114843378

還是以這個圖為例,黃色方格由於只有行、列、正對角,沒有反對角,所以反對角分數為0,其他大於0。具體是多少分還得參考打分表。

為了方便,我們將打分表以陣列形式儲存。三個不同字元,任意兩個相加,得到的數一定不會出現相同的,可以透過數學證明。

int scoretable[6][2] = { {C_pawn + C_pawn,50},
						 {P_pawn + P_pawn,30},
						 {C_pawn + ' ',6},
						 {P_pawn + ' ',4},
						 {' ' +' ',2},
						 {P_pawn + C_pawn,1},
};
  1. 行得分計算

    int row_score(char** QP,int row){
    	int score=0;
    	int type = 0;
    	for (int i = 0; i < N; i++)
    	{
    		type = QP[row][i] + type;
    	}
        //將落點對應那一行三個位置的字元相加,減去自身的空字元,得到型別type
    	type = type - ' ';
        //查詢打分表,type型別的對應得分score
    	for (int k = 0; k < 6; k++)
    	{
    		if (scoretable[k][0] == type)
    		{
    			score = scoretable[k][1]; 
    			break;
    		}
    	}
    	return score;
    }
    
  2. 列得分計算

    int column_score(char** QP, int column) {
    	int score = 0;
    	int type = 0;
        //將落點對應那一列三個位置的字元相加,減去自身的空字元,得到型別type
    	for (int i = 0; i < N; i++)
    	{
    		type = QP[i][column] + type;
    	}
    	type = type - ' ';
        //查詢打分表,type型別的對應得分score
    	for (int k = 0; k < 6; k++)
    	{
    		if (scoretable[k][0] == type)
    		{
    			score = scoretable[k][1];
    			break;
    		}
    	}
    	return score;
    }
    
  3. 正對角得分計算

    int Pdiag_score(char** QP, int postion) {
    	int score = 0;
    	int type ;
        //判斷該位置是否存在正對角情況
    	if (postion/N==postion%N)
    	{
            //若存在,同樣的將落點對應那一正對角三個位置的字元相加,減去自身的空字元,得到型別type
    		type = QP[0][0] + QP[1][1] + QP[2][2];
    		type = type - ' ';
            //查詢打分表,type型別的對應得分score
    		for (int k = 0; k < 6; k++)
    		{
    			if (scoretable[k][0] == type)
    			{
    				score = scoretable[k][1];
    				break;
    			}
    		}
    	}
    	return score;
    }
    
  4. 反對角計算得分

    int Ndiag_score(char** QP, int postion) {
    	int score = 0;
    	int type;
        //判斷該位置是否存在反對角情況,自行證明反對角滿足(postion / N+postion % N)==2
    	if ((postion / N+postion % N)==2)
    	{
    		type = QP[0][2] + QP[1][1] + QP[2][0];
    		type = type - ' ';
    		for (int k = 0; k < 6; k++)
    		{
    			if (scoretable[k][0] == type)
    			{
    				score = scoretable[k][1];
    				break;
    			}
    		}
    	}
    	return score;
    }
    
  5. 接下來是彙總分數,確定落點

    int computerPlay(char** QP) {
    	int index=0;
    	int score = 0;
    
    	for (int i = 0; i < N*N; i++)
    	{
    		int temp_score = 0;
            //遍歷棋盤,並且找出空格符,即可落子的位置,計算分數
    		if (QP[i / N][i % N] == ' ')
    		{
                //把4種情況的分數相加得到總分數
    			temp_score = row_score(QP, i/N) + column_score(QP, i%N) + Pdiag_score(QP, i) + Ndiag_score(QP, i);
    			if (temp_score > score)   //取分數最大值
    			{
    				score = temp_score;
    				index = i;
    			}
    			else if (temp_score == score)  //若分數相同,在兩個隨機選擇一個位置作為落點
    			{
    				srand((unsigned)time(NULL));
    				index=(rand() % 2)? i:index;
    			}	
    		}
    	}
    	return index;
    }
    

    注意返回值是索引,就是把二維陣列當一維陣列,方便遍歷和返回位置(二維陣列行號和列號是兩個值,不方便返回)。因此返回後需要根據索引index確定行和列。

    行:index / N

    列:index % N

列印棋盤介面

最後,我們把棋盤介面列印,就可以大功告成了。

這裡我使用延時函式模擬電腦思考過程,不然列印太快了,沒意思hhh。然後玩家也可以自行選擇先手和後手。

這部分比較簡單,C語言基礎語法,直接把程式碼放下面。

#define _CRT_SECURE_NO_WARNINGS 
#include <windows.h> 
#include "SZQ_engine.h"
#include "SZQ_basic.h"
int main() {
	//printf("%d", C_pawn+C_pawn);
	int hang, lie;
	int cmd = 1;
	int position;
	char** TicTacToe = creatQP();
	puts("______________________________________________________________________");
	puts("|*********************** Tic-Tac-Logic (game) ***********************|");
	puts("|** author:gofan-SiTu ***********************************************|");
	puts("|************************           請選擇以下功能      *************|");
	puts("|************************            0:退出遊戲          ***********|");
	puts("|************************     1:作為先手開始與電腦對弈   ***********|");
	puts("|************************     2:作為後手開始與電腦對弈   ***********|");
	puts("|********************************************************************|");
	puts("|——————————————————————————————————|");
	printf(">請輸入對應序號:>>");
	scanf_s("%d", &cmd);
	switch (cmd)
	{
	case 0:
		exit(0);
	case 1:
		printf(">您選擇與電腦對弈,落子時請輸入行號和列號確定位置,中間用空格隔開\n");
		printf(">您的棋子是“o”,電腦的棋子是“X”\n");
		printQP(TicTacToe);
		printf(">您先走棋  ");
		while (1) {
			printf(">請輸入您的下一步落子位置:>>");
			do {
				scanf_s("%d%d", &hang, &lie);
			} while (!inputQZ(TicTacToe, hang - 1, lie - 1));
			printf(">您走棋後,棋盤如下\n");
			printQP(TicTacToe);
			if (isPlayerWin(TicTacToe) == 1) {
				printf("\n>!恭喜玩家獲勝 !<\n >  您太強了  <\n");
				break;
			}
			else if (isPlayerWin(TicTacToe) == -1) {
				printf("\n >  電腦獲勝  < \n >!你太菜拉!< \n");
				break;
			}
			else if (isQPFull(TicTacToe)) {
				printf(" >!平局 !< \n");
				break;
			}
			position = computerPlay(TicTacToe);
			printf(" 電腦正在思考......\n");
			Sleep(1000);
			printf(">電腦的落子位置:>>%d %d\n>電腦落子後,棋盤如下\n", position / N + 1, position % N + 1);
			TicTacToe[position / N][position % N] = C_pawn;
			Sleep(100);
			printQP(TicTacToe);
			if (isPlayerWin(TicTacToe) == 1) {
				printf("\n>!恭喜玩家獲勝 !<\n >  您太強了  <\n");
				break;
			}
			else if (isPlayerWin(TicTacToe) == -1) {
				printf("\n >  電腦獲勝  < \n >!你太菜拉!< \n");
				break;
			}
			else if (isQPFull(TicTacToe)) {
				printf(" >!平局 !< \n");
				break;
			}
		}
		break;
	case 2:
		printf(">您選擇與電腦對弈,落子時請輸入行號和列號確定位置,中間用空格隔開\n");
		printf(">您的棋子是“o”,電腦的棋子是“X”\n");
		printf(">電腦先走棋\n");
		while (1) {
			position = computerPlay(TicTacToe);
			printf(" 電腦正在思考......\n");
			Sleep(1000);
			printf(">電腦的落子位置:>>%d %d\n>電腦落子後,棋盤如下\n", position / N + 1, position % N + 1);
			TicTacToe[position / N][position % N] = C_pawn;
			Sleep(250);
			printQP(TicTacToe);
			if (isPlayerWin(TicTacToe) == 1) {
				printf("\n>!恭喜玩家獲勝 !<\n >  您太強了  <\n");
				break;
			}
			else if (isPlayerWin(TicTacToe) == -1) {
				printf("\n >  電腦獲勝  < \n >!你太菜拉!< \n");
				break;
			}
			else if (isQPFull(TicTacToe)) {
				printf(" >!平局 !< \n");
				break;
			}
			printf(">請輸入您的下一步落子位置:>>");
			do {
				scanf_s("%d%d", &hang, &lie);
			} while (!inputQZ(TicTacToe, hang - 1, lie - 1));
			printf(">您走棋後,棋盤如下\n");
			printQP(TicTacToe);
			if (isPlayerWin(TicTacToe) == 1) {
				printf("\n>!恭喜玩家獲勝 !<\n >  您太強了  <\n");
				break;
			}
			else if (isPlayerWin(TicTacToe) == -1) {
				printf("\n >  電腦獲勝  < \n >!你太菜拉!< \n");
				break;
			}
			else if (isQPFull(TicTacToe)) {
				printf(" >!平局 !< \n");
				break;
			}

		}
		break;
	default:
		printf(">輸入序號不準確,程式異常退出!\n 請重新啟動!");
		exit(-1);
	}
	printf("\n  遊戲結束! \n");
	for (int i = 0; i < N; i++)
		free(TicTacToe[i]);
	free(TicTacToe);
	for (int i = 5; i >= 0; i--)
	{
		printf("程式將在%ds後關閉...\n", i);
		Sleep(1000);
	}
	return 0;
}

結果

目前這個演算法可以實現電腦不敗,當然,這個打分表也是我經過分析、篩選確定的分數,比較合理。但是如果把這思想擴充到五子棋上,似乎不太行,至少我現在還沒想到思路,因為五子棋比井字棋複雜得多了,而且棋盤很大,就不能簡單的對落點位置的附近棋子型別打分,就算可以,判斷規則也相當複雜。所以結論就算,這個演算法只適合井字棋子棋這種簡單的型別。

相關文章