PC/UVa:110208/10149
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 0000b
到1 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 6 | 15 | 23 |
2 5 5 6 6 | 10 | 0 |
根据不考虑奖励分时的计分策略,选择23 + 10 = 33使得总分达到最大,交换上述两种计分策略后,总分有17分的差距。虽然总分降低了,但是计五方式的得分变高了,很有可能使得前6个计分项超过了63分,从而拿到35分的奖励分,这样一来总分又多了18分。
因此除了计分策略外,前6个计分项的和也会影响后边的结果。只有把前6项的和也记录下来,最后才可以判断是否有奖励分。之前表的大小为(13! / 7!) / 6!
,而在每一种组合中,前6个计分项的和取值范围是[0, 126],所以表的大小应该为((13! / 7!) / 6!) * 127
。sScoreLast
由二维变成了三维,第一维表示计分策略,第二维表示前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
*/