标签:对抗搜索

“斗地主”残局的对抗搜索算法

“斗地主”残局的对抗搜索算法

Adversarial Search Algorithm on Chinese Poker Game

许久未打开手机斗地主,前日登录发现新增“残局”模式,便思考尝试编程给出解法。搜索网络上相关资料较少,去重后仅找到两个简单的Python实现,其一仅能判断必胜策略的存在性,另一给出了低效的简单实现,且牌型的支持不够完整,对于一个简单残局的运算耗时五分钟,相比下本文的C++多线程实现仅耗时数秒且复用对象减少内存消耗。

对抗搜索 Adversarial Search

        斗地主残局双方明牌,玩家作为地主方先出。完全信息,有限步数(手牌数有限,打完结束),符合策梅洛定理,且显然没有平局,双方必有一方有必胜策略。至于如何暴力搜索,此处非常符合“对抗搜索”的应用范围,即MinMax算法。某本书的一部分介绍得很清晰,很有帮助。

        放在双人斗地主的场景中来说,这意味着在每一次出牌时,玩家都会选择打出对自己最有利的一手牌,打出对对方最不利的一手牌。而这是一个零和博弈,每种出牌最终都会导致一个二选一的结果——地主赢(我们认为该节点得分为1)或农民赢(得分-1)。地主轮将努力最大化分值,从众多分支中挑分值最大的一个,并将其作为当前节点的分值;相应的,农民轮将从众多分支中挑分值最小的作为当前节点的分值。显然,地主要想有必胜策略,根节点得分需要为1 。

        至此,该问题已可以完全地用MinMax算法表述出来并简单地得到解决框架:状态为地主、农民手牌;地主/农民步骤函数根据状态计算出后续可能的状态(出牌型);一方手牌为空时该节点为叶子结点,得到结果分值;获取分值函数递归计算节点分值。借用资料中伪代码阐释:

MinMax算法伪代码

        至此,判断必胜策略的存在性问题已得到理论解决。关于保存步骤以供回溯,我选择在Action(s)函数中顺便构建状态树作为一个搜索时的副作用。当求得根节点分值的同时状态树已构建完成。

实现

        细节请参考文末附带的源码,以下为实现中粗略的一些要点。

树结构设计(Node)

  • layer 当前节点深度,根节点为0
  • score 当前节点分值,默认为0
  • move 上一步牌型
  • lord 当前地主手牌
  • farmer 当前农民手牌
  • children 子节点(们)
  • parent 父节点

        其中所有非标量对象均为指向堆上对象的指针。结构体包含一个析构函数,用以析构children指向的堆上的一个std::vector<Node*>对象。

对象复用

        斗地主可能出牌策略的规模在N!级别,考虑双人游戏,规模可达N!M!级别。若独立创建对象,极其容易栈溢出、占满内存。因此我实现了两个对象池,有效缓解了空间压力,分别用以储存出牌牌型、手牌。

牌型池

  • std::unordered_map
  • 牌型组成的vector(sorted)作为Key,boost::hash_range作为自定义hash
  • shared_mutex线程安全

        牌型池的效果应是最明显的,因为全局牌型总量在第一层便应被固定了,此后只减不增。复用后全局牌型对象一般维持在20~30个。

手牌池

  • std::unordered_map
  • std::vector<uint8_t>作为Key(后附解释),复用牌型池的hash
  • shared_mutex线程安全

Key uint8_t 算法:手牌对象储存结构为std::map,key为牌面大小,value为该牌数量。牌面大小数值为3~18(出于方便计算顺子的缘故,中间有空缺),而牌数最多支持两副,每张牌最多八张,为1~8 。故uint8_t高五位储存牌面,低3位储存数量。

手牌池的设计修改了很多次,最终采用了这个方法。由于决策树不深(最大深度应为两者牌数相加M+N),vector不会很大。使用后再未出现过内存不足、栈溢出甚至蓝屏的情况,最大内存使用量目前在10GB左右,一般在1GB左右。

剪枝

        由于决策树是一颗深度有限,但广度扩展较广的树,因此保留MinMax算法的递归结构,对栈压力较小。出于效率考虑,设法对其进行剪枝。考虑到应用场景为双人明牌斗地主,玩家(地主)先出,进行如下剪枝:

  • 对于某一次出牌产生的节点,若进行该行动的玩家行动后手牌为空,则该节点作为其父节点的唯一子节点也是叶子结点并立即返回上一层递归(当能一次出完牌时,没有理由出其他牌);
  • 对玩家(地主)而言,对于对方每一次出牌,玩家只需知道唯一一个有必胜策略的应对方式即可。因此遍历玩家可能的策略时,若子节点分值不为1则删除(见LumberJack节)子树,若子节点分值为1则该子树为其父节点的唯一子树,并立即break并删除所有其他子节点。这能使问题规模下降到最乐观情况下M!(此处配合另一处细节,见其他优化节);
  • 对农民的非叶子节点不应予以剪枝。

多线程

        尝试使用多线程充分利用计算资源,加速计算。对于这一本质上类似深度优先搜索的问题,采用了较为简单的“划分问题子空间”的方法。又考虑到需控制线程数量以免导致性能下降,故选取农民的第一步可能的出牌作为子空间划分点。选择一层划分的优点在于在“Reduce”最终获得的结果时,由于使用了std::future,在前面获得了确认分值的结果时,迭代可以直接break并返回上一层递归调用,而由后台进程继续构建其他尚未构建完成的子树(此处再次涉及到其他优化一节的设计)。

        多线程后计算中四核八逻辑处理器CPU可以跑满。相应计算时长降低到原1/4到1/8 。

LumberJack

        之前在剪枝一节中提到了玩家决策过程中删除非必要子树。此过程删除的子树数量较多、大小较大,采用了递归算法删除;于搜索过程中删除较为耗时,且删除子树行为本身不对搜索过程产生副作用,因此想到开后台线程删除;然而直接开线程挤占计算线程的资源,更拖慢了效率。于是构建了一个“伐木工”类,实现了一个“多生产者,单消费者”的模型。将欲删除的子树的根节点丢到任务队列,由该单线程循环获取任务并进行删除,有效降低了计算资源挤占,提高了速度。基本实现如下:

  • std::queue 任务队列
  • std::mutex + std::condition_variable 保障线程安全
  • 公开的AddTask函数,为线程安全的queue的push函数wrapper
  • 永续循环(想不到代替死循环的词了,想起了”永续年金”……)的Worker函数

其他优化

        最后一个出于提高剪枝效率考虑的设计在GetAllSolution(获取所有可能的出牌牌型)函数上。剪枝中提前返回的逻辑在最差情况下事实上是无法降低时间复杂度的,只能事后诸葛地降低空间复杂度。如何尽量避免最差情况呢。我尝试在获取出牌牌型时将长牌型(如顺子、连对)的搜索结果放置于前,短牌型(如单牌、对子)放置于后,使得遍历搜索结果时先尽量多出牌,使得先遍历的树深度、广度都较小,也较为偏向人类思考方式(先想着多出长牌型),提高剪枝的优化效果。

代码

https://github.com/Whotakesmyname/ChinesePokerGame

代码已托管于Github,并提供Windows下64位预编译的Release,欢迎Issue/Fork/Star。由于是个人第一个较完整的C++项目,代码可能较脏,欢迎指正。

之前有“Triggered a Breakpoint”Bug出现(Release版本下表现为闪退),但未能找到原因,修改了一些实现后再未出现过。

编译时请注意:该程序使用了boost库。

效率与资源占用

环境:

  • CPU:Intel(R) Core(TM) i7-4710MQ CPU @ 2.50GHz
  • 内存:DDR3 24G

运行时中等问题内存占用1G以下,2秒以下解出;偶现高复杂度问题(手牌多,单牌多;欢乐斗地主专家级前38关中出现2-3次)需要约10G内存,耗时30秒-1分钟。