五子棋AI第一篇 极大极小值搜索算法(c语言)

由来

最近人工智能很火,常常有各类新闻,做为一个程序员多少要懂一点吧,将来万一用得着呢。有心想去了解一下,奈何能力有限,皮毛都没学会。好吧,既然无法系统学,就无论那么多,就从如今开始动手,一步一步的往前走,遇到什么问题就查什么资料。虽然最后不必定能作成什么,起码作的过程遇到问题解决问题,总能学会点什么。
从最简单的五子棋游戏开始,先用普通的规则实现一个电脑下棋的算法,姑且叫作AI算法。做为一个只用过linux环境的C语言,作到这一步也仍是须要走很多的路,还要学习很多东西才能作好。此次算是我头一回认真学习并且还坚持这么久,因此记录一下所学和这个过程。本文适合像我同样的新手。linux

废话说完,正式开工。
五子棋基本规则:
一、棋盘大小15x15
二、五个棋子连成一条线即为赢
三、黑棋先下
四、黑棋禁手先不考虑程序员

棋型基本术语:
5连:5个子连成一条线,已经赢了。
活4:有4个子在一条线,并且连线的两端没有对方的子。
冲4:(也有说眠4)有4个子在一条线,连线的一端没有对方的子,另外一端有对方的子。
活3:有3个子在一条线,并且连线的两端没有对方的子。
相似的还有冲3,活2,冲2,活1。
其余更专业的术语就不考虑了。web

极大极小值

极大极小值算法须要两个条件:
一、 零和博弈,就是你死我活的游戏。
二、 彻底信息,玩家知道以前的全部步骤,象棋、五子棋都是交替落子在棋盘上,以前的步骤都一目了然。
若是把五子棋下子全部可能都列举出来,就能够看作一个树的结构。以电脑AI的视角看,每一步往下走均可以看作是树,树的根节点为0开始。奇数层表示电脑可能走法咱们称之为MAX层,偶数层是玩家的可能走法咱们称之为MIN层。
显然,
MAX层,电脑须要选择对本身最有利的节点。
MIN层,玩家会选择对本身最有利的节点,也就是对AI最不利的点。
这就是极大极小值算法。算法

这里有个很核心问题,什么叫最有利什么叫最不利呢?下面用源码一步一步详细讲解评估函数的由来,再用评估函数解释什么棋对电脑AI最有利和最不利。数组

(下面代码用C语言写的,linux下x86环境编译运行)
首先第一步:定义分值,其中score_e枚举就是分值定义
直接把头文件贴出来,方便后面的代码理解。svg

#ifndef __COBAND_MAIN_H
#define __COBAND_MAIN_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define DEBUG           0				//打开调试信息
#define M_SIZE          15              //棋盘大小为15x15
#define DEEP            3               //最大搜索多少层
#define check(x)    (((x)<0) || ((x)>=M_SIZE))	//棋子坐标是否溢出

//棋子状态颜色
#define    SPACE            0
#define    WHITE            1
#define    BLACE            2 

/*计分板
分数随意定的,只考虑了不让四个方向上的分数加起来超过更高一级的棋型*/
typedef enum score_e
{
    WIN5 = 100000,          //5连           
    ALIVE4 = 10000,         //活4
    ALIVE3 = 1000,          //活3
    DIE4 = 1000,            //死4
    ALIVE2 = 100,           //活2
    DIE3 = 100,             //死3
    DIE2 = 10,              //死2
    ALIVE1 = 10             //活1
}score;

//棋子
typedef struct chess_s
{
    u8 x;
    u8 y;
}chess_t;

//空子的序列
typedef struct chess_queue_s
{
    chess_t chess[M_SIZE*M_SIZE];
    u8 len;
}chess_queue;

#endif

第二步:依据连子个数计算分数函数

/*计分表,依据连子个数(bumber)和两端的空子个数(empty)*/
static u32 score_table(u8 number, u8 empty)
{
    if (number >= 5)
    {
        return WIN5;
    }
    else if (number == 4)
    {
        if (empty == 2)	
		{
            return ALIVE4;
		}
        else if (empty==1)
		{			
            return DIE4;
		}
    }
    else if (number == 3)	
    {
		if (empty == 2)	
		{
            return ALIVE3;
		}
		else if (empty == 1)
		{			
            return DIE3;
		}
	}
	else if (number == 2)
	{
		if (empty == 2)	
		{
            return ALIVE2;
		}
		else if (empty == 1)
		{			
            return DIE2;
		}
    }
    else if (number == 1&&empty == 2)
	{
        return ALIVE1;
	}
    return 0;
}

第三步:把棋盘的一行数据存到n[15],而后计算这一行的连子个数和两端空子个数,再用上面的API:score_table()计算出这一行的分数,最后返回分数。学习

/*正斜线、反斜线、横、竖,均转成一维数组来计算*/
static u32 count_score(u8 n[], u8 chessColor)
{   
    u8 i = 1;            //n[0]已经提早计算
	u8 empty = 0;       //空的位子
	u8 number = 0;      //连子的个数
	u32 scoretmp = 0;
    if (n[0] == SPACE)	
    {
        empty++;
    }
	else if (n[0] == chessColor)
    {
        number++;
    }
    
    while (i < M_SIZE)
    {
        if (n[i] == chessColor)
        {
            number++;
        }
        else if (n[i] == SPACE)
        {
            if (number == 0)
            {
                empty = 1;
            }
            else
            {
                scoretmp += score_table(number, empty+1);
				empty = 1;
				number = 0;
            }
        }
        else
		{
			scoretmp += score_table(number, empty);
			empty = 0;
			number = 0;
		}
		i++;
    }
    scoretmp += score_table(number, empty);
	return scoretmp;
}

第四步:重点来了,评估函数。评估整个棋盘的分值,分4个方向把棋盘数据存到一维数组用上面的API count_score()算出分值,并累加。AI和human都计算出总分数,最后AI分数减去human的分数,并返回这个分数。优化

下面代码中:
AIChessColor全局变量表示电脑AI棋子的颜色
humChessColor是玩家棋子颜色
gBoard[][]表示棋盘的二维数组。人工智能

/*把当前局势全部连线都存到一维数组,而后算一遍分数*/
static s32 evaluate_board (void)
{
	u32 AIScore=0;
	u32 humScore=0;
    u8 i, j;
    s8 x, y;        //若是是u8,x--,y--运算时可能溢出
    u8 n[M_SIZE];
    memset(n, 0, sizeof(n));
	//横排 
	for (i=0; i<M_SIZE; i++)
	{
        for(j=0; j<M_SIZE; j++)
        {
            n[j] = gBoard[i][j];
        }
    	AIScore += count_score(n, AIChessColor);
    	humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	}
	//纵排
	for (j=0; j<M_SIZE; j++)
	{
		for(i=0; i<M_SIZE; i++)
        {      
            n[i] = gBoard[i][j];
        }
		AIScore += count_score(n, AIChessColor);
		humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	} 
	//上半正斜线(\)
	for (i=0; i<M_SIZE; i++)
	{
		for(x=i,y=0; x<M_SIZE&&y<M_SIZE; x++,y++)
		{
            n[y] = gBoard[x][y];
		}
		AIScore += count_score(n, AIChessColor);
		humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	} 
	//下半正斜线
	for (j=1; j<M_SIZE; j++)
	{
		for(x=0,y=j; y<M_SIZE&&x<M_SIZE; x++,y++)
		{
            n[x] = gBoard[x][y];
		}
		AIScore += count_score(n, AIChessColor);
		humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	} 
	//上半反斜线(/)
	for (i=0; i<M_SIZE; i++)
	{
		for(y=i,x=0; y>=0&&x<M_SIZE; y--,x++)
		{
            n[x] = gBoard[x][y];
		}
		AIScore += count_score(n, AIChessColor);
		humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	} 
	//下半反斜线
	for (j=1; j<M_SIZE; j++)
	{
		for (y=j,x=M_SIZE-1; y<M_SIZE&&x>=0; y++,x--)
		{
            n[y-j] = gBoard[x][y];
		}
		AIScore += count_score(n, AIChessColor);
		humScore += count_score(n, humChessColor);
    	memset(n, 0, sizeof(n));
	} 
    return AIScore-humScore;
}

到这里就能够解释什么是对电脑AI最有利,
简单说就是找到用评估函数evaluate_board()算出来的分值最高的下棋点。更具体的说,
在MAX层轮到AI下棋时,假设这一层有50个空位置能够下子。先尝试下一个子,而后用评估函数evaluate_board()算此时的棋局分值。50个点这样都试一遍,找到分值最高的点,即为对电脑AI最有利的点。(固然,这时电脑AI只考虑了一层的模型)

在实现上述思路以前先说另外两个相关API:
has_neighbors():是否有邻居,这个就没什么好说的,看代码。

/*是否有邻居:两步以内是否有子存在(不管是对手仍是本身的子均可以)*/
static Boolean has_neighbors(int x, int y)
{
	int s = 2;	//两步以内有子
	u8 i = 0, j = 0;
	for (i=(x-s>0?x-s:0); i<=x+s&&i<M_SIZE; i++)
		for (j=(y-s>0?y-s:0); j<=y+s&&j<M_SIZE; j++)
			if (i!=0 || j!=0)
				if (SPACE != (gBoard[i][j]))
					return TRUE;
	return FALSE;
}

generate_point():空子序列,这是个很重要的API,咱们最开始设计,只是剔除了已经有子的地方和没有邻居的地方。剩余的空子位置做为能够下棋的地方保存到队列。后续还能够有很大的优化空间。

/*产生空子序列(能够下子的空位)*/		
static void generate_point(chess_queue *queue)	
{
    int i,j,k = 0;
	for (i=0; i<M_SIZE; i++)
    { 
		for (j=0; j<M_SIZE; j++)
        {      
			if ((gBoard[i][j]==SPACE) && has_neighbors(i,j))//有邻居的空子,作为可下子的队列
			{
                queue->chess[k].x = i;
                queue->chess[k].y = j;
                queue->len = k+1;
                k++;
			}
        }
    }
}

如今就按上述思路实现电脑AI下棋时的代码:

void chess_ai_minmax_alphabeta (chess_t *chess)
{
	u8 i = 0, k = 0;
    u8 x = 0, y = 0;
    s32 tmp = 0;
    s32 best = -WIN5;					//找到的最高分,初始值赋一个最小值
    chess_queue option_queue;           //待选的空子队列
    chess_queue sure_queue;             //最合适的下子位置
	generate_point(&option_queue);		 //生成可下子的空子队列
    
	for (i=0; i<option_queue.len; i++)		//一个一个子轮着试一遍
	{
        x = option_queue.chess[i].x;		
        y = option_queue.chess[i].y;
	    gBoard[x][y] = AIChessColor;    //尝试下一个子
		tmp = min_alphabeta(depth-1, option_queue.chess[i]);   
		if (tmp == best)			//找到一个和当前最高分同样的分数,保存下来
        {      
            sure_queue.chess[k].x = option_queue.chess[i].x;
            sure_queue.chess[k].y = option_queue.chess[i].y;
			sure_queue.len = k+1;
            k++;
        }
		if (tmp > best)    //找到一个更好的分,把之前存的位子所有清除
		{
			best = tmp;
            k = 0;
			sure_queue.len = 1;         //清空终选队列
            sure_queue.chess[k].x = option_queue.chess[i].x;
            sure_queue.chess[k].y = option_queue.chess[i].y;
		}
		gBoard[x][y] = SPACE;           //恢复成空子
	}
	k =(int)(rand()%sure_queue.len);    //若是有多个最高分数,随机选择一个
	chess->x = sure_queue.chess[k].x;
	chess->y = sure_queue.chess[k].y;
}

其中有一个很重要函数没有讲到:

int min_alphabeta(s8 depth, chess_t chess);

先假设一个这个AI只看一步棋,即只考虑眼前的利益。就是尝试下了一个AI颜色的子基础上,用评估函数把当前局面分算出来返回便可,不用考虑两个参数的含义。以下:

static int min_alphabeta(s8 depth, chess_t chess)	
{
	return evaluate_board();
}

接着往下,若是AI考虑两步棋,即本身一步对手一步棋。min_alphabeta()目的是找到最小分数,由于玩家下棋会下AI最不利的棋,思路就是,先算res = evaluate_board(),若是上一步AI下完棋已经赢了,那直接返回res分数。不然,在当前局面再一次调用generate_point()生产可下子队列,而后每一个子尝试一遍,计算局面分数后找到最小值返回。

/*当min层(玩家)下棋时,考虑最坏的状况*/
static int min_alphabeta(s8 depth, chess_t chess)										 
{
	s32 res = evaluate_board();
    u8 i, x, y;
    s32 tmpScore;
    s32 best = WIN5;
    chess_queue queue;
    if ((depth <= 0) || (is_win(chess.x, chess.y, AIChessColor)))//上一步是AI下棋可能产生输赢
    {
        return res;
    }
	generate_point(&queue);
	for (i=0; i<queue.len; i++)
	{
	    x = queue.chess[i].x;
        y = queue.chess[i].y;
        gBoard[x][y] = humChessColor;    //尝试下一个子
        tmpScore = max_alphabeta(depth-1, queue.chess[i]);
        gBoard[x][y] = SPACE;  
		if (tmpScore < best)
        {
            best = tmpScore; 
        }
	} 
	return best;
}

显然,若是只考虑两步,和以前同样其中的 max_alphabeta()实现以下:

static int max_alphabeta(depth-1, queue.chess[i]);
{
	return evaluate_board();
}

继续考虑,若是AI考虑3步棋。思路相似的:先算res = evaluate_board(),若是上一步玩家下完棋已经赢了,那直接返回res分数。不然,在当前局面再一次调用generate_point()生产可下子队列,而后每一个子尝试一遍,计算局面分数后找到最大值并返回。

/*当max层(电脑AI)下棋时,考虑最好的状况*/
static int max_alphabeta(s8 depth, chess_t chess)
{
	s32 res = evaluate_board();
    u8 i, x, y;
    s32 tmpScore;
    s32 best = -WIN5;
	chess_queue queue;
	if ((depth <= 0) || (is_win(chess.x, chess.y, humChessColor)))//上一步是玩家下棋可能产生输赢
	{
        return res;
	}
	generate_point(&queue);			//生成能够下棋子的空子序列
	for (i=0; i<queue.len; i++)
	{
	    x = queue.chess[i].x;
        y = queue.chess[i].y;
        gBoard[x][y] = AIChessColor;    //尝试下一个子
        tmpScore = min_alphabeta(depth-1, queue.chess[i]);
        gBoard[x][y] = SPACE;          //恢复成空子
		if (tmpScore > best)
        {
            best = tmpScore; 
        }
	} 
	return best;
}

显然,若是只考虑三步,和以前同样,上述API其中的
tmpScore = min_alphabeta(depth-1, queue.chess[i]);应该替换成
tmp = evaluate_board();

若是须要考虑depth步,就须要用到递归的思想。加上depth参数,直到depth<=0或者某一方赢了,递归结束。不然一直依次调用min_alphabeta和max_alphabeta。

到此已经按极大极小值的思想,完整的实现了五子棋AI下棋的算法。固然这个算法还有很大的优化空间。接下来会用比较常见的alpha,beta剪枝,和启发式搜索优化generate_point()。

参考博客:
http://www.noobyard.com/article/p-ytipbncl-hm.html

代码下载地址:
连接:https://pan.baidu.com/s/1CefnVaoojTqonTtlBqIt1g 密码:qkyp