日常生活中,我有一个非常浪费时间的爱好,那就是麻将。虽然没有经过系统的学习和训练,水平非常一般,但我还是乐此不疲。作为一个程序员,在打麻将之余,也总会不自觉地去思考麻将的和牌算法应该怎么去实现。今天就来谈一下我对这个问题的一些粗略想法。
麻将介绍
麻将,想必大家都不陌生,它是由饼子、万子、索子、字牌、三元牌组成。其中,饼子、万子、索子分别有数字1-9的9种牌,每个数字4张,共计108张。字牌由东、南、西、北组成,三元牌由白、发、中组成,每个也是4张,共计28张。在有些地方的玩法中,只包括饼子、万子、索子,会去掉字牌和三元牌。我们此处的介绍中,以现在比较流行的日麻规则为基础。日麻规则,包括了上述全部的136张牌。
借由国内知名日麻平台“雀魂”的一张图,来看一下所有的麻将牌种类。需要说明的是,除了上面说到的普通的牌之外,下图中还有额外的一张红5索、红5万、红5饼。这是日麻中的赤dora(宝牌),一张赤dora在和牌的时候就可以将手牌的价值提高一番。
麻将的和牌,是需要做成4面子+1雀头的牌型。雀头,由2张完全相同的牌组成,比如2张1饼、2张9万。面子,是顺子和刻子的统称。顺子,即3张连续的牌组合,比如1饼2饼3饼或是3万4万5万。刻子,是3张完全相同的牌组合,比如3张1饼或是3张东风。当然了,刻子也可以是4张相同的牌组合,比如4张5索,叫做杠子,可以认为是刻子的加强版。顺子只能由饼子、万子、索子组成,不能由字牌和三元牌组成,因为我们认为不同的字牌和三元牌之间没有连续性。
除了以上的规则之外,通常来说麻将还有两种额外的和牌规则。
一种是七对子,即最后手里的牌型可以组合成7个不同的对子。
还有一种叫做国士无双(国内通常叫做十三幺),即手里聚齐了1万9万1饼9饼1索9索东南西北白发中,再加上另外的以上提到的任意一张牌。
和牌算法
基于以上的规则,我们来构思判断是否可以和牌的算法。
为了下面行文的方面,我们将饼子称为p,万子称为m,索子成为s,则5饼则可以表示为5p,3万可以表示为3m,1索可以表示为1s,以此类推。字牌稍微有些麻烦,我们称之为z,按照东南西北白发中的顺序分别编号为1-7,即1z表示东风,7z表示中。其中,上文中提到的赤dora则分别别是为0p,0m和0s。
为了更好的判断顺子、刻子、雀头,我们可以将基于以上表示规则的麻将牌转化为数字表示。具体的规则如下:
- 万子表示为1-9.
- 饼子表示为11-19
- 索子表示为21-29
- 东西南北白发中按照顺序分别表示为31、33、35、37、39、41、43.
- 赤dora按照万子、饼子、索子,分别表示为5、15和25.
按照以上的规则来设计,保证了同种麻将牌之间数字是连续的,而不同种麻将牌之间则存在着数字的间隔,可以简化判断三张麻将牌是否为顺子的逻辑。
对于一手麻将牌,在进行是否和牌判断之前,先按照上述规则转换为数字表示,并进行排序。以下的说明,都是基于这个排过序的数字数组。
在上面的基本介绍中,我们说明了3种和牌牌型。七对子和国士无双判断逻辑十分简单,我们此处重点来说明普通和牌型,即4面子+1雀头型。
需要说明的是,本文种的算法默认不存在吃、碰、杠行为,手牌保持13 + 1张的状态。但本文描述的算法稍加改动即可适配,因为吃、碰等副露行为会使得手牌减少,算法调整为(4 - x)面子+1雀头型即可,x为副露次数。
以下为对算法流程的描述:
- 判断数组中是否包含对子,即两张一样的牌。如果没有,不可能和牌。有,则记录下来。
- 对步骤1中统计出的对子,从当前手牌中标记手牌使用情况,并分别执行以下逻辑:
2.1 如当前未使用的手牌剩余为0,则返回和牌成功
2.2 如当前剩余未使用手牌中的第1张手牌无法组成一组顺子或刻子,则执行步骤2.4。
2.3 当前剩余未使用手牌中的第1张手牌,验证是否包含顺子。如包含顺子,标记手牌使用状态,然后重复执行2.1-2.4。
2.4 将步骤2.3中标记已使用的顺子重新标记未未使用,判断未剩余手牌中的第1张是否可组成刻子,如不包含,则和牌失败,还原手牌状态,返回到步骤2中执行。如可组成,从手牌中将此刻子标记为已使用,然后重复执行2.1-2.4。 - 和牌成功,将在步骤2中返回成功。流程执行到步骤3,返回和牌失败。
算法实现
基于以上的算法流程,我们有如下的算法实现。为了编程上的方便,我们没有使用额外的数据结构来保存当前手牌的使用状态,而是简单执行了手牌的复制,并从中去除了已使用的手牌。
1 | /** |
其中,double7函数判断是否为7对子和牌,all19为判断是否为国士无双和牌。实现分别如下
1 | /** |
pickDouble函数为从手牌中统计出已有的对子。编程方便的考虑,此处记录的是对子起始的手牌序号,方便后面从手牌中删除此对子。
1 | function pickDouble(majs) { |
allTriple函数为逻辑的核心部分,作用在于判断剩下的手牌是否可完整组合成为面子牌型。
1 | function allTriple(hands) { |