麻将AI & 不完全信息博弈学习笔记(完结)

前言

在这学期的数据结构必修课中,老师向我们提供了两道题:
其一是六子棋问题;
其二是麻将AI问题;
前者是经典的完全信息博弈问题,根据我已有的知识,利用博弈树和合理的剪枝可以提供一种高效的解法(当然只是框架思路,具体实现会伴随各种细节问题)
而对于后者,也就是不完全信息博弈,目前大二的博主基本没有任何相对应的知识储备,也正是出于想扩展自己的专业知识这样一种想法,博主与另外两位同学组队,尝试解决这道题——
在经过一段时间的等待之后,我的队伍成功的入围了第二题(由于题目难度,编写周期以及最终测评的时间等,并不是所有队伍都能有机会尝试第二题),入选也意味着自学挑战新的专业知识,同时也有机会和大佬们的算法同台积分比赛,
由此,博主准备以日志的方式记录这道题的解决过程,或许最终受限于个人能力和问题难度,并不能得到一个很好的解决方案,但是博主希望在写日志的过程中,可以记录个人的学习状态,至少在学习“不完全信息博弈”这样一个问题上,能留下一些收获

OK,话不多说,接下来直接开始日志吧——

思路设计

3.31:初步准备

这是开始课设的第一天,在研究了相应的题目要求之后,本人发现了一个比学习不完全信息博弈更急迫的问题——麻将规则
咳咳,yysy,确实不怎么玩麻将,而同组组员也不太熟悉麻将规则。。。
所以第一天只能初步了解麻将规则了。。。

于是乎!博主找来了国际麻将规则释义,准备先通读一遍,如果阅读这篇文章的你也准备尝试麻将AI,同时也不熟悉麻将规则的话,或许这份国际麻将规则释义也会适合你——

链接:https://pan.baidu.com/s/1HOZZTPxBJm_GAFS_2ZKHbQ
提取码:ogkd

咳咳好,第一天确实也没有太多可记录的,那么就先这样吧,容我去啃麻将规则了(doge)

4.2:梳理思路

在研究了两天麻将规则以后,我对麻将的观念已经产生了翻天覆地的变化
以前我一直以为麻将是一种简易的大众娱乐方式,结果泛读了50多页的国际麻将规则以后——
对不起!(跪下)
直到现在,才算是弄清楚了麻将对局的流程和和牌的规则,即使这样复杂的和牌种类还需要不断的查找,同时国际麻将还要求八番起和,这对和牌的精确性做出了更高的要求(要是只追求和牌很可能八番一下)
所以,为了更好的学习理论知识并加以利用,今日我和其他两位组员讨论了麻将AI的基本思路,但由于大家都是麻将的初学者,对规则的了解还不透彻,思路只是一个很基本的框架,同时比较杂乱
一定要梳理一个核心框架的话——

  1. 抓牌
  2. 观察手牌和所有打出的牌,分析目前哪种和牌概率最高
  3. 向可能的和牌的情况靠拢
  4. 和牌

这个思路其实和PVP时的常规思路非常贴近,而理论知识则需要将 2 3 过程相对抽象化,并提高效率和精度,同时后期思路也必然伴随着优化,我会日后继续更新的

4.3:基础框架搭建

除了合适的基础思路以外,在开始编写算法前,还需要合适的基础框架
今晚在经过一段时间的讨论后,我用流程图的方式将我们小组的设计思路呈现出来——
在这里插入图片描述
(比起自己的另一篇博客,学过UML以后作图思路都清晰不少。。。)
这里可以看到我们将整个麻将AI算法初步地拆分为三部分:

  1. 理牌时的排序算法
  2. 非己回合的监测算法
  3. 自己回合的出牌算法

以上是按实现复杂度从低到高排序的,以下简单分析一下我们对于这三类算法的基础构思:
排序算法:
排序算法显然相对简单,手牌数保持在14张左右(某些和牌情况可能超过14张),对排序是一个极小的数量级,所谓排序更需要将不同的牌种归类,同时标记已经杠出或碰出的手牌,从而利于后续的算法决策;
监测算法:
监测算法的功能主要有两个:

  1. 记录已经打出的所有牌(参照一下炉石的概念,就称呼为坟场吧)
  2. 分析非己回合中对手的出牌对己方局面的影响

这两个功能都很重要,但前者所记录的坟场对后续出牌影响更大,举一个最简单的例子:坟场中已经出现了两张二万,而你的手牌中还有一张二万,如果后续决策认为这张二万没有用处的话,可以立刻打出,因为坟场已经存在两张二万,不存在被其他玩家吃碰杠的情况;
出牌算法:
出牌算法算是麻将AI的重中之重,是麻将AI的灵魂了,其中必然包括大量的概率测算不完全信息博弈,甚至在小组讨论的构想里后期想尝试神经网络
出牌算法只是一个统称,之后一定会拆分为更具体的算法,这里根据博主的个人想法罗列两条:

  1. 手牌分析(当前手牌缺什么,或者怎么打才能盘活手牌)
  2. 番种判断(当前手牌是否满足和牌条件,或者是否接近某种和牌)

日后还会继续完善思路,并逐步开始实现代码的

海萨尼转换

4.5:不完全信息博弈初学

逐渐确立了算法思路之后,博主开始了正式的不完全信息博弈学习
在接触有名的贝叶斯均衡之前,这两天先学习的概念是:海萨尼转换
首先看一个有意思的题目:斗鸡问题
两个勇士举着长枪,准备从独木桥的两端冲上桥中央决斗。显然每位勇士都有两个选择:冲上去(记为U)退下来(记为D),若两人都冲上去,则两败俱伤;若一方冲上去一方退下来,则冲上去的获得了胜利,退下来的丢了面子;若都退了下来,则都丢了面子
现在我们将两败俱伤记为-4,将一方胜一方败分别记为2和-2,没有胜者时(即都退下来)记为0,由此我们分析此次博弈可能出现的情况:

选择UD
U -4,-42,-2
D -2,20,0

我们可以看到两个纯策略的Nash均衡(U, D)(D, U)
但这里有趣的是,在实际场景中,我们往往要考虑博弈者的自身因素
那么在赌上生死的决斗中,决斗者自身的性格是一个重要的考量标准,现在我们假设决斗者自身的性格分为强硬(记为s)软弱(记为w),此时我们为软弱者附加一些特殊情况:当双方都退下时,软弱者获得心理上的安慰,记为1,;当一方冲上去一方退下来时,软弱者认为没有出现两败俱伤,记为0;
这里声明一下,因为查询的资料里没有对计分做出具体的解释,博主加入了一些个人理解,如果有大佬知道这些计分的具体缘由,欢迎指正交流
那么我们可以重新整理博弈结果:

选择sw
UDUD
sU -4,-42,-2*-4,-42,0*
D -2,2*0,0-2,00,1
wU -4,40,-2-4,-40,0
D 0,2*1,00,01,1*

各类情况的Nash均衡已经用 *号 标出
这时候我们就能发现,在开始前双方虽然知道自身的性格特点,但是通常无法了解对手的性格,这就导致了在博弈开始前就存在不确定性,从而无法在开始前就选取最优策略,这时候 Harsanyi 通过引用 “自然N” 的概念,将博弈后的不确定性转换为博弈前的不确定性,如下图:
在这里插入图片描述
此时N先选择博弈者1的性格,这个选择博弈者1知道但博弈者2不知道,从而将博弈的起点(X1或X2提前到了X0),在海萨尼转换中,规定博弈双方对N选择的推断为共同知识(可以理解为虽然博弈者2不知道博弈者1被选择为了什么,但是其对应为强硬的概率p和软弱的概率1-p是清楚的)
至此便是海萨尼转换的基本介绍,具体的公式可以参考下列描述:
在这里插入图片描述
在这里插入图片描述

Botzone交互实现

4.7:尝试代码实现

本来说再自学几天以后准备整理一篇贝叶斯均衡的日志的,但是计划赶不上变化,考虑到课题提交时间,小组必须开始实现一部分基础代码,所以我只能暂停不完全信息博弈的学习,将视线掉转到麻将AI代码上去——
说来标题里本身就包含了“麻将AI”,这也不算跑题吧
接下来是今日正题:
我们的麻将AI将会在Botzone平台上与其他的程序进行对战,所以第一个要解决的是交互问题。
这里我们组采用了简单交互的模式,如果可行的话后期会改为JSON交互
以下是我负责的交互代码:

int turnID;                                       //当前回合数
string stmp;                                  	  //记录交互信息
turnID = 3;//test
cin >> turnID;
turnID--;
getline(cin, stmp);
for (int i = 0; i < turnID; i++) {
	getline(cin, stmp);
	request.push_back(stmp);
	getline(cin, stmp);
	response.push_back(stmp);
}
getline(cin, stmp);
request.push_back(stmp);

如果你也想尝试在Botzone上进行对战的话,具体的交互规则可以了解这个网站:
https://wiki.botzone.org.cn/index.php?title=Bot#.E4.BA.A4.E4.BA.92

除了交互以外我还负责了初步的手牌分析和判和,我发现即使是单纯的判和都挺麻烦的,目前想到的考虑因素有:

  1. 国际麻将共有81种番型,最高计88番,最低计1番,种类多;
  2. 每种和牌型都有若干情况,如大四喜的情况多达16种(未考虑将牌)
  3. 手牌的排列方式不能确保和和牌型恰好匹配,容易漏判

具体的判和代码还在构思中,未来的两三天内会具体的去实现的

算番器的实现

4.11:思路&代码修改

在经历几天的尝试之后博主发现自己写的算番函数并不是很稳定
且考虑到和牌型多达81种,当手牌在一些较为极端的情况下,转字符串的算法会产生误判的情况,如果要进行优化要额外花费大量的精力,对于整个课设的进展而言又有些得不偿失
不过在队友的提醒下,我重新阅读了一遍Botzone平台的文档,发现平台在对战时提供算番库及其相应的接口,
虽然在调用算番库之前仍然要自行判断一些和牌条件并对手牌做出严格的分类
但比起自己从零开始写算番函数,怎么说也是稳赚不亏的
所以博主放弃了之前的算番判和方法,直接使用平台所提供的接口

注意!
当然,博主认为4.9的想法也不失为一种从零架构算番器的思路(需要在手牌划分上做的更严格),这里就不删除4.9的内容了,本身日志就伴随着部分失败的尝试,在最后完结时博主会根据内容进行模块划分,自然4.9的内容会被区分开来,不至于误导他人

接下来我们研究一下官方给予的算番器:

// 参考test.cpp
#include "MahjongGB/MahjongGB.h"

// 使用前初始化
void MahjongInit();

// 算番函数
vector<pair<int, string> > MahjongFanCalculator(
    vector<pair<string, pair<string, int> > > pack,
    vector<string> hand,
    string winTile,
    int flowerCount,
    bool isZIMO,
    bool isJUEZHANG,
    bool isGANG,
    bool isLAST,
    int menFeng,
    int quanFeng);

需要注意一下头文件 “MahjongGB/MahjongGB.h” 也不是真正的算番库,只是平台先为我们做好了接口,我们在利用平台测试时只需选择对应的编译条件即可,真正的算番库另有出处,但都是 Github 上的项目
更多Botzone算番器接口描述点击此处
如果想直接配置算番库并进行调用点击此处

回归正题,我们将重点放在算番函数上,参考官方文档对于各参数的描述为:

pack:玩家的明牌,每组第一个string为"PENG" "GANG" "CHI" 三者之一,第二个- string为牌代码(吃牌表示中间牌代码),第三个int碰、杠时表示上家、对家、下家供牌,吃时123表示第几张是上家供牌。
hand:玩家的暗牌,string为牌代码
winTile:和的那张牌代码
flowerCount:补花数
isZIMO:是否为自摸和牌
isJUEZHANG:是否为和绝张
isGANG:关于杠,复合点和时为枪杠和,复合自摸则为杠上开花
isLast:是否为牌墙最后一张,复合自摸为妙手回春,否则为海底捞月
menFeng:门风,0123表示东南西北
quanFeng:圈风,0123表示东南西北
返回值:函数返回vector,每组int表示番数,求和为总番数,string是每个番形的描述

可以发现,虽然免除了对81个和牌型依次的判断,但很多关键性的基础判定仍然是不可替代的(自我安慰一下,好歹之前的尝试没白做)
同时自摸和,抢杠和,和绝张,妙手回春需要自行判定
我们按参数从上至下逐个解决,先是最关键的pack
这个参数需要在获取交互指令 requestresponse 时做出实时的调整,以下是为了简化代码做出的一些类型的自定义:

typedef pair<string, int> cardGet;
typedef pair<string, cardGet> cardStatus;
vector<cardStatus> pack;
/*算番库函数传入的参数*/

具体的Botzone交互规则点击此处
在接受对应的指令 “CHI”,“PENG”,“GANG” 且判定为本玩家操作之后,就按规则将固定格式的指令添加至 pack 中,并将手牌中对应条件的牌利用函数 erase 删除,从而将参数 hand 也一并解决(从逻辑上讲吃碰杠所明示的牌后继回合中也不可能再被使用,可利用手牌减少是理所应当的)
依照该思路就较容易理解下列代码:

#if 1
	//int turnID = request.size();
	int turnID = 26;//test
	int itmp, myPlayerID, quan;                            //分别记录回合状态,门风和圈风
	string stmp;													  //记录操作和牌值
	int huaCard;													  //记录花牌数
	typedef pair<string, int> cardGet;
	typedef pair<string, cardGet> cardStatus;
	vector<cardStatus> pack;
	/*算番库函数传入的参数*/
	istringstream sin;
	sin.str(request[0]);
	sin >> itmp >> myPlayerID >> quan;           //记录算法代理玩家的初始信息
	sin.clear();
	sin.str(request[1]);                                           //记录系统的发牌信息
	for (int i = 0; i < 5; i++) {
		sin >> itmp;												  //过滤回合状态和花牌信息
		if (i == myPlayerID + 1)
			huaCard = itmp;									  //记录代理玩家初始摸到的花牌数
	}
	for (int i = 0; i < 13; i++) {
		sin >> stmp;
		hand.push_back(stmp);                              //载入初始手牌信息
	}
	for (int i = 2; i < turnID; i++) {
		sin.clear();
		sin.str(request[i]);                                        //读取第i次交互的操作
		sin >> itmp;                                               //记录回合状态
		if (itmp == 2) {                                           //若回合状态为2
			sin >> stmp;
			hand.push_back(stmp);                          //摸牌
		}
		if (itmp == 3) {											  //若回合状态为3
			sin >> itmp;											  //过滤玩家信息
			if (itmp == myPlayerID) {						  //若为当前玩家的操作
				sin >> stmp;
				if (stmp == "PLAY") {
					sin >> stmp;								  //找到弃牌
					hand.erase(find(hand.begin(), hand.end(), stmp));
					discard.push_back(stmp);
				}
				else if (stmp == "BUHUA") {
					huaCard++;									  //花牌加一,用于计数花牌番
				}
				else if (stmp == "CHI") {
					string doc;									  //记录吃牌的中间牌
					sin >> doc >> stmp;					  //获取弃牌
					hand.erase(find(hand.begin(), hand.end(), stmp));
					string pre = doc;
					pre[1]--;
					string lat = doc;
					lat[1]++;
					string chi = pre + doc + lat;			  //算出吃牌组成的顺子
					vector<string>::iterator it;
					it = find(hand.begin(), hand.end(), doc);
					if(it != hand.end())
						hand.erase(find(hand.begin(), hand.end(), doc));
					it = find(hand.begin(), hand.end(), pre);
					if (it != hand.end())
						hand.erase(find(hand.begin(), hand.end(), pre));
					it = find(hand.begin(), hand.end(), lat);
					if (it != hand.end())
						hand.erase(find(hand.begin(), hand.end(), lat));
					handShowS.push_back(chi);
					sin.clear();
					sin.str(request[i - 1]);
					sin >> itmp >> itmp;					  //过滤回合状态和玩家信息
					sin >> stmp >> stmp;					  //找到碰牌信息
					/**将吃牌状态加入到pack**/
					int sequence;								  //记录吃牌的排序
					if (doc == stmp) sequence = 2;
					else sequence = doc > stmp ? 1 : 3;
					cardGet cgtmp(stmp, sequence);
					cardStatus cstmp("CHI", cgtmp);
					pack.push_back(cstmp);
				}
				else if (stmp == "PENG") {
					sin >> stmp;								  //获取弃牌信息
					hand.erase(find(hand.begin(), hand.end(), stmp));
					sin.clear();
					sin.str(request[i - 1]);						  //找到上一回合的出牌信息*
					sin >> itmp >> itmp;					  //过滤回合状态和玩家信息
					sin >> stmp >> stmp;					  //找到碰牌信息
					cardGet cgtmp(stmp, itmp);
					cardStatus cstmp("PENG", cgtmp);
					pack.push_back(cstmp);
					string peng = "";
					for (int i = 0; i < 2; i++) {
						hand.erase(find(hand.begin(), hand.end(), stmp));
						peng += stmp;
					}
					peng += stmp;
					handShowKG.push_back(peng);
				}
				else if (stmp == "GANG") {
					sin.clear();
					sin.str(request[i - 1]);
					sin >> itmp;									  //获取回合状态
					string gang = "";
					if (itmp == 2) {								  //若为自摸则为暗杠
						sin >> stmp;
						for (int i = 0; i < 4; i++) {
							hand.erase(find(hand.begin(), hand.end(), stmp));
							gang += stmp;
						}
						handUnShow.push_back(gang);
					}
					else if (itmp == 3) {						  //若为杠操作则为明杠
						sin >> itmp;
						sin >> stmp >> stmp;				  //找到上回合打出的手牌
						cardGet cgtmp(stmp, itmp);
						cardStatus cstmp("GANG", cgtmp);
						pack.push_back(cstmp);
						for (int i = 0; i < 3; i++) {
							hand.erase(find(hand.begin(), hand.end(), stmp));
							gang += stmp;
						}
						gang += stmp;
						handShowKG.push_back(gang);
					}
				}
				else if (stmp == "BUGANG") {
					sin >> stmp;									  //获取补杠信息
					hand.erase(find(hand.begin(), hand.end(), stmp));
					for (auto& s : handShowKG)
						if (s[0] == stmp[0] && s[1] == stmp[1])
							s += stmp;
				}
			}
			else {
				sin >> stmp;
				if (stmp == "PLAY") {
					sin >> stmp;
					discard.push_back(stmp);				  //记录全场弃牌情况
				}
			}
		}
	}
#endif

代码段中有两点注意一下:

  1. 最开头处的 int turnID = 26 是调试语句,博主在测试时为了便于调试直接设置为了26,实际上的正确写法为 上一行 被注释的语句
  2. 条件编译 #if 1 也是博主为了区分其他代码块加的,单独拿出来没什么特殊意义,如果真的有想试试这段代码的也利于注释和修改(嗯你们也可以理解为我懒得删了)

至此前两个参数已经解决,后续参数会紧接着更新的

4.13

今日的工作量会相对少一些,无非是对其余参数的设置和确定
实际上在4.11的代码中也顺带实现部分

对于参数winTile
首先要参考4.3的基本框架,在我的函数内是只存在自摸和的,抢杠和或是点炮和应在监测模块中实现,而显然一旦该模块发出指令 “HU” ,整个对局随即结束,我的函数不可能被再次调用,更无从判断和牌型
在了解了上述之后
其次明确一个简单的逻辑,会打麻将的人一定非常理解:如果最后一回合不是自摸,必不产生和牌(显然手牌数量都无法满足和牌)
由此可得,我们只需读取最后一次的交互信息, 若为己方摸牌 ,则记录摸牌信息,该信息即为对应的 winTile

对于参数flowerCount:
在4.11的函数中已经记录了对应的huaCard,直接调用即可

对于参数isZIMO
根据前文必为TRUE,默认传参1即可

接下来是比较重要的三个参数:

  1. isJUEZHANG
  2. isGANG
  3. isLast

我们先来看参数1和3,绝张牌墙最后一张 都要对应两种情况:

  1. 自摸和
  2. 点炮和

虽然我不负责点炮和的判定,但是为了方便后续模块的调用,我写了两个函数来判断对应参数的取值,同时考虑了点炮和自摸的情况
isLast举例,
若为自摸和牌池中应该有91张(最后一张入手而未打出)
若为点炮和牌池中应该有92张(最后一张已打出)

建立在这种讨论上,两个求参函数分别为:

bool isJueZhang()
{
	//绝张判定
	istringstream sin;
	int itmp;
	string stmp;
	sin.str(request[request.size() - 1]);					  //读取最近一次交互信息
	sin >> itmp;
	if (itmp == 2)												  //如果为自摸
		sin >> stmp;											  //获取摸牌信息
	else if (itmp == 3) {
		sin >> itmp;
		sin >> stmp;
		if (stmp == "PLAY")									  //如果前回合为对手弃牌(不可能为自己弃牌)
			sin >> stmp;
		else if (stmp == "CHI")
			sin >> stmp >> stmp;
		else return false;
	}
	int count = 0;
	for (auto s : discard)
		if (s == stmp) count++;
	if (count == 3) return true;
	return false;
}
bool isLast()
{
	//牌墙最后一张判定
	istringstream sin;
	int itmp;
	string stmp;
	sin.str(request[request.size() - 1]);					  //读取最近一次交互信息
	sin >> itmp;
	if (itmp == 2) {												  //如果为自摸
		if (discard.size() == 91) return true;			  //摸进的为最后一张(除去各自手中共52张牌)
		else return false;
	}
	else if (itmp == 3) {
		sin >> itmp;
		sin >> stmp;
		if (stmp == "PLAY" || stmp == "CHI") {		  //如果前回合为对手弃牌(不可能为自己弃牌)
			if (discard.size() == 92) return true;		  //加计52张手牌共144张
			else return false;
		}
		else return false;
	}
}

而对于参数isGANG
虽然强调了不考虑抢杠和,但是自摸和时存在一种特殊的番型:
杠上开花:8番
即自摸的前一手操作为杠,考虑到文档中的描述:
isGANG:关于杠,复合点和时为枪杠和,复合自摸则为杠上开花
所以仍需对该参数进行确定
具体思路就是找到自摸的上一手操作检验是否为杠
以下为函数实现:

bool isGang(int ID)
{
	istringstream sin;
	int itmp;
	string stmp;
	sin.str(request[request.size() - 2]);					  //找到倒数第二次交互(调用此函数时最后一步一定为自摸)
	sin >> itmp;
	if (itmp == 3) {
		sin >> itmp;
		if (itmp == ID) {
			sin >> stmp;
			if (stmp == "GANG") return true;			  //若为本玩家的杠操作,则可计杠上开花
		}
	}
	return false;
}

接下来是最后两个参数 menFengquanFeng
这个在4.11的代码最初就有记录,分别对应 myplayerIDquan
综上,算番函数的参数全部设定完成,接下来只需要依照返回值判定目前累计的番数即可,完整实现如下:

sin.clear();
sin.str(request[request.size() - 1]);
sin >> itmp;
if (itmp == 2) {												  //上一次如果不是摸牌必不可能和牌(牌数不齐)
	sin >> stmp;
	bool isLAST, isJUEZHANG, isGANG;			  //是否为牌墙的最后一张,是否为绝张,是否杠上开花
	isJUEZHANG = isJueZhang();
	isLAST = isLast();
	isGANG = isGang(myPlayerID);
	vector<pair<int, string>> res;
	MahjongInit();											  //初始化算番库
	res = MahjongFanCalculator(pack, hand, stmp, huaCard, 1, isJUEZHANG, isGANG, isLAST, myPlayerID, quan);
	int sumFan = 0;
	for (auto p : res)
		sumFan += p.first;
	if (sumFan >= 8)
		return true;											  //达到八番返回true
}

实现了番数计算和判和之后,接下来就伴随了一个新的问题:
如果没有和牌,我怎么通过已知的 手牌信息和场面信息 给接下来的出牌模块一些可利用的信息?
比如 缺牌数以及和牌倾向 等等,这些问题在最初几回合手牌比较零散时尤为重要
博主觉得上述问题又回归到了不完全信息博弈上来,等博主再研究一会,之后更新

算番库从零搭建的尝试性建议

4.9:麻将和牌判定

经过思考和尝试以后,我采用了以下思路(只是目前认为的最佳)
首先需要明确以下几点:

  1. 在交互过程中,我们最初拿到的手牌都是以字符串形式 XY 表示的:
    W1-9: 万牌
    B1-9: 筒牌
    T1-9: 条牌
    F1-F4: 东南西北
    J1-J3: 中发白
    H1-H8: 春夏秋冬梅兰竹菊
  2. 所有手牌存储在全局变量 vector<string> hand 中,且在交互过程中H系列的 花牌会被单独区分出来 ,也就是说最后所构建的字符串向量中 是一定不存在花牌的

明确以上两点以后,我将获得的字符串向量经过一次转换变成字符串,并将手牌的具体值忽略,只留下对应的对子,刻子,杠子和顺子信息
该算法被写入函数 void analyseHand() 中,
并被封装在判和类 judgeWin 中,后续会具体介绍类的结构

void judgeWin::analyseHand()
{
	int len = hand.size();
	for (int i = 0; i < len; i++) {
		if (i + 1 < len) {
			if (hand[i + 1][1] == hand[i][1] && hand[i + 1][0] == hand[i][0]) {
				if (i + 2 < len) {
					if (hand[i + 1][1] == hand[i + 2][1] && hand[i + 2][0] == hand[i + 1][0]) {
						if (i + 3 < len) {
							if (hand[i + 2][1] == hand[i + 3][1] && hand[i + 3][0] == hand[i + 2][0]) {
								str += "4";
								i += 3;
							}
							else {
								str += "3";
								i += 2;
							}
						}
						else {
							str += "3";
							i += 2;
						}
					}
					else {
						str += "2";
						i++;
					}
				}
				else {
					str += "2";
					i++;
				}
			}
			else if (hand[i][1] + 1 == hand[i + 1][1] && hand[i + 1][0] == hand[i][0] && hand[i][0] != 'F' && hand[i][0] != 'J') {
				if (i + 2 < len) {
					if (hand[i + 1][1] + 1 == hand[i + 2][1] && hand[i + 2][0] == hand[i + 1][0]) {
						str += "111x";
						i += 2;
					}
					else {
						str += "11x";
						i++;
					}
				}
				else {
					str += "11x";
					i++;
				}
			}
			else str += "0";
		}
		else str += "0";
		if(i + 1 < len)
			if (hand[i][0] != hand[i + 1][0]) str += hand[i][0];
	}
	str += hand[len- 1][0];
	/*
	以上将手牌向量拆解为字符串,编码为刻子,杠子,顺子,对子,散牌的结构
	杠子为4
	刻子为3
	对子为2
	顺子为111x(x作为间隔符)
	以上在和牌体系中均有用处
	二连牌为11x,仅标记出可能成顺子,供策略参考
	散牌为0,具体的数值需要结合hand
	*/

	for (auto c : str) {
		if (c == '2') numPair++;
		else if (c == '3') numKe++;
		else if (c == '4') numGang++;
		else if (c == 'W' || c == 'B' || c == 'T' || c == 'F' || c == 'J') kind++;
	}
	vector<int> next(4);
	vector<int> num;
	next = findNext("111x", next);
	num = KMP(str, "111x", next);
	numShun = num.empty() ? 0 : num.size();
	/*以上将手牌的对子刻子信息进行统计,用于后期判和*/

#if 1
	cout << endl;
	cout << "手牌对子数:" << numPair << endl;
	cout << "手牌刻子数:" << numKe << endl;
	cout << "手牌杠子数:" << numGang << endl;
	cout << "手牌顺子数:" << numShun << endl;
#endif

	/************/
	cout << endl << "字符串编码为:" << str << endl;//test
	/************/
}

由此一来就可以直观得到整副手牌的组合信息
比如手牌状态为:

hand = {"J1", "J2", "J3", "J1", "J2", "J3", "J2", "J3", "W7", "W8", "W9", "B5", "B5", "B5"};

经过解析后就可得字符串:

str = "3B233J111xW";

并统计出:

手牌对子数:1
手牌刻子数:3
手牌杠子数:0
手牌顺子数:1

同时又注意到,多数情况下的和牌型需要满足最基本的结构:
4 * 3 + 2(4副刻子(顺子)加1副将牌)
将牌又一定是对子,参考百度百科:
将牌是麻将术语,按基本牌型和牌时必须具备的单独组合的对子
所以我们将:

numPair == 1 && numShun + numKe == 4

作为判和的第一步,接着是转字符串判和最关键的一步:
我们分析各个和牌类型的结构,同样转换为字符串,我们会发现多数和牌型也拥有固定的字符串模式
拿小三元举例,按照小三元的定义(参考百度百科):
小三元,和牌中有箭牌的两副刻子(杠)、另一种箭牌作将牌
依照博主的转换规则,可以得出特征字符串为:
“332J”, “323J”, “233J”
在手牌已经满足4 * 3 + 2结构的情况下,只要手牌母字符串出现上述任一子串时,就可以判定为小三喜和牌,至于如何判定呢?博主觉得了解过算法与数据结构的人都能想到KMP算法
这就是判和的核心思路,以下就不过多展开KMP算法了
这样的判和可以应用到多数牌型中,少部分特殊牌型
例如 十三幺,九莲宝灯,一色双龙会,连七对 等仍然需要单独判定,不过难度不大,这些牌型虽然不是常规和牌型,但是都有各自鲜明的结构特点,稍加观察也都能发现

同时,博主要强调上述的字符串转换存在一定的弊端:
例如当手牌中同时存在两副一二三万牌
到底是将其解析为 111x111xW 还是 222W 好,其实是需要根据整个场面考虑的,这里博主的算法没有细分那么多情况,因为这个算法直接对应的功能是判和,所以在使用时要稍加注意

在了解上述思路之后,我想对接下来类judgeWin的结构就非常好理解了,目前博主仅实现了88番和64番的判和:

class judgeWin
{
private:
	int numShun;								//顺子数量
	int numPair;								//对子数量
	int numKe;									//刻子数量
	int numGang;								//杠子数量
	int kind;									//手牌花色
	string str;									//存储拆解后的手牌信息
	/*判和类专用的KMP算法,暂时写成内联*/
	vector<int>& findNext(string str, vector<int>& next)
	{
		const int len = str.size();
		next[0] = -1;
		int k = -1, i = 0;
		while (i < len - 1) {
			if (k == -1 || str[i] == str[k]) {
				++k;
				++i;
				next[i] = k;
			}
			else
				k = next[k];
		}
		return next;
	}
	vector<int> KMP(string str1, string str2, vector<int>& next)
	{
		int i1 = 0, i2 = 0;
		int count = 0, flag;
		const int len1 = str1.size();
		const int len2 = str2.size();
		vector<int> res;
		while (i1 < len1) {
			if (str1[i1] == str2[i2]) {
				i1++;
				i2++;
				count++;
			}
			else if (i2 == 0)
				i1++;
			else {
				i2 = next[i2];
				count = i2;
			}
			if (count == len2) {
				flag = i1 - 1;
				//cout << flag - len2 + 1 << endl;
				res.push_back(flag - len2 + 1);
				count = 0;
				i2 = 0;
			}
		}
		return res;
	}
public:
	judgeWin() : 
		numShun(0), numPair(0), numKe(0), numGang(0), kind(0), str("")
	{ }
	void analyseHand();							//手牌拆解与信息分析
	bool isDaSiXi();							//大四喜
	bool isDaSanYuan();							//大三元
	bool isLianQiDui();							//连七对
	bool isSiGang();							//四杠
	bool isJiuLianBaoDeng();					//九莲宝灯
	bool isLvYiSe();							//绿一色
	bool isShiSanYao();							//十三幺
	/*以上为88番*/
	bool isQingYaoJiu();						//清幺九
	bool isXiaoSanYuan();						//小三元
	bool isXiaoSiXi();							//小四喜
	bool isZiYiSe();							//字一色
	bool isSiAnKe();							//四暗刻
	bool isYiSeShuangLongHui();					//一色双龙会
	/*以上为64番*/
	void isWin(vector<string>&);
	//调试时将返回值设为void,最后会改为bool
};

结语

4.18:最后总结

经过大半月的编写和尝试,再过几天这次麻将课设就要告一段落了。在此期间博主还是接触了一些新的知识和内容,但不得不说终究受限于能力,博主在20天有限的时间里还是没法面面俱到,加上近期其他课程的一些进度问题,导致最后这篇博文的最后有一些些烂尾了。。。
(好吧是我自己鸽了我有罪)
4.13-4.18的时间里博主自学了贝叶斯均衡并做了很多的尝试,但是很遗憾,均已失败告终,最终的尝试不是效率奇低就是策略结果离奇,不得不感叹博主自身还是太咸鱼了,道阻且长啊
在自学贝叶斯均衡的时间里,博主发现这个知识点本身就足够有厚度,仅靠这么几天时间想要研究通并加以熟练运用还是有一定难度的,如果之后有机会的话,我准备单独写一篇贝叶斯均衡的学习笔记

那么最后,根据之前的日志记录,我将日志按照实现模块进行了分类,之前提到的关于4.9中一些未成形的尝试放在了最后,仅供参考,等与队友的代码模块做好了最后的合并与调试后,可能会追加一些总体实现模块和更详细的思路分析,不过估计要到代码提交之后了
目前设计的代码主要是对已有的手牌信息分类和算番,并可以理解为“打包处理好”之后传给下一个模块进行分析,如果看到这篇博客的你恰巧也需要实现相关内容,希望能对你有帮助

那么这边万余字的博客到这里就算结束了,虽然是留有实现上的缺陷和遗憾地,但是这段时间对博主个人而言确实有提升,等日后有机会,能力更进一步时,希望能回头完善这篇博客的内容

  • 17
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
下面是一个使用 TensorFlow 和 Keras 实现的麻将AI代码示例,主要使用了深度强化学习算法: ```python import numpy as np import tensorflow as tf from tensorflow import keras class MahjongAI: def __init__(self): self.model = self.build_model() self.target_model = self.build_model() self.target_model.set_weights(self.model.get_weights()) self.replay_buffer = [] self.batch_size = 32 self.gamma = 0.99 self.epsilon = 1.0 self.epsilon_min = 0.01 self.epsilon_decay = 0.995 self.update_freq = 1000 self.steps_since_update = 0 def build_model(self): model = keras.Sequential() model.add(keras.layers.Dense(128, input_dim=34, activation='relu')) model.add(keras.layers.Dense(128, activation='relu')) model.add(keras.layers.Dense(34, activation='linear')) model.compile(loss='mse', optimizer=keras.optimizers.Adam()) return model def select_action(self, state): if np.random.rand() < self.epsilon: return np.random.randint(0, 34) q_values = self.model.predict(state.reshape(1, -1))[0] return np.argmax(q_values) def update_replay_buffer(self, state, action, reward, next_state, done): self.replay_buffer.append((state, action, reward, next_state, done)) if len(self.replay_buffer) > 100000: self.replay_buffer.pop(0) def update_model(self): if len(self.replay_buffer) < self.batch_size: return batch = np.random.choice(self.replay_buffer, self.batch_size) states = np.array([transition[0] for transition in batch]) actions = np.array([transition[1] for transition in batch]) rewards = np.array([transition[2] for transition in batch]) next_states = np.array([transition[3] for transition in batch]) dones = np.array([transition[4] for transition in batch]) q_values = self.model.predict(states) next_q_values = self.target_model.predict(next_states) for i in range(self.batch_size): if dones[i]: q_values[i][actions[i]] = rewards[i] else: q_values[i][actions[i]] = rewards[i] + self.gamma * np.max(next_q_values[i]) self.model.fit(states, q_values, verbose=0) self.steps_since_update += 1 if self.steps_since_update % self.update_freq == 0: self.target_model.set_weights(self.model.get_weights()) def train(self, env, episodes=100): for episode in range(episodes): state = env.reset() done = False total_reward = 0 while not done: action = self.select_action(state) next_state, reward, done, _ = env.step(action) self.update_replay_buffer(state, action, reward, next_state, done) self.update_model() state = next_state total_reward += reward print('Episode', episode+1, 'Total Reward:', total_reward) if self.epsilon > self.epsilon_min: self.epsilon *= self.epsilon_decay def play(self, env): state = env.reset() done = False while not done: action = self.select_action(state) print('Action:', action) state, _, done, _ = env.step(action) if __name__ == '__main__': ai = MahjongAI() # TODO: 实现麻将游戏环境,并传入 train 或 play 方法中进行训练或游戏 ``` 这个示例代码使用了深度强化学习算法,建立了一个神经网络模型来预测每个操作的质量分数,使用经验回放和目标网络来提高训练效率。在实际使用时,需要根据实际情况对代码进行修改和调整。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值