2. 基于动态规划的Rulebased
2.1 现有问题
我们可以看到,通过上听数、有效牌数能取得较好的效果,整体上牌型是向着胡牌前进的,但是它是基于很多指标的,而不同指标赋予什么样的权重,该如何组合这些指标,是否有些指标应该被忽略掉,这些都是需要人类经验的,并且未必准确。同一套指标,可能在某一局中适用,在另一局中就会给出意外的结果,这样的情况在调试的过程中是很常见的。因此,我思考的是如何减少人工选取参数对于估值的影响,甚至是没有任何参数。
受到了ZJOI2019省选的题目以及题解的启发,我抛弃了上听数和有效牌数,选取了巡目数作为估值指标。首先我们看巡目数的定义。假设牌山中剩余的牌集为 S 1 S_1 S1(即整副牌除开明牌、自家手牌、自家暗杠的牌集),现在 S 1 S1 S1中的牌会以相同的概率生成摸牌打牌的序列,对于单个生成序列 T i T_i Ti,它至少前 K T i K_{T_i} KTi张牌能和我的手牌凑成胡牌牌型,则对所有的 K T i K_{T_i} KTi取期望,该期望 E E E即为巡目数。
这里给出一个具体的例子,也是在调试算法时候常用的一个小例子。假设手牌是“113399条”这六张牌,剩余的牌是“1223条、南风”这五张牌,前面我已经碰了两次了(所以只剩6张手牌)。显然我还需要两张牌才能胡,要么给我13条,要么给我两个2条。剩余的五张牌一共可以生成等概率的120个序列,通过简单的排列组合知识可得,在这120种序列中,有24个序列的前两张牌即可让我胡,有48个序列需要我从前三张牌中取两张胡,有48个序列需要我从前四张牌中取两张胡。那么此时这个牌型的巡目数就是(24 * 2 + 48 * 3 + 48 * 3) / 120 = 3.2,其含义就是期望条件下我需要从剩余序列的前3.2张牌中取出2张牌才能胡。
从上面的例子中我们也可以看到,巡目数是综合了手牌和剩余牌进行通盘考虑的,两组牌在上听数相同、有效牌相近的情况下,根据剩余牌的不同,巡目数有可能大不一样。另外,如果剩余牌是接近无限的,那么上听数越小,巡目数也就越小。可以说,上听数就是剩余牌无限情况下的巡目数-1,巡目数是上听数结合剩余牌数的泛化。
2.2 算法思想
承接上节,我们的主要目标就是求出巡目数,然后以巡目数作为出牌的依据:巡目数越大,证明在现有牌堆的情况下越难胡,因此我们会选择相对较小的巡目数来出牌。
直接计算巡目数并不是一件简单的事情。如果直接枚举可能有那些出牌序列,然后计算每一个序列的 K T i K_{T_i} KTi,显然复杂度是阶乘级的,这对于每回合1s的限制是无法承受的。因此我们需要转换思路。
假设手牌数为 m m m张,牌山中剩余牌集 S 1 S_1 S1大小为n,那么我们要求的值为 E ( x ) = ∑ i = m + 1 m + n P ( x = i ) ⋅ ( i − m ) E(x)=\sum_{i=m+1}^{m+n}P(x=i)·(i-m) E(x)=∑i=m+1m+nP(x=i)⋅(i−m),其中 P ( i ) P(i) P(i)表示拿到 i i i张牌可以胡牌的概率,然后进行转化 E ( x ) = ∑ i = m + 1 m + n ( P ( x ≥ i ) − P ( x ≥ i + 1 ) ) ⋅ ( i − m ) E(x)=\sum_{i=m+1}^{m+n}(P(x\geq i) - P(x \geq i+1))·(i-m) E(x)=∑i=m+1m+n(P(x≥i)−P(x≥i+1))⋅(i−m),拆解然后合并同类项之后我们可以得到 E ( x ) = ∑ i = m + 1 m + n P ( X ≥ i ) E(x)=\sum_{i=m+1}^{m+n}P(X\geq i) E(x)=∑i=m+1m+nP(X≥i)。也就是说,我们需要求的变成了如何快速计算我拿到 i i i张牌之后还不胡的概率。
这里不妨先来看一看,如何计算一副牌是否胡了。胡牌是由很多牌型的,包含了很多特殊的胡法,不同的胡法番数也不同。这里我们先简化问题,将问题限制在只能平和和七对胡这两种情况之内。首先,我们对手牌做一个编码,具体编码如下:
牌型 | 对应区间 |
---|---|
万牌 | 1-9 |
条牌 | 12-20 |
筒牌 | 23-31 |
风牌 | 34-43 |
箭牌 | 46-52 |
代码中的card2num和num2card实现了这两者的转化。
int card2num(string s){
assert(s.length() == 2);
int num = s[1] - '0';
switch(s[0]){
case 'W' : return 0 + num; break;
case 'T' : return 11 + num; break;
case 'B' : return 22 + num; break;
case 'F' : return 31 + num * 3; break;
case 'J' : return 43 + num * 3; break;
default : break;
}
return 0;
}
string num2card(int num){
if(1 <= num && num <= 9) return "W" + to_string(num - 0);
else if(12 <= num && num <= 20) return "T" + to_string(num - 11);
else if(23 <= num && num <= 31) return "B" + to_string(num - 22);
else if(34 <= num && num <= 43 && !((num - 34) % 3)) return "F" + to_string((num - 31) / 3);
else if(num == 46 || num == 49 || num == 52) return "J" + to_string((num - 43) / 3);
else return "aha";
return "aha";
}
这样我们可以抛开各自的牌型,把他们当作相同类型的牌去对待。因为每种牌型之间都间隔了两个,所以形如“九万”和“幺鸡二条”是无法凑成顺子的。那么我们算法的输入就是大小为52的数组a, a i a_i ai表示编号为 i i i的牌有几张。我们在这上面做dp,令 f 0 / 1 , i , j , k f_{0/1,i,j,k} f0/1,i,j,k表示有无对子第 i − 1 i−1 i−1 种牌开头的顺子有 j j j 个,第 i i i 种牌开头的顺子有 k k k 个且其余牌全部作为刻子的最大面子数。如果不考虑对子,那么 f 0 , f 1 f_0, f_1 f0,f1会分开转移并且他们的状态转移方程是完全相同的,如果分开考虑,我们有
f 0 , i + 1 , k , t = ∑ j ( f 0 , i , j