草庐IT

基于Minimax和Alpha-Beta剪枝实现的C语言五子棋AI入门

R3bel 2024-03-15 原文

😎五子棋工程


需求→设计→编码→测试→发布


前言

五子棋AI是上大学第一学期做的第一个工程,其中断断续续做了近一个月时间,其中的思路和估值参考了许多这位大神的作品lihongxun,但其中有些算法功能还未能完全掌握运用,不过目前棋力很多时候已不输于其AI。由于没有掌握C++,而项目开始时过于自信,虽然程序中用了类,但实则为一个实实在在的C语言项目,可以直接忽略其中类的部分。就以后学习数据结构和算法前,此版为最终版,但此版中还存在着很多问题,其中最严重的就是由于拓展时建立了很多节点,当搜索广度为10,深度为时时,未经剪枝,则会创建约10^10个节点而每个节点都存有棋盘和下一步的点位棋盘,因此怀疑是内存消耗过多而导致程序意外退出,即使加入剪枝和内存清理也同样存在着闪退问题,暂未查明详细原因,待学习数据结构以后再深入探究重构。

github项目地址:yrd1240189774/GoBang (github.com)

我学到了什么


首先来说这是我独立完成的第一个包含GUI的完整的项目,学到了一种模块化的编程思想,先知道自己这个项目有什么需求,列清梳理好后再对函数进行设计,将各个功能区分开,最后再编码测试组装。其次是测试的重要性,这次项目中最头疼的无非就是找bug,当写好一串代码以后,不能运行或是不能安需求运行的概率很大,其中问题很可能就在一个数值的区别上,因此对更少的代码组织测试而不是等功能写好后在进行测试任意的多,具体的测试方法就不展开讨论,可以了解各种测试框架和最简单的利用print测试的方式。然后是代码的阅读能力有所提高,因为在学习是经常需要读懂别人的程序,尤其像java,js,python,对这些语言的语法和特点有了初步的认识。在一个是对算法尤其是博弈算法有了初步的了解,对Alphago有了更深的认识,就我目前的认知水平看来,机器眼里的世界是一个用代码表示的数学表示的世界,同样认识了许多崇拜的大佬。最后,Talk is cheap, show me your code.

这个AI的棋力如何


如果你对五子棋有一些研究,你是不那么容易被战胜的尤其是在你先手的时候。而若一个人并不是一个五子棋高玩就像我一样,通过和我还有身边以及网络上一些小伙伴的对战结果来看,这个AI已经能战胜绝大部分业余玩家。专业一点来说,根据棋力测试,目前我确定的AI的搜索深度是 8 层,广度为6,如果没有特殊的优化技巧,这个深度已经达到了一个比较高的水平但广度较低,这也是我程序本身的一大问题。

迭代

第一代

  • 实现棋盘
  • 实现双人对战

第二代

  • 评估每一个落子位置的分数,不参杂思维挖掘

第三代

  • 蒙特卡洛搜索(单例模式)

    评估函数搜索全局,选择3个落点

  • 选择
  • 拓展
  • 模拟
  • 回溯

第四代

  • 更新蒙特卡洛搜索为全局模式

  • 新增Alpha-Beta剪枝

第五代

  • 重写启发式评估函数,提高运行速度

实现

博弈算法


深蓝

从计算机问世后,博弈算法从来就没有停止过改进的步伐。最早打败人类顶级棋手的AI就是深蓝。以下内容摘自百度百科:

深蓝是美国IBM公司生产的一台超级国际象棋电脑,重1270公斤,有32个大脑(微处理器),每秒钟可以计算2亿步。"深蓝”输入了一百多年来优秀棋手的对局两百多万局。
1997年 6月,深蓝在世界超级电脑中排名第259位,计算能力为每秒113.8亿次浮点运算。
1997年的深蓝可搜寻及估计随后的12步棋,而一名人类象棋好手大约可估计随后的10步棋。每增加1步棋的搜寻能力约等于增加下棋强度约80 ELO分。

博弈AI

我的想法,博弈AI利用了现代计算机更加强大的算力,使得计算机能实现在某一功能上,对未来的评估能力和速度及细节上超过人类。目前,很多游戏都是"博弈"的,比如五子棋是双方博弈,斗地主也是。有些是双方竞争,有些会包含合作。在《人工智能-一种现代方法》一书中的第五章 《对抗搜索》中,介绍了博弈论专家们对博弈的一种定义:

有完备信息的,确定性的,轮流行动的,两个游戏者的零和游戏

这也是我们这套算法适用的场景,注意其中任何一个条件不满足,那么将无法直接使用这套算法进行设计。比如这些游戏就不行:

  • 斗地主和德州等大部分牌类游戏,因为不是完备信息,看不到对方玩家的牌,而且还有随机性(摸牌不确定)
  • 四国军棋,不是完备信息,不是双人游戏
  • DOTA,不是完备信息,不是双人游戏,而且不是轮流行动

而五子棋,围棋,黑白棋,象棋等都是满足这些条件的,因此都可以用这套算法来实现。

AI

这里说一下自己对该AI比较狭义的看法。就我从头到尾编写的看法感觉他并不能称作一个AI,而只是一个按照规则执行的既定的程序,并没有学习的能力,即使这一次失败了,也不会在进行总结修改提升,而一切的修改提升都是编程人员测试完成的,进行的一切的模拟和判断都是靠对现实情景的数字化符号化完成的,和Alphago类AI有着很大的差别。

极小化极大值搜索


Minimax算法是一种递归算法,是一种消极的算法,他会假定对手的每一步都会走在对自己最有利的位置,也是一种深度优先的算法,他会先探索一个分支直至最底层,这也为Alpha-Beta剪枝提供了条件。

五子棋看起来有各种各样的走法,而实际上把每一步的走法展开,就是一颗巨大的博弈树。在这个树中,从根节点为0开始,奇数层表示电脑可能的走法,偶数层表示玩家可能的走法。在全局搜索的情况下,算法将对棋面中的每一个点位进行延申,他将对未来规定深度和运行时间下的每一种可能进行模拟,拓展,对每一个最终节点进行评估,也就是后面的局势评估函数,我们规定对电脑越有利,分数越大,对玩家越有利,分数越小,分数的起点是0。最后进行回溯,由于对落点的选取根据的是末节点的局势,因此Minimax的性能很大取决于局势评估函数的准确性。

在拓展的层中,区别为Min层和Max层。

  • 电脑走棋的层我们称为 MAX层,这一层电脑要保证自己利益最大化,那么就需要选分最高的节点。
  • 玩家走棋的层我们称为MIN层,这一层玩家要保证自己的利益最大化,那么就会选分最低的节点。

下面是wiki上Minimax算法的一张图

回溯时,节点将对本节点和上一级节点的值进行比较,若本节点为上一级的第一节点,则将上一节点的分数与本节点同步,否则将比较两者分数的大小,在Max节点,则将取两者中的最大值,同理,Min选取最小值。若本节点是上一节点选择的最后一个,则在比较后对上一级节点回溯。

最后是wiki上的Minimax算法的伪代码,我的Minimax函数基本上是基于对该伪代码的实现

function  minimax( node, depth, maximizingPlayer ) is
    if depth = 0 or node is a terminal node then
        return the heuristic value of node
    if maximizingPlayer then
        value := −∞
        for each child of node do
            value := max( value, minimax( child, depth − 1, FALSE ) )
        return value
    else (* minimizing player *)
        value := +for each child of node do
            value := min( value, minimax( child, depth − 1, TRUE ) )
        return value

在我的代码中

节点对应结构
struct Node_Tree{
        int flag,cnt,depth,result=0,number=0,Alpha=-100000000,Beta=+100000000;
        int VmBoard[16][16];
        struct Node_Tree* son[216];
        COORD site[216];
        Node_Tree *Last;
    };

fiag储存棋子,cnt储存下一层节点的探索的索引,depth储存深度,resulte储存节点棋盘是否已有胜负,number储存下一层总节点数量,Alpha和Beta储存当前节点的最大最小值用以剪枝,VmBoard[16][16]储存模拟棋盘,struct Node_Tree* son[216]储存下一节点的指针,COORD site[216]储存下一层节点的落子点,Node_Tree *Last指向上一节点。

创建根节点
Node_Tree* createroot();

创建树的根节点,通过启发式评估函数选择下一层的点位,储存回溯得到的最大值和位置

创建叶节点
Node_Tree* createleaf();

和创建根节点类似

节点的延申
Node_Tree* createlist();

当当前的depth<deep和未有棋子连五时通过createleaf()函数进行延申

Minimax的实现
void Minimax (createlist(createroot()))

当depth!=0时对每一个节点与上一级节点比较并在符合min或max层条件时赋值,对createlist()得到的根节点再次进行Minimax处理

Alpha-Beta 剪枝


From wiki :Alpha–beta 修剪是一种搜索算法,旨在减少其搜索树最小最大值算法评估的节点数。它是一种对抗性搜索算法,通常用于双人游戏(井字游戏国际象棋连接 4 等)的机器游戏。当发现至少一种可能性证明该移动比先前检查的移动更糟糕时,它会停止评估移动,这些举措无需进一步评估,他会修剪掉不可能影响最终决策的分支。

剪枝是必须的

上一篇讲了极小化极大值搜索,其实单纯的极小化极大值搜索算法并没有实际意义。

可以做一个简单的计算,平均一步考虑 50 种可能性的话,思考到第四层,那么搜索的节点数就是 50^4 = 6250000,在我的酷睿I7的电脑上一秒钟能计算的节点不超过 5W 个,那么 625W 个节点需要的时间在 100 秒以上。电脑一步思考 100秒肯定是不能接受的,实际上最好一步能控制在 5 秒 以内。

顺便说一下层数的问题,首先思考层数必须是偶数。因为奇数节点是AI,偶数节点是玩家,如果AI下一个子不考虑玩家防守一下,那么这个估分明显是有问题的。
然后,至少需要进行4层思考,如果连4四层都考虑不到,那就是只看眼前利益,那么棋力会非常非常弱。 如果能进行6层思考基本可以达到对战普通玩家有较高胜率的水平了(普通玩家是指没有专门研究过五子棋的玩家,棋力大约是4层的水平),如果能达到8层或以上的搜索,对普通玩家就有碾压的优势,可以做到90%以上胜率。

下面是wiki上Alpha-Beta剪枝的一张图

核心思想

该算法维护两个值,alpha 和 beta,分别表示最大化玩家确信的最低分数和最小化玩家确保的最高分数。最初,alpha 是负无穷大,beta 是正无穷大,即两个玩家都从他们最差的分数开始。每当最小化玩家(即“测试版”玩家)保证的最高分数低于最大化玩家(即“阿尔法”玩家)保证的最低分数(即贝塔<阿尔法)时,最大化玩家不需要考虑该节点的进一步后代,因为它们在实际游戏中永远不会达到。

剪枝实现

每个节点存有一个alpha 和 beta值,在回溯的过程中,会区分min层和max层更新对应的alpha 和 beta分数,当探寻下一个枝节点时,会将上一级节点已有的alpha 和 beta值传入,而当该节点被后续节点更新后,判断该节点的alpha 和 beta的大小,若Beta值小于等于Alpha值,则证明后续节点不会存在更优解,剪去不会对性能造成影响,因此可以直接回溯该节点。

if (tmp->Beta<=tmp->Alpha)	Minimax(tmp);

这是我对Alpha-Beta剪枝的理解,下面是wiki给出的关于Alpha-Beta剪枝的伪代码

function alphabeta(node, depth, α, β, maximizingPlayer) is
    if depth = 0 or node is a terminal node then
        return the heuristic value of node
    if maximizingPlayer then
        value := −∞
        for each child of node do
            value := max(value, alphabeta(child, depth − 1, α, β, FALSE))
            α := max(α, value)
            if value ≥ β then
                break (* β cutoff *)
        return value
    else
        value := +for each child of node do
            value := min(value, alphabeta(child, depth − 1, α, β, TRUE))
            β := min(β, value)
            if value ≤ α then
                break (* α cutoff *)
        return value

局势评估


局势评估是对末节点局势判断的根本函数,因此,该函数的好坏直接影响了整合Minimax函数的评估质量,也因此需要对于该函数做的比较仔细。

在这里我的处理方法是读取棋盘四个方向的所有行(斜向上舍去不能成5的几列),并储存为字符串,用find函数与预设情况进行匹配

string five[10] = {"22222", "1222211", "1122221"};//活五活四
string four[10] = {"022221", "122220", "22212", "22122", "21222"};//冲四
string three[10] = {"122211", "112221", "121221", "122121"};//活三
string LowRank[10] = {"022211", "112220", "022121", "121220", "021221", "122120", "1211221", "1221121", "1212121",
                      "0122210"};//眠三
string LiveTwo[10] = {"112211", "12121", "121121"};//活二
string SleepTwo[10] = {"022111", "111220", "021211", "112120", "021121", "121120", "21112"};//眠二

最后为避免盲目冲4等情况,对眠四的分数与眠四活三等数量进行相关,降低单独冲四的分数,减少单独冲四的发生

启发式评估函数


通过我们对Alpha-Beta剪枝算法的理解,可以看到AB搜索的剪枝效率很大程度上取决于子节点的排序

上面可以看到在第二层中,第一个节点的值是3,第二个是6。因为3比较小,而这一层的最大值会被选中,所以第二个节点也需要完整计算所有孩子。如果3和6调换一下顺序,6在前,3在后。那么当第二个节点计算出第一个孩子5的时候就没有必要计算之后的孩子了。也就是,Alpha-Beta 剪枝的效率和节点排序有很大关系,如果最优的节点能排在前面,则能大幅提升剪枝效率。对于Max层节点同样适用。

那么如何排序呢?就是给所有待搜索的位置进行打分,按照分数的高低来排序。注意这个打分算法是对某一个空位进行打分,和对整个棋盘进行打分的 Allscore() 函数是不一样的。不过打分的基本原理是相同的。具体就是根据这个位置是否能成五,活四,活三等来进行打分。

有了打分之后,我们就可以按照分数高低进行排序了。具体实现的时候,是根据按照 成五,活四,双三,活三,其他 的顺序来排序的。

对于该函数在第四代中有一个重构,在此代之前,打分的方法和 Allscore() 函数类似,取空位周围米字形的8个方向从中间延申取4个点位,最后对得到的4个数组进行于 Allscore() 函数类似的匹配打分机制,得到每个节点的分数,但这样的AI即使在广度为3深度为8的情况下,运行时常也会达到近30s,而正常的速度应当小于5s为最佳,经过对printscore()函数的计时发现90%时间用在了这里,因此需要对该函数进行一次重构。

void PrintScore();				//启发式搜索函数

重构后我们确定几个参数, empty, count, block, roable

empty对应相同棋子间的空位,count对应相同棋子的数量,block对应棋子两端与棋局边缘或不同颜色棋子相连的数量,roable对应四周可落子的数量

int Myprint::ScoreGet(int empty,int count,int block,int roable)			//评分函数

首先要确定roable+count+empty>=5,必须满足这个条件才能使连五成为可能

其次对不同的empty,block,count进行分类讨论,具体实现可见代码ScoreGet()函数

由于不在进行复杂的匹配运算,只通过几个数值便可判断分数,虽然可能相比没有那么准确,但启发式评估函数毕竟需要计算的是全部节点,而Allscore()函数才是最终打分的关键,所以在尽量精确的情况下提高启发式搜索函数的速度才是首要的,经过重构,在广度为6,深度为8的情况下,运行时间依旧能保持小于5s

单元测试


本AI经过很多次测试,对局。主要对手为前文提及的github里的作者和手机app五子棋大师,而后为解决广度不够的问题,我们使第一层的搜索广度为棋盘上分数非零的位点,后续的7层广度为4,以尽可能解决深度过低或广度不够的问题。测试中,若设置广度为定值则容易出现走很多活三,眠四等问题,而广度过大则会出现深度不够或计算时间过久,因此分层计算不同的广度暂时为一个折中解决办法,充当一种类似于算杀的模块,但依旧受制于程序本身的问题

有关基于Minimax和Alpha-Beta剪枝实现的C语言五子棋AI入门的更多相关文章

  1. 叮咚买菜基于 Apache Doris 统一 OLAP 引擎的应用实践 - 2

    导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵

  2. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  3. LC滤波器设计学习笔记(一)滤波电路入门 - 2

    目录前言滤波电路科普主要分类实际情况单位的概念常用评价参数函数型滤波器简单分析滤波电路构成低通滤波器RC低通滤波器RL低通滤波器高通滤波器RC高通滤波器RL高通滤波器部分摘自《LC滤波器设计与制作》,侵权删。前言最近需要学习放大电路和滤波电路,但是由于只在之前做音乐频谱分析仪的时候简单了解过一点点运放,所以也是相当从零开始学习了。滤波电路科普主要分类滤波器:主要是从不同频率的成分中提取出特定频率的信号。有源滤波器:由RC元件与运算放大器组成的滤波器。可滤除某一次或多次谐波,最普通易于采用的无源滤波器结构是将电感与电容串联,可对主要次谐波(3、5、7)构成低阻抗旁路。无源滤波器:无源滤波器,又称

  4. 微信小程序开发入门与实战(Behaviors使用) - 2

    @作者:SYFStrive @博客首页:HomePage📜:微信小程序📌:个人社区(欢迎大佬们加入)👉:社区链接🔗📌:觉得文章不错可以点点关注👉:专栏连接🔗💃:感谢支持,学累了可以先看小段由小胖给大家带来的街舞👉微信小程序(🔥)目录自定义组件-behaviors    1、什么是behaviors    2、behaviors的工作方式    3、创建behavior    4、导入并使用behavior    5、behavior中所有可用的节点    6、同名字段的覆盖和组合规则总结最后自定义组件-behaviors    1、什么是behaviorsbehaviors是小程序中,用于实现

  5. kvm虚拟机安装centos7基于ubuntu20.04系统 - 2

    需求:要创建虚拟机,就需要给他提供一个虚拟的磁盘,我们就在/opt目录下创建一个10G大小的raw格式的虚拟磁盘CentOS-7-x86_64.raw命令格式:qemu-imgcreate-f磁盘格式磁盘名称磁盘大小qemu-imgcreate-f磁盘格式-o?1.创建磁盘qemu-imgcreate-fraw/opt/CentOS-7-x86_64.raw10G执行效果#ls/opt/CentOS-7-x86_64.raw2.安装虚拟机使用virt-install命令,基于我们提供的系统镜像和虚拟磁盘来创建一个虚拟机,另外在创建虚拟机之前,提前打开vnc客户端,在创建虚拟机的时候,通过vnc

  6. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  7. ES基础入门 - 2

    ES一、简介1、ElasticStackES技术栈:ElasticSearch:存数据+搜索;QL;Kibana:Web可视化平台,分析。LogStash:日志收集,Log4j:产生日志;log.info(xxx)。。。。使用场景:metrics:指标监控…2、基本概念Index(索引)动词:保存(插入)名词:类似MySQL数据库,给数据Type(类型)已废弃,以前类似MySQL的表现在用索引对数据分类Document(文档)真正要保存的一个JSON数据{name:"tcx"}二、入门实战{"name":"DESKTOP-1TSVGKG","cluster_name":"elasticsear

  8. ruby-on-rails - (Ruby,Rails) 基于角色的身份验证和用户管理...? - 2

    我正在寻找用于Rails的优质管理插件。似乎大多数现有的插件/gem(例如“restful_authentication”、“acts_as_authenticated”)都围绕着self注册等展开。但是,我正在寻找一种功能齐全的基于管理/管理角色的解决方案——但不是简单地附加到另一个非基于角色的解决方案。如果我找不到,我想我会自己动手......只是不想重新发明轮子。 最佳答案 RyanBates最近做了两个关于授权的railscast(注意身份验证和授权之间的区别;身份验证检查用户是否如她所说的那样,授权检查用户是否有权访问资源

  9. ruby - 在 Rakefile 中动态生成 Rake 测试任务(基于现有的测试文件) - 2

    我正在根据Rakefile中的现有测试文件动态生成测试任务。假设您有各种以模式命名的单元测试文件test_.rb.所以我正在做的是创建一个以“测试”命名空间内的文件名命名的任务。使用下面的代码,我可以用raketest:调用所有测试require'rake/testtask'task:default=>'test:all'namespace:testdodesc"Runalltests"Rake::TestTask.new(:all)do|t|t.test_files=FileList['test_*.rb']endFileList['test_*.rb'].eachdo|task|n

  10. ruby - 如何使用 Ruby 基于字母数字字符串生成颜色? - 2

    我想要像“嘿那里”这样的东西变成,例如,#316583。我希望将任意长度的字符串“归结”为十六进制颜色。我不知道从哪里开始。我在想,每个字符串的MD5散列都是不同的-但如何将该散列转换为十六进制颜色数字? 最佳答案 你可以只取几位前几位:require'digest/md5'color=Digest::MD5.hexdigest('Mytext')[0..5] 关于ruby-如何使用Ruby基于字母数字字符串生成颜色?,我们在StackOverflow上找到一个类似的问题:

随机推荐