题解:P7147 [THUPC2021 初赛] 麻将模拟器
fush
·
·
题解
更好的阅读体验。
前言
麻将模拟器
调了一早上结果发现 DP 中的 j 写成 i 了。
题目中的和牌距离基本和向听数差不多。
文中的面子指刻子和顺子的统称。
基础操作
定义
#define endl '\n'
#define FL(a, b, c) for(int a = (b), a##end = (c); a <= a##end; a++)
#define FR(a, b, c) for(int a = (b), a##end = (c); a >= a##end; a--)
#define pc putchar
struct player{
int a[20], len;
bool pass;
}p[4];
int cd[200], top, turn = 1;
我们用一个 player
结构体存一个玩家的信息,分别是玩家手中的牌,牌的数量,是否被 pass。
cd
用来存牌堆,top
表示最上面的牌,turn
是回合顺序。
牌面存储
因为直接存储牌是字符,并不利于判断优先级。
所以我们在输入的时候先转化成优先级,输出时再换回字符。
//main内
FR(i, top = 148, 1){
string s;
cin >> s;
//转化
if(isdigit(s[0]))
switch(cd[i] = s[0] ^ 48, s[1]){
case 'S':cd[i] += 9;
case 'P':cd[i] += 9;
}
else switch(cd[i] = 28, s[0]){
case 'P':cd[i]++;
case 'R':cd[i]++;
case 'D':cd[i]++;
case 'Z':cd[i]++;
case 'F':cd[i]++;
case 'B':cd[i]++;
case 'N':cd[i]++;
case 'W':cd[i]++;
case 'S':cd[i]++;
}
}
//优先级->字符
void outcd(int x){
if(x <= 27)cout << (x - 1) % 9 + 1 << ("MPS"[(x - 1) / 9]);
else if(x <= 34)cout << ("ESWNBFZ"[x - 28]);
else if(x == 35) cout << "DOUBLE";
else if(x == 36) cout << "REVERSE";
else cout << "PASS";
}
输出操作
#define in(x, y)pc(x + 'A'), cout << " IN ", outcd(y), pc(endl)
#define nxt(x) (((x) + turn + 4) % 4)
#define out(x, y) pc(x + 'A'), cout << " OUT ", outcd(y), (y == 37) && (pc(' '), pc(nxt(x) + 'A')), pc(endl)
#define outpeng(x, y) pc(x + 'A'), cout << " PONG ", outcd(y), pc(' '), outcd(y), pc(' '), outcd(y), pc(endl)
#define outchi(x, y) pc(x + 'A'), cout << " CHOW ", outcd(y), pc(' '), outcd(y + 1), pc(' '), outcd(y + 2), pc(endl)
void GameOver(int x, int y){
if(x == -1)cout << "DRAW", exit(0);
pc(x + 'A'), cout << (y ? " RON" : " SELFDRAWN") << endl;
pc(x + 'A'), cout << " WIN", exit(0);
}
这部分就没什么好说的,注意 pass 的输出特判。
摸牌和删牌
可以将每个人手中的牌按优先级维护,好判断特殊牌。
//摸牌
void mopai(int x){
if(!top)GameOver(-1, 0);
int *a = p[x].a, z = upper_bound(a + 1, a + p[x].len + 1, cd[top]) - a;
FR(i, p[x].len, z)a[i + 1] = a[i];
a[z] = cd[top--], p[x].len++, in(x, a[z]);
}
//出牌
void delpai(int x, int y){
int *a = p[x].a, z = lower_bound(a + 1, a + 1 + p[x].len, y) - a;
FL(i, z, --p[x].len)a[i] = a[i + 1];
}
和牌距离
我们来看最难的和牌距离部分。
我们定义:
$zy_{i,j,k,l,g}$ 表示此时要丢弃的优先级最高牌。
$nx$ 表示现在能转移的状态的和牌距离,$ny$ 表示需要丢弃的牌。
我们再枚举一个 $q$ 表示需要 $q$ 张第 $i$ 种牌,$r$ 表示除顺子后剩下的。
如果我们剩余的牌能凑出刻子,那么我们就可以转移到 $i+1,j+l+1,k,g,r-3$。
如果能凑出对子,可以转移到 $i+1,j+l,k,g,r-2$。
我们也可以开启新的顺子,可以转移到 $i+1,j+l,k,g,r$。
转移($dp',zy'$ 表示新状态):
如果 $nx < dp'$,那么 $dp' \gets nx, zy' \gets ny$。
如果 $nx = dp',ny > zy'$,那么 $zy' \gets ny$。
具体细节可以看代码。
```c++
int sl[40], dp[40][5][2][3][3], zy[40][5][2][3][3];//sl 表示每种牌的数量
#define zhuanyi(i, j, k, l, g) if(nx < dp[i][j][k][l][g] || (nx == dp[i][j][k][l][g] && ny > zy[i][j][k][l][g])) dp[i][j][k][l][g] = nx, zy[i][j][k][l][g] = ny
pair<int, int> JuLi(int x){//求和牌距离与要弃的牌,x 表示最后有几个面子
memset(dp, 0x3f, sizeof(dp)), memset(zy,-1,sizeof(zy));
dp[0][0][0][0][0] = 0;
FL(i, 0, 34) FL(j, 0, x) FL(k, 0, 1) //前第 i 种牌,有 j 个面子,k 个对子
FL(l, 0, min(2, x - j)){ //从第 i - 1 种牌开始的 l 个顺子
FL(g, 0, min(2, x - l - j)) //从第 i 种牌开始的顺子
if(dp[i][j][k][l][g] <= 14){//状态合法
FL(q, l + g, 4){
int nx = dp[i][j][k][l][g] + max(0, q - sl[i + 1]); //新的和牌距离
int ny = (sl[i + 1] > q ? i + 1 : zy[i][j][k][l][g]);//要打出的牌
int r = q - l - g; //剩了几个第 i 种牌
//可以凑出刻子
if(r >= 3 && j + l + 1 <= x)zhuanyi(i + 1, j + l + 1, k, g, r - 3);
//可以凑出对子
if(r >= 2 && !k)zhuanyi(i + 1, j + l, 1, g, r - 2);
//开启新的顺子
if(r <= 2)zhuanyi(i + 1, j + l, k, g, r);
}
//字牌不能组成顺子
if(i % 9 == 0 || i >= 27) break;
}
if(i % 9 == 0 || i >= 27) break;
}
return make_pair(dp[34][x][1][0][0], zy[34][x][1][0][0]);
}
pair<int, int> check(int x){//统计除了特殊牌的其他牌面
memset(sl, 0, sizeof(sl));
int *a = p[x].a, &len = p[x].len;
FL(i, 1, len) if(a[i] <= 34) ++sl[a[i]];
return JuLi(4 - (14 - len) / 3);
}
```
## 游戏部分
### 框架部分
```c++
//main内部
FL(i, 1, 13)FL(j, 0, 3)mopai(j);
int now = 0;
while(1){
if(p[now].pass)p[now].pass = 0, now = nxt(now);
else mopai(now), now = chupai(now);
}
```
### 出牌阶段
```c++
bool TeShuPai(int x){//没有特殊牌才可能和
FL(i, 1, p[x].len)if(p[x].a[i] >= 34)return 0;
return 1;
}
int chupai(int x){
int *a = p[x].a, &len = p[x].len;
//有特殊牌直接打
if(a[len] == 37)return --len, p[nxt(x)].pass = 1, out(x, 37), nxt(x);
if(a[len] == 36)return --len, turn *= -1, out(x, 36), nxt(x);
if(a[len] == 35)return --len, out(x, 35), x;
int chu = check(x).second; //要打出的牌
if(chu == -1)GameOver(x, 0);//自摸
delpai(x, chu), out(x, chu);
//先判断荣和
for(int i = nxt(x); i != x; i = nxt(i))
if(TeShuPai(i)){
p[i].a[++p[i].len] = chu;
if(check(i).second == -1)GameOver(i, 1);
p[i].len--;
}
//然后是碰
int z = Peng(chu);
if(z != -1)return chupai(z);
//吃
if(Chi(nxt(x), chu))return chupai(nxt(x));
return nxt(x);
}
```
特殊牌的优先级最高,有就直接打。
否则就看一下是不是自摸,不然就打出去。
荣和的比碰、吃都在前面,但是有截和,注意判断顺序。
而吃碰后不用摸牌,直接跳到出牌阶段。
### 碰
碰就比吃的可能少多了,注意要和牌距离要严格变小才会碰。
```c++
int Peng(int y){
FL(i, 0, 3){
int z = check(i).first;//原本的和牌距离
if((sl[y] -= 2) >= 0) //至少要两张
if(z > JuLi(3 - (14 - p[i].len) / 3).first)//和牌距离严格变小
return outpeng(i, y), delpai(i, y), delpai(i, y), i;
}
return -1;
}
```
### 吃
只有下家能吃,但有三种可能。
```c++
bool Chi(int x, int y){//判断吃,注意判断顺序,优先级越大越好。
if(y > 27)return 0;
int z = check(x).first;
if((y - 1) % 9 <= 6 && sl[y + 1] && sl[y + 2]){
sl[y + 1]--, sl[y + 2]--;
if(z > JuLi(3 - (14 - p[x].len) / 3).first)
return outchi(x, y), delpai(x, y + 1), delpai(x, y + 2), 1;
++sl[y + 1], ++sl[y + 2];
}
if(y % 9 > 1 && y % 9 != 0 && sl[y - 1] && sl[y + 1]){
sl[y - 1]--, sl[y + 1]--;
if(z > JuLi(3 - (14 - p[x].len) / 3).first)
return outchi(x, y - 1), delpai(x, y - 1), delpai(x, y + 1), 1;
++sl[y - 1], ++sl[y + 1];
}
if((y - 1) % 9 >= 2 && sl[y - 1] && sl[y - 2]){
sl[y - 1]--, sl[y - 2]--;
if(z > JuLi(3 - (14 - p[x].len) / 3).first)
return outchi(x, y - 2), delpai(x, y - 1), delpai(x, y - 2), 1;
++sl[y - 1], ++sl[y - 2];
}
return 0;
}
```
## 代码
**大功告成!**
[代码(约5KB)](https://www.luogu.com.cn/paste/wlbbbu1p)