LGP5279 [ZJTS 2019] 麻将 学习笔记

LGP5279 [ZJTS 2019] 麻将 学习笔记

Luogu Link

前言

九条可怜是一个热爱打麻将的女孩子。因此她出了一道和麻将相关的题目,希望这题不会让你对麻将的热爱消失殆尽。

哇袄!!!!!!

题意简述

九条可怜在打麻将。

具体来说,整个牌堆由面值 \(1\sim n\)\(n\) 种牌,每种各四张组成。除去现在已经有的十三张牌,还剩余 \(4n-13\) 张。

定义“刻子”为面值形如 \(\{i,i,i\}(1\le i\le n)\) 的三张牌,定义“顺子”为面值形如 \(\{i,i+1,i+2\}(1\le i\le n-2)\) 的三张牌。“顺子”和“刻子”合称“面子”。定义“对子”为面值形如 \(\{i,i\}(1\le i\le n)\) 的两张牌。

定义一个麻将牌集合 \(S\) 是“胡的”当且仅当它的大小为 \(14\) 且满足下面两个条件中的至少一个:

  • 传统胡牌:\(S\) 可以被划分成五个集合 \(S_1\)\(S_5\) 。其中 \(S_1\) 为对子,\(S_2\)\(S_5\) 为面子。
  • 七对子:\(S\) 可以被划分成七个集合 \(S_1\)\(S_7\) ,它们都是对子,且对应的大小两两不同。

定义“胡牌”为当前在手的麻将牌集合存在一个子集是“胡的”。给定 \(13\) 张麻将在手,问最小胡牌巡目数 \(x\) 的期望(即:期望至少再摸 \(x\) 张牌可以胡)。

\(5\le n\le 100\)

做法解析

首先这一大坨东西的判定的复杂程度就不像是能直接算概率的,考虑转化为先算方案数,再通过方案数算期望。

要想算方案数,就要先会判定一套牌能不能胡。怎么判定呢?

我们发现,手上牌的顺序和判定能胡与否没有关系,所以一副牌可以表示为一个长为 \(n\) 的数组 \(a_i\),表示有 \(a_i\) 个面值为 \(i\) 的牌。比如麻将牌集合 \(\{2,3,3,3,5,5,6\}\) 就可以被这样转化为 \(A=\{0,1,3,0,2,1\}\)

那么我们可以尝试造一个自动机出来。换句话说,就是预处理出来不同麻将牌集合状态之间的转移关系。

实际上,我们若不考察不同集合状态的内在关联,就几乎找不到一个可以快速判断一个状态是否胡牌的方法;而若是想利用这种内在关联,我们就要高效压缩集合的信息,然后做个搜索搜出它们的转移关系。

我们尝试设计 \(f_{i,j,k,0/1}\) 表示处理完了第 \(i\) 种编号的牌,预留了 \(j\) 对形如 \(i-1,i\) 的牌,\(0/1\) 表示有没有预留过对子,其值是这种情况下的最大面子数。转移的话类似刷表,用 \(f_{i,\dots}\) 转移 \(f_{i+1,\dots}\)

注意到上述的状态设计丢掉了 \(i-2\) 及以前的卡牌的所有具体信息。这是合理的、满足无后效性的。因为你去转移 \(f_{i+1,\dots}\) 的时候,面值为 \(i+1\) 及以后的牌和 \(i-2\) 及以前的牌不可能产生任何交互。\(i-2\) 及以前的那些牌如果没有融进某个对子或者面子,那就成纯纯飞舞了,啥用没有。

“胡牌”有两种判定,为了对七对子特判,我们最后在自动机上跑的状态形如这样:adat isp[2];int pcnt;。其中 adat 是一个 \(3\times 3\) 的DP数组。我们处理出形如这样的状态间的转移关系,然后就可以在自动机上跑计数DP了。

对于取值为正整数的随机变量 \(X\),有 \(\Bbb{E}(X)=\sum_{i=0}^{+\infin} P[X>i]\),就是说,\(X\) 的期望等于对“从 \(0\) 到正无穷的所有 \(i\)\(X\) 大于 \(i\) 的概率”求和。

我们可以这么理解这个式子:以 \(1\) 为单位长度把数轴划成无数份。\(P[X>i]\) 等效于我们能取到 \((i,i-1]\) 这一份贡献的概率。\(\sum_{i=0}^{+\infin}\) 的所有贡献段的期望加起来就是总的期望了。

根据这个道理,我们要想算最小胡牌巡目数的期望,只需要对于所有 \(i\in [0,4n-13]\) 算出已经摸了 \(i\) 张牌还是不胡的方案数加起来除以 \((4n-13)!\) 即可。

最终答案即为:

\[1+\frac{\sum_{i=1}^{4n-13}dp_i\times i!(4n-13-i)!}{(4n-13)!} \]

。解释一下,这里我们算的 \(dp_i\) 是这 \(i\) 张牌无序的方案数,所以还要乘上 \(i!(4n-13-i)!\),代表前面那 \(i\) 张牌和后面那 \(4n-13-i\) 张牌的顺序随意。加的那个 \(1\) 相当于“摸了 \(0\) 张牌不胡的期望”。

时间复杂度:\(O(|\Sigma|n^2)\)。其中 \(\Sigma\) 为自动机状态集合。在这种实现下 \(|\Sigma|=2092\)

代码实现

好像没完全讲清楚,那详见代码吧。

#include <bits/stdc++.h>
using namespace std;
using namespace obasic;
using namespace omodint;
using mint=m998;
const int MaxN=105,MaxM=4e2+5,MaxS=2250;
//MaxS:状态数
mint facr[MaxS],finv[MaxS];
namespace omathe{
    void premwork(int n){
        facr[0]=finv[0]=1;for(int i=1;i<=n;i++)facr[i]=facr[i-1]*i;
        finv[n]=facr[n].ginv();for(int i=n-1;i;i--)finv[i]=finv[i+1]*(i+1);
    }
    mint Comb(int n,int m){return facr[n]*finv[m]*finv[n-m];};
}
using namespace omathe;
int N,M,X,Y,sum[MaxN];
struct adat{
    int f[3][3];
    int* operator[](const int& x){return f[x];}
    adat(){memset(f,-1,sizeof(f));}
    friend bool operator<(adat a,adat b){
        for(int i=0;i<3;i++){
            for(int j=0;j<3;j++){
                if(a[i][j]!=b[i][j])return a[i][j]<b[i][j];
            }
        }
        return false;
    }
    friend bool operator!=(adat a,adat b){
        for(int i=0;i<3;i++){
            for(int j=0;j<3;j++){
                if(a[i][j]!=b[i][j])return true;
            }
        }
        return false;
    }
    //为了给anod重载运算符
    void tran(adat &tar,int icr){
        //把自己加上icr张最新牌"c"后的状态给tar;
        for(int i=0;i<3;i++){
            for(int j=0;j<3;j++){
                if(f[i][j]==-1)continue;
                //f[i][j]:预留了i个(c-2,c-1),j个(c-1)时最多几个面子。
                for(int k=0,rem=icr-(i+j+k);k<3&&rem>=0;k++,rem--){
                    //k:预留几个(c)。rem:
                    maxxer(tar[j][k],min(i+f[i][j]+rem/3,4));
                    //你考虑到一张牌只要做出贡献,在哪做出贡献都一样
                    //所以你能和(c-2,c-1)凑顺子就一定会凑。
                    //tar[j][k]:预留了j个(c-1,c),k个(c-1)。
                    //对4取min是因为4对面子及以上都一视同仁。
                }
            }
        }
    }
    bool chkhu(){
        for(int i=0;i<3;i++){
            for(int j=0;j<3;j++){
                if(f[i][j]>=4)return true;
            }
        }
        return false;
    }
};
//对应一个3*3的dp数组,具有判断胡牌的功能。
//dp[i][j]:预留了
struct anod{
    adat isp[2];int pcnt;
    //isp[0]对应没有预留对子时的DP数组
    //isp[1]对应预留了的。
    //pcnt统计累计有多少对子。
    void dest(){isp[0]=isp[1]=adat(),pcnt=-1;}
    //自毁(胡牌)状态
    friend bool operator<(anod a,anod b){
        if(a.pcnt!=b.pcnt)return a.pcnt<b.pcnt;
        if(a.isp[0]!=b.isp[0])return a.isp[0]<b.isp[0];
        if(a.isp[1]!=b.isp[1])return a.isp[1]<b.isp[1];
        return false;
    }
    //map的元素类型必须有比较运算符。
    bool chkhu(){
        if(pcnt>=7){dest();return true;}
        if(isp[1].chkhu()){dest();return true;}
        return false;
    }
    friend anod operator+(anod a,int x){
        if(a.pcnt==-1)return a;
        //胡牌加任何东西都是胡牌,所以处理胡牌状态的加法没有意义,直接返回。
        anod b;if(x>=2)a.isp[0].tran(b.isp[1],x-2);
        //一次加了两张以上的牌,可以让没预留对子的变预留对子的。
        a.isp[0].tran(b.isp[0],x),a.isp[1].tran(b.isp[1],x);
        //不考虑预留对子,各自转移各自的。
        b.pcnt=a.pcnt+(x>=2),b.chkhu();
        return b;
    }
};
anod sta(){
    anod res;res.dest();
    res.pcnt=0,res.isp[0][0][0]=0;
    return res;
}
map<anod,int> mp;
int tot,nxt[MaxS][5],este;
void bfs(anod s){
    //BFS。
    queue<anod> q;
    q.push(s),mp[s]=++tot;
    while(!q.empty()){
        anod u=q.front();q.pop();int &nu=mp[u];
        for(int i=0;i<=4;i++){
            anod v=u+i;
            //枚举加多少张牌。
            if(!mp.count(v))mp[v]=++tot,q.push(v);
            //转移!
            nxt[nu][i]=mp[v];
        }
    }
    s.dest(),este=mp[s];
    //终止状态就是。
}
mint dp[2][MaxM][MaxS],xis[5];
void DP(){
    dp[0][0][mp[sta()]]=1;
    //初始化:dp[0][0][初始态]有一种方案。
    for(int z=1;z<=N;z++){
        int cz=(z&1),lz=cz^1;
        //实则用dp[z-1]转移dp[z]。可以说是填表法。
        int clim=4-sum[z];
        //现在最多加多少张牌z。
        for(int j=0;j<=clim;j++)xis[j]=Comb(clim,j);
        //乘上一个系数,这个类型的牌里面clim选j。
        for(int i=M;i>=0;i--){
            for(int j=1;j<=tot;j++)dp[cz][i][j]=0;
            //滚动数组了所以先清理一下。
            for(int j=1;j<=tot;j++){
                if(dp[lz][i][j]==0)continue;
                for(int k=0;k<=clim&&i+k<=M;k++){
                    dp[cz][i+k][nxt[j][k+sum[z]]]+=dp[lz][i][j]*xis[k];
                }
            }
        }
    }
}
mint ans;
int main(){
    bfs(sta());premwork(tot);readi(N),M=N*4-13;
    //从“初始状态”开始,搜出所有状态间的转移关系。
    for(int i=1;i<=13;i++)readis(X,Y),sum[X]++;
    //开始DP,DP完了之后算概率
    DP();for(int i=1;i<=M;i++){
        for(int j=1;j<=tot;j++){
            if(j!=este)ans+=dp[N&1][i][j]*facr[i]*facr[M-i];
        }
    }
    ans=ans*finv[M]+1;writi(miti(ans));
    return 0;
}

后记

总的来说就是把先归纳并爆搜状态的转移,再在其自动机上计数DP。

posted @ 2025-06-05 12:03  矞龙OrinLoong  阅读(30)  评论(0)    收藏  举报