[早期][复习资料]动态规划的优化
好久以前写的博客了,想了想还是搬过来吧。
这只是一篇很水的文章而已……
关于动态规划转移方面的优化,可能要用到以下数据结构:
- 单调队列
- 树状数组
- 线段树
- ST表
- 堆
- 平衡树
- ......
例题一
这道题相当于区间覆盖问题的加强版,区间覆盖问题中每个可选区间的权值都是1,而这道题的权值则可能不一。
我们用 \(l[i]\) 和 \(r[i]\) 表示第 \(i\) 个区间的左右端点, \(v[i]\) 表示权值。
先考虑状态,我们可以定义 \(dp[i]\) 表示第 \(i\) 个区间必须选,且第 \(i\) 个区间的右端点之前的点都被覆盖到了,则如果第 \(j\) 个区间可以转移到当前的第 \(i\) 个区间,则第 \(j\) 个区间需满足 \(l[i]-1\le r[j]\) (即第 \(j\) 个区间可以无缝接上第 \(i\) 个区间),于是得到转移方程:
由此便可得到应该按 \(r[j]\) 递增排序(为什么?),于是便可以写程序了。
但是这样做时间复杂度是 \(O(n^2)\) (状态( \(O(n)\) ),转移( \(O(n)\) )),过不了,考虑一下优化转移。
在求解 \(dp[i]\) 时,我们是考虑所有的满足 \(l[i]-1\le r[i]\) 的 \(dp[j]\) 的最小值,区间查询最小值,于是便想到线段树,求解 \(dp[i]\) 时,查询 \(l[i]-1\) 之后的最小值,求出一个 \(dp[i]\) 后,便把它丢到线段树中去,这样时间复杂度就是 \(O(\log_2n)\) ,需要注意的就是要把每个区间的 \(r[i]\) 离散化一下,当然,由于每次只是修改最后一个,也可以用ST表来维护,这样就可以过这道题了。
思考题:
Q1: 请问能否按左端点排序?
当然可以,只需一个 reverse() 就可以了。
实际上这样做是不会有任何问题的,两个区间用两种方式排序后,如果先后顺序不同,则两个区间必定是包含关系(为什么?),所以最终的答案是不可能包括两个区间都选的(为什么?),所以说,这两个区间的先后关系对最终的答案并没有影响,同样,转移方程还是原来的那个转移方程,只是不能用ST表了(为什么?)。
Q2: 请问 Q1 对我们解决问题有没有什么帮助?
都找出解法还考虑那么多干嘛???
其实是有的,这样,我们每次在转移的时候就可以保证满足 \(l[i]-1\le r[j]\) 的 \(l[i]-1\) 会始终保持递增,过时了的 \(j(r[j]<l[i]-1)\) 就回永远保持过时状态。
这时可能就会有人想到单调队列,那么这道题可不可以用单调队列呢???
应该是不行的,因为 \(r\) 不是单调的,当前最优的 \(dp[j]\) 可能会“早退”,导致之前某个被放弃的方案 \(dp[k]\) ( \(k<j\land r[k]>r[j]\land dp[k]>dp[j]\) )成为最优的(为什么?)
所以 Q1 还是没用喽。
不能用单调队列,我们为何不试试堆,单调队列会放弃一些暂时不是最优的方案,但堆还是会保留它们,我们只需要看看堆顶是否满足当前要求,如果不满足,弹掉就是了。
放上蒟蒻的代码(堆优化):
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
#include<queue>
#define LL_MAX 5000000005
using namespace std;
template<typename T>void read(T &x){
x=0;int f(1);char c(getchar());
for(;!isdigit(c);c=getchar())if(c=='-')f=-f;
for(; isdigit(c);c=getchar())x=(x<<3)+(x<<1)+c-'0';
x*=f;
}
template<typename T>void write(T x){
if(x<0)putchar('-'),x=-x;
if(x/10)write(x/10),x=x%10;
putchar(x+'0');
}
struct sb{
int l,r,c;
bool operator < (const sb o)const{
return l==o.l?r<o.l:l<o.l;
}
}p[10005];
long long dp[10005];
struct cmp{
bool operator () (const int a,const int b)const{
return dp[a]>dp[b];
}
};
priority_queue<int,vector<int>,cmp>q;
int main(){
int N,L,R;
read(N),read(L),read(R);
for(int i=1;i<=N;++i){
read(p[i].l),read(p[i].r),read(p[i].c);
}
sort(p+1,p+N+1);
p[0].r=L-1;dp[0]=0;
q.push(0);
long long ans=LL_MAX;
for(int i=1;i<=N;++i){
dp[i]=LL_MAX;
int j=q.top();
while(p[j].r<p[i].l-1&&!q.empty())
q.pop(),j=q.top();
if(q.empty())break;
dp[i]=dp[j]+p[i].c;
if(p[i].r>=R){
ans=min(ans,dp[i]);
}
q.push(i);
}
if(ans!=LL_MAX)write(ans);
else write(-1);putchar('\n');
return 0;
}
例题2
题意就是询问在 \(n\) 个数中严格递增的长度大于等于 \(m\) 的子序列有多少个。
考虑最暴力的 \(dp\) ,设状态 \(dp[i][j]\) 表示前 \(i\) 个数以 \(a[i]\) 结尾的长度为 \(j\) 的子序列个数,则有转移:
初始化 \(dp[i][0]=1\) ,时间复杂度 \(O(n^2m)\) 。
怎么优化?看到 \(a[j]<a[i]\) 会想到什么?值域线段树(或树状数组),我们可以开 \(m\) 个线段树(树状数组),就可以在 \(O(\log_2n)\) 的时间内求出 \(\sum dp[j][k-1]\) ,时间复杂度降为 \(O(nm\log_2n)\) ,应该是可以过的,需要注意的就是要离散化。
有没有其他方式呢?
我们是从后往前枚举的,这样满足了 \(j<i\) ,然后用线段树来满足第二个条件 \(a[j]<a[i]\) ,那我们为何不反过来试试,先满足 \(a[j]<a[i]\) ,再利用数据结构满足 \(j<i\) 呢???
于是我们便找到了第二种优化方案,先对原序列按值进行一次排序,再从前往后扫一遍,这样做的好处就是可以省去离散化(但是多了个排序,其实时间复杂度差不多)。
放上蒟蒻的代码(第二种优化方式):
#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
template<typename T>void read(T &x){
x=0;int f(1);char c(getchar());
for(;!isdigit(c);c=getchar())if(c=='-')f=-f;
for(; isdigit(c);c=getchar())x=(x<<3)+(x<<1)+c-'0';
x*=f;
}
template<typename T>void write(T x){
if(x<0)putchar('-'),x=-x;
if(x/10)write(x/10),x=x%10;
putchar(x+'0');
}
struct chibi{
long long a;int i;
bool operator < (const chibi o)const{
return a==o.a?i>o.i:a<o.a;
}
}a[1004];
long long dp[1004][1004],tree[1004][1004];
const long long mod=1e9+7;
long long mo(long long x){
return x>mod?x-mod:x;
}
int lowbit(int t){
return t&-t;
}
int N;
void add(int n,int k,long long v){
while(n<=N){
tree[n][k]=mo(tree[n][k]+v);
n+=lowbit(n);
}return;
}
long long sum(int n,int k){
long long ans=0;
while(n){
ans=mo(ans+tree[n][k]);
n-=lowbit(n);
}return ans;
}
void output(int num){
int M;read(N),read(M);
for(int i=1;i<=N;++i){
read(a[i].a),a[i].i=i;
}
sort(a+1,a+N+1);
long long ans=0;
memset(dp,0,sizeof dp);
memset(tree,0,sizeof tree);
for(int i=1;i<=N;++i){
dp[i][0]=1;add(a[i].i,0,dp[i][0]);
for(int k=1;k<min(i,M);++k){
dp[i][k]=sum(a[i].i-1,k-1);
add(a[i].i,k,dp[i][k]);
}
ans=mo(ans+dp[i][M-1]);
}
putchar('C'),putchar('a'),putchar('s'),putchar('e');
putchar(' '),putchar('#'),write(num),putchar(':');
putchar(' '),write(ans),putchar('\n');return;
}
int main(){
int T;read(T);
for(int i=1;i<=T;++i)
output(i);
return 0;
}
可以看到,枚举是先枚举的 \(n\) ,再枚举的 \(k\) ,我们也可以先枚举 \(k\) ,再枚举 \(n\) ,这样就可以只开一个树状数组,同样 \(dp\) 的一维也可以滚掉。
斜率优化
推荐几个博客:
矩阵快速幂
推荐几个博客:
四边形不等式
总之,动态规划转移方面的优化,就是要缩小当前的转移集合或很快从当前的转移集合中找出最优转移,所以就是要考虑之前所求的转移集合对当前的转移集合有没有影响,能否用一种数据结构很快地找出最优转移。