论单调队列优化DP

前情提要,参考资料:单调队列优化DP(超详细!!!) - endl\n - 博客园

                                    单调队列/单调栈优化 - OI Wiki

                                  【动态规划】选择数字(单调队列优化dp)_哔哩哔哩_bilibili

背景:最近作者快被单调队列优化DP逼疯了,写篇博客做记录。


一、以下是对各DP的原理阐释

        

        单调队列通过队列元素的吸入与弹出,形成单调性的结构,使算法能够进行线性处理,大大优化了时间复杂度。接下来讲解单调队列在区间DP背包DP树形DP还有数位DP中的应用:

1.单调队列优化区间DP:
        (1)原理:区间DP通常用于求解与区间相关的最值问题。单调队列可以优化区间DP中的状态转移,通过维护一个单调递增或递减的队列,快速找到当前区间内的最优解。
        (2)案例:以“滑动窗口最大值”问题为例,要求在数组中找到每个长度为k的滑动窗口的最大值。使用单调队列时,队列中存储的是数组元素的索引,且队列中的元素对应的值是单调递减的。每次移动窗口时,只需要将队列中超出窗口范围的元素移除,并将新元素加入队列,同时保持队列的单调性。队列的队首元素对应的值即为当前窗口的最大值。这种方法的时间复杂度为O(n),相比暴力解法的O(nk)有显著提升。

2.单调队列优化背包DP:
        (1)原理:在背包DP中,单调队列优化主要应用于分组背包问题和多重背包问题。通过维护一个单调队列,可以快速找到在当前容量下能够获得的最大价值。
        (2)案例:在分组背包问题中,每组物品只能选择一个,且每组物品的价值和重量不同。使用单调队列优化时,对于每个容量,维护一个单调递增的队列,队列中的元素表示在当前容量下能够获得的最大价值。在状态转移时,通过队列快速找到最优解,从而避免了暴力枚举的高时间复杂度。

3.单调队列优化树形DP:
        (1)原理:树形DP通常用于求解树上的最值问题。但单调队列优化在树形DP中的应用相对较少,至少在某些特定问题中可以起到优化作用。通过维护一个单调队列,可以快速找到在当前子树中能够获得的最优解。
        (2)案例:在“树上最长路径”问题中,要求找到树上最长的路径。使用单调队列优化时,对于每个节点,维护一个单调递增的队列,队列中的元素表示从该节点到其子节点的路径长度。在状态转移时,通过队列快速找到最长路径,从而避免了暴力枚举的高时间复杂度。

4.单调队列优化数位DP:
        (1)原理:数位DP通常用于求解与数字相关的最值问题。单调队列优化在数位DP中的应用也相对较少,在某些特定问题中还是可以起到优化作用的。通过维护一个单调队列,可以快速找到在当前数位上能够获得的最优解。
        (2)案例:在“数位和最大”问题中,要求找到一个数字,使其数位和最大。使用单调队列优化时,对于每个数位,维护一个单调递增的队列,队列中的元素表示在当前数位上能够获得的最大数位和。在状态转移时,通过队列快速找到最优解,从而避免了暴力枚举的高时间复杂度。

需要注意的是,单调队列优化并非万能,它仅适用于具有特定性质的动态规划问题。在实际应用中,需要根据具体问题的特点选择合适的优化方法。(别说看了这篇文章后见DP就单调队列优化,优异的算法多的是,视境而择)


二、证明及示例代码

        1)单调队列优化区间DP:

                1.证明:

                        (1) 四边形不等式:对于函数 w(i, j),若满足 i ≤ i′ < j ≤ j′,有 w(i,  j) + w(i′,  j′) ≤ w(i′, j)+w(i, j′),则称 w 满足四边形不等式。

                        (2) 区间单调性:若满足i ≤ i' ≤ j ≤ j',有 w(i', j) ≤ w(i, j'),则称 w 具有区间单调性。

                        (3) 决策单调性:对于动归的状态转移方程 dp(i, j) = min(dp(i, k-1), dp(k, j)) + cost(i, j),若 cost 满足四边形不等式和区间单调性,则 dp 也满足四边形不等式,且其最优决策点 s(i, j)满足 s(i, j) ≤ s(i, j+1) ≤ s(i+1, j+1)

                2.代码ED:(以合并石子为例)

#include <iostream>
#include <vector>
#include <limits>
using namespace std;

int n;//记录石子堆数
vector<int> cost;//记录由1到i的花费
vector<vector<int>> s;//记录将石子i到j全部合并的最佳分割点

void fun_dp(){
    vector<vector<int>> dp(n + 5, vector<int>(n + 5, 0));
    for(int len = 2; len <= n; len++){
        for(int i = 1; i <= n - len + 1; i++){
            int j = i + len - 1;
            dp[i][j] = numeric_limits<int>::max();//初始化值
            for(int k = s[i][j - 1]; k <= s[i + 1][j]; k++){
                if(dp[i][k] + dp[k + 1][j] + cost[j] - cost[i - 1] < dp[i][j]){
                    dp[i][j] = dp[i][k] + dp[k + 1][j] + cost[j] - cost[i - 1];
                    s[i][j] = k;
                }
            }
        }
    }
    cout << dp[1][n] << endl;
}

int main(void){
    while(cin >> n){
        cost.resize(n + 5);//记录由1到i的花费
        s.resize(n + 5, vector<int>(n + 5, 0));
        int x = 0;
        for(int i = 1; i <= n; i++){
            cin >> x;
            cost[i] = cost[i - 1] + x;
            s[i][i] = i;//初始化最佳分割点
        }
        fun_dp();
    }
    return 0;
}

        2)单调队列优化背包DP:

                1.证明:

                        (1) 在多重背包问题中,状态转移方程为 dp[j] = max(dp[j], dp[j−k⋅v] + k*w),其中 v 是物品的体积,w 是物品的价值,k 是物品的数量。

                        (2) 通过将状态按体积 v 分类,每个同余类的状态转移则可以用单调队列进行优化。单调队列用来存储状态的下标,且队列中的状态值 dp[j] - k*w 需严格单调递增。

                        (3) 由于 dp[j] 仅依赖于同余类内的状态,因此可以在每个同余类中维护一个单调队列来寻找最优解。

                2.代码ED:

#include <iostream>
#include <cstring>
using namespace std;
const int N = 1e5+9;
 
int n, m;
int v, w, s;//体积、价值、数量
int dp[N], g[N];
int q[N];
 
int main(){
	ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
    cin >> n >> m;
    for(int i = 1; i <= n; ++i){
        cin >> v >> w >> s;
        memcpy(g, dp, sizeof dp);
        for(int j = 0; j < v; ++j){
            int head = 0, tail = -1;
            for(int k = j; k <= m; k += v){
                if(head <= tail  &&  k - q[head] > s * v)  head++;//队头超范围,队头指针后移
                if(head <= tail)  dp[k] = max(dp[k], g[q[head]] + (k - q[head]) / v * w);//队列部位空,取当前值和队头元素转移的最大值为dp[k]
                while(head <= tail && g[q[tail]] - (q[tail] - j) / v * w <= g[k] - (k - j) / v * w)  tail--;//维护队列单调性
                q[++tail] = k;//加入队列
            }
        }
    }
    cout << dp[m] << '\n';
    return 0;
}

        3)单调队列优化树形DP:

                1.证明:

                        (1) 树形DP中能用单调队列解决的,有求解树上最长路径问题。

                        (2) 对于每个节点,去维护一个单调递增队列,则以队列中的元素表示从该节点到其子节点的路径长度,从而快速找到最长路径。

                2.代码ED:

#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 1e5+9;
 
int n;
int u, v;
vector<int> g[N];//存储与节点i相邻的所有节点
int dp[N], f[N];
//dp数组以节点i为根的子树中,选取两条不相交路径的最大边权和
//f数组表示以节点i为根的子树中,选取一条或两条不相交路径的最大边权和
 
void dfs(int u, int fa){
    priority_queue<int> pq;//存储节点dp值
    for(int v : g[u]){
        if(v == fa)  continue;
        dfs(v, u);
        pq.push(dp[v]);
    }
    //当优先队列中的元素数量超过2个时,不断弹出最大的元素,只保留最大的两个元素
    while(!pq.empty() && pq.size() > 2){
        pq.pop();
    }
    dp[u] = 0;
    while(!pq.empty()){
        dp[u] += pq.top();//最大的两个子节点的dp值之和
        pq.pop();
    }
    f[u] = dp[u];
    if(!pq.empty()){//如果优先队列中还有元素,说明存在一个子节点的dp值可以加入,更新f值
        f[u] += pq.top();
    }
}
 
int main(){
	ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
    cin >> n;
    for(int i = 1; i < n; ++i){
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    dfs(1, 0);
    cout << f[1] << '\n';
    return 0;
}

        4)单调队列优化数位DP:

                1.证明:

                        (1) 对于数位DP的单调队列求解问题,有求解数位和最大问题。

                        (2) 对于每个数位,维护一个单调递增队列,队列中的元素表示在当前数位上能够获得的最大数位和,从而快速找到最优解。

                2.代码ED:

#include <iostream>
#include <vector>
#include <queue>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1e5+9;
 
int n;
int dp[N];//dp数组表示前i位数字的各位数字之和

int main(){
	ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
    cin >> n;
    vector<int> digits;//用于存储整数 n 的每一位数字
    while(n){//将整数 n 的每一位数字依次取出并存储到 digits中
        digits.push_back(n % 10);
        n /= 10;
    }
    //由于 digits中存储的数字顺序是从低位到高位,因此这里将其反转,使其变为从高位到低位
    reverse(digits.begin(), digits.end());
    int len = digits.size();//整数 n 的位数
    dp[0] = 0;
    for(int i = 1; i <= len; ++i){
        dp[i] = dp[i - 1] + digits[i - 1];//前 i 位数字的各位数字之和等于前 i - 1 位数字的各位数字之和加上第 i 位数字
    }
    cout << dp[len] << '\n';
    return 0;
}

三、实战演练

        1)区间DP:

                P1714 切蛋糕 - 洛谷

                分析题意,就是使用一个前缀和数组 sum 减去1~i-m+1的队列前缀即可。

        朴素算法ED:

//O(n^2) 朴素算法--计算前缀减去即可 
#include <iostream>
#include <cstring>
using namespace std;
const int N = 5e5+9;

int n, m;
int sum[N], dp[N];
int ans;

int main(){
    scanf("%d%d", &n, &m);
    int x;
    for(int i = 1; i <= n; i++){
        scanf("%d", &x);
        sum[i] = sum[i - 1] + x;//从 1~i 的前缀和
    }   
    memset(dp, -0x3f, sizeof dp);
    for(int i = 1; i <= n; i++){
        for(int j = max(i - m + 1, 0); j <= i; j++){
            dp[i] = max(dp[i], sum[i] - sum[j - 1]);
        }
        ans = max(ans, dp[i]);
    }
    printf("%d\n", ans);
    return 0;
}

                        朴素算法通过简单的前缀和减前缀可以获得76的高分。

                        对于单调队列优化,我们需要一个单调队列 q 数组来存储下标head 表示对头下标,tail 表示队尾下标。进而需要我们对队列的单调性进行维护和指针范围是否超了的检查,将不必要的空间进行压缩,存储 head tail 值来表示原队列的数值下标。下面减去前缀即可。

                单调队列优化ED:

//单调队列优化
//时间复杂度:O(n) 
#include <iostream>
#include <cstring>
using namespace std;
const int N = 5e5+9;

int n, m;
int x;
int sum[N];
int q[N];//用于存储下标
int head, tail = -1;//head 表示单调队列的队头下标,tail 表示单调队列的队尾下标初始化为 -1 表示队列为空
int ans = -0x3f3f3f3f;

int main(){
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= n; i++){
        scanf("%d", &x);
        sum[i] = sum[i - 1] + x;//从 1~i 的前缀和
    }
    for(int i = 1; i <= n; i++){
        if(head <= tail && i - m - 1 >= q[head])  head++;//判断队头元素是否超出m的范围
        while(head <= tail && sum[i - 1] <= sum[q[tail]])  tail--;//维护队列单调性
        q[++tail] = i - 1;
        ans = max(ans, sum[i] - sum[q[head]]);
    }
    printf("%d\n", ans);
    return 0;
}

        2)背包DP:

                P1776 宝物筛选 - 洛谷

                经典的多重背包。

//标签:动态规划DP、多重背包
//算法时间复杂度:O(n*m*log(c))
#include <iostream>
#include <algorithm>
#define ll long long
using namespace std;
const int N = 1e5+10;

struct Node{
	int v,w,c;//价值、重量、数量 
}g[N];

int n,m;
int f[N];//存储数据 

int main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
	cin >> n >> m;
	for(int i = 1; i <= n; ++i){
		cin >> g[i].v >> g[i].w >> g[i].c;
		int ac = g[i].c;//存储一下宝物数量 
		for(int j = 1; j <= ac; j = j<<1){
			int vv = g[i].v*j, ww = g[i].w*j;//计算总价值和总重量 
			for(int k = m; k >= 0; --k){//按时间计算,即--k 
				if(k-ww >= 0){//范围 
					f[k] = max(f[k], f[k-ww]+vv);//动态转移方程 
				}
			}
			ac = ac-j;
		}
		if(ac > 0){//宝物仍有剩余 
			int ww = g[i].w*ac;
			int vv = g[i].v*ac;
			for(int k = m; k >= 0; --k){
				if(k-ww >= 0){
					f[k] = max(f[k], f[k-ww]+vv);
				}//同上 
			}
		}
	}
	cout << f[m] << '\n';//上述已计算出结果,输出第m位置即可 
	return 0;
}

                单调队列优化版:对于一个给定物品 i,其容量m确定,所以对于每个枚举的容量 j,能够通过 dp[j - k*m] 中转移而来。(0 ≤ k ≤ g[i].w)可能部分背包题中存在重量w等于0的情况,像下面这篇代码注意一下即可。

//标签:动态规划DP、多重背包、单调队列
//算法时间复杂度:O(n*m)
#include <iostream>
#include <algorithm>
#define ll long long
using namespace std;
const int N = 1e5+9, inf = 1<<30;

struct Node{
	int v,w,c;//价值、重量、数量 
}g[N];

int n,m;//种类、容量
int ans;
int dp[N],q[N],p[N];

int main(){
	ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
    cin >> n >> m;
    for(int i = 1;i <= n; ++i){
        cin >> g[i].v >> g[i].w >> g[i].c;
        if(g[i].w == 0)  ans += g[i].v*g[i].c;//特判
        g[i].c = min(m/g[i].w, g[i].c);//最大能够承载的该物品的数量
        for(int d = 0; d < g[i].w; ++d){//枚举重量的余数
            int head = 0, tail = 0;
            int k = (m-d) / g[i].w;//在当前余数d下,能够放入的最多物品数
            for(int j = 0; j <= k; ++j){
            	//维护队列单调性,保证其单调递减
                while(head < tail  &&  dp[d+j*g[i].w]-j*g[i].v >= q[tail-1])  tail--;
                q[tail] = dp[d+j*g[i].w] - j*g[i].v;
				p[tail++] = j;
				//移除队列中超出当前物品数量限制的元素
                while(head < tail  &&  p[head] < j-g[i].c)  head++;
                dp[d+j*g[i].w] = max(dp[d+j*g[i].w], q[head]+j*g[i].v);
            }
        }
    }
    cout << dp[m]+ans <<'\n';
    return 0;
}

 

        3)树形DP:

                P1352 没有上司的舞会 - 洛谷

               从根节点开始,递归计算每个节点的状态。 f[u][0]表示不邀请节点 u 的情况下,以 u 为根的子树的最大快乐指数。f[u][1]:表示邀请节点 u 的情况下,以 u 为根的子树的最大快乐指数。

//标签:动态规划DP、树形DP
//算法时间复杂度:O(n)
#include <iostream>
#include <algorithm>
#include <vector>
#include <queue>
#define ll long long
using namespace std;
const int N = 6e3+10;
 
int n;
int x,y;
int per[N], tow[N];
vector<int> son[N];
int f[N][2];//二维处处理端点情况(开心与不开心/上司参与与不参与) 
 
void dp(int k){
	f[k][0] = 0;//初始化
	f[k][1] = per[k];
	for(int i = 0; i < son[k].size(); ++i){
		int l = son[k][i];
		dp(l);
		f[k][0] += max(f[l][0], f[l][1]);
		f[k][1] += f[l][0];//搜索累加 
	} 
}
 
int main(void){
	ios::sync_with_stdio(false);
    cin.tie(nullptr),cout.tie(nullptr);
    cin >> n;
    for(int i = 1; i <= n; ++i)  cin >> per[i];
    for(int i = 1; i <= n-1; ++i){
    	cin >> x >> y;
    	son[y].push_back(x);
    	tow[x] = 1;
	}
    int root;
    for(int i = 1; i <= n; ++i){
    	if(!tow[i]){
    		root = i;
			break;	
		}
	}
	dp(root);
	int ans = max(f[root][0], f[root][1]);
	cout << ans << '\n';
    return 0;
}

        4)数位DP:

                P2602 [ZJOI2010] 数字计数 - 洛谷

                ???数位DP没找到单调队列优化的......贴个代码吧......

#include <iostream>
#include <cstdio>
#define ll long long
using namespace std;
const int N = 20+10;

ll l, r;
ll f[N], p[N];//f数组表示长度为 i 的所有数字中,每个数位上各个数字出现的总次数
ll a[N];//a数组用于临时记录数字的每一位
ll ans1[N], ans2[N];//ans1存储从 0到 r每个数字出现的次数,ans2 存储从 0 到 l - 1 每个数字出现的次数

inline void solve(ll n, ll *ans){
    ll res = n;
    int len = 0;
    //将数字 n的每一位存储到 a数组中
    while(n)  a[++len] = n % 10, n /= 10;
    for(int i = len; i >= 1; --i){
    	//对于当前位小于 a[i]的数字,它们在当前位出现的次数为 p[i-1]
    	//所以将 f[i-1] * a[i]累加到 ans数组中
        for(int j = 0; j < 10; j++)  ans[j] += f[i - 1] * a[i];
        for(int j = 0; j < a[i]; j++)  ans[j] += p[i - 1];//减去当前为及更高位的数字对结果的影响
        res -= p[i - 1] * a[i], ans[a[i]] += res + 1;//当前位数字 a[i]出现次数为 res+1
        ans[0] -= p[i - 1];//除前导零
    }
}

int main(void){
    scanf("%lld %lld", &l, &r);
    p[0] = 1ll;
    for(int i = 1; i <= 13; ++i){
    	//f[i] 的递推公式:长度为 i 的所有数字中,每个数位上各个数字出现的总次数
    	//等于长度为 i - 1 的所有数字中,每个数位上各个数字出现的总次数乘以 10,再加上长度为 i - 1 的所有数字的个数
    	f[i] = f[i - 1] * 10 + p[i - 1];
		p[i] = 10ll * p[i - 1];
    }
    solve(r, ans1);//计算从 0到 r每个数字的出现次数
	solve(l - 1, ans2);//计算从 0到 l-1每个数字的出现次数
    for(int i = 0; i < 10; ++i){
        printf("%lld ", ans1[i] - ans2[i]);
    }
    return 0;
}

四、课下习题

        P1440 求m区间内的最小值 - 洛谷

        P1725 琪露诺 - 洛谷

        P1775 石子合并(弱化版) - 洛谷

        P1973 [NOI2011] NOI 嘉年华 - 洛谷

        P2034 选择数字 - 洛谷

单调队列优化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、付费专栏及课程。

余额充值