间隔约束的一维 DP:dp[i] 表示第 i 座必点火时前缀的最小代价,转移是长度 m 滑动窗口求最小值,用单调队列把 O(nm) 降到 O(n)。
OJ: roj
题目 ID: 1602
难度:提高
标签:动态规划单调队列滑动窗口python
创建: 2026-09-30 21:04
更新: 2026-10-07 13:50
形式化题目
给定长度为
样例:
正解
思路
1. 把"连续 座"改写成位置条件
设选中的下标从小到大为
前两条对应前缀窗口:如果
第三条是尾部豁免,也是本题最容易写错的地方:最后一段
反过来,只要这三条成立,任意长度
于是问题变成:选一串下标,相邻两项距离不超过
a: 1 2 5 6 2 n = 5, m = 3
# * # # * * = 点火(选 2 号、5 号)
\___________/ | 相邻点火间隔 3 <= m = 3 ✓
\___/ 尾部 0 座 <= m-1 = 2 ✓ 尾部豁免2. 状态定义与转移
沿着链从左往右推,需要记住的唯一信息是"上一座点火的烽火台在哪",因为只有它限制了下一座能选多远。于是:
枚举上一座点火的位置
3. 答案是尾部窗口的最小值
设
为什么不能直接输出
4. 滑动窗口最小值:单调队列
朴素地对每个
随
队列中的
值自队首到队尾严格递增,于是队首就是窗口最小值。
每步按固定顺序做三件事:
- 过期:把所有
的队首弹出(它们已经不在窗口里)。 - 取最小值:
dp[i] = dp[队首] + a[i]。 - 入队:先比较
dp[i]与dp[队尾],把队尾所有的下标弹出再让 入队。被弹掉的下标比 更靠左(更早过期)且不更优,永远不会再成为任何窗口的最小值,淘汰是安全的。
主循环结束后队列里剩下的就是"还没过期"的那些下标,此时把过期阈值改成
5. 样例演算
按 main.py 的定义逐步演算 窗口 是 队首 / 队首 dp 是弹出过期元素后单调队列的头部,队列 是入队
| 窗口 |
队首 | 队首 |
入队后的队列 | |||
|---|---|---|---|---|---|---|
| 1 | 1 | [0, 0] | 0 | 0 | 1 | [0, 1] |
| 2 | 2 | [0, 1] | 0 | 0 | 2 | [0, 1, 2] |
| 3 | 5 | [0, 2] | 0 | 0 | 5 | [0, 1, 2, 3] |
| 4 | 6 | [1, 3] | 1 | 1 | 7 | [1, 2, 3, 4] |
| 5 | 2 | [2, 4] | 2 | 2 | 4 | [2, 5] |
三点值得注意:
- 第 4 步下标 0 过期(
),队首换成 1, 值从 0 变 1,这才让 而不是 ; - 第 5 步
, 对应"前缀里只点 2 号",加上 5 号就是样例方案 ; - 第 5 步入队时,队尾
,于是 4 被弹掉——同一时刻 4 更靠左却更贵,留它没有意义。
最后做尾部筛选:
代码
Python 版:
#!/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-09-30 21:10
# update_at: 2026-09-30 21:10
import sys
from collections import deque
INF = 1 << 60 # 不可达状态的初值:比任何合法代价都大,用作单调队列的初始地板
def min_cost(n: int, m: int, cost: list[int]) -> int:
"""在前 n 座烽火台上选出「任意连续 m 座至少选一座」的最小总代价。
记 dp[i] 为「第 i 座必须点火,且前缀 [1, i] 已经满足约束」的最小代价。
i 与 i-1 之间最多隔 m-1 座不点,所以上一个点火位置落在 [i-m, i-1],
于是 dp[i] = a[i] + min(dp[i-m .. i-1]):滑动窗口取最小值,用单调队列做到均摊 O(1)。
"""
# 位置 0 是虚拟哨兵:滑到 i = 1 时它正好离开窗口,代表「前面一段没点火也不越界」。
dp = [INF] * (n + 1)
dp[0] = 0
window = deque([0]) # 存下标,对应 dp 值自队首到队尾单调不减
for i in range(1, n + 1):
while window[0] < i - m: # 队首滑出左界 i-m,不再能作为转移来源
window.popleft()
dp[i] = dp[window[0]] + cost[i - 1]
while window and dp[window[-1]] >= dp[i]: # 更晚进队却更优,旧的下标永远没用
window.pop()
window.append(i)
# 尾部没有强制点火的烽火台:最后一座点火的 j 只要满足 n - j <= m-1,
# 即 j >= n-m+1,剩下的 j+1..n 就都不超过 m-1 座,同样合法。
# 下界再和 1 取大:m > n 时整条链不足 m 座,答案退化为「选最便宜的一座」,
# 否则会把哨兵 dp[0]=0 当成合法结尾,误输出 0。
while window[0] < max(1, n - m + 1):
window.popleft()
return dp[window[0]]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
cost = [next(data) for _ in range(n)]
print(min_cost(n, m, cost))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-05 12:07
* update_at: 2026-10-05 12:07
*/
// 烽火传递:单调队列优化的线性 DP。
// 约束等价于「相邻两座点火台的距离不超过 m,且最后一座离 n 不超过 m-1」,
// 于是 dp[i] = a[i] + min(dp[i-m .. i-1]),答案是 min(dp[n-m+1 .. n])。
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
ll a[MAXN]; // a[i] 第 i 座烽火台发出信号的代价
ll dp[MAXN]; // dp[i] 表示第 i 座必须点火、且前缀 1..i 已满足约束时的最小代价
ll que[MAXN]; // 单调队列,存下标;对应的 dp 值自队首到队尾严格递增
int head, tail; // 队列区间 [head, tail),队首 que[head] 即窗口最小值所在下标
int main() {
ll n, m;
scanf("%lld%lld", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
// 下标 0 是虚拟哨兵,dp[0] = 0,负责 i <= m 时「前面一座都不点火」的情形
dp[0] = 0;
head = 0;
tail = 0;
que[tail++] = 0;
for (int i = 1; i <= n; i++) {
while (head < tail && que[head] < i - m) {
head++; // 队首已经滑出窗口左界 i-m,不能再作为转移来源
}
dp[i] = dp[que[head]] + a[i];
// 更晚进队却更优的旧下标永远不会再成为最小值,直接淘汰
while (head < tail && dp[que[tail - 1]] >= dp[i]) {
tail--;
}
que[tail++] = i;
}
// 尾部豁免:最后一座点火台 t 只需满足 n - t <= m-1,即 t >= n-m+1,
// 所以答案不是 dp[n]。下界与 1 取大,兼顾 m > n 时退化为选最便宜的一座
ll low = max(1LL, n - m + 1);
while (head < tail && que[head] < low) {
head++;
}
printf("%lld\n", dp[que[head]]);
return 0;
}复杂度
- 时间复杂度:
。读入 ;主循环执行 次,每次至多一次队首弹出、一次队首查询、均摊一次队尾弹出,全部是均摊 ;每个下标入队 1 次、出队至多 1 次,所以总操作数是 量级的常数倍。相比朴素 的最坏 , 时不到 次基本操作。 - 空间复杂度:
。 dp与代价数组各个整数,单调队列至多存 个下标。实测峰值内存 MB,远低于 128 MB 限制。
总结
- 关键的建模动作是把"任意连续
座至少一座"翻译成相邻选中点距离 ,并单独记住尾部 座可以不选这条不对称的豁免。 - 有了"相邻距离受限"的认识,状态只需要记"上一座点火台在哪",得到
;虚拟哨兵 统一处理 的情形。 - 答案是
而不是 ,因为最后一座点火台不必是第 座。 - 窗口左右端点都单调右移,是滑动窗口最小值问题;单调队列保持队内
递增,队首即最小值,把 的枚举降到均摊 ,总复杂度 。 - 边界:
时窗口只含 ,答案退化为 ; 时整个链都不足 ,答案退化为 。
图示解析
这张图串起从题面到答案的推理主线:
任意连续 m 座至少一座点火
`- 等价改写:相邻选中点距离 <= m,首位 <= m,末位 >= n-m+1(尾部豁免)
`- 沿链 DP:dp[i] = 第 i 座必点火时前缀的最小代价
`- dp[i] = a_i + min(dp[i-m .. i-1]):固定长度的滑动窗口最小值
`- 单调队列:窗口左右端点单调右移,队首即最小值,均摊 O(1)
`- ans = min(dp[n-m+1 .. n]),O(n) 结束顺着每一层看:第一层把"窗口覆盖"这种二维说法压成一维的位置条件,第二层的"尾部豁免"提示答案不是