Programming Challenges 习题 2.8.8

PC/UVa:110208/10149

Yahtzee

UVa上一道难度为3的DP题目。简单来翻译一下题目:

Yahtzee游戏包含5个骰子,掷13轮,13种不同的计分方式,分别为:

  • 计一:分数为所有点数为1的骰子值
  • 计二:分数为所有点数为2的骰子值
  • 计三:分数为所有点数为3的骰子值
  • 计四:分数为所有点数为4的骰子值
  • 计五:分数为所有点数为5的骰子值
  • 计六:分数为所有点数为6的骰子值
  • 机会:分数为所有骰子点数和
  • 三同:如果有3个或者以上骰子值相同,分数为所有骰子和
  • 四同:如果有4个或者以上骰子值相同,分数为所有骰子和
  • 五同:如果5个骰子值相同,分数为50
  • 小顺:如果4个骰子的值组成顺子,分数为25分
  • 大顺:如果5个骰子的值组成顺子,分数为35分
  • 葫芦:有3个骰子值相同,且另外2个骰子值也相同,分数为50分

游戏规则如下:

  • 每种计分方式只能使用一次
  • 如果投掷的结果不满足最后六种描述的情况,分数为0
  • 如果前6种计分方式(计一~计六)的分数和大于等于63,则在总分上加上35分奖励分

程序要做的事情是根据一次游戏的13个投掷结果,求出最大分数、每种计分方式分值、是否有奖励分。

第一反应肯定是深搜啊,但是深搜需要枚举的情况有13!种。如果不把13种计分方式都选择一次,也就是不搜到树的最底层,是没办法算出这种计分策略的总分的,所以也就没有任何可以剪枝的方法。我也没写深搜的,估计肯定没法AC。

那肯定就是动态规划。先不考虑奖励分的问题。

既然要动态规划,那么最本质的问题就是该如何从已有状态,根据此次掷的结果,计算新的状态,关键在于这个状态应该是什么。

动态规划中一个最主要的性质就是无后效性,为了满足这个性质,其实要考虑的就是能从当前的输入算出些什么来。对于这道题,一共掷了13次,假如现在只有6次的结果,那么可以算出来这6次的最大值(例如用枚举的方法,也就是从13个中选出6个进行全排列,枚举13! / 7!种情况是能算出来的,但是不一定要这么算)。在计算最大值的过程中,是可以算出每种计分策略(一种全排列)的分值的,这也就是在动态规划中需要用空间换时间,通过查表减少重复计算的思想。

当投掷了第7次的时候,就要算7次的最大值。对于第7次的分值,需要从13个计分项中选一个。如果选择计一的方式,那么前6个计分策略就不能使用计一的方式,而刚才已经把6次的所有计分策略都存下来了,直接查表,看看哪些计分策略中没有使用计一的方式,并在这些计分策略中选择一个分值最大的,加上第7次结果使用计一方式所得的分值,就能根据之前的结果算出来本次的结果。因为只需要计分策略中分值最大的那一个,所以表的大小就从13! / 7!变成了(13! / 7!) / 6!

通过上一段的描述,已经可以看出,解决问题的思路是需要对第7次的结果枚举每一种计分方式,然后和前6种情况进行组合,以算出这一次输入之后的最大值。抽象成程序,就是对当前的输入i,枚举每一种计分方式method,和已经在表中的combinaiton策略进行组合,算出此次的最大值;也可以抽象为,对于当前的输入i,枚举已有的计分策略combination,对于combination中没有使用的计分方式method,当前输入使用method计分方式,计算总体的最大值(代码是按照后面的方式写的)。

combination中,如果该比特位为1,表示已经使用了这种计分策略,为0表示没有使用这种策略。动态规划的题目一般只求最值,如果需要求策略,需要根据最后的结果倒推回去。

下面的calMax1(),是最原始的版本。

void calMax1(const vector<vector<int>> &vviScore)
{
	/*
	vmipii中的每一个元素是一个映射
	键为计分组合情况
	值为一个二元组,表示此种计分组合情况下分数的最大值和选择的计分方法
	这样此轮取得的最值可以由上一轮迭代
	*/
	int method = 0, combination, score;
	vector<map<int, pair<int, int>>> vmipii(13, map<int, pair<int, int>>());
	//第一次单独计算
	for (method = 0; method < METHODS; method++)
	{
		vmipii[0].insert(pair<int, pair<int, int>>(1 << method, pair<int, int>(vviScore[0][method], method)));
	}
	//对当前的投掷结果
	for (int i = 1; i < ROUNDS; i++)
	{
		//mipiiPrev表示上一轮过后的计分策略
		const map<int, pair<int, int>> &mipiiPrev = vmipii[i - 1];
		//mipiiCurr表示本轮过后的计分策略
		map<int, pair<int, int>> &mipiiCurr = vmipii[i];
		bitset<METHODS> bits;
		//对于所有上一轮的计分策略
		for (auto iter = mipiiPrev.begin(); iter != mipiiPrev.end(); iter++)
		{
			bits = iter->first;
			//枚举每一种没有使用过的计分方式
			for (method = 0; method < METHODS; method++)
			{
				if (!bits.test(method)){
					bits.set(method);
					combination = bits.to_ulong();
					score = mipiiPrev.at(iter->first).first + vviScore[i][method];
					if (mipiiCurr.find(combination) == mipiiCurr.end()
						|| score > mipiiCurr.at(combination).first){
						mipiiCurr[combination].first = score;
						mipiiCurr[combination].second = method;
					}
					bits.reset(method);
				}
			}
		}
	}
	cout << vmipii[12].at(MAX_COMBINATION - 1).first << endl;
}

上边的代码可以算出来结果,但是太慢了,因为在map中查找整数,杀鸡用了宰牛刀,所以改成了calMax2()

void calMax2(const vector<vector<int>> &vviScore)
{
	/*
	对于整数来说,使用map进行索引会耗时,不如数组直接存取方便
	*/
	bitset<METHODS> bits;
	int score = 0, combination = 0, method;
	//第三维只有两个值,用来替换之前的pair
	//[0]表示分值,[1]表示次轮选择的计分方式
	short sScoreLast[ROUNDS][MAX_COMBINATION][2];
	memset(sScoreLast, -1, sizeof(sScoreLast));
	for (method = 0; method < METHODS; method++)
	{
		bits.set(method);
		combination = bits.to_ulong();
		sScoreLast[0][combination][0] = vviScore[0][method];
		sScoreLast[0][combination][1] = method;
		bits.reset(method);
	}
	for (int i = 1; i < ROUNDS; i++)
	{
		//枚举已经使用过的计分组合,-1表示未使用过这种组合
		for (int k = 0; k < MAX_COMBINATION; k++)
		{
			if (sScoreLast[i - 1][k][0] != -1){
				bits = k;
				for (method = 0; method < 13; method++)
				{
					if (!bits.test(method)){//method方法未使用过
						bits.set(method);
						combination = bits.to_ulong();
						score = sScoreLast[i - 1][k][0] + vviScore[i][method];
						if (score >= sScoreLast[i][combination][0]){
							sScoreLast[i][combination][0] = score;
							sScoreLast[i][combination][1] = method;
						}
						bits.reset(method);
					}
					/*
					method方法已经使用过
					例如当前已使用的方法包括0,2,3,此时method为2,那么已使用的2可以被替换为1,4~12
					如果2被替换为1,那么情况等同于已使用的方法包括0,1,3,新的method为2,在前面已经处理
					如果2被替换为4,那么情况等同于已使用的方法包括0,3,4,新的method为2,在后面会处理
					*/
				}
			}
		}
	}
	vector<int> viMethod(METHODS, 0);
	combination = MAX_COMBINATION - 1;
	for (int i = ROUNDS; i > 0; i--)
	{
		method = sScoreLast[i - 1][combination][1];
		viMethod[method] = vviScore[i - 1][method];
		combination &= ~(1 << method);
	}
	for_each(viMethod.begin(), viMethod.end(), [](const int score){cout << score << ' '; });
	cout << sScoreLast[ROUNDS - 1][MAX_COMBINATION - 1][0] << endl;
}

calMax2()中的sScoreLast空间有些浪费,因为计分策略的范围在0 0000 0000 0000b1 1111 1111 1111b,并且其中1的数目已经可以反映出第一维轮数的信息了,申请了13 * 8192大小的空间,其中有12 * 8192一直都是-1,所以又优化成了calMax3(),此时表的大小才真正变为(13! / 7!) / 6!

void calMax3(const vector<vector<int>> &vviScore)
{
	/*
	在calMax2中,申请特别大的空间其实是浪费的
	第一维表示当前投掷的次数,第二维表示当前采用的计分方式
	计分方式中1的数量其实是和第一维相同的
	所以在13 * 8192大小的数组中,只有8191个值不为-1
	虽然在calMax3中节省了空间,但是运行时间会长一些
	*/
	bitset<13> bits;
	short sScoreLast[MAX_COMBINATION][2];
	memset(sScoreLast, -1, sizeof(sScoreLast));
	int combination, method, score;
	for (method = 0; method < METHODS; method++)
	{
		combination = 1 << method;
		sScoreLast[combination][0] = vviScore[0][method];
		sScoreLast[combination][1] = method;
	}
	//对于每一次新的投掷结果
	for (int i = 1; i < ROUNDS; i++)
	{
		//根据已选择的计分策略进行扩展
		for (int j = 0; j < MAX_COMBINATION; j++)
		{
			bits = j;
			if (bits.count() == i){
				for (method = 0; method < METHODS; method++)
				{
					if (!bits.test(method)){
						bits.set(method);
						combination = bits.to_ulong();
						score = sScoreLast[j][0] + vviScore[i][method];
						if (score >= sScoreLast[combination][0]){
							sScoreLast[combination][0] = score;
							sScoreLast[combination][1] = method;
						}
						bits.reset(method);
					}
				}
			}
		}
	}
	vector<int> viMethod(METHODS, 0);
	combination = MAX_COMBINATION - 1;
	for (int i = ROUNDS; i > 0; i--)
	{
		method = sScoreLast[combination][1];
		viMethod[method] = vviScore[i - 1][method];
		combination &= ~(1 << method);
	}
	for_each(viMethod.begin(), viMethod.end(), [](const int score){cout << score << ' '; });
	cout << sScoreLast[MAX_COMBINATION - 1][0] << endl;
}

在不考虑奖励分的情况下,calMax3()可以算出最大值,以及每种计分方式的分值,并且时间还挺快。下面考虑奖励分的问题。

在考虑奖励分的情况下,刚才叙述的解题方式已经不满足动态规划的无后效性了,因为6次的结果最大,并不能保证计一到计六的分值和最大,比如下面的例子:

策略1策略2
投掷结果计五三同
2 5 5 5 61523
2 5 5 6 6100

根据不考虑奖励分时的计分策略,选择23 + 10 = 33使得总分达到最大,交换上述两种计分策略后,总分有17分的差距。虽然总分降低了,但是计五方式的得分变高了,很有可能使得前6个计分项超过了63分,从而拿到35分的奖励分,这样一来总分又多了18分。

因此除了计分策略外,前6个计分项的和也会影响后边的结果。只有把前6项的和也记录下来,最后才可以判断是否有奖励分。之前表的大小为(13! / 7!) / 6!,而在每一种组合中,前6个计分项的和取值范围是[0, 126],所以表的大小应该为((13! / 7!) / 6!) * 127sScoreLast由二维变成了三维,第一维表示计分策略,第二维表示前6个计分项的和,第三维的[0]表示此时的最大分值,[1]表示此轮选取的计分方式。

#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <algorithm>
#include <bitset>
#include <cstring>
#include <map>

#define DICES 5
#define MAX_COMBINATION (1 << 13)
#define ROUNDS 13
#define METHODS 13
#define SUM_SIX (1 + ((1 + 2 + 3 + 4 + 5 + 6) * 6))

using namespace std;

short sScoreLast[MAX_COMBINATION][SUM_SIX][2];

int score(const vector<int> &vi, int i)
{
	int sum = 0;
	for (int num : vi)
	{
		if (num == i) sum += i;
	}
	return sum;
}

int all(const vector<int> &vi)
{
	int sum = 0;
	for (int num : vi)
	{
		sum += num;
	}
	return sum;
}

int same(const vector<int> &vi, int iSame)
{
	vector<int> viCnt(6, 0);
	int sum = 0;
	for (int num : vi)
	{
		viCnt[num - 1]++;
		sum += num;
	}
	for (auto cnt : viCnt)
	{
		if (cnt >= iSame){
			if (iSame == 5) return 50;
			else if(iSame == 4) return sum;
			else if (iSame == 3) return sum;
		}
	}
	return 0;
}

int straight(const vector<int> &vi, int len)
{
	vector<int> viCnt(6, 0);
	for (int num : vi)
	{
		viCnt[num - 1]++;
	}
	bool bStraight;
	for (int i = 0; i <= 6 - len; i++)
	{
		bStraight = true;
		for (int j = i; j < i + len; j++)
		{
			if (viCnt[j] == 0){
				bStraight = false;
				break;
			}
		}
		if (bStraight) break;
	}
	if (bStraight){
		if (len == 4) return 25;
		else if (len == 5) return 35;
	}
	return 0;
}

int threetwo(const vector<int> &vi)
{
	vector<int> viCnt(6, 0);
	bool bThree = false, bTwo = false, bFive = false;
	for (int num : vi)
	{
		viCnt[num - 1]++;
	}
	for (auto cnt : viCnt)
	{
		if (cnt == 2) bTwo = true;
		else if (cnt == 3) bThree = true;
		else if (cnt == 5) bFive = true;
	}
	if (bTwo && bThree) return 40;
	else if (bFive) return 40;
	else return 0;
}

int getScore(const vector<int> &vi, int type)
{
	switch (type)
	{
	case 1:
	case 2:
	case 3:
	case 4:
	case 5:
	case 6:
		return score(vi, type);
	case 7:
		return all(vi);
	case 8:
	case 9:
	case 10:
		return same(vi, type - 5);
	case 11:
	case 12:
		return straight(vi, type - 7);
	case 13:
		return threetwo(vi);
		break;
	default:
		return 0;
	}
}

void calMax4(const vector<vector<int>> &vviScore)
{
	/*
	现在考虑奖励分的问题,当前6个计分项的和达到63时,会有35分的额外奖励分
	奖励分的存在使得计分策略的选择变得有后效性
	假如存在两次投掷的结果,分别为 
	投掷结果 \ 计分策略	计五	三同
	2 5 5 5 6			15		23
	2 5 5 6 6			10		0
	按照不考虑奖励分时的计分策略,选择23 + 10 = 33使得总分达到最大
	交换上述两种计分策略使得总分有17分的差距
	虽然总分降低了,但是计五方式的得分变高了
	就很有可能导致前6个计分项超过了63分,从而拿到35分的奖励分
	这样总分又多了18分,因此前6个计分项的和也应该作为状态的一部分
	表示采用combination计分策略、且前6项分值为bounces
	*/
	bitset<13> bits;
	//short sScoreLast[MAX_COMBINATION][SUM_SIX][2];
	memset(sScoreLast, -1, sizeof(sScoreLast));
	int combination, method, score, sum, max = 0, bonus = 0;
	for (method = 0; method < METHODS; method++)
	{
		combination = 1 << method;
		score = vviScore[0][method];
		sum = method < 6 ? score : 0;
		sScoreLast[combination][sum][0] = score;
		sScoreLast[combination][sum][1] = method;
	}
	//对于每一次新的投掷结果
	for (int i = 1; i < ROUNDS; i++)
	{
		//根据已选择的计分策略进行扩展
		for (int j = 0; j < MAX_COMBINATION; j++)
		{
			bits = j;
			if (bits.count() == i){
				//枚举这种计分策略下所有可能的奖励分
				for (int k = 0; k < SUM_SIX; k++)
				{
					if (sScoreLast[j][k][0] != -1){
						for (method = 0; method < METHODS; method++)
						{
							if (!bits.test(method)){
								bits.set(method);
								combination = bits.to_ulong();
								score = sScoreLast[j][k][0] + vviScore[i][method];
								sum = k;
								if (method < 6){
									sum += vviScore[i][method];
								}
								if (score >= sScoreLast[combination][sum][0]){
									sScoreLast[combination][sum][0] = score;
									sScoreLast[combination][sum][1] = method;
								}
								bits.reset(method);
							}
						}
					}
				}
			}
		}
	}
	//先在没有奖励分的最终策略中找最大值
	for (int k = 0; k < 63; k++)
	{
		if (sScoreLast[MAX_COMBINATION - 1][k][0] != -1){
			if (sScoreLast[MAX_COMBINATION - 1][k][0] > max){
				max = sScoreLast[MAX_COMBINATION - 1][k][0];
				sum = k;
			}
		}
	}
	//再在有奖励分的最终策略中找最大值
	for (int k = 63; k < SUM_SIX; k++)
	{
		if (sScoreLast[MAX_COMBINATION - 1][k][0] != -1){
			if (sScoreLast[MAX_COMBINATION - 1][k][0] + 35 > max){
				max = sScoreLast[MAX_COMBINATION - 1][k][0] + 35;
				bonus = 35;
				sum = k;
			}
		}
	}
	vector<int> viMethod(METHODS, 0);
	combination = MAX_COMBINATION - 1;
	for (int i = ROUNDS; i > 0; i--)
	{
		method = sScoreLast[combination][sum][1];
		viMethod[method] = vviScore[i - 1][method];
		combination &= ~(1 << method);
		if (method < 6) sum -= vviScore[i - 1][method];
	}
	for_each(viMethod.begin(), viMethod.end(), [](const int score){cout << score << ' '; });
	cout << bonus << ' ' << max << endl;
}

int main()
{
	bool bEnd = false;
	string strLine;
	while (1){
		vector<vector<int>> vviGame(ROUNDS, vector<int>(DICES, 0));
		vector<vector<int>> vviScore(ROUNDS, vector<int>(METHODS, 0));
		for (int i = 0; i < ROUNDS; i++)
		{
			getline(cin, strLine);
			if (strLine.empty()){
				bEnd = true;
				break;
			}
			istringstream iss(strLine);
			for (int j = 0; j < DICES; j++)
			{
				iss >> vviGame[i][j];
			}
			sort(vviGame[i].begin(), vviGame[i].end());
			for (int k = 0; k < METHODS; k++)
			{
				vviScore[i][k] = getScore(vviGame[i], k + 1);
			}
		}
		if (bEnd) break;
		calMax4(vviScore);
	}
	return 0;
}
/*
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5
1 1 1 1 1
6 6 6 6 6
6 6 6 1 1
1 1 1 2 2
1 1 1 2 3
1 2 3 4 5
1 2 3 4 6
6 1 2 6 6
1 4 5 5 5
5 5 5 5 6
4 4 4 5 6
3 1 3 6 3
2 2 2 4 6
*/

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值