动态规划类型题之最长递增子序列
最长递增子序列大家肯定都不陌生,属于动态规划算法的必学内容,根据最近做题的经验,发现了许多跟最长递增子序列非常类似的题目,做以下总结。
如果还不了解最长递增子序列的可以看一下我之前写的另一个博客
浅谈最长上升子序列和最长公共子序列(含n²优化)
接下来进入正题
先看一下最长递增子序列的核心代码
for(int i=2;i<=n;i++){
for(int j=1;j<i;j++){
if(a[j]<=a[i])
dp[i]=max(dp[i],dp[j]+1);
}
}
代码这样写比较简单易懂,其中ans表示dp数组的最大值,max表示前i个数中满足递增条件的最大长度值,这样写不仅容易理解,还减少了对dp[i]的频繁赋值,将代码分布到一个循环里面,减小了时间复杂度和代码量。
int ans = 0;
for(int i=2;i<=n;i++){
int max = 0;
for(int j=1;j<i;j++){
if(a[j]<=a[i] && dp[j]>max)
max=dp[j];
}
dp[i]=max+1;
if(dp[i]>ans) ans=dp[i];
}
在上面两段代码里面,dp[i]的含义是以第i个数为结尾的最长递增子序列的数目,做法就是二重循环对第i个数之前的i-1个数进行遍历,当i位置的数和前面某一个数满足递增序列的条件时选取可以使i位置递增长度最大的值,当不满足条件时值不发生变化(也就是取自己本身的值),最后的结果就是对dp数组进行遍历求最大值。
其中重点部分进行了加粗,先不解释,我们先来看一个题
挖地雷
题目描述
在一个地图上有N个地窖(N≤20),每个地窖中埋有一定数量的地雷。同时,给出地窖之间的连接路径。当地窖及其连接的数据给出之后,某人可以从任一处开始挖地雷,然后可以沿着指出的连接往下挖(仅能选择一条路径),当无连接时挖地雷工作结束。设计一个挖地雷的方案,使某人能挖到最多的地雷。
这是该题的题解
#include <bits/stdc++.h>
using namespace std;
int n,g[25][25],a[25],dp[25],pre[25],link,maxn,res,pos;
void myprint(int i){
if(pre[i]!=-1){
myprint(pre[i]);
}
printf("%d ",i+1);
}
int main()
{
scanf("%d",&n);
for(int i=0;i<n;i++) scanf("%d",&a[i]);
for(int i=0;i<n-1;i++)
for(int j=i+1;j<n;j++){
scanf("%d",&link);
if(link==1) g[i][j]=link;
}
memset(pre,-1,sizeof(pre));
for(int i=0;i<n;i++){
maxn=0;
for(int j=0;j<n;j++){
if(g[j][i] && dp[j]>maxn){
maxn=dp[j];
pre[i]=j;
}
}
dp[i]=maxn+a[i];
if(dp[i]>res){
res=dp[i];
pos=i;
}
}
myprint(pos);
printf("\n%d",res);
return 0;
}
这是核心代码
for(int i=0;i<n;i++){
maxn=0;
for(int j=0;j<n;j++){
if(g[j][i] && dp[j]>maxn){
maxn=dp[j];
pre[i]=j;
}
}
dp[i]=maxn+a[i];
if(dp[i]>res){
res=dp[i];
pos=i;
}
像,实在是太像了! 再来回头看看刚才最长递增子序列的分析
做法就是二重循环对第i个数之前的i-1个数进行遍历,当i位置的数和前面某一个数满足递增序列的条件时选取可以使i位置递增长度最大的值,当不满足条件时值不发生变化(也就是取自己本身的值),最后的结果就是对dp数组进行遍历求最大值。
二重循环、遍历、满足条件时进行相应操作、最后的结果存在于dp数组中,都对上了,那有人就要说了,你这不对啊,你这个题目二重循环的条件不对啊,你这个二重循环对所有的点都遍历了,而最长递增子序列只对前i-1个点进行了遍历。
其实在最长递增子序列中隐含了一个条件,就是递增子序列的组成只能由i和其前i-1个数构成,但是在挖地雷的题目中,以第i个地窖为结尾时最多挖地雷的数目可以由前面和后面的所有的点连接的路径所构成,所以在该类题目中二重循环的条件其实默认是遍历所有的点。
简单总结一下
我们来分析以下挖地雷和最长递增子序列的做法的相同点:
- dp[i]都表示以i为结尾时的结果,即动态规划的特点,所以一重循环遍历所有点,为这一点求最优值。
- 二重循环的遍历都是为一重循环中的点i求最优值,最后的dp数组中的值都是以某一点为结尾的最优值,这也决定了最后的结果存在于dp数组中,但有的题目中会对二重循环遍历的点进行限制。
- 在二重循环中,当满足题目中的符合最小的最优结构的条件时,对二重循环的值进行记录,否则不做处理,或做其他处理,在二重循环结束时将该最优值赋值给dp[i]。其实有的题目不需要进行条件的判定,但往往给你省了一步条件判定的题只会更难,最后的结果是dp数组中某一部分的和。
- 最后的结果就在dp数组中寻找。
好了,其实这就是最简单通用的线性DP的做法思路 /doge ,需要满足dp数组是一维的,二维的更复杂一点
如何分辨题目用一维还是二维数组呢?其实最简单的一点就是看数据量,如果数据量超过 10 5 ^5 5那指定是一维,否则大概率是二维。
就像这个题
大师
你说好巧不巧的,数据就给你10
4
^4
4,指定是需要开二维数组,这个题难点在于二维数组的定义和转化,dp[i][j]数组表示满足以第i个数字为结尾以j为公差的最优解,最后的结果就是所有二维数组之和,能想到这一步,其实就成功了百分之80了。
那怎么才能想到这一步呢? 为什么dp[i][]j]不能表示第i个数到第j个数之间满足条件的所有数量呢,其实稍微一想就可以知道,如果这样做那就需要开三重循环来遍历公差的值了,所以dp数组这样定义是不行的。
那么只有修改一下dp数组的定义了。
大部分人思考本题是会受限的,因为除了上述dp数组的定义,可能还会想到用dp[i][j]来表示以i为结尾,公差小于j的最优值,最后的结果只需要输出dp[1~n][max]的和,但是仔细一想还是能想到如果这样定义还是会使用到三重循环,还是会超时。
如何不适用三重循环?
那就是把最后的结果隐藏在所有的二维数组中,而不是某一行或某一列,最后结果需要用二维数组的所有值相加,这样就减少了一重循环。
这里我大胆的得出一个结论:当dp需要使用三(k=3)重循环,而结果隐藏在二维数组的某一列或某一行中(n=1)时才能解决问题的时候,可以使n=2,k=2来解决问题。
也存在n=0的情况,n=0的情况就是存在条件判定语句,最后结果为dp数组中的某一个值,前两个题都是n=0的情况。
再大胆得出一个结论,条件判定语句可以减少n的值
AC代码
#include <iostream>
using namespace std;
#define mod 998244353
#define Num 20005
int n,dp[1005][2*Num],a[1005],ans,d;
int main()
{
scanf("%d",&n),ans=n;
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=2;i<=n;i++){
for(int j=i-1;j;j--){
dp[i][a[i]-a[j]+Num]+=dp[j][a[i]-a[j]+Num]+1;
//这里的1表示i位置的数和j位置的数这两个数构成的数列
dp[i][a[i]-a[j]+Num]%=mod;
}
}
for(int i=2;i<=n;i++)
for(int j=0;j<=2*Num;j++){
ans+=dp[i][j];
ans%=mod;
}
printf("%d",ans);
return 0;
}
题解就不写了,这里主要就是对比一下前两道题总结一些规律,二重循环的时候还可以反着来,这里没有上面总结的第三点条件判定,但是题目反而更难了。
以后做题时可以按照以下代码的模板进行思考问题,可能会更快解出题目
int ans = 0;
for(int i=2;i<=n;i++){//1.基本不变
int max = 0;
for(int j=1;j<i;j++){//2.遍历方法和遍历结束条件可能会变化
if(a[j]<=a[i] && dp[j]>max)
//3.可能不用进行判定,这样结果就是就是dp数组相加的形式
//4.根据题意,还可以在判定条件里面记录路径
max=dp[j];
}
dp[i]=max+1;
if(dp[i]>ans) ans=dp[i];
}
内容都是自己的思考和想法,具体正确与否还需读者自己思考,还请大佬们批评指正。