「Cleaning Shifts」 清理班次

GitHub跳转原题关系图返回列表

把奶牛看成区间,用 dp[x] 表示铺满班次 1..x 的最少奶牛数;按右端点升序合并后,转移只依赖一段区间最小值,用线段树做到 O(N log T)。

OJ: roj

题目 ID: 3156

难度:提高

标签:动态规划线段树数据结构

创建: 2026-10-01 21:31

更新: 2026-10-10 11:30

形式化题目

给定 NN 个闭区间 [li,ri][l_i, r_i] 与整数 TT,求最小的子集 SS,使得

⋃i∈S[li,ri]⊇[1,T];\bigcup_{i \in S} [l_i, r_i] \supseteq [1, T];

若不存在这样的子集,则报告无解。区间之间允许重叠,每头奶牛要么整段使用、要么不用。

正解

思路

朴素做法与瓶颈

把"班次"看成数轴上的点,每头奶牛就是一个闭区间 [li,ri][l_i, r_i],问题即最小区间覆盖。

设 dp[x]dp[x] 表示铺满班次 1…x1 \dots x 所需的最少奶牛数,dp[0]=0dp[0] = 0,其余初值为 +∞+\infty。 若一个方案的最后一头牛是 [l,r][l, r] 且它盖住班次 xx,那么班次 1…l−11 \dots l-1 必须已经被铺满,于是

dp[x]=min⁡i: li⩽x⩽ri(dp[li−1]+1).dp[x] = \min_{i:\, l_i \leqslant x \leqslant r_i} \bigl(dp[l_i - 1] + 1\bigr).

直接按这个式子做,要枚举 xx(TT 个)并对每个 xx 扫过所有奶牛(NN 头), 复杂度 O(N⋅T)O(N \cdot T)。N=25000N = 25000、T=106T = 10^6 时约 2.5×10102.5 \times 10^{10},太慢。 瓶颈不在状态定义,而在"每个 xx 都重新枚举一遍所有牛"。

观察一:反过来按奶牛转移

dp[x]dp[x] 的定义只关心两件事:已经铺满的前缀,和最后一头牛。 所以反过来按奶牛来推:一头牛 [l,r][l, r] 被选中时,它可以接在任意一个"已经铺满到 pp"的方案后面, 只要 p⩾l−1p \geqslant l - 1(p=l−1p = l - 1 表示这头牛独立接上;p>l−1p > l - 1 表示与前面的牛有重叠,重叠是合法的)。

于是每头牛只需要做一次更新:

dp[min⁡(r,T)]←min⁡(dp[min⁡(r,T)],  min⁡p∈[ l−1,  min⁡(r,T) ]dp[p]+1).dp[\min(r, T)] \leftarrow \min\Bigl(dp[\min(r, T)],\; \min_{p \in [\,l - 1,\; \min(r, T)\,]} dp[p] + 1\Bigr).

r>Tr > T 的部分对覆盖 [1,T][1, T] 没有贡献,所以右端点截断到 TT。 每头牛一次"区间最小值查询 + 单点取 min",总共只有 NN 次。

观察二:按右端点升序,来源一定已经就绪

上面的窗口 [l−1,min⁡(r,T)][l-1, \min(r,T)] 里可能有下标 p>l−1p > l-1,也就是"更早的牛已经铺到了 pp"。 这就要求这些 dp[p]dp[p] 在处理当前牛之前算好。只要按右端点 rr 从小到大处理, 每处理完一头牛立刻写入 dp[min⁡(r,T)]dp[\min(r,T)],那么任何 p⩽rp \leqslant r 的位置要么是初始的 dp[0]dp[0], 要么在更早的轮次已经确定。

更强的理由是:一头牛只写「自己右端点」对应的那个下标,右端点更大的牛写的位置一定 >r> r, 所以处理到 rr 时下标 ⩽r\leqslant r 的 dpdp 值再也不会被改动,窗口里的值全是最终值,不会漏解。

观察三:同一个右端点只留最靠左的牛

固定 rr,若有两头牛 [l1,r][l_1, r] 与 [l2,r][l_2, r] 且 l1<l2⩽rl_1 < l_2 \leqslant r,则 [l2,r]⊆[l1,r][l_2, r] \subseteq [l_1, r], 两者的转移窗口分别是 [l1−1,r][l_1 - 1, r] 与 [l2−1,r][l_2 - 1, r],前者包含后者,因此

min⁡p∈[l1−1, r]dp[p]  ⩽  min⁡p∈[l2−1, r]dp[p].\min_{p \in [l_1 - 1,\, r]} dp[p] \;\leqslant\; \min_{p \in [l_2 - 1,\, r]} dp[p].

用 [l1,r][l_1, r] 替换 [l2,r][l_2, r] 既不会更贵,覆盖率也不会变差。所以每个右端点只需保留最小的 ll, 转移条数降到 min⁡(N,T)\min(N, T) 以内。顺带两个小剪枝:l>Tl > T 的牛一个班次都盖不到,直接丢弃; r>Tr > T 的牛右端点截断成 TT,与别的右端点截断后相同的牛一起合并。

样例推演

样例为 N=3N = 3、T=10T = 10,三头牛是 [1,7][1,7]、[3,6][3,6]、[6,10][6,10]。下表把班次轴画成表格, ✅ 表示这头牛覆盖该班次,★ 标记最终选中的两头牛。

班次 1 2 3 4 5 6 7 8 9 10
牛1 [1,7][1,7] ★ ✅ ✅ ✅ ✅ ✅ ✅ ✅
牛2 [3,6][3,6] ✅ ✅ ✅ ✅
牛3 [6,10][6,10] ★ ✅ ✅ ✅ ✅ ✅
是否被覆盖 ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅

牛2 [3,6][3,6] 完全落在牛1 [1,7][1,7] 内部,选它只会更贵(它的右端点与别人都不同,不会被「同右端点合并」这条规则消掉,但在最优解里它没有位置)。 牛1 和牛3 在班次 6、7 重叠,正好把 [1,10][1,10] 连成一片,所以答案是 2。

按右端点升序处理时的快照如下(+∞+\infty 表示该前缀还铺不满):

处理 dp[0..10]dp[0..10] 窗口 窗口最小值 结论
牛2 [3,6][3,6] 0,+∞×100, +\infty \times 10 dp[2..6]dp[2..6] +∞+\infty 前面第 1、2 班次还铺不满,这头牛接不上,跳过
牛1 [1,7][1,7] 0,+∞×6,dp[7]=1,+∞×30, +\infty \times 6, dp[7]=1, +\infty \times 3 dp[0..7]dp[0..7] 00 dp[7]←1dp[7] \leftarrow 1
牛3 [6,10][6,10] 0,+∞×6,dp[7]=1,+∞×30, +\infty \times 6, dp[7]=1, +\infty \times 3 dp[5..10]dp[5..10] 11 dp[10]←2dp[10] \leftarrow 2

注意第一行:虽然牛2 的右端点最小、最先被处理,但它的窗口里没有任何可达状态, 所以被正确跳过——这正说明"来源必须已就绪"这一条件由升序 + 窗口共同保证。

线段树

剩下的就是支持两种操作的数据结构:

  • 查询区间 [l−1,min⁡(r,T)][l-1, \min(r,T)] 的最小值;
  • 单点 pp 与一个值取 min。

这是线段树的标准用法,单次都是 O(log⁡T)O(\log T),也就是常说的"线段树优化 DP"。 叶子数取 T+2T+2 是为了让 dpdp 的下标 0…T0 \dots T 都落在树里(00 是状态而不是哨兵)。

实现上把这两件事拆成了两个工具函数:push_min 负责"单点取 min 并向上维护", range_min 负责"查询左闭右开区间的最小值";主循环 min_cows 里 left_of 是按右端点合并后的字典,covered 就是 min⁡(r,T)\min(r, T),prev 就是窗口最小值。

最终答案就是 dp[T]dp[T];它仍是 +∞+\infty 时输出 -1。

代码

cpp
/**
 * 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;
}
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-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()

复杂度

  • 时间:排序 O(Nlog⁡N)O(N \log N),每头牛一次查询一次更新,各 O(log⁡T)O(\log T),合计 O(Nlog⁡N+Nlog⁡T)O(N \log N + N \log T)。
  • 空间:线段树 O(T)O(T),合并右端点的字典 O(N)O(N),合计 O(N+T)O(N + T)。

总结

  • 模型是"最小区间覆盖",状态 dp[x]dp[x] 取"铺满前缀 1…x1 \dots x"。
  • 关键变化是从按班次枚举改成按奶牛枚举:每头牛只贡献一次"区间最小值查询 + 单点取 min"。
  • 两个化简让更新次数降到 NN 级别:按右端点升序保证来源就绪;同右端点只留最靠左的牛。
  • 用线段树把每次区间查询压到 O(log⁡T)O(\log T),整体 O(Nlog⁡N+Nlog⁡T)O(N \log N + N \log T)。
  • 无解的判定很自然:dp[T]dp[T] 从未被更新(仍是 +∞+\infty)就输出 -1,不需要额外特判。

补充:区间覆盖的经典贪心(每次在 li⩽posl_i \leqslant pos 的牛里选 rir_i 最大的)也能求出同样的最小值, 但它无法解释上面这个转移式,也容易在"空隙"上写错,因此这里不作为主解。

图示解析

这张图串起本题从建模到得到答案的主线:

text
输入:若干牛,每头牛是一个区间 [l, r]
`- 目标:用最少的区间盖满 1..T
   |- 形式化:dp[x] = 铺满 1..x 的最少奶牛数
   |  `- dp[r] <- min(dp[l-1 .. r]) + 1(每个右端点只有最优的那个 l 有用)
   `- 加速:按右端点升序,把"区间取 min + 单点 chmin"交给线段树
      `- 答案 dp[T];仍是 +∞ 说明盖不满,输出 -1

先看每个节点代表的推理结论:区间覆盖 → 一维 DP → 转移只依赖一段区间最小值 → 线段树把朴素的 O(N⋅T)O(N \cdot T) 转移降到 O(Nlog⁡T)O(N \log T)。 图中只保留正文已经证明过的关键关系,不替代正文中的样例推演和正确性说明。