按 S 排序后设 f[i][j] 为前 i 个工匠刷前 j 块木板的最大报酬;枚举段左端的转移项可提出 P*j,化为窗口 [j-L,S-1] 上 f[i-1][m]-P*m 的最大值,右端固定左端单调右移,用单调队列做到 O(NM)。
OJ: roj
题目 ID: 3157
难度:提高+/省选-
标签:动态规划单调队列滑动窗口python
创建: 2026-10-01 21:32
更新: 2026-10-10 12:00
形式化题目
有
:他必须覆盖的木板位置; :他最多能连续粉刷的木板数; :他每粉刷一块木板得到的报酬。
每个工匠要么完全不粉刷,要么恰好粉刷一个包含第
并获得
题面保证
以样例为例:
N = 8,M = 4
工匠 1: L=3 P=2 S=2 工匠 3: L=3 P=3 S=5
工匠 2: L=3 P=2 S=3 工匠 4: L=1 P=1 S=7一种最优安排是:工匠 1 刷
正解
思路
先排序。 每个工匠的区间都含
状态与三类转移。 设
看第
- 他不刷:
; - 他刷了,但右端点
:他的整个区间落在前 块里,归入 ; - 他刷的区间右端点恰好是
,左端点为 :报酬 。
第 3 类里
注意
瓶颈。 照着上式实现,枚举
代入
决策区间在滑动。 关键的最后一步观察:这个窗口的右端
m: 0 1 2 ... S_i-1 S_i ...
[============] j = S_i 窗口 m ∈ [S_i-L_i, S_i-1]
[============] j = S_i+1 左端右移一位
[============] j 再变大 左端继续右移,右端不动这正是单调队列的适用条件:候选区间只在一端进出,且要维护区间内的最大值。
队列中按
- 插入:
小、值又不大于新元素的候选,既更早过期又更差,可以弹掉; - 过期:
的队首弹出,因为从它起刷已经超过长度限制; - 查询:弹完以后队首的值就是窗口最大值,配上
即为第三类的最优值。
Python 实现上的两点简化。 因为右端固定为
样例推演
把样例按
| 行 | 工匠 |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 无 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
| 0 | 0 | 4 | 6 | 6 | 6 | 6 | 6 | 6 | ||
| 0 | 0 | 4 | 6 | 8 | 10 | 10 | 10 | 10 | ||
| 0 | 0 | 4 | 6 | 8 | 13 | 15 | 17 | 17 | ||
| 0 | 0 | 4 | 6 | 8 | 13 | 15 | 17 | 17 |
读表要点:第
再看
| 候选窗口 |
队首 |
第三类取值 | ||
|---|---|---|---|---|
| 2 | 0 | 4 | ||
| 3 | 0 | 6 | ||
| 4 | 1 | 6 | ||
| 5 | — | 无候选 | 6 |
可以看到第 4 列在
代码
代码里的命名与公式的对应关系:must_paint limit price best 是上一轮的 cur 是本轮的 window 就是那个单调队列。
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <cstdio>
#include <algorithm>
#include <deque>
using namespace std;
typedef long long ll;
const int MAXN = 16005;
const int MAXM = 105;
struct Painter {
ll limit; // L:最多连续粉刷的木板数
ll price; // P:每块木板的报酬
ll must; // S:必须覆盖的木板位置
};
ll n, m;
Painter painter[MAXM]; // 全部工匠,按 S 升序排列后依次加入 DP
ll best[MAXN]; // best[j]:已加入的工匠在「前 j 块木板」上能拿到的最大报酬
ll cur[MAXN]; // 加入当前工匠后的新报酬表
bool cmp_must(const Painter &a, const Painter &b) { return a.must < b.must; }
// 让一个工匠(必刷 must、一次最多刷 limit 块、每块 price)加入后,更新报酬表。
// 新表先继承 best(这个工匠不刷,或刷的段不含第 j 块);另一种情况是他刷的段右端正好是 j,
// 左端为 k,需满足 k <= must(段要盖住必刷的那块)和 k >= j-limit+1(长度受限),
// 收益 best[k-1] + (j-k+1)*price = price*j + (best[m] - price*m),其中 m = k-1。
// m 的窗口是 [j-limit, must-1],j 增大时窗口右端固定、左端右移,用单调队列维护窗口最值。
void add_painter(const Painter &p) {
for (ll j = 0; j <= n; j++) cur[j] = best[j];
deque<pair<ll, ll> > win; // (best[m] - price*m, m),值从队首到队尾单调递减
for (ll t = 0; t < p.must; t++) {
ll value = best[t] - p.price * t;
while (!win.empty() && win.back().first <= value) win.pop_back();
win.push_back(make_pair(value, t));
}
for (ll j = p.must; j <= n; j++) {
while (!win.empty() && win.front().second < j - p.limit) win.pop_front();
if (!win.empty()) {
ll brushed = win.front().first + p.price * j; // 这个工匠刷 [k, j] 的收益
if (brushed > cur[j]) cur[j] = brushed;
}
if (cur[j - 1] > cur[j]) cur[j] = cur[j - 1]; // 「前 j 块」可退化成「前 j-1 块」
}
for (ll j = 0; j <= n; j++) best[j] = cur[j];
}
int main() {
scanf("%lld%lld", &n, &m);
for (ll i = 0; i < m; i++)
scanf("%lld%lld%lld", &painter[i].limit, &painter[i].price, &painter[i].must);
// 按 S 排序后,每个工匠刷的段都排在后面工匠要盖住的 S 之前,逐行 DP 才成立
sort(painter, painter + m, cmp_must);
for (ll i = 0; i < m; i++) add_painter(painter[i]);
printf("%lld\n", best[n]);
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 21:32
# update_at: 2026-10-01 21:40
import sys
from collections import deque
def add_painter(best: list[int], limit: int, price: int, must_paint: int) -> list[int]:
"""让一个工匠(必刷 must_paint、一次最多刷 limit 块、每块 price)加入后,返回新的报酬表。
best[j] 是已安排的工匠在「前 j 块木板」上能拿到的最大报酬。新表先继承 best:
这个工匠不刷,或者刷的那一段不包含第 j 块木板。剩下的一种情况是「他刷的段右端正好是 j」,
设左端为 k,k 必须满足 k <= must_paint(段要盖住必刷的那块)和 k >= j-limit+1(长度受限),
收益为 best[k-1] + (j-k+1)*price = price*j + (best[k-1] - price*(k-1))。
右边只有 price*j 随 j 变化,所以每块木板只需在窗口 m = k-1 ∈ [j-limit, must_paint-1] 里
取 best[m] - price*m 的最大值;j 增大时窗口左端右移、右端不动,用单调队列维护即可。
"""
n = len(best) - 1
cur = best[:] # 先承接「不刷」和「刷的段不含第 j 块」两种可能
window = deque() # (best[m] - price*m, m),值从队首到队尾单调递减
for m in range(must_paint): # 窗口右端恒为 must_paint-1,候选一次性入队
value = best[m] - price * m
while window and window[-1][0] <= value:
window.pop()
window.append((value, m))
for j in range(must_paint, n + 1):
while window and window[0][1] < j - limit: # 从这些起点起刷,长度已超过 limit
window.popleft()
if window:
brushed = window[0][0] + price * j
if brushed > cur[j]:
cur[j] = brushed
if cur[j - 1] > cur[j]: # 「前 j 块」可退化成「前 j-1 块」
cur[j] = cur[j - 1]
return cur
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
fences: list[tuple[int, int, int]] = [] # (limit, price, must_paint),即题面的 (L, P, S)
for _ in range(m):
limit, price, must_paint = next(data), next(data), next(data)
fences.append((limit, price, must_paint))
# 按 S 排序后,每个工匠刷的段都排在后面工匠要盖住的 S 之前,逐行 DP 才成立
fences.sort(key=lambda fence: fence[2])
best = [0] * (n + 1)
for limit, price, must_paint in fences:
best = add_painter(best, limit, price, must_paint)
print(best[n])
if __name__ == "__main__":
solve()复杂度
- 排序:
; - DP:每个工匠对
扫一遍 ,队列入队/出队各至多 次,均摊 , 总计 ; - 空间:两张长度
的表加一个队列, 。
总结
- 按
排序后,每个工匠的区间都落在后面工匠必刷的 左侧,「前 个工匠处理前 块」 这个线性 DP 才成立。 - 三类转移中只有第三类需要枚举段左端;代入
后收益拆成与 无关的 和 只与 有关的 ,把枚举变成求窗口最值。 - 决策窗口
的右端固定、左端单调右移,是单调队列的标准信号; 右端固定这一点还让候选可以一次性入队,主循环只剩弹队首与读队首。 - 空间上只需滚动
的 维, 即可。 - 边界:
时窗口为空、只保留前两类转移; 时队列会被弹空, 同样自动退化为前两类转移。
图示解析
这张图串起本题从建模到得到答案的主线:
N 块木板 + M 个工匠(各刷一段连续区间,两两不交)
`- 按 S 排序:区间按右端点有序,DP 可线性推进
`- 状态 f[i][j] = 前 i 个工匠、只在前 j 块上的最大报酬
|- 三类转移
| |- 不刷 -> f[i-1][j]
| |- 刷的段不含第 j 块 -> f[i][j-1]
| `- 刷的段右端是 j -> max_k f[i-1][k-1] + P*(j-k+1)
`- 第三类代入 m = k-1
|- 拆出 P*j 与 f[i-1][m] - P*m
|- 窗口 [j-L, S-1]:右端不动、左端单调右移
`- 单调队列维护窗口最大值 -> O(NM)先看每个节点代表的推理结论:排序解决「按什么顺序决策」,状态定义解决「子问题怎么划分」,
三类转移穷尽一个工匠的所有选择,而展开与窗口滑动则是把