1. 程式人生 > 實用技巧 >「THUPC2021 初賽」麻將模擬器 題解

「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\) 張牌。對於玩家 A\(14\) 張可以看成發了 \(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\),以此判斷即可。

  • 對於 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\),即需要用 \(34^8\) 次去判斷向聽與扔走哪張牌)肯定是不行的。

於是定義 \(dp_{i,j,k=0 \operatorname{or} 1,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 雲剪貼簿吧。