把「最长复制时间最小」转成判定问题:二分上限 L,用贪心数出 L 下最少的段数;再倒序贪心让后面的人尽量多抄,重建出前面人少抄的字典序最小方案。
OJ: roj
题目 ID: 1278
难度:普及+/提高-
标签:二分答案贪心前缀和python
创建: 2026-09-30 03:03
更新: 2026-10-07 12:15
形式化题目
给定一个正整数序列
把第
- 最小化复制时间
( 是页数前缀和),也就是"抄得最多的人"的页数; - 在
取到最小值的前提下,使前面的人尽量少抄,即字典序最小化 。
样例:1 5 / 6 7 / 8 9(三段分别
正解
思路
朴素做法想清楚瓶颈。 划分由
答案就是 std.cpp 就是这么写的——但它是
换个问法:判定"复制时间能不能不超过
判定比最优化简单得多:从头往右扫,每人手里能装就装,装不下才换人。这样得到的段数就是在上限 people_needed(L)。判定只需要数段数,完全不需要决定切在哪——这正是它能绕开
判定关于 people_needed(L) 只会变小或不变。于是"最少人数
- 下界
:至少要放得下最厚的那本书,也不能低于人均页数; - 上界
:一个人全抄完,必然可行。
为什么
重建方案:从后往前贪心。 题目还要求"多解时让前面的人少抄"。总量固定,前面少抄就是后面多抄,所以从最后一本往前扫,让第
used + pages[i] > limit:再吞这本书就超过,这一段的复制时间会变长; i < person - 1:位置只剩 本书,而前面还有 person-1个人,必须每人至少一本。
用样例 limit = 17):
| 扫到的书 |
页数 | 当前的人 | 累计页数 | 动作 |
|---|---|---|---|---|
| 9 | 9 | 3 | 9 | 继续吞 |
| 8 | 8 | 3 | 17 | 继续吞(正好装满 17) |
| 7 | 7 | 2 | 7 | 8 9 |
| 6 | 6 | 2 | 13 | 继续吞 |
| 5 | 5 | 1 | 5 | 6 7 |
| 4 | 4 | 1 | 9 | 继续吞 |
| 3 | 3 | 1 | 12 | 继续吞 |
| 2 | 2 | 1 | 14 | 继续吞 |
| 1 | 1 | 1 | 15 | 继续吞,最后第 1 人收尾为 1 5 |
读者可以重点看第 3 和 5 行(扫到第 7 本、第 5 本时):收尾都发生在"再加一本就超过 17"的那一刻,这就是"后面的人尽量多抄"的含义;而最后一行第 1 个人的 1 5 只有 15 页,是把余量全留给了前面的人。
为什么这样贪心就是"前面的人少抄"?把题目那句话严格化:在所有使复制时间等于
贪心的最后一个切点已经是所有合法方案里最小的。 任何一个合法的 i < person - 1 那条保护又把它抬到不低于
字典序最小的方案也只能在这里切。 设
- 若
,把书 挪给第 个人。第 个人拿到后缀 ,和 ;前 位切点不变,第 位变小——得到一个字典序更小的合法方案。 - 若
,把 的前 段(恰好覆盖前缀 ,而 )中某个长度 的段劈成两段:劈出的两段和都严格小于原段和 ,人数就从 补成 ,写出的切点向量前 位里有一位变小,同样比 字典序更小。
两种情形都与 i < person - 1)完全一样,所以它对这个前缀做的事正是同一个子问题的贪心——逐个人从后往前固定切点,两个方案完全重合。
作为对照,同一组样例用区间 DP 算出的
| 人数 \ 前 |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 3 | 6 | 10 | 15 | 21 | 28 | 36 | 45 | |
| — | 2 | 3 | 6 | 9 | 11 | 15 | 21 | 24 | |
| — | — | 3 | 4 | 6 | 9 | 11 | 15 | 17 |
网格里填不了 — 占位)。右下角
代码
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 03:12
# update_at: 2026-09-30 03:20
import sys
def people_needed(pages: list[int], limit: int) -> int:
"""每人上限 limit 页时,按"装不下才换人"连续切分,最少需要几个人。"""
used = cuts = 0
for page in pages:
used += page
if used > limit: # 这本书装不下:刚装满的那一段算一个人,它另起一段
cuts += 1
used = page
return cuts + bool(used) # 末尾没装满的那段也要一个人;空书稿谁也不抄
def shortest_limit(pages: list[int], k: int) -> int:
"""二分出最短复制时间 C*:limit 越大所需人数越少,人数关于 limit 单调不增。"""
lo = max(max(pages), -(-sum(pages) // k)) # 下界:容得下最厚的书,且不超过人均页数
hi = sum(pages) # 上界:一个人全抄完
while lo < hi:
mid = (lo + hi) // 2
if people_needed(pages, mid) <= k: # mid 页够 k 个人抄完
hi = mid
else:
lo = mid + 1
return lo
def split_plan(pages: list[int], k: int, limit: int) -> list[tuple[int, int]]:
"""从后往前按 limit 页切分:每人尽量多抄,把少抄的机会留给前面的人。
倒序贪心让第 k、k-1、… 个人依次"能吞多少吞多少",于是第 1 个人剩下的最少;
两个必须收尾的条件是:再吞这本书会超过 limit,或剩下的书不够前面的人各分一本。
"""
plan: list[tuple[int, int]] = []
person, used, end = k, 0, len(pages) # end 是当前这个人已抄区间的右端(不含)
for i in range(len(pages) - 1, -1, -1):
if used + pages[i] > limit or i < person - 1: # 超页数,或前面的人不够分
plan.append((i + 2, end)) # 收尾:0 下标区间 [i+1, end) 转 1 下标闭区间
person -= 1
used, end = 0, i + 1
used += pages[i]
plan.append((1, end))
return plan[::-1]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
m, k = next(data), next(data)
pages = [next(data) for _ in range(m)]
if m == 0:
return
print('\n'.join(f'{start} {end}' for start, end in split_plan(pages, k, shortest_limit(pages, k))))
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-04 23:49
* update_at: 2026-10-04 23:49
*/
#include <iostream>
using namespace std;
typedef long long ll;
const int MAXM = 505; // 书的数量最多 500 本,下标从 1 开始
ll m, k;
ll pages[MAXM]; // pages[i] 表示第 i 本书的页数
ll pre[MAXM]; // pre[i] = 前 i 本书的总页数
ll start_id[MAXM]; // start_id[person] = 第 person 个人抄写的起始书号
ll end_id[MAXM]; // end_id[person] = 第 person 个人抄写的终止书号
// 判定函数:每人上限 limit 页时,从头「能装就装、装不下才换人」,
// 返回抄完整本书稿最少需要的人数。页数都是正数,所以这样贪心得到的段数最少。
ll people_needed(ll limit) {
ll used = 0; // 当前这个人已抄的页数
ll cnt = 0; // 已经用掉的人数
for (ll i = 1; i <= m; i++) {
used += pages[i];
if (used > limit) { // 第 i 本装不下:上一个人到此为止,它另起一段
cnt++;
used = pages[i];
}
}
if (used > 0) cnt++; // 末尾没装满的那一段也得算一个人
return cnt;
}
// 二分最短复制时间 C*:limit 越大所需人数越少,人数关于 limit 单调不增,
// 所以「最少人数不超过 k」一旦成立就不会再变回去,可以二分出最小的可行 limit。
ll shortest_limit() {
ll lo = pages[1]; // 下界:至少要放得下最厚的那本书
for (ll i = 1; i <= m; i++) {
if (pages[i] > lo) lo = pages[i];
}
ll avg = (pre[m] + k - 1) / k; // 也不能低于人均页数(向上取整)
if (avg > lo) lo = avg;
ll hi = pre[m]; // 上界:一个人全抄完,必然可行
while (lo < hi) {
ll mid = (lo + hi) / 2;
if (people_needed(mid) <= k) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}
// 倒序贪心重建方案:让第 k、k-1、… 个人依次能吞多少吞多少,
// 「前面的人少抄」等价于「后面的人多抄」,于是第 1 个人剩下的最少。
// 注意这里是 1 下标扫描:收尾时第 i 本归新的(更靠前的)那个人,
// 前面只剩 1..i-1 共 i-1 本书,要够 person-1 个人每人一本。
void build_plan(ll limit) {
ll person = k;
ll used = 0;
ll right_end = m; // 当前这个人已确定区间的右端书号
for (ll i = m; i >= 1; i--) {
// 收尾条件缺一不可:再吞第 i 本会超过 limit,
// 或者前面剩的 i-1 本书不够 person-1 个人每人一本(即 i < person)。
if (used + pages[i] > limit || i < person) {
start_id[person] = i + 1;
end_id[person] = right_end;
person--;
used = 0;
right_end = i;
}
used += pages[i];
}
start_id[1] = 1;
end_id[1] = right_end;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> m >> k;
if (m == 0 || k == 0) return 0; // 空书稿或没有人抄,不输出任何行
for (ll i = 1; i <= m; i++) {
cin >> pages[i];
pre[i] = pre[i - 1] + pages[i];
}
build_plan(shortest_limit());
for (ll person = 1; person <= k; person++) {
cout << start_id[person] << ' ' << end_id[person] << '\n';
}
return 0;
}复杂度
- 求
:判定一次 ,在 上二分,共 次,合计 。 - 重建方案:一次倒序扫描
,输出 。 - 总时间复杂度
,空间复杂度 。
总结
- 本题是"最小化最大段和"的 min-max 划分:答案的判定形式(“上限
够不够 个人抄完”)比最优化形式好算得多,因为判定只需数段数、不必决定切点。 - 页数为正
段和随段变长严格增大 贪心得到最少段数,且这个段数关于 单调不增,于是可以二分出 。这把 降到 。 - 有了
后,“前面的人少抄"等价于"后面的人多抄”,所以倒序贪心:每人能吞就吞,遇到超上限、或剩余书不够前面的人分,就收尾换人。这就是字典序最小的负载分配。 - 两个收尾条件缺一不可:只判超页数会让前面有人无书可抄;只判人手会让复制时间变长。
books5.in是的空输入,代码在 m == 0时直接返回,不输出任何行。
图示解析
这张图串起本题从建模到输出方案的主线:
flowchart TD
A["输入:m 本书的页数 + k 个人"] --> B["模型:切 c_1..c_k 成恰好 k 段<br/>最小化最大的段和"]
B --> C["朴素:枚举切点 / O(k m^2) 区间 DP<br/>瓶颈:为求最大值最小值而逐格枚举"]
C --> D["换成判定:上限 L 能否被 k 个人抄完"]
D --> E["正数序列 ⇒ 能装就装的贪心<br/>给出 L 下最少段数 people_needed(L)"]
E --> F["people_needed 关于 L 单调不增<br/>⇒ 二分出最短时间 C*"]
F --> G["倒序贪心:第 k、k-1…个人能吞就吞<br/>超页数 or 剩余书不够前面分 才收尾"]
G --> H["输出 k 行区间:前面的人少抄"]
顺着箭头看,每个节点都是对上一个节点瓶颈的回答:枚举太慢,所以换问法;判定之所以快,是因为它只数段数;数段数之所以能二分,是因为页数为正带来的单调性;而拿到