【蓝桥杯】省塞模拟赛 摆动序列(动态规划)

省塞模拟赛 摆动序列

问题描述

如果一个序列的奇数项都比前一项大,偶数项都比前一项小,则称为一个摆动序列。即 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[2i1],a[2i+1]>a[2i]
小明想知道,长度为 m m m,每个数都是 1 到 n n n 之间的正整数的摆动序列一共有多少个。

输入格式

输入一行包含两个整数 m , n m,n mn

输出格式

输出一个整数,表示答案。答案可能很大,请输出答案除以 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 1n,m5
对于 50% 的评测用例, 1 ≤ n , m ≤ 10 1 \le n, m \le 10 1n,m10
对于 80% 的评测用例, 1 ≤ n , m ≤ 100 1 \le n, m \le 100 1n,m100
对于所有评测用例, 1 ≤ n , m ≤ 1000 1 \le n, m \le 1000 1n,m1000



—— 分割线之初入江湖 ——


读完这道题后,第一反应是能不能搜索?答案肯定是可以的,通过在 dfs 函数中传递进当前位置序号,来决定当前位置的值应该比前面大还是小(当然,还要求落在区间 [ 1 , n ] [1,n] [1,n] 内),在填充了相关值后便继续向后搜索,这样不断搜索下去,最终即可得到总的方案数。但是题目中的数据范围仅有 50% 的数在 [ 1 , 10 ] [1,10] [1,10] 内,其余的都超过了这个范围。而在实际的搜索中,通常数据范围超过了 20 层的时候,递归树就会过于庞大,会导致最终的超时。因此搜索肯定是不可取的。

当搜索算法不行的时候,我们通常可以用记忆化搜索或动态规划来替之解决。本题最简单的做法正是动态规划(记忆化搜索有爆内存的风险)。



—— 分割线之乘风破浪 ——


既然是动态规划,那么我们就必须寻找动态转移方程。本题中有两个关键点:

  1. 位置的奇偶会影响当前位置上的取值规则;
  2. 所取值必须停在区间 [ 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的奇偶性有关:

  1. 当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[i1][1]dp[i1][2]dp[i1][3]……dp[i1][j1]。换言之 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[i1][1]+dp[i1][2]+dp[i1][3]+……+dp[i1][j1]
  2. 当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[i1][j+1]dp[i1][j+2]dp[i1][j+3]……dp[i1][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[i1][j+1]+dp[i1][j+2]+dp[i1][j+3]+……+dp[i1][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[i1][1]+dp[i1][2]++dp[i1][j1],dp[i1][j+1]+dp[i1][j+2]++dp[i1][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]=1dp[1][2]=1dp[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[i1][1]dp[i1][2]……dp[i1][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] 的意义根据位置的奇偶性进行如下重定义:

  1. i i i 为奇数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择大于等于数 j j j 的方案数;
  2. i i i 为偶数时, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 个数选择小于等于数 j j j 的方案数。

这样的改变有点类似于将奇数位上的数用后缀数组来表达,而偶数位上的数用前缀数组来表达。那么此时我们的动态转移方程也会发生相应的改变,如下:

  1. i i i 为奇数时,由于奇数项都比前一项大,那么其前一项只能是 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1]。又由于此时 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[i1][j1]+dp[i][j+1]
  2. i i i 为偶数时,由于偶数项都比前一项小,那么其前一项只能是 d p [ i − 1 ] [ j + 1 ] dp[i-1][j+1] dp[i1][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][j1]。因此,此时 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[i1][j+1]+dp[i][j1]

这样一来,我们就省去了上面那个算法的最内层循环,从而将时间复杂度降低到 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]=ndp[1][2]=n1dp[1][3]=n2……dp[1][n1]=2dp[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;
}

最近老是遇到这种和序列相关的题目,下面推荐两道与之类似,但是更有挑战的题:
蓝桥杯 校内模拟赛 奇怪的数列
蓝桥杯 历届试题 波动数列


END


评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

theSerein

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值