我写了个“女朋友”陪自己打麻将……

一个月黑风高的晚上,我正坐在工位的电脑前精力充沛的疲倦不已的写着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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值