单调队列优化 dp
单调队列优化 dp
适用条件
只关注“状态变量”“决策变量”及其所在的维度,如果转移方程形如:
则可以使用单调队列优化。具体的,把 \(cost(i,j)\) 分成两部分,第一部分仅与 \(i\) 有关,第二部分仅与 \(j\) 有关。对于每个 \(i\),无论采取哪个 \(j\) 作为最优决策,第一部分的值都是相等的,可以在选出最优决策更新 \(f[i]\) 时再进行计算、累加。而当 \(i\) 发生变化时,第二部分的值不会发生变化,从而保证原来较优的决策,在 \(i\) 改变后仍然较优,不会产生乱序现象。于是,可以在队列中维护第二部分的单调性,及时排除不必要的决策,让 DP 更高效。\(cost(i,j)\) 的每一项仅与 \(i\) 和 \(j\) 中的一个有关,是使用单调队列进行优化的前提。
AcWing298. 围栏
先把所有木匠按照 \(s[i]\) 排序,这样一来,每个木匠粉刷的木板一定在上一个木匠粉刷的木板之后,使得能按顺序 dp
设 \(f[i][j]\) 表示安排前 \(i\) 个木匠粉刷前 \(j\) 块木板,能获得的最多报酬。
-
- 第 \(i\) 个木匠可以什么也不刷,此时 \(f[i][j] = f[i - 1][j]\)
-
- 第 \(j\) 块木板可以空着不刷,此时 \(f[i][j] = f[i][j - 1]\)
-
- 第 \(i\) 个木匠粉刷第 \(k + 1\) 块到第 \(j\) 块木板,根据题意,该木匠粉刷总数不能超过 \(l[i]\),且必须粉刷 \(s[i]\),所以需满足:\(k + 1 ≤ s[i] ≤ j , j - k ≤ l[i]\)。
因此有状态转移方程:
将常量提出有:
当 \(j\) 增大时,\(k\) 的取值范围上界 \(s[i] - 1\) 不变,下界 \(j - l[i]\) 变大。
我们比较一下任意两个决策 \(k1\) 和 \(k2\),设 \(k1 < k2 ≤ s[i] - 1\)。因为 \(k2\) 比 \(k1\) 更靠后,所以随着 j 的增加,
\(k1\) 会比 \(k2\) 更早从范围 \([j - l[i], s[i] - 1]\) 中被排除,如果还满足 \(f[i - 1][k1] - p[i] * k1 ≤ f[i - 1][k2] - p[i] * k2\),
那么就意味着 \(k2\) 不但比 \(k1\) 更优,还比 \(k1\) 的存活时间更长,在这种情况下,\(k1\) 就是一个无用的决策,应该被直接排除出候选决策集合。
综上所述,我们可以维护一个决策点 $ k$ 单调递增,数值 \(f[i - 1][k] - p[i] * k\) 单调递减的队列,只有这个队列中的决策才有可能在某一时刻称为最优策略,这个单调队列支持如下操作:
-
- 当 \(j\) 变大时,检查队头元素,把小于 \(j - l[i]\) 的决策出队
-
- 需要查询最优策略时,队头即为所求
-
- 有一个新的决策要加入候选集合时,在队尾检查 \(f[i - 1][k] - p[i] * k\) 的单调性,把无用策略从队尾直接出队,
最后把新决策加入队列
- 有一个新的决策要加入候选集合时,在队尾检查 \(f[i - 1][k] - p[i] * k\) 的单调性,把无用策略从队尾直接出队,
在本题中具体来说,当内层循环开始时 \(j = s[i]\),建立一个空的单调队列,把 \([max(s[i] - l[i], 0), s[i] - 1]\)
中的决策依次加入候选集合(执行操作 \(3\)),对于每个 $j = s[i] $~ \(n\),先在队头检查决策合法性(执行操作 \(1\)),然后取队头为最优决策(执行操作 \(2\))进行状态转移。
由于单调队列的优化,枚举决策的时间复杂度是线性 \(O(1)\) 的,总的时间复杂度为 $ O(nm)$
int n, m;
int f[N][M], q[M];
struct rec
{
int l, p, s;
friend bool operator < (rec a, rec b)
{
return a.s < b.s;
}
} a[N];
int calc(int i, int k)
{
return f[i - 1][k] - a[i].p * k;
}
signed main()
{
cin >> n >> m;
for (rint i = 1; i <= m; i++)
{
cin >> a[i].l >> a[i].p >> a[i].s;
}
sort(a + 1, a + m + 1);
for (rint i = 1; i <= m; i++)
{
int l = 1, r = 0;
int st = max(0ll, a[i].s - a[i].l);
for (rint k = st; k <= a[i].s - 1; k++)
{
while (l <= r && calc(i, q[r]) <= calc(i, k)) r--;
q[++r] = k;
}
for (rint j = 1; j <= n; j++)
{
f[i][j] = max(f[i - 1][j], f[i][j - 1]);
if (j >= a[i].s)
{
while (l <= r && q[l] < j - a[i].l) l++;
if (l <= r)
f[i][j] = max(f[i][j], calc(i, q[l]) + a[i].p * j);
}
}
}
cout << f[m][n] << endl;
return 0;
}
AcWing299. 裁剪序列
设 \(f[i]\) 表示前 \(i\) 个数分成若干段,在满足每段中所有数的和不超过 \(m\) 的前提下,各段的最大值之和最小是多少
枚举最后一段的情况来进行转移,得出状态转移方程:
因为 \(max\{ a[k] \}\) 不容易用一个简单的多项式来表示,不容易找到特性如单调性。
在上述方程中,若 \(j (0 ≤ j < i)\) 可能成为最优决策,则除了 \(\sum_{k=j+1}^{i}A_k≤M\) 外,
一定还满足以下两个条件之一:
-
- \(A[j]=\max_{j≤k≤i}{\{A_k\}}\)
- 2.$ \sum_{k=j}^{i}A_k>M$ (即 \(j\) 是满足 \(\sum_{k=j+1}^{i}A_k≤M\) 的最小的 \(j\))
第 \(2\) 个条件比较简单,只需预处理出对于每个 \(i\),满足 \(\sum_{k=j+1}^{i}A_k≤M\) 的最小的 \(j\),
记为 \(c[j]\),在计算 \(f[i]\) 时,从 $ c[i]$ 进行一次状态转移即可,下面单独讨论满足定理中第 \(1\) 个条件的决策 \(j\) 的维护方法。
当一个新的决策 \(j_2\) 插入候选集合时,若候选集合中已有的决策 \(j_1\) 满足条件 $j_1 < j_2 $ 并且 \(a[j_1] < a[j_2]\),则 \(j_1\) 就是无用策略,可以直接排除。
综上所述,我们可以维护一个决策点 \(j\) 单调递增、数值 \(a[j]\) 单调递减的队列,只有该队列中的元素才可能成为最优决策。
光这样还不够,该队列只是一个 $a[j] $单调递减的队列,关于转移方程等式右边的 \(f[j] + max\{ a[k] \}\) 并没有单调性。所以不能简单的取队头作为最优决策,而是再加一个额外的数据结构,如二叉堆,二叉堆与单调队列保存相同的候选集合,该插入时一起插入,该删除时一起删除,只不过单调队列以 \(a[j]\) 递减作为比较大小的依据,二叉堆以 \(f[j] + max\{ a[k] \}\) 作为比较大小的依据,保证能快速的在候选集合中查询最值,我们称这种操作为 "二叉堆与单调队列建立了映射关系",但是二叉堆要想跟着队列一起删除元素需要用 "懒惰删除",这里直接采用 \(multiset\) 来实现,队列中某一项的 \(\max_{j+1≤k≤i}\{A_k\}\)
的结果其实就是队列中下一个元素的 \(A\) 值。
复杂度 \(o(n \log n)\)
int n, m;
int a[N], f[N], c[N];
int q[N];
//队首 q[j] 表示 max{a[k]} , j <= k <= i
multiset<int> s;
//存储 f[j] + max{a[k]} , j + 1 <= k <= i
//将队列 q[j] 和 set 存储的 f[q[j]] + a[q[j + 1]] 之间建立映射关系
signed main()
{
cin >> n >> m;
for (rint i = 1; i <= n; i++)
{
cin >> a[i];
if (a[i] > m)
{
puts("-1");
return 0;
}
}
int sum = 0;
for (rint i = 1, j = 0; i <= n; i++)
{
sum += a[i];
while (sum > m)
{
sum -= a[j + 1];
j++;
}
c[i] = j;
}
int l = 1, r = 0;
for (rint i = 1; i <= n; i++)
{
while (l <= r && q[l] < c[i])
{
s.erase(f[q[l]] + a[q[l + 1]]);
l++;
}
while (l <= r && a[q[r]] <= a[i])
{
s.erase(f[q[r - 1]] + a[q[r]]);
r--;
}
if (l <= r) s.insert(f[q[r]] + a[i]);
q[++r] = i;
f[i] = f[c[i]] + a[q[l]];
if (s.size()) f[i] = min(f[i], *s.begin());
}
cout << f[n] << endl;
return 0;
}
P2569 [SCOI2010] 股票交易
\(f[i][j]\):第 \(i\) 天结束后,持有股票数为 \(j\) 的情况下能获得的最大收益
第\(3\)、\(4\)个方程可以单调队列优化。踢队头保证对头小于 \(j-b\) 就行,踢队尾可以观察发现有共性 : \(f[i-t,k]+a*k\)
P4852 yyf hates choukapai
\(f[i][j]\) 表示前 \(i\) 张牌连抽 \(j\) 次的最大欧气值。
枚举本次连抽的位置 \(k\) 并设 \(k\) 为本次连抽的起始卡牌的前一个位置),转移方程:
当 \(i\) 增大时显然 \(k\) 决策区间的左右边界都单调递增,而队列里维护的为 \(f[k][j-1]+a[k+1]-s[k+c]\)
P5665 [CSP-S2019] 划分
\(f[i,j]\) 表示当前已经划分好了前 \(i\) 个数,下一次直接划分出 \([i+1,j]\) 的最小运行时间。设 \(s_i=\sum_{j=1}^ia_j\)
优化的用一个 \(val\) 维护 \(f[k,i]\) 最小值即可。
然后考虑贪心,不难想到如果分的段越少,将求和多项式展开相对于 \(a_i^2\) 项就会越多,所以要尽可能多的分段。对于每一个 \(i\),转移的时候,只需要找到第一个可行的 \(k\) 就可以。用 \(g[i]\) 记录这个第一个 \(k\)。 \(g[i]\)是最大的 \(k\) 使得$ s_i-s_k\ge s_k-s_{g[k]}$
同时,\(g[i]\) 是递增的。所以对于 \(i\),如果 \(j<g(i)\),那么 \(j\) 不可能成为 \(g[i+1]\),这就是决策的单调性。考虑往单调队列里面插入一个值。设 \(f[i]=2s_i-s_{g(i)}\),则我们应该在插入的时候弹掉队尾的所有 \(f \ge f[i]\) 的值。假如 \(f\) 对应的位置可以成为一个 \(g\),那么 \(i\) 必然可以,而且更优。
然后需要一个高精。
CF940E
每段越短越好,如果把一个段增大的话,段内的最小值可能减小,而删去的数的总数也可能减少。对于长度小于 \(c\) 的区间对答案没有正向贡献,于是每次取长度为 \(c\) 的一段是最优的,设 \(f[i]\) 表示把前 \(i\) 个数分成若干段,每段的最小值之和的最大值。那么方程的两项分别对应断开区间和不断。有
\(i\) 的值是连续的,后面那一堆可以用单调队列优化掉,复杂度 \(O(n)\)