吐槽:动态规划这个东西,只要推不出状态转移方程,一切都白搭
基础知识
一. 动态规划
动态规划中最重要的三个概念:最优子结构,重复子问题,无后效性。
- 最优子结构:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。即“现在决定未来,未来与过去无关。”
- 重复子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。虽然该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势
二 . 线性动态规划
线性动态规划是各种动态规划的基础,是动态规划中变化最多的一类。
线性动态规划的主要特点是状态的推导是按照问题规模 i 从小到大或从大到小依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
线性动态规划针对的问题是最常见的数组,字符串,矩阵等,这三种数据结构本身就是线性的。因此出现这些类型的输入的时候,如果要用到动态规划,首先考虑线性动态规划就很合理了,因此很多问题不论最后正解是不是线性动态规划,都会首先想一下线性动态规划是否可行。
单个数组或字符串上设计一维状态,两个数组或字符串上设计两维状态,以及矩阵上设计两维状态等等,同时以上三种情况的状态设计都有可能再加上额外的判断标准的状态,这里面变化就很多了,有的题目可能会在新增的这一维上使用二分,贪心的策略,有的题目需要 DP 状态与数据结构配合来解决问题。
除此之外,还有一类问题没有显式的数组,字符串,但是在求解的时候依然满足前面提到的动态规划三条基本概念,可以用动态规划求解,这种问题通常也是线性动态规划。
背包问题也属于线性动态规划,但是由于变化较多,会拿出来专门介绍。
一 . 求最长上升子序列Longest Increasing Subsequence(LIS问题)
题目描述
给出一个由 个不超过 的正整数组成的序列。请输出这个序列的最长上升子序列的长度。
最长上升子序列是指,从原序列中按顺序取出一些数字排在一起,这些数字是逐渐增大的。
输入格式
第一行,一个整数 n,表示序列长度。
第二行有 n 个整数,表示这个序列。
输出格式
一个整数表示答案。
输入输出样例
输入 #1
6 1 2 4 1 3 4
输出 #1
4
说明/提示
分别取出 1、2、3、4 即可。
思路:
先从大循环开始,从 1 到 n,计算 ,记得初始化的值是 1;接下来从 1 到 i-1,即用 j 控制 i 之前的数值,如果 小于 的话,说明这个数可以和 组成上升子序列,则 取,最后统计在这段序列中最长上升子序列中长度的最大值。
时间复杂度为 的做法:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
const int maxn=1e6+5,INF=0x3f3f3f3f;
int a[maxn],f[maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<3)+(x<<1)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
n=read();
for(int i=1;i<=n;++i)
{
a[i]=read();
f[i]=1; //记得初始化为1;
}
for(int i=1;i<=n;++i)
{
for(int j=1;j<i;++j)
{
if(a[i]>a[j])
{
f[i]=max(f[i],f[j]+1);
}
}
}
int Max=-INF;
for(int i=1;i<=n;++i)
{
if(f[i]>Max) Max=f[i]; //记录所有数据中能保证得到的最大的子序列的长度
}
printf("%d",Max);
return 0;
}
二 . 求最长公共子序列 The longest common subsequence (LCS问题)
题目描述
给出 的两个排列 和 ,求它们的最长公共子序列。
输入格式
第一行是一个数 n 。
接下来两行,每行为 n 个数,为自然数 的一个排列。
输出格式
一个数,即最长公共子序列的长度。
输入输出样例
输入 #1
5 3 2 1 4 5 1 2 3 4 5
输出 #1
3
说明/提示
- 对于50%的数据, ;
- 对于100%的数据, 。
思路:
设dp[i][j]表示两个串从1到第一个串的第 i 位,求与第二个串的第 j 位最多有多少个公共子元素
时间复杂度为 的做法:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
const int maxn=1e5+5;
int a[maxn],b[maxn],dp[3005][3005];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
n=read();
for(int i=1;i<=n;++i)
{
a[i]=read();
}
for(int i=1;i<=n;++i)
{
b[i]=read();
}
dp[0][0]=1; //还没进行比较的时候,每一个位置只有一个数
for(int i=1;i<=n;++i)
{
for(int j=1;j<=n;++j)
{
if(a[i] == b[j])
{
dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1);
//与还没有进行a[i]与b[j]匹配之前的子序列数量进行比较
}
else
{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]); //继承未匹配前的最大值
}
}
}
printf("%d",dp[n][n]);
return 0;
}
因为数据范围很大,开不了1e5*1e5的二维数组,而且相当于每一个串的每一个位置都需要比较,因此需要优化程序。
(待填坑)优化后的程序时间复杂度为 。
需要注意的是:最长公共子序列是按位向后比对的,所以a序列每个元素在b序列中的位置如果递增,就说明b中的这个数在a中的这个数整体位置偏后。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
const int maxn=1e5+5,INF=0x3f3f3f3f;
int a[maxn],b[maxn],dp[maxn],change[maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
n=read();
for(int i=1;i<=n;++i)
{
a[i]=read();
change[a[i]]=i; //记录下标
}
for(int i=1;i<=n;++i)
{
b[i]=read();
dp[maxn]=INF;
}
int len=0; //最开始的最长公共子序列的长度为0
dp[0]=0;
for(int i=1;i<=n;++i)
{
int l=0,r=len,mid;
if(change[b[i]]>dp[len]) //如果新加入的数比子序列的最大值还要大
{
dp[++len]=change[b[i]]; //直接加入到子序列中
}
else //利用二分查找降低复杂度
{
while(l<r)
{
mid=(l+r)>>1;
if(dp[mid]>change[b[i]]) //缩小区间
{
r=mid;
}
else l=mid+1;
}
}
dp[l]=min(dp[l],change[b[i]]);
}
printf("%d",len);
return 0;
}
三 . DAG中的最长路与最短路
例题:洛谷 P2583 地铁间谍 / UVA1025 城市里的间谍 A Spy in the Metro
思路:因为在时间轴上,能影响决策的只有所在时间点和当前所在的车站。
在进行决策时可以有三种选择:1 . 在当前车站等待一分钟;2.在当前车站搭上向左行驶的列车;3.在当前车站搭上向右行驶的列车;
设dp[i][j],下标表示时间点 i 和当前所在的车站 j ,求需要等待的最少时间;设train[i][j][0/1] ,表示在时刻 i 下,在车站 j 有一趟向右或者向左出发的列车。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,T,num=0;
const int INF=0x3f3f3f3f;
int t[205];
int dp[2005][55];
bool train[2005][55][3];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
while(1)
{
n=read();
if(n==0)
{
break;
return 0;
}
memset(train,0,sizeof(train));
memset(t,0,sizeof(t));
T=read();
for(int i=1;i<=n-1;++i)
{
t[i]=read();
}
int M1=read();
for(int i=0;i<M1;++i)
{
int a=read();
for(int j=1;j<=n;++j) //记录从左往右开的列车
{
train[a][j][0]=1;
a+=t[j]; //记录这一段的时间和
}
}
int M2=read();
for(int i=0;i<M2;++i) //记录从右往左开的列车
{
int b=read();
for(int j=n;j>=1;--j)
{
train[b][j][1]=1;
b+=t[j-1]; //记录时间前缀和
}
}
for(int i=1;i<n;++i)
{
dp[T][i]=INF; //寻找最小值需要初始化为最大值
}
dp[T][n]=0;
for(int i=T-1;i>=0;--i)
{
for(int j=1;j<=n;++j)
{
dp[i][j]=dp[i+1][j]+1; //停在原地
if(j<n && train[i][j][0] && i+t[j]<=T) dp[i][j]=min(dp[i][j],dp[i+t[j]][j+1]);
//搭上向右行驶的列车(条件解释:右边还存在站台,存在向右的列车,并且在规定时间范围内)
if(j>1 && train[i][j][1] && i+t[j-1]<=T) dp[i][j]=min(dp[i][j],dp[i+t[j-1]][j-1]); //搭上向左行驶的列车
}
}
cout<<"Case Number "<<++num<<": ";
if(dp[0][1] >= INF) cout<<"impossible"<<endl;
else cout<<dp[0][1]<<endl;
}
return 0;
}
其他无统称线性动态规划汇总
一 . 入门类线性动态规划
1 . 洛谷 P1799 数列
思路:设dp[i][j]为:在前 i 个数中删去 j 个数后,剩余最多的符合题意的数的个数。
可以分为当前位置的数是否删去:如果删去当前位置的数,那么:dp[i][j]=dp[i−1][j−1];若不删当前位置的数,依然分两种情况:如果这个数不符合题意:dp[i][j]=dp[i-1][j]dp[i][j]=dp[i−1][j];如果这个数符合题意:dp[i][j]=dp[i-1][j]+1dp[i][j]=dp[i−1][j]+1。
判断条件为:当前的数值与位置下标的值是否相等即(a[i] == i - j),当前位置在操作前可能已经删减了 i 个数,也可能没有。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
bool flag;
const int maxn=1e7+5,INF=0x3f3f3f3f;
int a[maxn],dp[1005][1005],Max=-INF;
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
n=read();
for(int i=1;i<=n;++i)
{
a[i]=read();
}
dp[0][0]=0;
for(int i=1;i<=n;++i)
{
for(int j=0;j<=i;++j)
{
if(j>0) dp[i][j]=dp[i-1][j-1]; //要注意一下j不能等于0,因为j-1前面没有数啦!
if(a[i]==i-j) dp[i][j]=max(dp[i][j],dp[i-1][j]+1);
else dp[i][j]=max(dp[i][j],dp[i-1][j]);
}
}
for(int i=1;i<=n;++i)
{
for(int j=0;j<=n;++j)
{
Max=max(Max,dp[i][j]);
}
}
printf("%d",Max); //查找最大值
return 0;
}
二 . 根据某种条件选取两个数,求总情况
题目描述
对于一个数列 ,如果有 i<j 且 ,那么我们称 与 为一对逆序对数。若对于任意一个由 1∼n 自然数组成的数列,可以很容易求出有多少个逆序对数。那么逆序对数为 k 的这样自然数数列到底有多少个?
输入格式
第一行为两个整数n,k。
输出格式
写入一个整数,表示符合条件的数列个数,由于这个数可能很大,你只需输出该数对10000求余数后的结果。
输入输出样例
输入 #1
4 1
输出 #1
3
说明/提示
样例说明:
下列3个数列逆序对数都为1;分别是1 2 4 3 ;1 3 2 4 ;2 1 3 4;
测试数据范围
30%的数据
100%的数据 ,
思路:
设 f [i][j] 表示从1~ i 的数列,有 j 个逆序对。
在1 ~ i-1 的数列中插入 i 这个数,可以产生0 ~ i-1 个逆序对
所以,可以推出式子:f [i][j]=∑ f [i-1][j-k] (&& )
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,k;
const int maxn=1005,mod=10000;
int f[maxn][maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
int main()
{
n=read(); k=read();
f[0][0]=f[1][0]=1; //初始化
for(int i=2;i<=n;++i)
{
int sum=0;
for(int j=0;j<=k;++j)
{
(sum+=f[i-1][j])%mod;
f[i][j]=sum%mod;
if(j-i+1>=0) sum=(sum-f[i-1][j-i+1]+mod)%mod;
}
}
printf("%d\n",f[n][k]);
return 0;
}
三. 线段覆盖问题
在这类问题中,通常是:在一维坐标系中,给出左右节点,问线段不重合的情况下,最长能覆盖多少,一般会通过建图的思想存储线段的左右节点。
思路:在一些线性结构中,可以将两个端点看做图的一条边,从大的端点向小的端点连线,两点之间的距离即y-x+1看做可修改的路径长度。在这道题中即对当前稻草的向前的边所指向的稻草 的最大价值加这条边的价值取最大值,就是当前稻草时的最大价值。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
long long int n,sum=0;
const int maxn=1e7+5,INF=0x3f3f3f3f;
struct node{
int to,next;
}edge[maxn<<1];
int head[maxn],num=0;
int dp[maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<3)+(x<<1)+(c^48);
c=getchar();
}
return x*f;
}
inline void add(int u,int v)
{
edge[++num].to=v;
edge[num].next=head[u];
head[u]=num;
}
int main()
{
n=read();
int Max=-INF;
memset(head,-1,sizeof(head));
for(int i=1;i<=n;++i)
{
int x,y;
x=read(); y=read();
add(y,x);
if(y>Max) Max=y; //找到最大的点
}
for(int i=0;i<=Max;++i) //范围从0到当前所有数据的最大值
{
dp[i]=dp[i-1];
for(int j=head[i];j!=-1;j=edge[j].next)
{
int v=edge[j].to;
dp[i]=max(dp[i],dp[v-1]+(i-v+1));
//当前节点的最大值:与当前节点相连的节点 的最大值 与这两个节点之间的距离之和
}
}
printf("%d",dp[Max]);
return 0;
}
变式1:已知线段的其中一个端点及线段的长度
思路:在某个时间点上,尼克存在两种状态,即空闲状态和正在完成任务的状态。因为“如果在同一时刻有多个任务需要完成,尼克可以任选其中的一个来做,而其余的则由他的同事完成,反之如果只有一个任务,则该任务必需由尼克去完成,假如某些任务开始时刻尼克正在工作,则这些任务也由尼克的同事完成。”,所以第 i 时刻的最大空闲时间与后面的选择任务的持续时间有关系,那么从最后选的事情进行倒推。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,k;
const int maxn=1e7+5;
struct node{
int p,t;
}edge[maxn<<1];
int f[maxn],dp[maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
inline bool cmp(node x,node y)
{
return x.p>y.p;
}
int main()
{
n=read(); k=read();
memset(f,0,sizeof(f));
for(int i=1;i<=k;++i)
{
edge[i].p=read();
edge[i].t=read();
f[edge[i].p]++; //标记这个点是否存在任务
}
sort(edge+1,edge+k+1,cmp);
for(int i=n;i>=1;--i)
{
if(f[i]==0) //若该点不存在任务
{
dp[i]=dp[i+1]+1; //比前一个空闲状态下多一个空闲时间
}
else
{
for(int j=1;j<=k;++j)
{
if(edge[j].p==i) //该点存在任务
dp[i]=max(dp[i],dp[i+edge[j].t]);
//选出空闲时间最长的点,从p开始t秒后全部休息不了
}
}
}
printf("%d",dp[1]);
return 0;
}
变式2:已知线段的两个端点,区间范围为左闭右开
例题:洛谷 P2439 [SDOI2005]阶梯教室设备利用
题目中要求“假设在某一演讲结束的瞬间我们就可以立即开始另一个演讲”,那么说明到达 k 时刻的时候已经算下一场演讲的范围了,所以在建图的时候,应该从 k-1 这个节点开始建立,意识到这一点其他的都跟原来的处理方式一样。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,p,k;
const int maxn=1e7+5,INF=0x3f3f3f3f;
int dp[maxn];
struct node{
int to,next;
}edge[maxn<<1];
int head[maxn],num=0;
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
inline void add(int u,int v)
{
edge[++num].to=v;
edge[num].next=head[u];
head[u]=num;
}
int main()
{
n=read();
memset(head,-1,sizeof(head));
int Max=-INF;
for(int i=1;i<=n;++i)
{
p=read(); k=read();
add(k-1,p); //!!这里建图要注意
Max=max(Max,k);
}
for(int i=1;i<=Max;++i)
{
dp[i]=dp[i-1];
for(int j=head[i];j!=-1;j=edge[j].next)
{
int v=edge[j].to;
dp[i]=max(dp[i],dp[v-1]+i-v+1);
}
}
printf("%d",dp[Max]);
return 0;
}
变式3:已知线段的两个端点,存在区间扩大的情况
例题:洛谷 P2889 [USACO07NOV]Milking Time S
思路:由题得:“每次 FJ 给 Bessie 挤奶之后,Bessie 都要休息 R 个小时,才能开始下一次挤奶”,那么,我们可以将休息的时间与挤奶的时间看成同一段时间,最后统计总时间段能够获得多少奶。
#include <iostream>
#include <cstring>
#include <cstring>
#include <algorithm>
using namespace std;
int n,m,r;
int start,End,ef;
const int maxn=1e7+5;
struct node{
int to,next,w;
}edge[maxn<<1];
int head[maxn],num=0,dp[maxn];
inline int read()
{
int x=0,f=1;
char c=getchar();
while(c<'0'||c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while(c>='0'&&c<='9')
{
x=(x<<1)+(x<<3)+(c^48);
c=getchar();
}
return x*f;
}
inline void add(int u,int v,int w)
{
edge[++num].to=v;
edge[num].w=w;
edge[num].next=head[u];
head[u]=num;
}
int main()
{
n=read(); m=read(); r=read();
memset(head,-1,sizeof(head));
for(int i=1;i<=m;++i)
{
start=read(); End=read(); ef=read();
add(End,start,ef);
}
for(int i=0;i<=n;++i)
{
dp[i]=dp[i-1];
for(int j=head[i];j!=-1;j=edge[j].next)
{
int v=edge[j].to;
dp[i]=max(dp[i],dp[max(0,v-r)]+edge[j].w);
//注意start节点向前推长度r时,可能出现超过时间0的情况,这里需要特判
}
}
printf("%d",dp[n]);
return 0;
}