退役后的麻将生活
AzusaShirasu · · 科技·工程
引子
NOI 的时候是不是看到有选手在搓面麻(面基麻将)?在机房训练的时候是不是看到有人在奇妙的二字日麻网站上玩?
著名笑话:“当你看到一个程序员在电脑前面时而紧张时而放松的时候,他是在调试程序。”
那是不是也可以说:“当你看到一个 OIer 在电脑前面时而紧张时而放松的时候,他不是在打 CF 之类的比赛就是在打雀魂。”
退役了照样能打麻将。
前言
本文章的前半部分非原创,来自搬运外网文章并翻译,并加入了一定的个人理解。
本人的注释会通过这样的引用方式写在原文翻译中。另外,为了表意连贯性和简洁性,不一定会全部照原文翻译。
原文链接:@tomohxx [麻雀]シャンテン数計算アルゴリズム
立直麻将就是日本规则玩法的麻将。
背景
立直麻将中通常用「向听数」来表示做牌进度,其定义为一副手牌达到听牌状态需要的最小的自摸数。
通俗解释:向听数就是理想情况下(摸进来的牌都有用)当前手牌还要打掉多少张。可以理解为废牌数量。之后简记向听数为
S 。
向听数的计算需要考虑和牌的形状有三种:七对子,国士无双 / 十三幺,面子手(四个面子加一个雀头)。其中七对子和国士无双的计算方法很简单,只需要考虑对子或幺九牌:
汉字太多用公式写很丑。这一部分的表达式就不用公式写了。
-
七对子:向听数 = 7 - 对子数。如果手上的牌型不足 7 种,则还要加上 7 - 牌的种类数。
-
国士无双:向听数 = 13 - 幺九牌种类数。如果幺九牌存在对子,则额外加 1。
-
面子手:常规算法是将手牌分为面子、对子、搭子。则向听数 = 8 - 2 * 面子数 - 对子数 - 搭子数。如果存在雀头,则额外加 1。
关于面子手,多数情况下手牌有多种分解方式,此时要考虑所有分解方式,取向听数最小的一个即为最终结果。
但是,不是所有分解形式都可以和牌,比如一个搭子转化为顺子的时候需要第五枚进张;再比如做染手(即:混一色或清一色役种)的话,含四枚牌的和牌分解形式不好处理,为了解决这个问题,本文采用另一种方法计算面子手的向听数。
抽象,不理解讲的是什么。
麻将牌的表示
用
- 1 万到 9 万:编号为
0 到8 ; - 1 饼到 9 饼:编号为
9 到17 ; - 1 索到 9 索:编号为
18 到26 ; - 东西南北白发中:依次编号为
27 到33 。
手牌顺序无关紧要,重要的只是手中有哪些牌、每种牌又各有几张。
距离
为了计算向听数,需要引入距离的概念。对于两副手牌
也就是说,距离定义为表示
h 和g 各种牌数量差值的代数和。下面也写作置换数。
由于向听数的定义为“一副手牌达到听牌状态需要的最小的自摸数”,换言之,也就是“一副手牌达到和牌需要的最小的自摸数减去
如上定义也适用于七对子和国士无双型和牌。
置换数的计算
直接用上述表达式计算在理论上可行,但是实际上,光是面子手和牌的种类就有
假设
这里的上角标表示花色,
再定义两个部分置换数记号
其中
对于一副只包含两种花色共 14 张的手牌,两种花色的数量有十种情况:
可以发现,其实就是按照雀头花色分成了两类、然后细分成各种拆分模式。比如,
(3,11) 指的是:第一种花色只有一个面子(3 张)、第二种花色是三个面子加一个雀头(3\times 3+2=11 张)。
所以,计算
而对于任意
所以,
上面是只考虑两种花色的情况,可以重复使用这个式子来推广到四种花色的情况。没错,这就是动态规划的思想。状态转移方程有:
边界条件为:
如果能够预处理所有
对上述动态规划式子再进行一次变形,可以得到:
注意
t^x_y 和t^{(x)}_y 的含义是不一样的。t^x_y 是可以预处理的,预处理之后代入上式,递推后,自身的值会被更新,更新后的结果就是t^{(x)}_y 。
代码实现使用的递推式子即为上述,配合预处理出的
/*
参数中:
lhs[5~9] 为 t???? 到 t????
lhs[0~4] 为 u???? 到 u????
rhs[5~9] 为 t??1? 到 t??1?
lhs[0~4] 为 u??1? 到 u??1?
计算完成后
lhs[5~9] 为 t???1?? 到 t???1??
lhs[0~4] 为 u???1?? 到 u???1??
*/
using Vec = std::vector<int>;
void add(Vec& lhs, const Vec& rhs)
{
// 计算 t? 到 t?
for(int j=9; j>=5; --j){
int sht = std::min(lhs[j]+rhs[0], lhs[0]+rhs[j]);
for(int k=5; k<j; ++k){
sht = std::min({sht, lhs[k]+rhs[j-k], lhs[j-k]+rhs[k]});
}
lhs[j] = sht;
}
// 计算 u? 到 u?
for(int j=4; j>=0; --j){
int sht = lhs[j]+rhs[0];
for(int k=0; k<j; ++k){
sht = std::min(sht, lhs[k]+rhs[j-k]);
}
lhs[j] = sht;
}
}
这里用的是类似滚动数组的优化,会修改传进来的参数。可以把这个函数理解为 dp 循环的循环体。
用如上算法实现了向听数计算,以及蒙特卡洛法计算配牌的向听数的示例程序。更多详细信息请前往 GitHub:shanten-number-calculator - GitHub。
代码长度更短的,JavaScript 实现的版本:snc_js - GitHub。
用如上算法进行一亿次随机模拟(即:随机生成一副手牌然后计算向听数),得到结果中,亲家配牌向听数的期望值为
实战应用
接下来的部分不是原文内容。实战需要比向听数多得多的信息,下面讲述必要的几个信息的计算。
有效进张的计算
如果要在实践中实用,光是计算向听数可能还不够,计算有效进张的算法:necessary-and-unnecessary-tiles-calculator - GitHub。原文作者没有给出具体解释。
显然,一次切牌,最多只能让向听数减少
天凤也可以计算向听和有效进张,通过扒代码出来看,可以发现确实用的就是枚举法。其 JavaScript 代码如下(原本的源代码是被混淆过的,这里格式化并且规范命名才比较可读):
for (discard = 0; 34 > discard; ++discard) {
if (handCount[discard]) {
handCount[discard]--;
expecting[discard] = [];
for (income = 0; 34 > income; ++income){
if (discard != income && 4 > handCount[income]) {
handCount[income]++;
if (CalculateShanTen(handCount, isRegular) == currentShanTen - 1) expecting[discard].push(income);
handCount[income]--;
}
}
handCount[discard]++;
if (expecting[discard].length) {
expecting[discard] = {
"摸": expecting[discard],
"切": discard,
"进张枚数": ExpectingCount(expecting[discard], handCount)
}
}
}
}
顺带提一句,天凤用的计算向听数的方法似乎并非与上面用的算法完全一致。有能力的可以去啃一啃源码看看具体原理。
期望打点的计算
前往原作者的仓库有现成的实现:https://github.com/nekobean/mahjong-cpp/。大概看了一下,原理是通过建立状态转移图,然后在每个终止状态(也就是和了的状态)计算打点,再一步步换算回来。由于要计算所有可能的路径,算法相对耗时,哪怕已经有预处理出的表,算 3 向听也明显有性能开销。
此外期望打点还受到鸣牌(吃碰杠)的影响,这一点也要考虑进去。包括潜在的鸣牌可能,比如手中有三元牌的对子。真正用于实战的话还有不少的路要走。
这个仓库需要 boost 库来编译,所以不要下载下来然后扔 Dev C++ 里说编译不了——见过一堆这样的 issue(相当数量的提出者是有 OI 背景的)。如果嫌安装 boost 库麻烦,魔改一下编译文件然后用 Github Actions 构建是可行的。
实在不愿意构建可以去作者在主页放的链接麻雀何切るシミュレーター version 0.9.5。
铳率的计算
有一个仓库的提到了计算铳率,是根据牌河的存量结合筋壁理论去计算的,只是非常粗略的估计。
具体计算先放个坑在这。
(大约两个月后得出结论)
非完美博弈算个头啊
而且大部分人打不过纯牌效天凤牌理
闭着眼纯牌效做牌都能天凤六段甚至七段
科学麻将死路一条
我选择训练 AI
(一个月后)
30 小时速通雀豪段位!
(三小时后)
号被封了。
广告
-
单文件,离线可玩,无资源的日麻防守小游戏:https://github.com/WarfarinBloodanger/RiichiReaver。也可以在线玩。
-
以及某个霉到不行的 AI,基于本文所说的算法设计出来的,也有全牌效计算功能。接入了 QQ 机器人。欢迎来体验。
退役生活快乐。不过如果是高中生的话,还是先去学习吧。