题解 P7147 【[THUPC2021 初赛]麻将模拟器】

· · 题解

历时三天,写代码时间四个半小时。重构一次,代码总长度约 30k,AC 代码 8.53kb。

另外吐槽一下这道题的出题人是不是不太会打麻将( 明明有更好理解的向听定义然而搞了个和牌距离弄了我半天。

首先开一个 class 类是传统艺能。因为构造函数不知道干什么就占了个位,析构函数比较好说,我们可以存下几个信息,即 isWon 是否有人和牌,wonWay 表示和牌方式。另外存下一个值 ope,这个东西可以多用,既可以表示当前是谁出牌(不是摸牌的原因是因为鸣牌会跳过摸牌),也可以表示在结束之时谁赢了。

根据这个写一下函数。用析构函数的原因是当过程结束这个函数会自动执行。

    bool isWon=false;
    int wonWay=0;
    char gameEnding[3][10]={"","RON","SELFDRAWN"};
    MahjongGame(){}
    ~MahjongGame()
    {
        if(!isWon)  puts("DRAW");
        else    printf("%s %s\n%s WIN\n",playerName[ope],gameEnding[wonWay],playerName[ope]);
    }

捋一遍过程。首先我们需要将牌山读下来。我们需要写一个函数完成牌的名称与编号的转化。至于不需要写编号转化成牌的函数,因为我们可以很简单的用字符串数组完成这个过程。

    char brickName[40][10]=
    {
        "",
        "1M","2M","3M","4M","5M","6M","7M","8M","9M",
        "1P","2P","3P","4P","5P","6P","7P","8P","9P",
        "1S","2S","3S","4S","5S","6S","7S","8S","9S",
        "E","S","W","N","B","F","Z",
        "DOUBLE","REVERSE","PASS"
    };
    int nameTrans(char *s)
    {
        int len=int(strlen(s));
        if(len==1)
        {
            switch (s[0])
            {
                case 'E':   return 28;
                case 'S':   return 29;
                case 'W':   return 30;
                case 'N':   return 31;
                case 'B':   return 32;
                case 'F':   return 33;
                case 'Z':   return 34;
            }
        }
        else if(len==2)
        {
            int p=s[0]-'0';
            switch (s[1])
            {
                case 'M':   return p;
                case 'P':   return p+9;
                case 'S':   return p+18;
            }
        }
        else
        {
            switch(s[0])
            {
                case 'D':   return 35;
                case 'R':   return 36;
                case 'P':   return 37;
            }
        }
        return -1;
    }

考虑与牌有关的,需要保存下来的信息。无疑我们需要保存每个玩家的牌是什么,有多少张牌,每个玩家所拥有的每种牌的数量以及牌山。写一个读牌山的函数:

    void Initialization()
    {
        for(int i=1;i<=148;++i)
        {
            char s[10];
            scanf("%s",s);
            brickHill[i]=nameTrans(s);
        }
    }

然后我们需要发牌。在这里比较好处理的方法是一开始就只给每个人发 13 张牌。对于玩家 A14 张可以看成发了 13 张牌并且摸牌进行出牌操作。

同时我们需要写几个基本函数去辅助。比如调整当前某个玩家的牌(按顺序,实现就是一个 sort);得到牌以及扔掉牌,输出得到牌以及扔掉牌的信息(注意要包含 PASS 的特殊用法);下一个玩家是谁,上一个玩家是谁;调整可能出现错误的当前操作用户信息(比如 5 \to 1,0 \to 4)。定义两个变量 operevope 意义如上,rev 表示当前的顺序是什么:1 代表正序,-1 代表反序。两个变量初始值皆为 1

    void adjustBricks(int who){sort(brick[who]+1,brick[who]+1+brickCnt[who]);}
    void getBrick(int who,int wch)
    {
        ++brickApp[who][wch];
        ++brickCnt[who];
        brick[who][brickCnt[who]]=wch;
        adjustBricks(who);
    }
    void outBrick(int who,int wch)
    {
        --brickApp[who][wch];
        for(int i=1;i<=brickCnt[who];++i)
        {
            if(brick[who][i]==wch)
            {
                for(int j=i+1;j<=brickCnt[who];++j) brick[who][j-1]=brick[who][j];
                brick[who][brickCnt[who]]=0;
                --brickCnt[who];
                return ;
            }
        }
    }
    int fixOpe(int wch)
    {
        if(wch==5)  wch=1;
        if(wch==0)  wch=4;
        return wch;
    } 
    void outputGetBrick(int who,int wch){printf("%s IN %s\n",playerName[who],brickName[wch]);}
    void outputOutBrick(int who,int wch)
    {
        printf("%s OUT %s",playerName[who],brickName[wch]);
        if(wch==37) printf(" %s",playerName[fixOpe(who+rev)]);
        puts("");
    }
    void dealBrick()
    {
        for(int i=1;i<=52;++i)
        {
            int who=i%4;
            if(!who)    who=4;
            getBrick(who,brickHill[i]);
            outputGetBrick(who,brickHill[i]);
        }
    }
    int nxtPlayer(int who){return fixOpe(who+rev);}
    int lasPlayer(int who){return fixOpe(who-rev);}

其他的东西比较复杂。我们先考虑怎么写我们的主要执行函数 void execute()

首先肯定需要执行一次 Initialization()dealBrick() 函数。然后枚举当前的牌用到哪里了。用一个 cnt 存下来。初始值设成 53,因为前面的 52 张牌已经发出去了。

然后执行一次 getBrick(ope,brickHill[cnt])outputGetBrick(ope,brickHill[cnt]),表示发牌的过程。然后写一个函数 bool isTsumo(int who) 表示当前这个人手中的牌是否自摸了(因为涉及到听牌距离一概念暂且不谈,实际上判断是否自摸还有一个贪心做法),那么游戏结束,isWon 置为 true 并且 wonWay 置为 2,退出程序。否则需要写一个函数 void outBricker(int who),表示对该玩家进行一次出牌操作。然后将玩家置为下一个玩家,并且将 cnt 自增 1。根据思路写出代码。

    void execute()
    {
        Initialization();
        dealBrick();
        int cnt=53;
        while(cnt<=148)
        {
            getBrick(ope,brickHill[cnt]);
            outputGetBrick(ope,brickHill[cnt]);
            if(isTsumo(ope))
            {
                isWon=true;
                wonWay=2;
                exit(0);
            }
            outBricker(ope);
            ope=nxtPlayer(ope);
            ++cnt;
        }
    }

这里不把发牌放入 void outBricker(int) 函数中的原因是因为可能会出现鸣牌需要递归的情况,为了方便将摸牌操作拿出来,传参的原因也是因为能够方便递归。

然后考虑 void outBricker(int) 函数的实现。

根据题目所述,我们需要按 PASSREVERSEDOUBLE 的顺序去实现。根据 int nameTrans(char*) 内置的编号,分别为 37,36,35,以此判断即可。

写一个函数 int decideThrowBrick(int*) 表示对于当前牌,在题目的说明下进行选择丢弃哪一张牌。因为这个东西涉及到的东西更复杂在后面再说。

然后就对当前执行的玩家将决定扔出的牌扔掉。考虑荣和的过程,相当于得到一张牌并且判断是否自摸即可。注意顺序判断。

如果有人荣和,游戏立刻结束,将 ope 置为荣和的人的编号,isWon 置为 true 并且 wonWay 置为 1,退出程序自动执行析构函数。

否则我们需要判断有没有人能够碰。如果能够碰,那我们就将当前的需要出牌的人置为这个人,然后再递归执行 outBricker 函数即可。

吃同理。不再赘述。根据思路写下代码。

    void outBricker(int who)
    {
        if(brickApp[who][37])
        {
            outBrick(who,37);
            outputOutBrick(who,37);
            ope=nxtPlayer(ope);
            return ;
        }
        if(brickApp[who][36])
        {
            outBrick(who,36);
            outputOutBrick(who,36);
            rev=-rev;
            return ;
        }
        if(brickApp[who][35])
        {
            outBrick(who,35);
            outputOutBrick(who,35);
            ope=lasPlayer(ope);
            return ;
        }
        int outBrk=decideThrowBrick(brick[who]);
        outBrick(who,outBrk);
        outputOutBrick(who,outBrk);
        for(int i=nxtPlayer(ope);i!=ope;i=nxtPlayer(i))
        {
            getBrick(i,outBrk);
            if(isTsumo(i))
            {
                ope=i;
                isWon=true;
                wonWay=1;
                exit(0);
            }
            outBrick(i,outBrk);
        }
        for(int i=nxtPlayer(ope);i!=ope;i=nxtPlayer(i))
        {
            if(allowPong(i,outBrk))
            {
                ope=i;
                Pong(i,outBrk);
                outBricker(i);
                return ;
            }
        }
        int i=nxtPlayer(ope),type=allowChow(i,outBrk);
        if(allowChow(i,outBrk))
        {
            ope=i;
            Chow(i,outBrk,type);
            outBricker(i);
        }
    }

于是我们成功的给自己挖了很多坑。按吃和碰又分别讲解。

首先我们要写一个计算和牌距离的函数以及需要扔掉哪张牌的函数,分别定义为 int calcXt(int*)int decideThrowBrick(int*)。因为这个东西是最最复杂的所以又延后说。

注意,这里的和牌距离不等同于向听。如果当前并不是你出牌的回合,和牌距离是向听加一;否则就是向听数。听牌即为 0 向听。害死我这个日麻玩家了(

假设这两个函数能够返回正确的值。我们需要实现判断是否能吃/碰的函数。

先说碰 bool allowPong(int who,int brk)。首先如果这个人没有两张牌 brk,那么肯定是不可以的。计算一下当前的和牌距离(即调用 calcXt(brick[who])),然后扔出去两张再算一下和牌距离。如果扔出去之后的听牌距离严格小于(即,向听数小于等于)之前的和牌距离,这个碰的动作就是允许的;否则禁止。注意将牌放回来。

输出碰这个动作(void Pong(int who,int wch))就比较 naive。直接来就行了。注意在这个函数中要执行副露的过程。

    bool allowPong(int who,int brk)
    {
        if(brickApp[who][brk]<2)    return false;
        int nowXt=calcXt(brick[who]);
        outBrick(who,brk);
        outBrick(who,brk);
        int presentXt=calcXt(brick[who]);
        getBrick(who,brk);
        getBrick(who,brk);
        if(presentXt<nowXt) return true;
        return false;
    }
    void Pong(int who,int wch)
    {
        printf("%s PONG %s %s %s\n",playerName[who],brickName[wch],brickName[wch],brickName[wch]);
        outBrick(who,wch);
        outBrick(who,wch);
    }

然后是吃。因为吃有三种可能分别判断一下和牌距离即可。注意不要字牌吃字牌,万吃索这样的情况出现(预防就可以直接看有没有,会不会出去等的判断即可)。然后取最小值判断是否比之前的和牌距离小即可。做法一样。

注意的是方案具有优先级。这个时候返回一个方案即可。与执行吃这个操作的函数一起用就行了。

    int allowChow(int who,int wch)
    {
        if(wch>27)  return false;
        int nowXt=calcXt(brick[who]);
        int Xts[4];
        memset(Xts,63,sizeof Xts);
        if(wch!=8 && wch!=9 && wch!=17 && wch!=18 && wch!=26 && wch!=27 && brickApp[who][wch+1] && brickApp[who][wch+2])
        {
            outBrick(who,wch+1);
            outBrick(who,wch+2);
            Xts[3]=calcXt(brick[who]);
            getBrick(who,wch+1);
            getBrick(who,wch+2);
        }
        if(wch!=9 && wch!=1 && wch!=18 && wch!=10 && wch!=27 && wch!=19 && brickApp[who][wch+1] && brickApp[who][wch-1])
        {
            outBrick(who,wch-1);
            outBrick(who,wch+1);
            Xts[2]=calcXt(brick[who]);
            getBrick(who,wch-1);
            getBrick(who,wch+1);
        }
        if(wch!=1 && wch!=2 && wch!=10 && wch!=11 && wch!=19 && wch!=20 && brickApp[who][wch-1] && brickApp[who][wch-2])
        {
            outBrick(who,wch-2);
            outBrick(who,wch-1);
            Xts[1]=calcXt(brick[who]);
            getBrick(who,wch-2);
            getBrick(who,wch-1);
        }
        int minn=min({Xts[1],Xts[2],Xts[3]});
        if(minn>=nowXt) return 0;
        if(minn==Xts[1] && minn==Xts[2] && minn==Xts[3])    return 3;
        if(minn==Xts[1] && minn==Xts[2] && minn!=Xts[3])    return 2;
        if(minn==Xts[1] && minn!=Xts[2] && minn==Xts[3])    return 3;
        if(minn!=Xts[1] && minn==Xts[2] && minn==Xts[3])    return 3;
        if(minn!=Xts[1] && minn!=Xts[2] && minn==Xts[3])    return 3;
        if(minn!=Xts[1] && minn==Xts[2] && minn!=Xts[3])    return 2;
        if(minn==Xts[1] && minn!=Xts[2] && minn!=Xts[3])    return 1;
        return -1;
    }
    void Chow(int who,int brk,int type)
    {
        if(type==3)
        {
            printf("%s CHOW %s %s %s\n",playerName[who],brickName[brk],brickName[brk+1],brickName[brk+2]);
            outBrick(who,brk+1);
            outBrick(who,brk+2);
            return ;
        }
        if(type==2)
        {
            printf("%s CHOW %s %s %s\n",playerName[who],brickName[brk-1],brickName[brk],brickName[brk+1]);
            outBrick(who,brk-1);
            outBrick(who,brk+1);
            return ;
        }
        if(type==1)
        {
            printf("%s CHOW %s %s %s\n",playerName[who],brickName[brk-2],brickName[brk-1],brickName[brk]);
            outBrick(who,brk-2);
            outBrick(who,brk-1);
            return ;
        }
    }

最后我们还有两个函数没有实现,即 int calcXt(int*)int decideThrowBrick(int*)。暴力判断(一般型最大向听数为 7,对应和牌距离应该是 8,即需要用 34^8 次去判断向听与扔走哪张牌)肯定是不行的。

于是定义 dp_{i,j,k=0 \operatorname{or} 1,l,o} 表示当前用到了第 i 张牌,已经组了 j 个面子,组了 k 个对子(一定要有对子),现在还存在 l 个单张以及有 o 个搭子快成为顺子,的和牌距离。

转移的过程和大多数麻将题目一样枚举下一张牌使用什么。至于实现出牌可以用另外一个数组保存。

显然我们必须将 l 个搭子补全并新加上一些单张。分情况讨论:

注意字牌的特殊情况。此时不存在补全顺子一说。

然后暴力枚举就行了。因为这样状态数还是比较多,因为最大和牌距离小于等于 8,所以枚举到 8 即可。

于是暴力转移即可。保存该扔掉哪张牌直接放到状态转移即可。

为了方便,我们再开一个函数,int calcXt(int*)int decideThrowBrick(int*) 就只剩下传参区别了。

    #define mp make_pair
    int dpExecuter(int *brk,int type)
    {
        int len=0,mz;
        while(brk[len+1])   ++len;
        mz=len/3;
        sort(brk+1,brk+1+len);
        int dp[40][5][2][3][3],brkApp[40],usf[40][5][2][3][3];
        memset(brkApp,0,sizeof brkApp);
        for(int i=1;i<=len;++i) ++brkApp[brk[i]];
        memset(dp,63,sizeof dp);
        memset(usf,-1,sizeof usf);
        dp[0][0][0][0][0]=0;
        for(int i=0;i<=33;++i)
        {
            for(int j=0;j<=mz;++j)
            {
                for(int k=0;k<=1;++k)
                {
                    for(int l=0;l<=2 && l+j<=mz;++l)
                    {
                        for(int o=0;o<=2 && o+l+j<=mz;++o)
                        {
                            if(dp[i][j][k][l][o]<=8)
                            {
                                for(int nxtUse=l+o;nxtUse<=4;++nxtUse)
                                {
                                    int dx=dp[i][j][k][l][o]+max(0,nxtUse-brkApp[i+1]),dy=(brkApp[i+1]>nxtUse?i+1:usf[i][j][k][l][o]);
                                    pair<int,int> mvdStatement=mp(dx,dy);
                                    int rest=nxtUse-l-o;
                                    if(rest>=3 && j+l+1<=mz)
                                    {
                                        pair<int,int> Statement=lesserPair(mp(dp[i+1][j+l+1][k][o][rest-3],usf[i+1][j+l+1][k][o][rest-3]),mvdStatement);
                                        dp[i+1][j+l+1][k][o][rest-3]=Statement.first,usf[i+1][j+l+1][k][o][rest-3]=Statement.second;
                                    }
                                    if(rest>=2 && !k)
                                    {
                                        pair<int,int> Statement=lesserPair(mp(dp[i+1][j+l][k+1][o][rest-2],usf[i+1][j+l][k+1][o][rest-2]),mvdStatement);
                                        dp[i+1][j+l][k+1][o][rest-2]=Statement.first,usf[i+1][j+l][k+1][o][rest-2]=Statement.second;
                                    }
                                    if(rest<=2)
                                    {
                                        pair<int,int> Statement=lesserPair(mp(dp[i+1][j+l][k][o][rest],usf[i+1][j+l][k][o][rest]),mvdStatement);
                                        dp[i+1][j+l][k][o][rest]=Statement.first,usf[i+1][j+l][k][o][rest]=Statement.second;
                                    }
                                }
                                if(i%9==0 || i>=27) break;
                            }
                        }
                        if(i%9==0 || i>=27) break;
                    }
                }
            }
        }
        if(type==1) return dp[34][mz][1][0][0];
        return usf[34][mz][1][0][0];
    }
    #undef mp
    int calcXt(int *brk){return dpExecuter(brk,1);}
    int decideThrowBrick(int *brk){return dpExecuter(brk,2);}

将这个类取名 MahjongGame,定义一个 MahjongGame 类的变量 MainGame。主函数只需要执行 MainGame.execute() 即可。

至此,我们不算太过条理清晰地解决了这个问题。

因为所有的模块都放过一遍了,所以完整代码扔 Luogu 云剪贴板吧。