前情提要,参考资料:单调队列优化DP(超详细!!!) - endl\n - 博客园
【动态规划】选择数字(单调队列优化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:
分析题意,就是使用一个前缀和数组 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:
经典的多重背包。
//标签:动态规划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:
从根节点开始,递归计算每个节点的状态。 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:
???数位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;
}
四、课下习题