HDU 6787 Chess 2020百度之星 初赛三 T5 题解 dp

时间:2023-08-26 17:17:18

传送门:HDU 6787 Chess

Problem Description

你现在有一个棋盘,上面有 n 个格子,格子从左往右,1,…,n 进行标号。你可以在棋盘上放置恰好 m 个传送器,并且对于每个传送器设置传送位置。

传送位置需满足:对于在 i 号格子上的传送器,传送目标位置 j 满足 j<i。 1 号格子不能放置传送器。

现在有一名玩家,拿着一枚 1,…,11 的骰子(骰子每次等概率地投出 1 到 11 中的一个数字),从位置 1开始,用骰子决定前进步数,即如果当前在位置 y 且投出数字 x,那棋子将会跳到位置 y+x。如果棋子跳到棋盘外的位置(x+y>n),玩家失败。如果位置

y+x上恰好有个传送器,那玩家的棋子立刻会被传送到传送器目标位置。如果传送器目标位置有另一个传送器,玩家的棋子会沿着另一个传送器继续传送。这个过程持续若干次,直到目标位置没有传送器为止。

如果玩家棋子可以到达点 n 且不被传送到别的地方(意味着位置 n 没有传送器),则玩家获胜。

问现在有多少种不同的放置传送器的方法,使得玩家有可能获胜。如果摆放传送器的格子不同或者某个格子上的传送器传送的位置不同则视为不同的方案。

每个格子最多放置一个传送器。

Input

第一行一个正整数 test (1≤test≤20) 表示数据组数。

对于每组数据,第一行两个整数 n,m (1≤n≤1000,0≤m≤1000) 表示格点数和传送器数。

Output

对于每组数据,一行一个整数表示答案模 109+7。如果无解(没有任何获胜的方案),输出 −1。

提醒一下:骰子 的读音:tóuzi

1. 文献综述

主流方法有两种我将其分别命名为添块法与扩展法。(当然本质都是dp,名字只是个记号不必过分纠结)

添块法:

【starve_to_death】 博文一,文章是言简意赅的,但代码与表述有所出入,看时要注意。(后面也会提到这篇文章)

【stduy_ing】博文二,代码是清楚简洁的。

扩展法:

【二分抄代码】博文三 ,表述基本上也是清楚的。

【矮矮的夏祭】博文四,一开始我没看他的公式,但后来添加了一些解释后就能看懂了,公式的表述上我认为可以再简化一点。

以上博文都是可以一读的,同时也对他们表示感谢,因为我一开始是不会这道题的,看了他们题解之后才懂的。

2. 前言

因为这题不是我独立做出来的,所以我不能保证这道题应该按照本文所说的去思考。我只是把我认为的可能的分析和解决问题的思路表述出来。主要是我先讲了一个塞块法,而这种方法是无法求解出问题的,怕把人带跑偏了,所以还请带上批判性的眼光去看待本文。有哪些地方说的不恰当的话,欢迎批评指正。

2. 特殊含义名词声明

  • 块:一个位置是一个块
  • 白块: 没有放传送器的位置
  • 排列/序列可行: 能获胜

3. 整体分析

传送器只能让人往回走,且传送完一定落在一个白块上。刨去传送的过程,其实就是从一个白块到另一个白块。我们每次最多往前走11格,如果两个白块之前间隔大于11,那我们就无法再往前了,也就无法获胜了。所以我们现在有:

获胜\(\Rightarrow\)任意两个白块之间距离\(\leq\) 11

  • 那传送器传送到哪呢?

事实上传送到哪都行,因为无论怎么传送我们都能停在一个白块上,我们也只能通过这些白块向前走。

所以对于传送器传到哪我们是没有限制的(合法就行)。

而如果任意两个白块之前距离\(\leq\) 11,那么我们就一定能由一个白块直接跳到另一个白块的位置,也就能一直往前走,只要最后一个位置是白块,我们一定能获胜。

那么我们现在有:

获胜\(\Leftrightarrow\)任意两个白块之前距离\(\leq\) 11 且最后一个位置是白块

白块之间的是传送器,所以 “任意两个白块之前距离\(\leq\) 11”等价于:连续的传送器个数\(\leq\) 10

分析到这里我们就知道这题想让我们干什么了,就是让我们排排m个传送器,保证上述白块的位置的要求下,每种排列的传送器的目标位置可以任意取,累乘一下得到这种排列对应的方案数,最后再把所有排列累加一下得到答案。

上述是我们容易想到的方法,好想但是貌似不太好做啊,如果就几个位置,在纸上画画还行,位置一多就不知道要怎么处理了。

4. 先来几个简单的例子感受一下

n总块数,m传送器个数,r[n][m]为对应的方案数

  • n=1 m=0

    就一个块,就一种方案,r[1][0]=1
  • n=2 m=0

    只能两个白块,还是只有一种方案,r[2][0]=1
  • n=3
    • m=0

      r[3][0]=1
    • m=1

      第2个位置传送器,第3个位置白块 r[3][1]=1
  • n=4
    • m=0

      r[4][0]=1
    • m=1

      第2个位置传送器 : 1

      第3个位置传送器 : 2

      r[4][1]=1+2=3
    • m=2

      第2,3 位置传送器 : 1*2

      r[4][2]=2

我们试着去找一下每步之间的联系

5. 塞块法

直接分析n个位置m个传送器不好分析,我们可以尝试从n和m都比较小的情况一点点地扩大,递推。

由一个可行的的排列到一个元素更多的可行的排列,我们可以往里面塞块,只要保证塞完还能获胜就行。

现考虑往里塞一个白块,也就是只增加n,而不增加m。因为是白块所以也不用担心太多连在一起,所以可以随便塞,我们考虑塞完了之后对传送器的影响:由于多了一个位置,传送器最右可以到的地方,便向右扩展了一块。比如n=3 m=1,传送器只能在位置2。而n=4 m=1,传送器既可以在位置2,也可以在位置3.对于这个新位置:n-1,既可以放传送器也可以不放。

  • 如果不放,那就相当于只是在(n-1,m)的后面加了一个白块其他全都不变,方案数:r[n-1][m]
  • 如果放,那我们得从左边挑一个传送器到:n-1,这个时候要注意,放完之后可能就会有多于10个传送器连在一起。即使你知道放那肯定不会让连续的传送器多于10个,那n-2位置是传送器还是白块也是不知道的,一个可行的排列要求最后一个块是白块,也就是说我们不能确定前n-2个块是不是一个可行的排列。于是我们便无法和之前我们分析过的可行的排列建立联系。

    现在阻碍我们的是:我们不知道把传送器放到一个位置会不会使连续的传送器多于10个。这个时候,我们就得考虑多记录一些信息了比如:某个位置附近有多少连续的传送器。

我们还没考虑塞传送器的情况,便发觉这种方法行不通了,最起码维护的信息是不足。当然你也可以直接考虑塞传送器的情况,同样也会发现,为了保证我塞完还可行,我需要多维护一些信息。

现在我们知道了我们需要维护传送器的位置信息,这个位置是所有位置吗?好像是的。那不成了维护传送器的排列情况了吗?仿佛又回到了我们一开头想的那个可想而不可及的方法。可能的排列的个数虽然数量级比较大但我们可以算,但具体怎么排,就不容易知道了。

这时我们可以想:我们一定得由一个可行的序列直接蹦到一个更大的可行的序列吗?

6. 添块法

想扩展序列除了塞,我们还可以直接在末尾添啊。

在扩展过程中我们可以允许这个序列暂时是不可行的,但得是可行序列的一部分。用c表示传送门,b表示白块。

bcc 虽然不可行,但却是bccb的一部分,我们可以由bcc扩展到bccb。

6.1 添传送器

现在我们尝试往末尾填一个传送器

基于前述分析得知我们需要知道:包括这个位置往左连续的传送器有几个。因为获胜的条件是:连续的传送器个数\(\leq\) 10,所以只有个数在1-10 这10种情况。(当然维护“不包含这个位置往左连续的传送器有几个”也可以,博客一的代码就是当不包含做的)

为了区分这十种情况我们再加入一个维度记录到前位置有连续的几个传送器。

我们用f[i][j][k] 表示

表示当前考虑到第i个格子,已经用了 j个传送器,包括 i 号位置有连续k个传送器。

对于i,j,k 没添进来之前的状态必然是i-1,j-1,k-1

所以转移函数:f[i][j][k] = f[i-1][j-1][k-1]*(i-1)

乘上i-1是因为在i位置的传送器有i-1种目标位置。

例子:

f[4][1][1]=f[3][0][0]*3=3

(很多题解的代码里写的是:f[i][j][k] = f[i][j][k]+ f[i-1][j-1][k-1]*(i-1)

我认为后面的f[i][j][k]是没必要加上的,加上可能是为了形式上更加符合dp的一般表示方法吧。之所以加上没错是因为f[i][j][k]初始值是0)

6.2 添白块

我们来再考虑新块是白块的情况。

白块对应k=0.但这时刚才那个转移函数就没法用了,因为k-1=-1

这时我们得分类讨论了,考虑第i-1个块有几种情况,

它可能是白块,也可能是传送器,如果是传送器可能是连续的第1-10个

所以

f[i][j][0]=f[i-1][j][0]+f[i-1][j][1]+...+f[i-1][j][10]

至此我们便可以由初始状态一点点地递推到答案状态:f[n][m][0]。

代码:

注意这块,我加了个特判,其实不加直接输出func()结果也行。

我加上只是为了提醒大家:不能认为m必须<=n-2。

传送器不能在头尾,所以我们认为m应该<=n-2。但当n=1时,头尾变成了一个,这时我们便不能要求m<=-1了,因为m等于0也存在一种可行方案。

if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;

完整代码

**展开查看代码**
#include<iostream>
#include<cstring>
using namespace std;
int test=1;
int n,m;
const int n_max=1000;
int f[n_max+1][n_max-1][10+1]; // i 位置之前(包括i)总共有j个传送器 且i是连续的第k个
const int mod=1e9+7;
typedef long long ll; int func()
{
memset(f,0,sizeof(f));
for(int i=1;i<=n;i++)
{
f[i][0][0]=1; //没有传送器的方案数都是1种
for(int j=1;j<=m;j++)
{
for(int k=0;k<=10; k++)
{ f[i][j][0]=(f[i][j][0]+f[i-1][j][k])%mod;
//cout<<"i: "<<i<<" j: "<<j<<" k: "<<k<<" f0: "<<f[i][j][0]<<endl;
if (k!=0)
{
if(f[i-1][j-1][k-1]==0) //可以起到一定的加速作用不至于TLE
continue;
f[i][j][k]=(ll)f[i-1][j-1][k-1]*(i-1)%mod;
//cout<<"i: "<<i<<" j: "<<j<<" k: "<<k<<" f: "<<f[i][j][k]<<endl;
}
//你也可以算f[i+1][j][0]
//f[i+1][j][0]=(f[i+1][j][0]+f[i][j][k])%mod;
//cout<<"i+1: "<<i+1<<" j: "<<j<<" f: "<<f[i+1][j][0]<<endl;
} }
}
if (f[n][m][0]!=0)
return f[n][m][0];
else
return -1;
}
int main()
{ cin>>test;
for(int k=0;k<test; k++)
{
cin>>n>>m;
if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;
}
}

7. 扩展法

我们考虑往一个可行的序列后加一个白块使其扩展为一个更大的可行序列,与上述两种思路不同的是:我们不再限制一次加一个块,也不再限制新加的块紧贴末尾,白块可以加到距离末尾1-11的任意地方,中间的填上传送器。

现在我们变换一下坐标系,我们固定新加的白块的位置,设为i位置,左边距离i 1-11的位置便是所有可能的扩展前序列的尾部的位置。也就是说我们只能通过这些位置所代表的序列扩展到i位置。

注意:我们不关心是怎么跳到这个新块上的,题目没问你怎么跳的方案数,我们关心的是白块的排列,当然白块的排列与传送器的排列是等价的我们确定了其中一个另一个也就被确定了。

(这里变换坐标系只是方便表示与编码,从i递推i+1和从i-1递推i本质上都是一样的。)

令 r[i][j] 为n=i m=j 对应的方案数

转移函数:r[i][j]=r[i-1][j]+r[i-2][j-1]*(i-2)+r[i-3][j-2]*(i-2)*(i-3)+....+r[i-11][j-10]*(i-2)*(i-3)...*(i-10)

其中:

  • r[i-3][j-2]*(i-2)*(i-3)表示白块加在了 n=i-3 m=j-2 所代表的可行序列后 的第3个位置,中间填上了2个传送器,位置分别为i-1和i-2,传送目标位置分别有:i-2,i-3种。剩余的同理。
  • r[i-1][j] 表示白块加在了 n=i-1 m=j 所代表的可行序列后,中间没有传送器。

至此我们便可由初始状态递推到答案状态:f[n][m]

代码:

注意:其中mod一定要定义成常量:const int !!!

不能是普通的int,否则你再怎么优化都会TLE(血的教训)

加与不加const差距多达1000多ms!

其中的temp便是公式中乘的那些系数,由于可以由上一个temp多乘一个数得到,所以写成了这种更新的形式而不是一个for循环。

**展开查看代码**
#include<iostream>
#include<cstring>
using namespace std;
int test=1;
int n,m;
const int n_max=1000;
typedef long long ll;
int f[n_max+1][n_max-1];
const int mod=1e9+7; int func()
{
memset(f,0,sizeof(f)); for(int i=1;i<=n;i++)
{
f[i][0]=1; //没有传送器的方案数都是1种
for(int j=1;j<=m;j++)
{
int temp=1;
for(int k=1;k<=11; k++)
{
if(i-k<=0 || j-(k-1) <0)
break;
if(k==1)
temp = 1 ;
else
temp=(ll)temp*(i-k)%mod; f[i][j]=(f[i][j]+(ll)f[i-k][j-(k-1)]*temp)%mod;
} }
}
if (f[n][m]!=0)
return f[n][m];
else
return -1;
}
int main()
{ cin>>test;
for(int k=0;k<test; k++)
{
cin>>n>>m;
if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;
}
}

8. 不同方法对比

  • 扩展法与添块法的不同。

    添块法中我们维护了连续的传送器的位置信息,存储了可行序列的一部分对应的的方案数。

    用c表示传送门,b表示白块。

    例如:bcc

    但我们是不会询问到这些部分序列的方案数的,我们只可能询问bcb或者bccb。所以理论上我们不用存储这些部分序列对应的的方案数。

    其实这两种方法本质上也是相同的,扩展法转移函数后面加的那一串也正是这些方案数。只不过一个是现算一个是把他们存起来了。
  • 我们再来重新审视一下塞块法为何不行?

    我们发现扩展法与添块法都是在一端操作,不考虑上一个状态的详细的内部情况。而塞块法则恰恰需要知道上一个状态的详细的内部情况,而只有忽略这些才能简化计算。当然理论上依旧是可行的只不过时间复杂度或编码困难度可能是我们不能接受的。

9. 关键破题点总结

  • 不讨论传送器的目标位置,它可以传送到任意合法位置。
  • 不能有多于10个传送器连续排列
  • 在一端进行扩展,寻找转移函数

10. 做题记录与心路历程

  这题想了一个多小时也就那么一点点思路,还是在知道往dp方向去想的前提下。但还是理不清楚不知如何处理,上述关键破题点都是我一开始没想到的,无奈之下去搜了波题解。我觉得难爆了的题,代码竟然那么简单,仅用一个转移函数便可解决。研究了几天,也算是懂个差不多吧,毕竟dp博大精深,还需日积月累,反复琢磨,方可驾轻就熟。

  代码中那个特判我真是想破头都没想到,问题竟出在那!

  其实我刚开始写这篇博客时还是有些地方还是没搞清楚,就是一边写一边理思路,写了删,删了又写,反复修改,耗时一个晚上加一天终得此文。

  在我完成前本文前的最后一步:确定最终代码时,本打算跑一跑没啥问题一粘我就去睡觉了,然而扩展法的代码竟然TLE了。然后调了一个半小时!从半夜0.32到1.57。任我想破脚趾头都没想到竟然是因为没把mod设置成常整型!简直刷新我认知边界,有时间的话我得好好研究一下这个问题。

哦对了,文献综述部分除了博文2,其他博文下面留言的人都是我(至少目前是的),或挑错或问问题,他们都进行了耐心的回答,所以说博主人都是很好的,所以有啥不对您就指出来,有啥不懂的您就问。