单调队列优化DP

DP作为一种结果一定正确的算法,在推出了正确方程的情况下,最难解决的就是效率问题。

先来看一道题:洛谷P1725琪露诺

对于这道题,我们可以很轻松的得出下面这段代码:

for(int i = L; i <= n; i++)
        for(int j = max(0, i - R); j <= i - L; j++)
            f[i] = max(f[j] + a[i], f[i]);

这个方程很好推,但是效率为O(n * (R - L - 1)),看看数据范围,很明显是不够的(实测60分)

那么我们该怎么优化呢?

很明显a[i]是不变的,所以我们的方程等价于f[i] = max(f[j]) + a[i] (i - R <= j <= i - L)。

到了这一步可能有人就会说:这不是线段树优化DP吗?

其实也可以,就是效率多一个log,在这里献上我的同学神犇NSH的线段树AC码:

#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>

using namespace std;
int L,R,a[200001],n;
int dp[200001];
int nxt[200001];
struct DP{
    int val,loc;
};
DP Max(DP x,DP y)
{
    return (x.val>y.val)?x:y;
}
struct note{
    int l,r;
    DP va;
}rt[2000001];
int num=0;
void build(int x,int L,int R)
{
    if(L==R)
    {
        rt[x].va.val=-2100000000;
        return;
    }
    
    int mid=(L+R)/2;
    
    rt[x].l=++num;
    build(num,L,mid);
    rt[x].r=++num;
    build(num,mid+1,R);
    rt[x].va=Max(rt[rt[x].r].va,rt[rt[x].l].va);
}

void Insert(int x,int L,int R,int aim,int v,int lo)
{
    if(L==R)
    {
        rt[x].va.val=v;
        rt[x].va.loc=lo;
        return;
    }
    
    int mid=(L+R)/2;
    
    if(aim<=mid)
    {
        Insert(rt[x].l,L,mid,aim,v,lo);
    }
    else
    {
        Insert(rt[x].r,mid+1,R,aim,v,lo);
    }
    
    rt[x].va=Max(rt[rt[x].l].va,rt[rt[x].r].va);
    
}

DP Query(int x,int L,int R,int al,int ar)
{
    if(R<al||ar<L)
    {
        DP tmp;tmp.val=-2100000000;
        return tmp;
    }
    if(al<=L&&R<=ar)
    {		
        return rt[x].va;
    }
    
    int mid=(L+R)/2;
    return Max(Query(rt[x].l,L,mid,al,ar),Query(rt[x].r,mid+1,R,al,ar));
}
int main()
{
    scanf("%d%d%d",&n,&L,&R);
    
    for(int i=0;i<=n;i++)
    {
        scanf("%d",a+i);
    }
    memset(dp,-127,sizeof(dp));
    num++;
    build(1,0,n);
    
    dp[n]=a[n];
    Insert(1,0,n,n,dp[n],n);
    
    for(int i=n-1;i>=0;i--)
    {
        DP temp;
        if(i+R>n)
        {
            if(dp[i]<a[i])
            {
                dp[i]=a[i];
            }
            if(i+L<=n)
            {
                temp=Query(1,0,n,i+L,n);
                if(dp[temp.loc]+a[i]>dp[i])
                {
                    dp[i]=dp[temp.loc]+a[i];
                    nxt[i]=temp.loc;
                }
            }
            
            
        }
        if(i+R<=n)
        {
            temp=Query(1,0,n,i+L,i+R);
            if(dp[temp.loc]+a[i]>dp[i])
            {
                dp[i]=dp[temp.loc]+a[i];
                nxt[i]=temp.loc;
            }
        }
        
        
        Insert(1,0,n,i,dp[i],i);
        //cout<<dp[i]<<endl;
    }
    printf("%d\n",dp[0]);
    return 0;
}

但是这份码在洛谷上跑了544ms,有没有什么更快的方法呢?

单调队列!

很明显这道题的维护目标是单调的,而且是单变量的(j)

那么考虑维护一个队列,其中队列头部的元素是最大的,我们需要的最优解也就是队头元素了

但是这样有几个问题:如何维护单调性?如何维护合法性?

考虑单调性,我们的初始代码为什么慢?因为有很多没有必要的比较,如果a>b,在之后的过程中我们只需要让新来的c和a比较,因为我们只取较大的那个,但是你的程序会让c和b再比一次,这就是无意义的比较。而我们可以将一个新的元素插在队尾,在插入之前先把队尾比它小的元素删除,那么整个队列就维持了单调性,免除了无意义的比较。

再考虑合法性,在这个问题中,所谓的合法性就是指的可供转移的状态必须在i - R ~ i - L之间,我们注意到队列中的元素最初都是从队尾插入的,所以这个队列不仅满足所维护的值单调,也满足时间单调性,所以队头元素不仅是最优的,也是最老的(雾),那么在更新之前,我们检查一下队头元素是否有“过期”,是就删除,否则这就是你要的最大值。

献上本人丑陋的代码(40ms):

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cstdlib>
#include <iostream>
using namespace std;
const int MAXN = 300040;
typedef int Array[MAXN];
Array a, f, p, p2;
int n, L, R, ans, pos, cnt;
inline int read()
{
    int ch; int x = 0; int f = 1; ch = getchar();
    while(ch != '-' && (ch < '0' || ch > '9')) ch = getchar();
    ch == '-' ? f = -1 : x = ch - '0', ch = getchar();
    while(ch >= '0' && ch <= '9')
        {x = x * 10 + ch - '0'; ch = getchar();}
    return x * f;
}
namespace DP {
    Array Q;int h, t;
    void init() {
        h = 0; t = 1;
        memset(Q, 0, sizeof(Q));
    }
    void dp() {
        for(int i = L; i <= n; i++) {
            while(h <= t && f[Q[t]] <= f[i - L]) t--;//删除比它小的 
            Q[++t] = i - L;//插入(顺带维护了j <= i - L) 
            while(h <= t && Q[h] < i - R) h++;//销毁过期产品 
            f[i] = f[Q[h]] + a[i];//更新 
        }
        for(int i = n - R + 1; i <= n; i++) 
            if(ans < f[i]) 
                ans = f[i], pos = i;
        printf("%d\n", ans);
    }
}
signed main() {
    n = read(), L = read(), R = read();
    for(int i = 0; i <= n; i++) a[i] = read();
    for(int i = 0; i < L; i++) f[i] = 0;
    DP::init();
    DP::dp();
    return 0;
}

再来一题:洛谷P2627修剪草坪,或bzoj2442(同一题)

这道题同样有一个比较好推的方程,不能出现连续的k个,也就等同于每k个都必须有一个断点,所以一个枚举断点的DP方程就和明显了,不取断点j,从j-1继承,然后加上a[j + 1]到a[i]:f[i] = max(f[i], f[j] + sum[i] - sum[j]);其中sum数组为前缀和数组

先献上一份70分(LG)的代码:

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cstdlib>
#include <cmath>
#include <iostream>
#define LL long long
using namespace std;
int n, k;
const int MAXN = 100100;
inline int read()
{
    int ch; int x = 0; int f = 1; ch = getchar();
    while(ch != '-' && (ch < '0' || ch > '9')) ch = getchar();
    ch == '-' ? f = -1 : x = ch - '0', ch = getchar();
    while(ch >= '0' && ch <= '9')
        {x = x * 10 + ch - '0'; ch = getchar();}
    return x * f;
}
typedef long long Array[MAXN];
Array f, sum;
signed main() {
    scanf("%d%d", &n, &k);
    for(int i = 1; i <= n; i++) sum[i] = sum[i - 1] + (LL) read();
    for(int i = 1; i <= n; i++)
        for(int j = max(i - k, 0); j <= i; j++)
            f[i] = max(f[i], f[j - 1] - sum[j] + sum[i]);
    printf("%lld\n", f[n]);
    return 0;
} 

然后同样的,sum[i]是不变的,所以可以将sum[i]提出来,那么我们需要维护的就是f[j - 1] - sum[j],单调队列的维护同上题:

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cstdlib>
#include <cmath>
#define LL long long
using namespace std;
int n, k;
const int MAXN = 100100;
inline int read()
{
    int ch; int x = 0; int f = 1; ch = getchar();
    while(ch != '-' && (ch < '0' || ch > '9')) ch = getchar();
    ch == '-' ? f = -1 : x = ch - '0', ch = getchar();
    while(ch >= '0' && ch <= '9')
		{x = x * 10 + ch - '0'; ch = getchar();}
    return x * f;
}
typedef long long Array[MAXN];
Array f, sum;
namespace DP {
	Array Q; int h, t;
	void init() {
		h = 0; t = 1;
	}
	bool check(int x, int y) {
		return f[x - 1] - sum[x] < f[y - 1] - sum[y];
	}
	void dp() {
		for(int i = 1; i <= n; i++) {
			while(h <= t && Q[h] < i - k) h++;
			int j = (int) Q[h];
			f[i] = f[j - 1] - sum[j] + sum[i];
			while(h <= t && check((int)Q[t], i)) t--;
			Q[++t] = (LL)i;
		}
		printf("%lld\n", f[n]);
	}
}
signed main() {
	scanf("%d%d", &n, &k);
	for(int i = 1; i <= n; i++) sum[i] = sum[i - 1] + (LL) read();
	DP::init();
	DP::dp();
	return 0;
}

单调队列同样可以用在斜率优化上

留坑,Thanks for your watching



  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
单调队列优化DP是一种常用的优化方法,可以将时间复杂度从 $O(n^2)$ 降低到 $O(n)$ 或者 $O(n \log n)$。以下是一道利用单调队列优化DP的典型题目: 题目描述: 给定一个长度为 $n$ 的序列 $a_i$,定义 $f(i)$ 为 $a_i$ 到 $a_n$ 中的最小值,即 $f(i) = \min\limits_{j=i}^n a_j$。现在定义 $g(i)$ 为满足 $f(j) \ge a_i$ 的最小下标 $j$,即 $g(i) = \min\{j \mid j > i, f(j) \ge a_i\}$。如果不存在这样的下标 $j$,则 $g(i) = n+1$。 现在请你计算出 $1 \le i \le n$ 的所有 $g(i)$ 的值。 输入格式: 第一行包含一个整数 $n$。 第二行包含 $n$ 个整数 $a_1,a_2,\cdots,a_n$。 输出格式: 输出 $n$ 行,第 $i$ 行输出 $g(i)$ 的值。 输入样例: 5 3 1 2 4 5 输出样例: 2 5 5 5 6 解题思路: 设 $dp(i)$ 表示 $g(i)$,那么 $dp(i)$ 与 $dp(i+1)$ 的转移关系可以表示为: $$dp(i)=\begin{cases}i+1, &\text{if}\ f(i+1)\ge a_i \\dp(i+1), &\text{else}\end{cases}$$ 这个转移方程可以使用暴力 DP 解决,时间复杂度为 $O(n^2)$。但是,我们可以使用单调队列优化 DP,将时间复杂度降为 $O(n)$。 我们定义一个单调队列 $q$,存储下标。队列 $q$ 中的元素满足: - 队列中的元素是单调递减的,即 $q_1 < q_2 < \cdots < q_k$; - 对于任意的 $i\in [1,k]$,有 $f(q_i) \ge f(q_{i+1})$。 队列 $q$ 的作用是维护一个长度为 $k$ 的区间 $[i+1,q_k]$,满足这个区间中的所有 $j$ 都满足 $f(j) < f(i+1)$。 根据定义,当我们要求 $dp(i)$ 时,只需要查找队列 $q$ 中第一个满足 $f(q_j) \ge a_i$ 的位置 $q_j$,那么 $g(i) = q_j$,如果队列 $q$ 中不存在这样的位置,则 $g(i) = n+1$。 那么如何维护单调队列 $q$ 呢?我们可以在每次 DP 的过程中,将 $i$ 加入队尾。然后判断队首元素 $q_1$ 是否满足 $f(q_1) \ge a_i$,如果满足则弹出队首元素,直到队首元素不满足条件为止。 由于每个元素最多被加入队列一次,并且最多被弹出一次,因此时间复杂度为 $O(n)$。具体实现细节可以参考下面的代码实现:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值