「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 的特殊用法);下一个玩家是谁,上一个玩家是谁;调整可能出现错误的当前操作用户信息(比如 51,04)。定义两个变量 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,以此判断即可。

  • 对于 PASS,我们将当前执行的玩家置为下一个玩家,在返回到 execute() 函数中就会跳过应该被跳过的玩家;
  • 对于 REVERSE,我们将 rev 取反即可;
  • 对于 DOUBLE,我们将当前执行的玩家置为上一个玩家,在返回到 execute() 函数中就会变成该玩家。

写一个函数 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,即需要用 348 次去判断向听与扔走哪张牌)肯定是不行的。

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

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

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

  • 补全完 l 个搭子之后剩下三张能够组成一个新刻子并且组完之后面子数还不超过需要的面子数;
  • 补全完 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 云剪贴板吧。

posted @   SyadouHayami  阅读(653)  评论(1编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现

My Castle Town.

点击右上角即可分享
微信分享提示