把奶牛看成区间,用 dp[x] 表示铺满班次 1..x 的最少奶牛数;按右端点升序合并后,转移只依赖一段区间最小值,用线段树做到 O(N log T)。
OJ: roj
题目 ID: 3156
难度:提高
标签:动态规划线段树数据结构
创建: 2026-10-01 21:31
更新: 2026-10-10 11:30
形式化题目
给定
若不存在这样的子集,则报告无解。区间之间允许重叠,每头奶牛要么整段使用、要么不用。
正解
思路
朴素做法与瓶颈
把"班次"看成数轴上的点,每头奶牛就是一个闭区间
设
直接按这个式子做,要枚举
观察一:反过来按奶牛转移
于是每头牛只需要做一次更新:
观察二:按右端点升序,来源一定已经就绪
上面的窗口
更强的理由是:一头牛只写「自己右端点」对应的那个下标,右端点更大的牛写的位置一定
观察三:同一个右端点只留最靠左的牛
固定
用
样例推演
样例为
| 班次 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 牛1 |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |||
| 牛2 |
✅ | ✅ | ✅ | ✅ | ||||||
| 牛3 |
✅ | ✅ | ✅ | ✅ | ✅ | |||||
| 是否被覆盖 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
牛2
按右端点升序处理时的快照如下(
| 处理 | 窗口 | 窗口最小值 | 结论 | |
|---|---|---|---|---|
| 牛2 |
前面第 1、2 班次还铺不满,这头牛接不上,跳过 | |||
| 牛1 |
||||
| 牛3 |
注意第一行:虽然牛2 的右端点最小、最先被处理,但它的窗口里没有任何可达状态, 所以被正确跳过——这正说明"来源必须已就绪"这一条件由升序 + 窗口共同保证。
线段树
剩下的就是支持两种操作的数据结构:
- 查询区间
的最小值; - 单点
与一个值取 min。
这是线段树的标准用法,单次都是
实现上把这两件事拆成了两个工具函数:push_min 负责"单点取 min 并向上维护",
range_min 负责"查询左闭右开区间的最小值";主循环 min_cows 里
left_of 是按右端点合并后的字典,covered 就是 prev 就是窗口最小值。
最终答案就是 -1。
代码
/**
* 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
*/
// 清理班次:按右端点升序的 DP,线段树维护 dp 的区间最小值。
#include <cstdio>
#include <map>
#include <vector>
typedef long long ll;
const ll INF = 1000000000LL; // 不可达哨兵:答案最多 N,与真实值不会混淆
std::vector<ll> tree;
ll seg_size;
// 把叶子 pos 的值与 value 取 min,并向上维护区间最小值
void push_min(ll pos, ll value) {
ll i = seg_size + pos;
if (tree[i] <= value) return;
tree[i] = value;
i >>= 1;
while (i) {
ll merged = tree[i << 1] < tree[i << 1 | 1] ? tree[i << 1] : tree[i << 1 | 1];
if (tree[i] == merged) break; // 本节点没变,祖先自然也不会变
tree[i] = merged;
i >>= 1;
}
}
// 查询下标区间 [left, right) 的最小值
ll range_min(ll left, ll right) {
ll res = INF;
left += seg_size;
right += seg_size;
while (left < right) {
if (left & 1) {
if (tree[left] < res) res = tree[left];
left++;
}
if (right & 1) {
right--;
if (tree[right] < res) res = tree[right];
}
left >>= 1;
right >>= 1;
}
return res;
}
int main() {
ll n, T;
if (scanf("%lld %lld", &n, &T) != 2) return 0; // 空输入安全返回
// 右端点相同的牛只留左端点最靠左的那头,其余都被它完全支配
std::map<ll, ll> left_of;
for (ll i = 0; i < n; i++) {
ll left, right;
scanf("%lld %lld", &left, &right);
if (left <= T) { // 一个班次都盖不到的牛直接丢弃
std::map<ll, ll>::iterator it = left_of.find(right);
if (it == left_of.end()) left_of[right] = left;
else if (left < it->second) it->second = left;
}
}
seg_size = 1;
while (seg_size < T + 2) seg_size <<= 1; // dp 下标是 0..T
tree.assign(seg_size << 1, INF);
push_min(0, 0); // dp[0] = 0:还没铺任何班次
for (std::map<ll, ll>::iterator it = left_of.begin(); it != left_of.end(); ++it) {
ll right = it->first, left = it->second;
ll covered = right < T ? right : T; // 覆盖到 T 之后再多的班次没有意义
ll prev = range_min(left - 1, covered + 1);
if (prev < INF) push_min(covered, prev + 1);
}
ll ans = range_min(T, T + 1);
printf("%lld\n", ans < INF ? ans : -1);
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:34
# update_at: 2026-10-01 21:45
import sys
INF = 10 ** 9 # 不可达哨兵:答案最多 N = 25000,与真实值不会混淆
def push_min(tree: list[int], size: int, pos: int, value: int) -> None:
"""把叶子 pos 的值与 value 取 min,并向上维护区间最小值。"""
i = size + pos
if tree[i] <= value:
return
tree[i] = value
i >>= 1
while i:
merged = min(tree[i << 1], tree[i << 1 | 1])
if tree[i] == merged: # 本节点没变,祖先自然也不会变
break
tree[i] = merged
i >>= 1
def range_min(tree: list[int], size: int, left: int, right: int) -> int:
"""查询下标区间 [left, right) 的最小值。"""
res = INF
left += size
right += size
while left < right:
if left & 1:
res = min(res, tree[left])
left += 1
if right & 1:
right -= 1
res = min(res, tree[right])
left >>= 1
right >>= 1
return res
def min_cows(left_of: dict[int, int], T: int) -> int:
"""返回铺满班次 1..T 的最少奶牛数,不可达时返回 INF。
left_of[r] 是所有右端点为 r 的牛里最小的左端点。按右端点升序处理,
一头牛 [l, r] 能接在任意一个"已铺满到 p"的方案后面(p >= l-1,重叠合法),
所以每次只需查一次 dp[l-1 .. min(r,T)] 的最小值,再更新 dp[min(r,T)]。
"""
size = 1
while size < T + 2: # dp 下标是 0..T,加 2 保证叶子数够用且是 2 的幂
size <<= 1
tree = [INF] * (size << 1)
push_min(tree, size, 0, 0) # dp[0] = 0:还没铺任何班次
for right, left in sorted(left_of.items()):
covered = min(right, T) # 覆盖到 T 之后再多的班次没有意义
prev = range_min(tree, size, left - 1, covered + 1)
if prev < INF: # 前面那段铺不满,这头牛接不上任何方案
push_min(tree, size, covered, prev + 1)
return range_min(tree, size, T, T + 1) # 答案就是 dp[T]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, T = next(data), next(data)
# 右端点相同的牛只留左端点最靠左的那头,其余都被它完全支配
left_of: dict[int, int] = {}
for _ in range(n):
left, right = next(data), next(data)
if left <= T: # 一个班次都盖不到的牛直接丢弃
left_of[right] = min(left_of.get(right, T + 1), left)
ans = min_cows(left_of, T)
print(ans if ans < INF else -1)
if __name__ == "__main__":
solve()复杂度
- 时间:排序
,每头牛一次查询一次更新,各 ,合计 。 - 空间:线段树
,合并右端点的字典 ,合计 。
总结
- 模型是"最小区间覆盖",状态
取"铺满前缀 "。 - 关键变化是从按班次枚举改成按奶牛枚举:每头牛只贡献一次"区间最小值查询 + 单点取 min"。
- 两个化简让更新次数降到
级别:按右端点升序保证来源就绪;同右端点只留最靠左的牛。 - 用线段树把每次区间查询压到
,整体 。 - 无解的判定很自然:
从未被更新(仍是 )就输出 -1,不需要额外特判。
补充:区间覆盖的经典贪心(每次在
的牛里选 最大的)也能求出同样的最小值, 但它无法解释上面这个转移式,也容易在"空隙"上写错,因此这里不作为主解。
图示解析
这张图串起本题从建模到得到答案的主线:
输入:若干牛,每头牛是一个区间 [l, r]
`- 目标:用最少的区间盖满 1..T
|- 形式化:dp[x] = 铺满 1..x 的最少奶牛数
| `- dp[r] <- min(dp[l-1 .. r]) + 1(每个右端点只有最优的那个 l 有用)
`- 加速:按右端点升序,把"区间取 min + 单点 chmin"交给线段树
`- 答案 dp[T];仍是 +∞ 说明盖不满,输出 -1先看每个节点代表的推理结论:区间覆盖 → 一维 DP → 转移只依赖一段区间最小值 →
线段树把朴素的