序
一个月黑风高的晚上,我正坐在工位的电脑前精力充沛的疲倦不已的写着bug。
突然,一通备注着“小仙女”的电话打了过来,我一看赶紧接通了女朋友的电话。
电话那边传来温柔的声音“好无聊呀,过来陪我一起打麻将吧”
可是事业心强并且写着bug的我怎么能够去和女朋友打麻将呢?
于是在犹豫了一秒之后我便起身而出。
见到了可爱的女朋友,我们便兴奋的搓起了麻将。
搓了一轮一轮又一轮……
可是实在是太困,搓着搓着我竟然睡着了。
“涛哥,涛哥,快醒醒~“
在同事的呼唤中我揉了揉朦胧的双眼,看着眼前模糊的桌面,转了转脑袋,我这才反应过来我是在公司写代码写睡着了,难道刚才的一切都是梦?
我向同事述说着刚才的事情,可同事带着嘲笑的口气说“别做梦了,还女朋友?除了你写的机器人还有谁会陪你打麻将哈哈哈”
我很生气,但是拿出手机看了通话记录确实没有什么“小仙女”。
一番冷静后,我将双手放在了笔记本上,抚摸着顺滑的键盘,去她的小仙女,我还是陪我的机器人吧。
虽然自己很难受,但还是秉承着助人为乐的精神教教单身汪萌怎么写机器人陪自己玩。
以下,正文。
麻将预备知识
我们先来了解一下麻将的预备知识,由于全国各地的麻将规则和叫法不尽相同,在这以日麻做统一。有一定麻将基础的可以跳过
定义规则:「」
表明和国内麻将有所不同之处; 加•
为通用标准定义;加#
为本文章定义或麻将牌友之间的通俗用法(非通用用法)
名称定义:(其中加粗的是比较重要的)
#条子简称s,饼子简称p,万子简称m。一般多用于快速形容手牌。用法例如:234p55678s3478m西
•老头牌:一到九的s(索)、p(饼)、m(万)的统称。共24张
•三元牌:白发中 牌统称「日麻顺序」。共12张
•四风牌:东南西北 牌统称。共16张
•暗刻子:牌中含有三个一样的牌组
•明刻子:碰形成的三个一样的牌组
•刻子:暗刻子和明刻子统称
•顺子:相同花色,相连的三张构成的牌组
•面子:顺子与刻子的统称
•将牌\雀头:一对相同的牌(如果一对相同的牌被分在不同的牌组里必然不算),简称:"将"。
•吃:上家打出牌,与下家的牌正好组成一副顺子
•碰:其他人打出一张牌,自己手中有两张相同的牌正好组成一副刻子
•杠子:四个一样的牌形成的牌组(需声明 才成立)
•明杠:自己手中有暗刻,然后获取别人打出的牌声明杠子
•暗杠:自己手中有一组杠子,直接声明杠子
•和了:「完成带番数的特殊和牌型」
#标准和牌型:四组面子和一将牌
#和牌型:标准和牌型+七对子+国士无双(中麻里的"十三幺")
•自摸:自己摸牌和了。
•荣和:他家打出牌使你和了。又称食和。
•出统:一家打的牌使另一家荣和。中麻里叫"点炮"。
•听牌:差一张牌就可以完成和牌型的状态
•向听:差n张牌就可以听牌称n向听
•手牌:麻将开局,每人按顺序分次先后摸得的13张,就叫作手牌。
和牌的判断
和牌的规则
我们都知道打麻将的最终目的是「和牌」,首先我们需要知道和牌的规则。
有多种多样的和牌,但是总归起来可分为:标准和牌型、七对子和国士无双这三种。
- 标准和牌型为4面子+1雀头,可以理解为
n*AAA+m*XYZ+DD
,如下就是一种标准型的和牌。
- 七对子比较好理解,就是七个对子,如下是一种七对子的和牌。
- 国士无双(中国麻将又称十三幺),指其中
1种一对,剩下12种各一张的和牌。如下是一种国士无双的和牌。
和牌的判断思路
那么如何教机器人判断和牌呢?
1、打表
给每种牌编一个id,麻将中共34种牌因此需要至少6位空间。手牌14张也就是84位。要保存和牌的全部组合约1700万种需要约175MB存储。
查表法比较吃内存,但是据说天凤之类的网游就是这么算的。
2、暴力拆解
可以使用递归,拆出顺子、刻子、将牌。
暴力拆解比较耗时间,但是可维护性较好。
鱼和熊掌不可兼得?为了绝佳的游戏体验,我冒着秃头的危险找到了改进的方案。
3、方案来自 hp.vector.co.jp
改进的思路是先给手牌排序,然后不管具体牌面,只计算连续牌的张数,得到一个「牌型」,再从表中查牌型是否胡。
例如「222456万345678饼北北」,可以编码为「30111011111102」(三张相同牌,三张连续牌,六张连续牌,两张相同牌,中间隔开)。
下一步是将其二进制化,采用如下特制规则:
1→0
2→110
3→11110
4→1111110
10→10
20→1110
30→111110
40→11111110
这样编码后每张牌只占用1到2位空间,最坏情况子下(十四张不连单牌)仅占用27位。跟之前的84位相比,单组数据压缩了三分之二以上。更牛逼的是,和牌表从1700万种具体组合下降到仅仅9362种形状排列!
和牌的判断标准
对于和牌的判断标准,我们需要了解「shanten (向听)」这个概念,上面简单说过,差n张牌就可以听牌称n向听。下面我们以一例牌型来详细说明。
-
当前手牌为3向听状态
-
打【北】,摸【一万】,到2向听
-
打【发】,摸【四万】,到1向听
-
打【一筒】,摸【五万】,到听牌(0向听)
-
打【七筒】,摸【二条】,和牌(-1向听)
向听的计算
所以,让机器人和牌转为了向听的计算这一问题,朝着向听减少的方向,直至 -1,即为和牌。
由前面所提的和牌规则,我们可将向听数的计算也分为:标准和牌型、七对子和国士无双这三种。
我们先解决两种特殊情况。
- 七对子的向听计算
正如它的名字一样,在 七对子中 收集七个对子是和的条件。因此,为了计算向听,计算对子的数量是基本的。作为例子考虑下面的手牌吧。
对子数是 5 个,是 1 向听。换句话说,**在七对子的情况下,如果你计算对子的数量,然后用 6 减去它,那就是向听。**例如,如果对子有6个即听牌(0向听) ,如果有7个即和了(- 1向听)。
七对子的向听 = 6 - 对子数
但是需要注意的是以下情况。
六饼是暗子,不过,这个也作为对子需要数数。
另外,还需要特别注意的是以下情况。
这是六个对子,所以根据上面的公式,它会变成一个听牌,但实际上是一向听。像这样牌的种类不够的话,从7减去牌的种类的东西必须加到向听数。
例如,这个牌子有四个对子和四种牌子,
向听数 = 6-4 + (7-4) = 5
- 国士无双(十三幺)的向听计算
如果数出一九字牌的种类,再用13减去,那就是向听数。但是如果有一个对子,可以把一个向听减少到最大限度。
如下面这种情况,因为一九字牌有12种,对子是一个,所以以13-12-1是0 chanten,也就是听牌。
在这种情况下,你不能使用这两种方法,所以13-11-1就是1 chanten。
最后是比较麻烦的标准和牌型
- 标准和牌型的向听计算
求解方法包括计算面子和面子候选数的数量,然后用下面的公式计算。
向听数 = 8- 面子数 × 2- 面子候选数
这里面子是指已经完成的面子,也就是顺子和刻子。面子候选是指再过一张就要面子的东西,也就是对子和搭子。
基本上,我们只需要注意两种情况。
⚠️一种是,面子数和面子候选数的总数不得超过4个。
举个例子,这个牌子有两个面子,四个候选面子,所以如果你用上面的公式来计算
8-2×2-4=0
然而,实际上,不能使用面子候选数。 这是所谓的多余面子的状态。 在这种情况下,为了使面子和面子候选数的总数为4,必须将面部候选的数量计为两个。 如果以此计算
8-2×2-2=2
然后你会发现这副牌确实是两个向听。
⚠️另一种是雀头。如果除了在上面使用的牌以外,可以得到麻雀头,从向听的数量中减去1。例如:
计算为
8-2×2-2=2
但是因为一对八条可以作为雀头使用,所以变成了一个向听。
机器人出牌
我们知道了【和牌的判断】和【向听的计算】,接下来就可以教机器人如何出牌了。我们的目的是让机器人和牌(若果是想获得碾压的快感,只做个摸啥打啥的傻瓜🤖️也行。但是我这么聪明,傻瓜机器人肯定是满足不了我的啦)
我们也知道不可能机器人开局的手牌就可和牌,所以机器人要朝着和牌的方向,也即【向听减少的方向】,前面说过【和牌的向听是-1】
我们首先要计算摸牌后的当前向听,若为-1则和牌,否则需要出牌,而出什么牌则是出哪张牌可以保持向听不变,下一轮判断摸牌的向听,摸到哪张牌可以使得向听前进(这在麻将术语中叫做「进张」)
那如何让机器人实现呢?
我们可以建立搜索树,当前手牌为根,往向听数减少方向搜索,直至胡牌。
文字表述可能不是很好理解,那我画个图示意一下:
代码实现
思路已经有了,那如何用代码实现呢?
注⚠️:因为涉及到商业性质,具体的技术细节不便公开,这里只分享伪代码思路和网上了解的开源代码。
手牌的结构体
首先是手牌的定义,总共有34种牌,我们可以用一个34长度的数组,值为对应牌出现的次数。
比如「111万 11筒 111条」,则为 [3 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
向听的计算
根据前文,我们可以实现如下版本的向听的计算
// 一般型的向听计算
func (st *Shanten) CalcNormalShanten() int32 {
shanten := MaxShanten - 2*st.shunKeNums - st.daZiNums - st.pairsNums
numMentsuKouho := st.shunKeNums + st.daZiNums
if st.pairsNums > 0 {
numMentsuKouho += st.pairsNums - 1 //有雀头时加上对子数-1
} else if st.anGangBitMap > 0 && st.singleBitMap > 0 {
if st.singleBitMap|st.anGangBitMap == st.anGangBitMap { // 没有雀头,且除了暗杠外没有孤张,这个时候连单听都听不了
// 比如 5555m 应该算作一向听
shanten++
}
}
if numMentsuKouho > 5 { // 面子候补过多
shanten += numMentsuKouho - 5
}
//3N+2 是自己出牌,出掉一张必出牌
if shanten != -1 && CountOfTiles34(st.handTiles34)%3 == 2 {
st.mustOutNums--
}
if shanten != -1 && shanten < st.mustOutNums {
return st.mustOutNums
}
return shanten
}
另外,如上文所述,用一种编码可以进行优化,看网上说是改进的霍夫曼编码。则可使用如下代码判断和牌,代码来自http://hp.vector.co.jp/authors/VA046927/mjscore/mjalgorism.html
// 3k+2 张牌,返回所有可能的拆解,没有拆解表示未和牌(不检测国士无双)
// http://hp.vector.co.jp/authors/VA046927/mjscore/mjalgorism.html
// http://hp.vector.co.jp/authors/VA046927/mjscore/AgariIndex.java
func DivideTiles34(tiles34 []int) (divideResults []*DivideResult) {
fmt.Println(tiles34)
tiles14 := make([]int, 14)
tiles14TailIndex := 0
key := 0
bitPos := -1
// 数牌(1-9万条筒)
idx := -1
for i := 0; i < 3; i++ {
prevInHand := false // 上一张牌是否在手牌中
for j := 0; j < 9; j++ {
idx++
if c := tiles34[idx]; c > 0 {
tiles14[tiles14TailIndex] = idx
tiles14TailIndex++
prevInHand = true
bitPos++
switch c {
case 2:
key |= 0x3 << uint(bitPos)
bitPos += 2
case 3:
key |= 0xF << uint(bitPos)
bitPos += 4
case 4:
key |= 0x3F << uint(bitPos)
bitPos += 6
}
} else {
if prevInHand {
prevInHand = false
key |= 0x1 << uint(bitPos)
bitPos++
}
}
}
if prevInHand {
key |= 0x1 << uint(bitPos)
bitPos++
}
}
// 字牌(东南西北中发白)
for i := 27; i < 34; i++ {
if c := tiles34[i]; c > 0 {
tiles14[tiles14TailIndex] = i
tiles14TailIndex++
bitPos++
switch c {
case 2:
key |= 0x3 << uint(bitPos)
bitPos += 2
case 3:
key |= 0xF << uint(bitPos)
bitPos += 4
case 4:
key |= 0x3F << uint(bitPos)
bitPos += 6
}
key |= 0x1 << uint(bitPos)
bitPos++
}
}
results, ok := winTable[key]
if !ok {
return
}
// 3bit 0: 刻子数(0~4)
// 3bit 3: 顺子数(0~4)
// 4bit 6: 雀头位置(1~13)
// 4bit 10: 面子位置1(0~13) 刻子在前,顺子在后
// 4bit 14: 面子位置2(0~13)
// 4bit 18: 面子位置3(0~13)
// 4bit 22: 面子位置4(0~13)
// 1bit 26: 七对子
// 1bit 27: 九莲宝灯
// 1bit 28: 一气通贯
// 1bit 29: 两杯口
// 1bit 30: 一杯口
for _, r := range results {
// 雀头
pairTile := tiles14[(r>>6)&0xF]
// 刻子
numKotsu := r & 0x7
kotsuTiles := make([]int, numKotsu)
for i := range kotsuTiles {
kotsuTiles[i] = tiles14[(r>>uint(10+i*4))&0xF]
}
// 顺子的第一张牌
numShuntsu := (r >> 3) & 0x7
shuntsuFirstTiles := make([]int, numShuntsu)
for i := range shuntsuFirstTiles {
shuntsuFirstTiles[i] = tiles14[(r>>uint(10+(numKotsu+i)*4))&0xF]
}
divideResults = append(divideResults, &DivideResult{
PairTile: pairTile,
KotsuTiles: kotsuTiles,
ShuntsuFirstTiles: shuntsuFirstTiles,
IsChiitoi: r&(1<<26) != 0,
IsChuurenPoutou: r&(1<<27) != 0,
IsIttsuu: r&(1<<28) != 0,
IsRyanpeikou: r&(1<<29) != 0,
IsIipeikou: r&(1<<30) != 0,
})
}
return
}
搜索树的实现
可以构建两个结构体作为搜索树的两种节点,进行递归调用
//3N+2节点
type SearchNode3N2 struct {
shanten int32
children map[int32]*SearchNode3N1 // 向听不变的舍牌-node13
}
//3N+1节点
type SearchNode3N1 struct {
shanten int32
waits map[int32]int32 // map[进张牌]剩余数
children map[int32]*SearchNode3N2 // 向听前进的摸牌-node14
huType map[string]bool // 胡牌类型,用于显示
huScores map[int32]int32 // map[进张牌]胡牌期望分
}
因为递归调用的栈层数太多,效率比较低,所以我们可以使用一个全局map做备忘录进行优化,我想过用dp动态规划替代递归写法,但是状态转移实在太多了,没有实现。
// 优化--全局map,记录当前手牌推荐出牌
var _Search3N2MapType *Search3N2MapType
type Search3N2MapType struct {
sync.RWMutex // 读写锁
search3N2Map map[string]*SearchNode3N2 // map[手牌+剩余牌]出牌可能
}
首先调用的是出牌,以下是伪代码
// 出牌算法,3N2数量出牌,返回3N2节点
func _search3N2(当前向听,停止向听,用户信息,map备忘录) *SearchNode3N2 {
孩子节点 := map[int32]*SearchNode3N1{}
//1.已经和牌,直接返回
if 当前向听 == -1 {
return &SearchNode3N2{当前向听,孩子节点}
}
//2.在map备忘录中查是否存在当前手牌情况
searchNode3N2, exsit := _Search3N2MapType.Get(hash(手牌+剩余牌))
if exsit {
return searchNode3N2
}
//3.map备忘录中没有,则遍历出牌情况
for i := range 当前手牌 {
//计算出牌后的向听,如果向听不变,则舍牌正确
if 出牌向听 == 当前向听 {
//递归搜索孩子节点的摸牌情况
孩子节点[i] = _search3N1(出牌向听,停止向听,用户信息,map备忘录)
}
}
//4.将SearchNode3N2存入map中
_Search3N2MapType.Set(hash(手牌+剩余牌), &SearchNode3N2{当前向听,孩子节点})
//5.返回
return &SearchNode3N2{当前向听,孩子节点}
}
正确的出牌后会调用到摸牌,摸牌进张时也会递归调用出牌
// 摸牌算法,3N1数量摸牌,返回3N1节点
func _search3N1(当前向听,停止向听,用户信息,map备忘录) *SearchNode3N1 {
//1.从剩余牌堆里摸牌
for i := range 剩余牌堆 {
//2.计算摸牌后摸牌向听,
//优化,若摸牌向听>2,只比较牌效率
if 摸牌向听 > 2 {
//小于当前向听为进张
if 摸牌向听 < 当前向听 {
//递归搜索孩子节点的出牌情况
孩子节点[i] = _search3N2(摸牌向听, 停止向听, 用户信息, map备忘录)
}
}
//优化,若摸牌向听<2,计算到和牌
else {
//检测能否和牌
if 和牌 {
设置和牌数据
}else if 摸牌向听 < 当前向听 {
孩子节点[i] = _search3N2(摸牌向听, 停止向听, 用户信息, map备忘录)
}
}
}
return &SearchNode3N1{当前向听,进张牌,孩子节点,胡牌期望分,和牌类型}
}
絮
以上就是机器人麻将算法的大致内容了,你也快写个机器人陪自己打麻将吧!什么?你有女朋友?
等等!看在我这么费心的份上点个【在看】在”滚“呗~
另外,欢迎大家关注个人公众号“Kevin的学堂“,更多干货等着你!
参考资料
和牌判定 http://hp.vector.co.jp/authors/VA046927/mjscore/mjalgorism.html
向听计算方法 http://ara.moo.jp/mjhmr/shanten.htm