牌桌趣事

丙午马年的春节之前,我跑了趟杭州,约了两个在杭州的朋友去雀庄打了两把立直麻将。其中一位朋友是个日麻小白,为了帮助他快速习得基础玩法,我发给了他一个很早以前做的网页:雀魂役种一览

都说到这了,就顺便讲下那天的麻将趣事(一点也不有趣)吧。下面场景在同一个半庄出现两次:

我满贯确听牌,切出一张牌 dama,那位新手:“诶,你过来帮我看一下我是不是胡了”
我过去一看:你这没有役,荣胡不了(没毁了我的大牌,暗自庆幸中…)
同一巡,还是那位新手:“哎 没事 我自己摸到了”

还是同一个半庄,我亲位:

配完牌后,那位新手:“你刚刚是不是说七个对子也算一种役?”
我:“算的算的”
我朋友:“一般你开局有四个对子才会考虑直线七对子”
那位新手:“我数数,1 2 3 4 5 6,我六个对子”

然后我幸运地铳了:w立、七对、d2,一把万二大牌,喜提第二(不是哥们你w立七对叫我怎么防???)。

我决定以后打雀再也不坐新手的上家了…


正题

说回正题,我发现我乱糊的网页版似乎没有那么方便(因为当时懒得写前端,选了一个使用websocket通信的懒人框架),一段时间不看它就得刷新重连,体验非常不好。正好这段时间我在捣鼓微信小程序,遂想着要不做个小程序吧,也更易于推广给别人用。

说干就干,在vibe coding的加持下,很快我就把已有的功能全部移植了过去。接下来还能搞点什么呢?作为一个科学麻将爱好者,我想到了牌效分析功能,即任给一手牌,要能计算出切各张牌后的进张效率,而这个功能的底层逻辑则是需要能精确地计算出一手牌的向听数。

N向听(Nシャンテン,N-ready),指的是手牌距离听牌仍需N枚有效牌。如果一手牌还需1枚有效牌才能达致听牌,则称为1向听(イーシャンテン,1-ready),如此类推。
麻将维基:向听数

很早以前我曾写过麻将和牌判断算法,一般而言,判断一手牌是否和牌有两种方法:DFS和打表法。当时我都实现了一遍。对于经常需要检查是否和牌的场景,通过DFS枚举手牌拆分方法会显得迟钝,于是这种场景得考虑给和牌型打个表。那么对于向听数的计算而言,是否有类似的算法呢?有的兄弟,有的!

向听数算法

方便起见,我们先考虑14张牌的情形。立直麻将共有三种和牌形状,其中最常见的「$mAAA+(4-m)ABC+DD$」型,被称为一般型,另外还有七对和国士无双型,后两者的向听数非常容易计算,可以直接略过,唯一需要注意的是立直麻将的七对子牌型不允许出现两个相同的对子,因此计算时需要做一些额外的处理。

我们主要来考虑一般型的向听数。根据向听数的定义,我们可以额外定义「0向听」表示听牌、「-1向听」为和牌。

启发式的思考过程

那么如何计算向听数呢?首先我们来思考一下人类玩家自己是怎么算的。不妨从和牌型倒着推算:

  1. 一手已经组成四个面子和一个雀头的牌,是-1向听。
  2. 将其中一个面子换成一个搭子+一个孤张,则显而易见,这手牌的向听数+1。
  3. 将其中一个面子换成三个孤张,则向听数+2(其中两个孤张需要替换为第三个孤张的靠张才能形成面子)。
  4. 如果把雀头换为两张不同的牌,则在组合中没有其他对子的情况下,需要额外替换一张牌形成雀头,此时向听数需要再加1。
  5. 一手牌最多只需要4组面子+搭子的组合,因此对于搭子数而言,应设置其上限为4-面子数。

根据上面naive的思考,似乎一个完美的算法已经形成了:

对于一手牌的某种拆分方法,记其中的面子数为 $m$,搭子数为 $d$,雀头数为 $q, q\in\left\lbrace0、1\right\rbrace$,可以计算出向听数 $s$ 如下:

随便找几组牌测试看看:

将九索视为雀头:
$m = 1,\ d=2,\ q=1,\ s=2\times 3-2-1=3$
将九索视为刻搭:
$m = 1,\ d=3,\ q=0,\ s=2\times 3-3-0=3$

向听数为3

将五饼视为雀头:
$m = 1,\ d=2,\ q=1,\ s=2\times 3-2-1=3$
将五饼视为刻搭:
$m = 1,\ d=3,\ q=0,\ s=2\times 3-3-0=3$
将六饼视为雀头,五六饼视为顺搭:
$m = 0,\ d=3,\ q=1,\ s=2\times 4-3-1=4$
将六饼视为雀头,五饼视为刻搭:
$m = 0,\ d=3,\ q=1,\ s=2\times 4-3-1=4$

向听数为3

将一万视为雀头:
$m = 1,\ d=4,\ q=1,\ s=2\times 3-3-1=2$

向听数为2

$m = 0,\ d=0,\ q=0,\ s=2\times 4-0-0=8$

向听数为8

好像看起来没什么问题。容易发现,对于一手牌而言,其拆分方法可能有很多种,我们需要对它进行不同的拆分,得到上述参数后分别计算向听数,并取其最小值,才能得到最终的向听数。

特殊情形

偶然间发现这样一手牌:

我们按上面向听数的计算公式,得到:

计算结果为1向听,但这一手牌移除3组面子和一个雀头后,剩下的三个孤张「东南西」,没有一张能摸成搭子,故这手牌理应是2向听。

这种特殊情况的存在使得原先的算法需要进行一定程度的调整,经过一番搜索,我找到了这篇文章:

最终的算法

愚钝的我研读了一下上面这篇文章后,感到醍醐灌顶,于是在这里记录一下对这个调整后的算法的理解。

首先,定义一个概念: 爆满,是指手牌中数量已经达到4张的牌的状态。

我们发现前面提到的特殊情形正是由于爆满的存在而产生的。更不巧的是,这里爆满的牌是三种字牌,而字牌不能形成顺子,这才导致向听数的计算公式产生了bug。

为应对这些复杂的情况,文章作者提出了一些新的参数。

  • G3:面子(顺子或刻子)的总数。

  • G2:搭子(差一张牌就能变成面子)的总数(不能是脏搭子)。

  • DG2:脏搭子(组成搭子的两张牌都处于爆满状态)的数量。

  • P:雀头的数量,只能是0或1。

  • DN:脏数牌(孤立的数牌,并处于爆满状态)的数量。

  • DZ:脏字牌(孤立的字牌,并处于爆满状态)的数量。

  • R:剩余的牌的数量。

  • N:手牌的数量,取值为2、5、8、11、14。

  • K:K=(N-2)/3,需要做出的面子的数量,取值为0、1、2、3、4。

    下面是算法对应的部分代码:

s = -1
R = N - 3 * G3 - 2 * (G2 + DG2 + P) - DN - DZ
K = N // 3

先对部分参数进行初始化,s代表向听数,从和牌型开始计算,逐步往上加,因此初始化为-1,R表示未形成面子、搭子、雀头,但也未处于爆满状态的牌的数量。


接下来的一段代码,将处理搭子溢出:

if G3 + G2 + DG2 > K:
    t = K - G3 - DG2
    if t <= 0:
        R += 2 * G2
        DN -= 2 * t
    else:
        R += 2 * t
    G2 = K - G3
else:
    G2 += DG2

逻辑是:当面子+搭子的总数超过K时,溢出的搭子必须被拆为孤张,由于孤张的脏牌对向听数优化的贡献较低,这里可以贪心一下,优先拆普通搭子。普通搭子拆完以后,继续拆脏搭子。同时这一步将更新剩余孤张数R以及脏数牌的数量DN


接下来处理雀头:

if P == 0:
    if R > 0:
        if DZ > 0:
            R -= 1
            DZ -= 1
        elif DN > 0:
            R -= 1
            DN -= 1
        else:
            R -= 2
        s += 1
    else:
        if DZ > 0:
            if DZ >= 2:
                DZ -= 2
            else:
                DZ -= 1
                DN -= 1
        else:
            DN -= 2
        s += 2

当没有雀头时,我们至少需要额外一个进张来形成雀头,这会导致向听数+1。不过,根据剩余牌的类型,又能分为几类情况。

首先只有非脏牌的孤张牌才能形成雀头,这要求R > 0,此时,我们可以直接用其中的某张孤牌来做雀头;否则,手上所有孤立牌都处于爆满状态,已经摸不成雀头了,故至少需要再进两张牌才能凑一个雀头出来。这两种情况分别对应了上面代码中的 s += 1以及s += 2

既然进来了新的牌凑成了雀头,我们当然得打掉对应数量的牌,按牌的利用价值,我们优先打走脏字牌,其次是脏数牌。最后才会打走孤张非脏牌。


接下来,函数进行收尾:

R += DN
DZ -= G2 + 2 * R
if DZ > 0:
    s += DZ // 3

这部分似乎不大好理解。不如通过刚刚的那手牌来看看是怎么回事。

对于上面这手牌,最优的参数组合是:G3 = 3, G2 = 0, DG2 = 0, P = 1, DN = 0, DZ = 3。另外我们可以计算得到 K = 4, R = 0。

DZ -= G2 + 2 * R这一步意味着我们希望在组面子的过程中消耗掉一些脏字牌。上面这手牌中,G2和R均为0,因此最后会剩余3张脏字牌。如果是三个普通的孤张,相比于一个面子而言,会“贡献”两向听,这一部分已经体现在 $s=2\times(4-m)-\max(d,4-m)-q$ 这个公式里了(每3张散牌的存在会减少一个面子),但如果三个孤张都是脏字牌——无法成搭,那么我们还需要额外的一次换牌,将其中一张脏字牌替换成普通的孤张,才能顺利组成面子,因此还需要额外+1。

这也是最后这行代码s += DZ // 3所做的事。


在进行了以上调整后,函数计算向听数:return 2 * (K - G3) - G2 + s,这个公式本质上和前面那个naive的公式是一样的。

综上,这个函数相当于对数量达到4张的牌做了更完善的处理。

3n+1型的向听数计算

上面这个函数只适用于3n+2型的向听数计算,应用到3n+1型需要做一些修改。除了 s 初值修改为0外,还需要修改一下对 P 和 DZ的处理代码。不过思路是差不多的。

打表

编码

字牌和数牌行为存在差异,因此我将字牌和数牌分别进行了编码。而三种数牌则是全排列对称的,因此只需要编码一种数牌就行了。

接下来需要定义一个距离。由向听数的计算方法,我们容易得到距离为0、1、2的牌(可以形成面子和搭子)才会影响向听数,3及以上不影响向听数。因此定义距离函数如下:

我们可以将一手同花色的牌编码为枚数序列:s1和距离序列:s2两个序列,需要满足:

  1. len(s1) 至多为9(同种花色至多9种,如果是字牌,则为7)
  2. s2 的长度为 len(s1) - 1
  3. 对于数牌,s2 序列的总和不超过8($\infty$视为其最小值3)
  4. 对于字牌,s2 的元素总为 $\infty$

仅这样做仍有非常大的组合数量,实际上我们还能去重。我们可以通过 $\infty$ 将序列分割为多个子序列,容易发现:

  1. 这些子序列任意交换顺序,不影响向听数。
  2. 对每个子序列进行倒序排列,不影响向听数。

举两个例子:

示例1:

上面两手牌的枚数序列分别是:

3 1 1 1, 1 2

1 2, 3 1 1 1

逗号表示子序列的分割符(对应位置有一个距离为 $\infty$​ 的跳跃)。这即为「不同子序列的换序」,这种操作不影响向听数。

示例2:

上面两手牌的枚数序列分别是:

3 1 1 1, 1 2

1 1 1 3, 1 2

这即为「子序列的倒序」,同样不影响向听数。


我们可以根据这两点,定义一个序列间的偏序关系,从而对每个序列进行标准化。比如,可以定义其偏序关系为依次比较其枚数序列的字典序、其距离序列的字典序。

枚举与DFS

这一部分比较容易理解。

分别枚举各个花色的总枚数,生成其枚数的分拆,对每一种分拆枚举可行的距离序列。

对每一对枚数、距离序列,通过DFS得到所有可行的参数组合,再把四种花色的参数组合起来,调用前面的向听数函数计算得到向听数,取其最小值即为序列的向听数。

囿于篇幅这里省略了相关代码。

由于编码优化的不是很好,最终得到了一个30mb的压缩包(


最后嫖了一个huggingface space,把小程序后端部署起来了。本文相关代码可以在这个space中找到。

欢迎各位立直麻将爱好者使用帮我测试bug