2869 字
14 分钟

DP优化之单调队列优化

封面来源

はに-絶対綺麗

Part 1: P1725 琪露诺 (Sliding Window Maximum)#

在上一篇文章中,我们初步了解了如何用单调队列优化 DP。现在,我们来看一个更直接的例子:P1725 琪露诺。这道题是单调队列优化 DP 的一个非常标准和清晰的模板题。

题目大意解析#

在一个长度为 N+1N+1 的数轴上(从 00NN),每个点 ii 都有一个权值 aia_i。琪露诺从某个起点 ss 出发(0s<L0 \le s < L),然后进行一系列跳跃。

每次跳跃,如果当前在位置 ii,下一步可以跳到区间 [i+L,i+R][i+L, i+R] 内的任意位置 jj。路径的总得分是路径上所有点的权值之和。

我们的目标是找到一条路径,使其结束位置 kk 满足 NR+1kNN-R+1 \le k \le N,并且总得分最大。

思路分析与 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] 最大的位置跳过来。状态转移方程如下:

dp[i]=ai+maxj[iR,iL]dp[j]dp[i] = a_i + \max_{j \in [i-R, i-L]} {dp[j]}

3. Base Cases

题目有一个关键的限制:“琪露诺会选择一个起点 s,满足 0s<L0 \le s < L”。这意味着任何一条合法的路径,它的第一个点(起点)必须在区间 [0,L1][0, L - 1] 内。

对于这些起点 ss,它们不是从任何其他点跳过来的,所以它们的基础得分就是自身的权值。因此,我们的 base cases 是:

  • dp[s] = a[s],对于所有 s[0,L1]s \in [0, L - 1]
  • 对于其他不作为起点的 i,在计算前,我们将其 dp 值初始化为一个极小值(例如 -INF),表示还未被访问到。

4. 最终答案

根据题意,路径的终点必须在 [NR+1,N][N - R + 1, N] 区间内。所以,最终的答案就是 maxk[NR+1,N]dp[k]\max_{k \in [N-R+1, N]} {dp[k]}

单调队列优化#

直接根据上述方程进行计算,对于每个 i,都需要遍历长度为 RL+1R-L+1 的区间来找最大值,总时间复杂度为 O(N(RL))O(N \cdot (R - L)),无法通过本题。

我们再次观察转移方程的核心部分:maxj[iR,iL]dp[j]max_{j \in [i - R, i - L]} {dp[j]}。 当 iL 递增到 N 时,区间 [i - R, i - L] 也在同步向右滑动。这正是“滑动窗口求最大值”的经典场景,是单调队列大显身手的地方。

我们可以创建一个双端队列 deque,用来存储候选位置的下标。并始终保持队列中下标对应的 dp 值是单调递减的。这样,队首 q.front() 对应的 dp 值就永远是当前窗口内的最大值。

在计算 dp[i] 的循环中,我们按以下步骤维护队列和进行计算:

  1. (维护窗口右边界)加入新元素: 在计算 dp[i] 之前,窗口 [i - R, i - L] 的最右侧(最新的)候选点是 i - L。我们将 i - L 加入队列。入队前,为了维持队列的单调性,需要从队尾开始,将所有 dp 值小于等于 dp[i - L] 的元素弹出。
  2. (维护窗口左边界)移除过期元素: 检查队首的下标 j = q.front() 是否小于 i - R。如果是,说明 j 已经不在当前窗口内,需要从队首弹出。
  3. 计算 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 转移中的“滑动窗口最值”模式,并利用单调队列这一数据结构将查询最值的时间复杂度从 O(K)O(K) 降至均摊 O(1)O(1),从而实现整体效率的飞跃。

Part 2: [USACO11OPEN] Mowing the Lawn G (DP with Monotonic Queue)#

告别了数位 DP,我们来探索另一类经典的动态规划优化技巧。这道题 P2627 [USACO11OPEN] Mowing the Lawn G 是一个典型的例子,它展示了如何使用单调队列将一个 O(NK)O(N \cdot K) 的 DP 优化到 O(N)O(N)

题目大意解析#

我们有 NN 头奶牛,每头都有一个效率值 EiE_i。我们需要选择一部分奶牛来工作,以获得最大的总效率。但是有一个限制:我们不能选择超过 KK连续的奶牛。

例如,如果 K=2K=2,我们可以选择奶牛 {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,所以 1ij+1K1 \le i-j+1 \le K。这个牛群 [j, i] 之前,第 j - 1 头牛必须是不被选择的,因此我们从 dp[j-1] 状态转移而来。

综合起来,dp[i] 的转移方程是: dp[i]=max(dp[i1],maxiK+1jidp[j1]+l=jiEl)dp[i] = \max(dp[i-1], \max_{i - K + 1 \le j \le i} {dp[j - 1] + \sum_{l=j}^{i} E_l})

这个方程的时间复杂度是 O(NK)O(N \cdot K),对于 N=105N=10^5 的数据规模来说太慢了。我们需要优化。

2. 优化转移方程

优化的关键在于内层的 max\max 循环。我们引入前缀和数组 pre 来快速计算区间和,其中 pre[i]=E1+...+Eipre[i] = E_1 + ... + E_il=jiEl=pre[i]pre[j1]\sum_{l=j}^{i} E_l = \text{pre}[i] - \text{pre}[j-1]

代入原方程: dp[i]=max(dp[i1],maxiK+1jidp[j1]pre[j1]+pre[i])dp[i] = \max(dp[i-1], \max_{i-K+1 \le j \le i} {dp[j-1] - \text{pre}[j-1]} + \text{pre}[i])

我们把只与 i 相关的 pre[i] 提出来。现在,对于每个 i,我们需要高效地求出 maxiK+1jidp[j1]pre[j1]\max_{i-K+1 \le j \le i} {dp[j-1] - \text{pre}[j-1]}

p=j1p = j-1,则 jj 的范围 [i-K+1, i] 对应 p 的范围 [i-K, i-1]。 问题转化为,在计算 dp[i] 时,需要快速求出 maxp[iK,i1]dp[p]pre[p]\max_{p \in [i-K, i-1]} {dp[p] - \text{pre}[p]}

这是一个典型的滑动窗口最大值问题!随着 i 的增加,p 的窗口 [i-K, i-1] 也在向右滑动。解决这个问题最完美的工具就是单调队列

单调队列优化#

单调队列(通常用 std::deque 实现)可以在均摊 O(1)O(1) 的时间内完成窗口的滑动和最大值的查询。

我们的队列将存储下标 p。为了快速求最大值,我们维护一个队列,使得其中存储的下标 p 所对应的 dp[p] - pre[p] 的值是单调递减的。

这样,队首元素 dq.front() 始终是当前窗口中,使得 dp[p] - pre[p] 最大的那个 p

在计算 dp[i] 的循环中,我们执行以下三步:

  1. 移除过期元素:检查队首元素的下标 p 是否已经滑出窗口 [i-K, i-1]。即检查 p < i-K,如果是,则 pop_front
  2. 计算当前 DP 值:使用当前的队首 p = dq.front() 来计算 dp[i]dp[i] = max(dp[i-1], (dp[p] - pre[p]) + pre[i])
  3. 维护队列单调性:将当前下标 i 插入队列。在插入前,从队尾开始,将所有 dp[q] - pre[q] 小于 dp[i] - pre[i] 的下标 qpop_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])
  • f[i]=maxp[iK,i1]{g[p]}+sum(p+1,i)f[i] = max_{p \in [i-K, i-1]} \{g[p]\} + sum(p+1, i) 最终,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 问题的关键。

DP优化之单调队列优化
https://blog.mengh04.top/posts/单调队列优化/
作者
mengh04
发布于
2025-08-22
许可协议
CC BY-NC-SA 4.0