省塞模拟赛 摆动序列
问题描述
如果一个序列的奇数项都比前一项大,偶数项都比前一项小,则称为一个摆动序列。即 a [ 2 i ] < a [ 2 i − 1 ] , a [ 2 i + 1 ] > a [ 2 i ] a[2i]<a[2i-1], a[2i+1]>a[2i] a[2i]<a[2i−1],a[2i+1]>a[2i]。
小明想知道,长度为 m m m,每个数都是 1 到 n n n 之间的正整数的摆动序列一共有多少个。输入格式
输入一行包含两个整数 m , n m,n m,n。
输出格式
输出一个整数,表示答案。答案可能很大,请输出答案除以 10000 的余数。
样例输入
3 4
样例输出
14
样例说明
以下是符合要求的摆动序列:
2 1 2
2 1 3
2 1 4
3 1 2
3 1 3
3 1 4
3 2 3
3 2 4
4 1 2
4 1 3
4 1 4
4 2 3
4 2 4
4 3 4评测用例规模与约定
对于 20% 的评测用例, 1 ≤ n , m ≤ 5 1 \le n, m \le 5 1≤n,m≤5;
对于 50% 的评测用例, 1 ≤ n , m ≤ 10 1 \le n, m \le 10 1≤n,m≤10;
对于 80% 的评测用例, 1 ≤ n , m ≤ 100 1 \le n, m \le 100 1≤n,m≤100;
对于所有评测用例, 1 ≤ n , m ≤ 1000 1 \le n, m \le 1000 1≤n,m≤1000。
—— 分割线之初入江湖 ——
读完这道题后,第一反应是能不能搜索?答案肯定是可以的,通过在 dfs 函数中传递进当前位置序号,来决定当前位置的值应该比前面大还是小(当然,还要求落在区间 [ 1 , n ] [1,n] [1,n] 内),在填充了相关值后便继续向后搜索,这样不断搜索下去,最终即可得到总的方案数。但是题目中的数据范围仅有 50% 的数在 [ 1 , 10 ] [1,10] [1,10] 内,其余的都超过了这个范围。而在实际的搜索中,通常数据范围超过了 20 层的时候,递归树就会过于庞大,会导致最终的超时。因此搜索肯定是不可取的。
当搜索算法不行的时候,我们通常可以用记忆化搜索或动态规划来替之解决。本题最简单的做法正是动态规划(记忆化搜索有爆内存的风险)。
—— 分割线之乘风破浪 ——
既然是动态规划,那么我们就必须寻找动态转移方程。本题中有两个关键点:
- 位置的奇偶会影响当前位置上的取值规则;
- 所取值必须停在区间 [ 1 , n ] [1,n] [1,n] 中。
对于第 1 点,其具体的规则是 “奇数项都比前一项大,偶数项都比前一项小”,这个我们可以通过用一个二维状态( d p [ i ] [ j ] dp[i][j] dp[i][j])来进行标记。比如对于 d p [ i ] [ j ] = x dp[i][j]=x dp[i][j]=x,其具体的逻辑意义是:在第 i i i 个位置上选择数字 j j j 时共有 x x x 个方案。
有同学肯定开始迷了,第 i i i 个位置上选择数字 j j j,这不是一个唯一方案么?哪儿来的 x x x 个呢?
我想你肯定是忘了一件事了:第
i
i
i 个位置上选择数字
j
j
j 是唯一确定的,但是它前面还有其他的组合方案啊。于是我们现在就来思考,怎么来得到
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 呢?
这显然和i的奇偶性有关:
- 当i是奇数时,由于其比前面的每一项都大,因此对于dp[i][j]而言,其前面可以存在的合法项就有: d p [ i − 1 ] [ 1 ] 、 d p [ i − 1 ] [ 2 ] 、 d p [ i − 1 ] [ 3 ] 、 … … 、 d p [ i − 1 ] [ j − 1 ] dp[i-1][1]、dp[i-1][2]、dp[i-1][3]、……、dp[i-1][j-1] dp[i−1][1]、dp[i−1][2]、dp[i−1][3]、……、dp[i−1][j−1]。换言之 d p [ i ] [ j ] = d p [ i − 1 ] [ 1 ] + d p [ i − 1 ] [ 2 ] + d p [ i − 1 ] [ 3 ] + … … + d p [ i − 1 ] [ j − 1 ] dp[i][j]=dp[i-1][1]+dp[i-1][2]+dp[i-1][3]+……+dp[i-1][j-1] dp[i][j]=dp[i−1][1]+dp[i−1][2]+dp[i−1][3]+……+dp[i−1][j−1];
- 当i是偶数时,由于其比前面的每一项都小,因此对于dp[i][j]而言,其前面可以存在的合法项就有: d p [ i − 1 ] [ j + 1 ] 、 d p [ i − 1 ] [ j + 2 ] 、 d p [ i − 1 ] [ j + 3 ] 、 … … 、 d p [ i − 1 ] [ n ] dp[i-1][j+1]、dp[i-1][j+2]、dp[i-1][j+3]、……、dp[i-1][n] dp[i−1][j+1]、dp[i−1][j+2]、dp[i−1][j+3]、……、dp[i−1][n]。换言之 d p [ i ] [ j ] = d p [ i − 1 ] [ j + 1 ] + d p [ i − 1 ] [ j + 2 ] + d p [ i − 1 ] [ j + 3 ] + … … + d p [ i − 1 ] [ n ] dp[i][j]=dp[i-1][j+1]+dp[i-1][j+2]+dp[i-1][j+3]+……+dp[i-1][n] dp[i][j]=dp[i−1][j+1]+dp[i−1][j+2]+dp[i−1][j+3]+……+dp[i−1][n];
于是我们就得到了本题的状态转移方程为:
d p [ i ] [ j ] = { d p [ i − 1 ] [ 1 ] + d p [ i − 1 ] [ 2 ] + ⋯ + d p [ i − 1 ] [ j − 1 ] , i 为奇数 d p [ i − 1 ] [ j + 1 ] + d p [ i − 1 ] [ j + 2 ] + ⋯ + d p [ i − 1 ] [ n ] , i 为偶数 dp[i][j]=\begin{cases} dp[i-1][1]+dp[i-1][2]+\dots+dp[i-1][j-1], & i 为奇数\\ dp[i-1][j+1]+dp[i-1][j+2]+\dots+dp[i-1][n], & i 为偶数 \end{cases} dp[i][j]={dp[i−1][1]+dp[i−1][2]+⋯+dp[i−1][j−1],dp[i−1][j+1]+dp[i−1][j+2]+⋯+dp[i−1][n],i为奇数i为偶数
最后还需要对 d p dp dp 数组进行初始化。试想,对于序列的首项而言,其并不存在所谓的“前一项”,因此在首项处可以任意取值,当然,其每个取值都只有一种方案。即: d p [ 1 ] [ 1 ] = 1 , d p [ 1 ] [ 2 ] = 1 , d p [ 1 ] [ 3 ] = 1 , … … , d p [ 1 ] [ n ] = 1 dp[1][1]=1,dp[1][2]=1,dp[1][3]=1,……,dp[1][n]=1 dp[1][1]=1,dp[1][2]=1,dp[1][3]=1,……,dp[1][n]=1。
求出 d p dp dp 数组后怎么使用呢?要知道 d p [ i ] [ j ] dp[i][j] dp[i][j] 的逻辑意义是在第 i i i 个位置上选择数字 j j j 时的方案数,那么在长度为 m m m,取值范围为 [ 1 , n ] [1,n] [1,n] 的前提下,总的摆动序列的数量就可以用 d p [ m ] [ 1 ] + d p [ m ] [ 2 ] + d p [ m ] [ 3 ] + … … + d p [ m ] [ n ] dp[m][1]+dp[m][2]+dp[m][3]+……+dp[m][n] dp[m][1]+dp[m][2]+dp[m][3]+……+dp[m][n] 来表示。
下面直接给出基于上述思路写出的完整代码(含详细注释):
#include<iostream>
using namespace std;
const int N=1005;
const int MOD=10000;
int dp[N][N];
int DP(int m,int n)
{
int temp,ans=0;
for(int i=1;i<=n;i++) dp[1][i]=1; //初始化dp数组
for(int i=2;i<=m;i++){
for(int j=1;j<=n;j++) //dp[i][j]中j可以在[1,n]中进行选择
{
temp=0;
if(i&1){ //如果i为奇数,则需要累加前[1,j-1]项
for(int k=1;k<j;k++)
temp=(temp+dp[i-1][k])%MOD;
}else{ //否则为偶数,则需要累加后[j+1,n]项
for(int k=j+1;k<=n;k++)
temp=(temp+dp[i-1][k])%MOD;
}
dp[i][j]=temp;
}
}
//最后将第m个位置上选择的n个数的各个情况都累加即可
for(int i=1;i<=n;i++) ans=(ans+dp[m][i])%MOD;
return ans;
}
int main()
{
int m,n;
cin>>m>>n;
cout<<DP(m,n)<<endl;
return 0;
}
这个代码很容易理解,但是问题也很突出,那就是在 DP 时,我们用到了 3 层循环!!!(更严格的说,其时间复杂度为 O ( n 3 ) O(n^3) O(n3))这在 m = n = 1000 m=n=1000 m=n=1000 的极限情况下超时无疑,因此我们不得不进行优化。
—— 分割线之剥雾见云 ——
在上面之所以用了 3 层循环,是因为 d p [ i ] [ j ] dp[i][j] dp[i][j] 这个状态表示的是其在第 i i i 个位置上选择数字 j j j 时的方案数,这是一个 “1对1” 的存储模式。它导致程序在后面进行状态转移时,需要对每一个 d p [ x ] [ y ] dp[x][y] dp[x][y] 进行更新维护,而更新维护的时候又是从前一次的 { d p [ i − 1 ] [ 1 ] , d p [ i − 1 ] [ 2 ] , … … , d p [ i − 1 ] [ n ] } \{dp[i-1][1],dp[i-1][2],……,dp[i-1][n] \} {dp[i−1][1],dp[i−1][2],……,dp[i−1][n]} 中寻找。遍历 d p [ x ] [ y ] dp[x][y] dp[x][y] 是一个二重循环,更新维护又是一重循环,因此上面的程序是一个三重循环。
如果我们试图降低本程序的时间复杂度,就不得不从上面的短板中寻找突破。
一个比较直观,且容易想到的改进是:能不能对 d p [ i ] [ j ] dp[i][j] dp[i][j] 这个存储模式进行更改?如果我们直接将 d p [ i ] [ j ] dp[i][j] dp[i][j] 的存储模式改为 “1对多”,那就相当于降低了一重循环。
现在我们就来着手实现对 d p [ i ] [ j ] dp[i][j] dp[i][j] 的模式更改。由于位置i的奇偶性会影响当前位置上的取值规则,因此我们也可以将 d p [ i ] [ j ] dp[i][j] dp[i][j] 的意义根据位置的奇偶性进行如下重定义:
- 当 i i i 为奇数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择大于等于数 j j j 的方案数;
- 当 i i i 为偶数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择小于等于数 j j j 的方案数。
这样的改变有点类似于将奇数位上的数用后缀数组来表达,而偶数位上的数用前缀数组来表达。那么此时我们的动态转移方程也会发生相应的改变,如下:
- 当 i i i 为奇数时,由于奇数项都比前一项大,那么其前一项只能是 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i−1][j−1]。又由于此时 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示的是 “第 i i i 个数选择大于等于数 j j j 的方案数”,那么我们还需要额外加上当前项的后一项,即 d p [ i ] [ j + 1 ] dp[i][j+1] dp[i][j+1]。因此,此时 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + d p [ i ] [ j + 1 ] dp[i][j]=dp[i-1][j-1]+dp[i][j+1] dp[i][j]=dp[i−1][j−1]+dp[i][j+1]。
- 当 i i i 为偶数时,由于偶数项都比前一项小,那么其前一项只能是 d p [ i − 1 ] [ j + 1 ] dp[i-1][j+1] dp[i−1][j+1]。又由于此时 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示的是 “第 i i i 个数选择小于等于数 j j j 的方案数”,那么我们还需要额外加上当前项的前一项,即 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j−1]。因此,此时 d p [ i ] [ j ] = d p [ i − 1 ] [ j + 1 ] + d p [ i ] [ j − 1 ] dp[i][j]=dp[i-1][j+1]+dp[i][j-1] dp[i][j]=dp[i−1][j+1]+dp[i][j−1]。
这样一来,我们就省去了上面那个算法的最内层循环,从而将时间复杂度降低到 O ( n 2 ) O(n^2) O(n2)。即使在极限情况下,改进后的算法也能从容应对。
接下来是初始化问题,我们在将 d p [ i ] [ j ] dp[i][j] dp[i][j] 存储的信息进行更改后,其意义是一个累加值(随当前位置i的奇偶性而决定累加方向)。由于首项 d p [ 1 ] [ y ] dp[1][y] dp[1][y] 所在位置 1 是一个奇数,那么其累加方向是自右向左,即: d p [ 1 ] [ 1 ] = n , d p [ 1 ] [ 2 ] = n − 1 , d p [ 1 ] [ 3 ] = n − 2 , … … , d p [ 1 ] [ n − 1 ] = 2 , d p [ 1 ] [ n ] = 1 dp[1][1]=n,dp[1][2]=n-1,dp[1][3]=n-2,……,dp[1][n-1]=2,dp[1][n]=1 dp[1][1]=n,dp[1][2]=n−1,dp[1][3]=n−2,……,dp[1][n−1]=2,dp[1][n]=1。
现在求出 d p dp dp 数组后又怎么使用呢?还是从 d p [ i ] [ j ] dp[i][j] dp[i][j] 的逻辑意义出发:“当 i i i 为奇数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择大于等于数 j j j 的方案数;当 i i i 为偶数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择小于等于数 j j j 的方案数”。显然,这时候我们的结果是依赖于序列长度的奇偶性:
- 如果序列长度为奇数,那为了能将所有的结果都包含进来,就应该用 d p [ m ] [ 1 ] dp[m][1] dp[m][1] 来表示该波动序列的总量;
- 如果序列长度为偶数,那为了能将所有的结果都包含进来,就应该用 d p [ m ] [ n ] dp[m][n] dp[m][n] 来表示该波动序列的总量;
下面给出改进后,本题的完整代码:
#include<iostream>
using namespace std;
const int N=1005;
const int MOD=10000;
int dp[N][N];
int DP(int m,int n)
{
for(int i=1;i<=n;i++) dp[1][i]=n-i+1; //初始化dp数组
for(int i=2;i<=m;i++){
if(i&1) //如果i为奇数
for(int j=n;j>=1;j--)
dp[i][j]=(dp[i-1][j-1]+dp[i][j+1])%MOD;
else //否则则为偶数
for(int j=1;j<=n;j++)
dp[i][j]=(dp[i-1][j+1]+dp[i][j-1])%MOD;
}
return m&1?dp[m][1]:dp[m][n]; //根据m的奇偶性返回最后的答案
}
int main()
{
int m,n;
cin>>m>>n;
cout<<DP(m,n)<<endl;
return 0;
}
最近老是遇到这种和序列相关的题目,下面推荐两道与之类似,但是更有挑战的题:
蓝桥杯 校内模拟赛 奇怪的数列
蓝桥杯 历届试题 波动数列