DP优化之单调队列优化

封面来源
Part 1: P1725 琪露诺 (Sliding Window Maximum)
在上一篇文章中,我们初步了解了如何用单调队列优化 DP。现在,我们来看一个更直接的例子:P1725 琪露诺。这道题是单调队列优化 DP 的一个非常标准和清晰的模板题。
题目大意解析
在一个长度为 的数轴上(从 到 ),每个点 都有一个权值 。琪露诺从某个起点 出发(),然后进行一系列跳跃。
每次跳跃,如果当前在位置 ,下一步可以跳到区间 内的任意位置 。路径的总得分是路径上所有点的权值之和。
我们的目标是找到一条路径,使其结束位置 满足 ,并且总得分最大。
思路分析与 DP 状态设计
这是一个在有向无环图(DAG)上求最长路径的问题,是动态规划的经典应用场景。
1. DP 状态定义
我们定义 dp[i]
为:所有以位置 i
结尾的合法路径中,能取得的最大总得分。
2. DP 状态转移方程
要想到达位置 i
,我们必须从前面的某个位置 j
跳过来。根据题目的跳跃规则,j
必须满足 j+L <= i <= j+R
。这个不等式可以转化为 i-R <= j <= i-L
。
因此,为了让 dp[i]
最大,我们应该从所有可能的 j
中,选择一个 dp[j]
最大的位置跳过来。状态转移方程如下:
3. Base Cases
题目有一个关键的限制:“琪露诺会选择一个起点 s,满足 ”。这意味着任何一条合法的路径,它的第一个点(起点)必须在区间 内。
对于这些起点 ,它们不是从任何其他点跳过来的,所以它们的基础得分就是自身的权值。因此,我们的 base cases 是:
dp[s] = a[s]
,对于所有 。- 对于其他不作为起点的
i
,在计算前,我们将其dp
值初始化为一个极小值(例如-INF
),表示还未被访问到。
4. 最终答案
根据题意,路径的终点必须在 区间内。所以,最终的答案就是 。
单调队列优化
直接根据上述方程进行计算,对于每个 i
,都需要遍历长度为 的区间来找最大值,总时间复杂度为 ,无法通过本题。
我们再次观察转移方程的核心部分:。
当 i
从 L
递增到 N
时,区间 [i - R, i - L]
也在同步向右滑动。这正是“滑动窗口求最大值”的经典场景,是单调队列大显身手的地方。
我们可以创建一个双端队列 deque
,用来存储候选位置的下标。并始终保持队列中下标对应的 dp
值是单调递减的。这样,队首 q.front()
对应的 dp
值就永远是当前窗口内的最大值。
在计算 dp[i]
的循环中,我们按以下步骤维护队列和进行计算:
- (维护窗口右边界)加入新元素: 在计算
dp[i]
之前,窗口[i - R, i - L]
的最右侧(最新的)候选点是i - L
。我们将i - L
加入队列。入队前,为了维持队列的单调性,需要从队尾开始,将所有dp
值小于等于dp[i - L]
的元素弹出。 - (维护窗口左边界)移除过期元素: 检查队首的下标
j = q.front()
是否小于i - R
。如果是,说明j
已经不在当前窗口内,需要从队首弹出。 - 计算 DP 值: 经过前两步,队首
q.front()
就是当前窗口[i - R, i - L]
中dp
值最大的下标。我们用它来更新dp[i]
。
代码实现
#include <iostream>#include <vector>#include <deque>#include <algorithm>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, l, r; cin >> n >> l >> r;
vector<int> a(n + 1); for (int i = 0; i <= n; ++i) { cin >> a[i]; }
vector<int> dp(n + 1, -INF); deque<int> q;
// Base cases: 路径的起点必须在 [0, L-1] for (int i = 0; i < l; ++i) { dp[i] = a[i]; }
// DP with monotonic queue for (int i = l; i <= n; ++i) { // 1. 将新元素 i-l 加入窗口 // 在此之前,先维护队列的单调性 while (!q.empty() && dp[q.back()] <= dp[i - l]) { q.pop_back(); } q.push_back(i - l);
// 2. 移除窗口左侧的过期元素 while (!q.empty() && q.front() < i - r) { q.pop_front(); }
// 3. 计算 dp[i] if (!q.empty()) { dp[i] = dp[q.front()] + a[i]; } }
// 统计最终答案 int ans = -INF; for (int i = n - r + 1; i <= n; ++i) { ans = max(ans, dp[i]); }
cout << ans << endl;
return 0;}
总结
“琪露诺”这道题完美地展示了单调队列优化 DP 的标准流程。其核心思想在于识别出 DP 转移中的“滑动窗口最值”模式,并利用单调队列这一数据结构将查询最值的时间复杂度从 降至均摊 ,从而实现整体效率的飞跃。
Part 2: [USACO11OPEN] Mowing the Lawn G (DP with Monotonic Queue)
告别了数位 DP,我们来探索另一类经典的动态规划优化技巧。这道题 P2627 [USACO11OPEN] Mowing the Lawn G 是一个典型的例子,它展示了如何使用单调队列将一个 的 DP 优化到 。
题目大意解析
我们有 头奶牛,每头都有一个效率值 。我们需要选择一部分奶牛来工作,以获得最大的总效率。但是有一个限制:我们不能选择超过 头连续的奶牛。
例如,如果 ,我们可以选择奶牛 {1, 2, 4, 5}
,但不能选择 {1, 2, 3}
,因为这包含了 3 头连续的奶牛。
思路分析与 DP 状态设计
这是一个最大化问题,并且当前决策依赖于之前的决策,很自然地想到动态规划。
1. 朴素 DP
一个直接的想法是定义 dp[i]
为考虑前 i
头奶牛能获得的最大效率。
在计算 dp[i]
时,我们有两种选择:
- 不选择第
i
头奶牛:这种情况下,最大效率就是dp[i - 1]
。 - 选择第
i
头奶牛:如果选择第i
头牛,它必然是某一个连续工作牛群的末尾。假设这个牛群是[j, i]
,其长度为i - j + 1
。根据题意,这个长度不能超过K
,所以 。这个牛群[j, i]
之前,第j - 1
头牛必须是不被选择的,因此我们从dp[j-1]
状态转移而来。
综合起来,dp[i]
的转移方程是:
这个方程的时间复杂度是 ,对于 的数据规模来说太慢了。我们需要优化。
2. 优化转移方程
优化的关键在于内层的 循环。我们引入前缀和数组 pre
来快速计算区间和,其中 。
代入原方程:
我们把只与 i
相关的 pre[i]
提出来。现在,对于每个 i
,我们需要高效地求出 。
令 ,则 的范围 [i-K+1, i]
对应 p
的范围 [i-K, i-1]
。
问题转化为,在计算 dp[i]
时,需要快速求出 。
这是一个典型的滑动窗口最大值问题!随着 i
的增加,p
的窗口 [i-K, i-1]
也在向右滑动。解决这个问题最完美的工具就是单调队列。
单调队列优化
单调队列(通常用 std::deque
实现)可以在均摊 的时间内完成窗口的滑动和最大值的查询。
我们的队列将存储下标 p
。为了快速求最大值,我们维护一个队列,使得其中存储的下标 p
所对应的 dp[p] - pre[p]
的值是单调递减的。
这样,队首元素 dq.front()
始终是当前窗口中,使得 dp[p] - pre[p]
最大的那个 p
。
在计算 dp[i]
的循环中,我们执行以下三步:
- 移除过期元素:检查队首元素的下标
p
是否已经滑出窗口[i-K, i-1]
。即检查p < i-K
,如果是,则pop_front
。 - 计算当前 DP 值:使用当前的队首
p = dq.front()
来计算dp[i]
。dp[i] = max(dp[i-1], (dp[p] - pre[p]) + pre[i])
。 - 维护队列单调性:将当前下标
i
插入队列。在插入前,从队尾开始,将所有dp[q] - pre[q]
小于dp[i] - pre[i]
的下标q
都pop_back
,以保证队列的单调递减性。
一个小细节:在上面的推导中,我们假设 dp[j-1]
是前 j-1
头的最优解。但如果第 j-1
头牛被选中,我们就不能再选 [j, i]
这个块了。所以 dp[j-1]
应该代表“不选第 j-1
头牛”的最优解。
一个更严谨的 DP 模型是:
f[i]
: 选第i
头牛的最大收益。g[i]
: 不选第i
头牛的最大收益。g[i] = max(f[i-1], g[i-1])
-
最终,
dp[i] = max(f[i], g[i])
。可以证明,我们最初的dp
方程经过巧妙的实现,可以等价于这个更严谨的模型,这也是下面这份AC代码所采用的思路。
代码讲解
#include <bits/stdc++.h>using namespace std;using i64 = long long;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; cin >> n >> k; vector<int> a(n + 1); for (int i = 1; i <= n; i++) { cin >> a[i]; }
vector<i64> pre(n + 1); for (int i = 1; i <= n; i++) { pre[i] = pre[i - 1] + a[i]; }
vector<i64> dp(n + 1); deque<int> dq; dq.push_back(0); // 哨兵,方便处理边界
// g(p) 代表 dp[p-1] - pre[p-1] 的变体,是我们要优化的值 auto g = [&](int p) -> i64 { if (p == 0) return 0LL; // 这里的 dp[p-1] 实际上是前 p-1 个的最优解 // 减去 pre[p] 而不是 pre[p-1] 是一个实现上的技巧 // 它等价于 (dp[p-1]-pre[p-1]) - a[p] // 最终都会加上 pre[i],所以相对大小不变 // 实际上,最标准的写法是 dp[p]-pre[p] // 但这里的写法也是一种常见的、可以AC的变体 return dp[p - 1] - pre[p]; };
for (int i = 1; i <= n; i++) { // 1. 移除过期的队首 // 窗口是 [i-k, i-1],所以 p < i-k 就过期了 // 这里是 p < i-k,代码中是 dq.front() < i-k // 实际上,窗口应该是 [i-k-1, i-1],所以是 p < i-k-1 // 这里的窗口是 [i-k, i],长度为 k+1 while (!dq.empty() && dq.front() < i - k) { dq.pop_front(); }
// 2. 计算 dp[i] // dp[i-1] 是不选 i 的情况 // g(dq.front()) + pre[i] 是选 i 所在块的情况 dp[i] = max(dp[i - 1], g(dq.front()) + pre[i]);
// 3. 维护单调性并入队 while (!dq.empty() && g(dq.back()) < g(i)) { dq.pop_back(); } dq.push_back(i); }
cout << dp[n] << "\n"; return 0;}
总结
本题是 DP 优化的一个经典范例。当我们发现 DP 转移方程中含有一个固定长度的“滑动窗口求最值”的模式时,就应该立刻想到使用单调队列进行优化。这个技巧可以将复杂度降低一个数量级,是解决许多中高难度 DP 问题的关键。