大师 题解

题目传送门

1 题目介绍

本题的大意就是在一个序列 aa 中,求删去若干个数字,使得剩下的数字是等差数列得方法数,从动态规划的角度,这是标准的计数类 DP。

最后的数据也告诉我们时间复杂度是 O\operatorname{O}{nvnv} 或 大约 O\operatorname{O}{n2n^2} 的。

2 解析

2.1 开端

猎奇解法的开端:勇于尝试

对于等差数列,最显著的特征就是公差相等。我的想法是首先计算出任意两个数字的差(显然是平方复杂度),再研究有什么 好 算 法 。

假设 ci,jc_{i,j} 表示 aiaja_i-a_j,对于样例11 的数据(注:后文所有的举例推导都是来自样例11 的数据):

8

13 14 6 20 27 34 34 41

手算(也可以电脑算)很容易得出这样的关于 cc 数组的表:

ay6dtf.jpg

在这个表里面,找到了两个特点:

  • 该表的左上到右下的对角线格子(即所有 ci,ic_{i,i})都是 00
  • 以该线为对称轴,对称的两个格子的数互为相反数。

主要利用的是第二个特点,这样子就没有必要求出整个 cc 数组,只需要求一半(这里选择哪一半形都行,这里我选择右上的这一半)。

其次还是利用第一个特点,这样子就不需要存储一个对角线的 00 了,那么在我存储的内容中,对于坐标 (i,j)(i,j),一定有 i>ji>j

2.2 核心算法

既然等差数列的公差相等,也就是说在这个 cc 数组里面,数值一样的几个地方可以作为等差数列的一部分。

这句话非常不好理解,我举个例子好了:c1,4c_{1,4}c4,5c_{4,5} 可以作为等差数列,还原到输入的 aa 数组里面,对应的就是 13,20,2713,20,27 三个数。同样第五行的两个 77 也可以进来,但是注意同一行的多个可行方案(公差不是 00)只能选择入一个,这是为什么呢?

假设你把 c5,6c_{5,6}c5,7c_{5,7} 两个都并进去了,还原到原来的 aa 数组,那么你留下的数字就是 13,20,27,34,3413,20,27,34,34,很明显这不是等差数列。

说得逻辑化一点,因为同一行的数字其实是不同的 aja_j 减去了一个相同的 aia_i,之所以数字一样,就是因为这几个 aja_j 都一样,除非你的等差数列都是同一个数,这是不合法的。

我貌似没有特判公差非 00 也对了

那么选择 c1,4c_{1,4}c5,6c_{5,6}c7,8c_{7,8} 可不可以呢?遇事不决,暴力还原。选择这两个数字,意味着你的 aa 数组选择了 13,34,4113,34,41,这也很明显不是等差数列,如果加上 20,2720,27,它就是完整的等差数列了。

对应一下,也就是说要加上 c4,5,c6,7c_{4,5},c_{6,7},发现什么规律了吗?

按从上到下的顺序选择相等的公差时,保证“坐标的连续性”,也就是说,假若选择 ca1,b1,ca2,b2,ca3,b3......cam,bmc_{a_1,b_1},c_{a_2,b_2},c_{a_3,b_3}......c_{a_m,b_m},一定有对于所有的 1<k<m1<k<mcak=cbk1,cbk=cak+1c_{a_k}=c_{b_{k-1}},c_{b_k}=c_{a_{k+1}}

这又是为何呢?因为 ci,jc_{i,j} 表示的是 aiaja_i-a_j,所以一定要保证“坐标连续性”,后一个的 aia_i 才能等于前一个的 aja_j,它才是一个完美无缺的等差数列。

那么就是说,c1,4c_{1,4} 对应的相等的数只能在第四行找,c2,5c_{2,5} 对应的相等的数只能在第五行找,ca,bc_{a,b} 对应的相等的数只能在第 bb 行找……

然后就有了下面的关系:

c1,4c4,5c5,6c5,7c6,8c7,8c_{1,4}→c_{4,5}→c_{5,6}|c_{5,7}→c_{6,8}|c_{7,8}(这里的 | 单纯是或者的意思没有位运算或其他意思)

是不是觉得这个关系可以……

建一个 DAG ?

姑且只对这个样例建的图进行分析,那么在 13,20,27,34,4113,20,27,34,41 里找等差数列这个问题,不就转换成找这个图里有几条路径(最后再加上节点个数)吗?

进一步分析全局,对于每一个不同的公差,都会有一个这样子的图,也就是说,对于每一个公差(也就是每一个图),都求解一遍 这个图里有几条路径 这个问题就好了。

关于一个图内部有几条路径,很明显的搜索或 DP 吧?

不过既然是在图上 DP,还是打搜索(或记忆搜)比较容易防止出现奇奇怪怪的细节 bug。

void dfs(int u){// u表示现在搜到哪个节点
   if(vis[u]) return; // 如果已经走过了,回溯
   vis[u]=dp[u]=1; // 标记这个点已经走过,对dp赋初值
   for(register int i=0;i<l[u].size();++i){

   /*用邻接矩阵存图,l[i]表示i号点连向的所有点
    *注意这里不可以写 if(vis[l[u][i]) continue;
    *因为无论搜不搜都需要更新dp[u]的值
    */
      dfs(l[u][i]),
      dp[u]+=dp[l[u][i]],
      dp[u]%=mod; // 一定不要忘记取模
   }
   s+=dp[u],s%=mod; // s存储答案
   return;
}

这里有人质疑了:如何保证这个图是 DAG ?如果要 DP ,是不是还要先拓扑排序?

如何保证图是 DAG ?

很显然,因为对于一个三角形内,坐标 (i,j)(i,j) 所连向的点一定在三角形下方,而如果三角形下方的点要连回上方,则 j<ij<i,但是对于我的存储方法(右上角为直角的直角三角形存储,见前文,一定要 i>ji>j),倘若 jij\leqslant i,那么这个点根本不在三角形内,也就是无论怎样都不会连上去。

同理,如果你选择的是左下的三角形的存储,那么要从下面跳到上面,一定要有 (i,j)(i,j),满足 j>ij>i,但是对于这种存储方法,一定有 j<ij<i,所以也不必担心。

图是个 DAG,并不是用其他的特判来保证的,而是它本来就一定是 DAG。

DP 或搜索需不需要先拓扑排序?

按照上述的方法建图,不难想到,一定会有后面的点所在行比前面的点所在行要大。也就是行数一定是递增的(在任意一条路径上,行数是严格递增的),本身不存在后效性。

而这个图本身的拓扑序也一定是 1n1-n 递增的,所以不需要拓扑排序。

对于其他问题,请评论提问,基本我有空就会回答。

2.4 失误与特判

这个代码我交了很多遍,总是有一个数据点 MLE,但是理论上 MLE 的现象基本不会出现,我尝试进行空间优化,依旧是 其他数据跑得很快,唯独一个 MLE。 我仔细思考了可能的原因,发现了代码缺陷。

一个非正解而且如此猎奇的代码固然是有缺陷的。那就是对于本身所有数字都相等,又因为我的建图方式是邻接表,这种方式会造成很大的空间浪费。

对于这类无法解决的 55 分,我采用的是最无脑的方法——特判。

假设输入 aa 数组全部相等,经过手算可以得知,设总共 nn 个数,则有 2n12^n-1 种方法。注意此时不能直接用 pow 函数,要快速幂方便取模。

2.5 复杂度分析

对于构造 cc 数组,很明显的 n2n^2

对于建图,这是个很悬的问题,理论复杂度是 n3n^3,因为对于每个点,都需要扫描一整行来寻找有没有和它相等的点。但是因为各种常数优化(比如本身只存了半个 cc 数组)和玄学东西,导致它复杂度比期望值小得多得多。(关于这一段的复杂度,我和机房其他神佬也没有研究明白,如果有研究明白了的,请私信我,谢谢)

对于跑图,因为每一个公差都会造一个图,这些图总共的点数是 n2n^2,搜索时每个点只会到一次,所以这一段复杂度也是 n2n^2

3. 代码

注:各位既然都在做这道蓝题了,非常简单的代码部分注释不会很详细的,而且谁闲着无聊给输入输出之类的都打上注释啊

#include<vector>
#include<cstdio>
#define mod 998244353

int n,t,s,a[1001],c[1001][1001],id[1001][1001],dp[500001];
bool vis[500001],tp=1;
std::vector<int> l[500001]; // 邻接表存图

void dfs(int u){// 搜索,前面已经展示过详细注释了
   if(vis[u]) return;
   vis[u]=dp[u]=1;
   for(register int i=0;i<l[u].size();++i){
      dfs(l[u][i]);
      dp[u]+=dp[l[u][i]],dp[u]%=mod;
  }
   s+=dp[u],s%=mod;
   return;
}

inline long long pw(long long a,long long b){// 快速幂
   long long s=1,t=a;
   while(b>0){
   	if(b&1) s*=t,s%=mod;
   	b>>=1, t*=t,t%=mod;
   }
   return s;
}

int main(){
   scanf("%d",&n);
    s=n; // 千万不要忘记了单个点也是等差数列
   for(register int i=1;i<=n;++i){
   	scanf("%d",&a[i]);
   	if(i>1 && a[i]!=a[i-1]) tp=0; // 特判:是不是所有数字相等
   }
   if(tp){ // 特判:如果是,直接快速幂
   	printf("%lld",pw(2,n)-1);
   	return 0;
   }
   for(register int i=1;i<n;++i) for(register int j=i+1;j<=n;++j){
  	//printf(""); 这是个玄学的地方,没有它程序有几率炸掉,读代码时不必理会
       id[i][j]=++t; // 给每个点记一个编号方便建图
       c[i][j]=a[i]-a[j];
   }
   for(register int i=1;i<n;++i)
     for(register int j=i+1;j<=n;++j)
       for(register int k=j+1;k<=n;++k)
  // 第1、2层循环枚举每一个点(i,j),第2、3层循环枚举与之对应的相等的点(j,k)
         if(c[i][j]==c[j][k])
             l[id[i][j]].push_back(id[j][k]);// 邻接表式建边
   for(register int i=1;i<=t;++i) if(!vis[i]) dfs(i);
   // 每个图之间不连通,不能直接dfs(1);
   printf("%d",s);
   return 0;
}